lxl-dev #2

Merged
zhanghao merged 9 commits from lxl-dev into dev 2026-08-03 16:56:48 +08:00
52 changed files with 1789 additions and 4253 deletions
Showing only changes of commit 320d1a9d92 - Show all commits

View File

@ -25,7 +25,7 @@
| Element Plus | 2.13.1 |
| Pinia | 3.0.4 |
| Vue Router | 4.6.4 |
| 可视化 | ECharts、LogicFlow、Three.js、Monaco Editor、XGPlayer |
| 可视化 | ECharts、Vue Flow、Three.js、Monaco Editor、XGPlayer |
## 环境要求
@ -100,4 +100,3 @@ src/views/corpusManger 语料管理
- `src/settings.js`:布局、页签和版权配置。
生产密码、访问密钥和内部网络配置应通过部署环境注入,不要提交新的敏感信息。

View File

@ -15,8 +15,9 @@
},
"dependencies": {
"@element-plus/icons-vue": "2.3.2",
"@logicflow/core": "^2.0.13",
"@logicflow/extension": "^2.0.18",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"@vueup/vue-quill": "1.2.0",
"@vueuse/core": "14.1.0",
"ace-builds": "^1.43.0",

234
pnpm-lock.yaml generated
View File

@ -14,12 +14,15 @@ importers:
'@element-plus/icons-vue':
specifier: 2.3.2
version: 2.3.2(vue@3.5.26)
'@logicflow/core':
specifier: ^2.0.13
version: 2.2.4
'@logicflow/extension':
specifier: ^2.0.18
version: 2.3.0(@logicflow/core@2.2.4)
'@vue-flow/background':
specifier: ^1.3.2
version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.26))(vue@3.5.26)
'@vue-flow/controls':
specifier: ^1.1.3
version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.26))(vue@3.5.26)
'@vue-flow/core':
specifier: ^1.48.2
version: 1.48.2(vue@3.5.26)
'@vueup/vue-quill':
specifier: 1.2.0
version: 1.2.0(vue@3.5.26)
@ -159,9 +162,6 @@ packages:
'@antfu/utils@0.7.10':
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
'@antv/hierarchy@0.6.14':
resolution: {integrity: sha512-V3uknf7bhynOqQDw2sg+9r9DwZ9pc6k/EcqyTFdfXB1+ydr7urisP0MipIuimucvQKN+Qkd+d6w601r1UIroqQ==}
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
@ -440,14 +440,6 @@ packages:
'@lezer/lr@1.4.10':
resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
'@logicflow/core@2.2.4':
resolution: {integrity: sha512-lupgH9pUJ5cv3JB1+U+ahVwgQJyp1QBK6t3TXGOczzL6FwE53z8DYBj91WQQ2dFgDGShdcp4oSY1ZHMDhRZvmQ==}
'@logicflow/extension@2.3.0':
resolution: {integrity: sha512-7PGiWlGJ8oqXvHkfrr7vaMnLZgp4Ue6+TD371p8IxK2UbDQ6uWcJOoyij0pRpseU3+rL9oVyPo8qtIODKiaENQ==}
peerDependencies:
'@logicflow/core': 2.2.4
'@marijn/find-cluster-break@1.0.3':
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
@ -751,6 +743,23 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@vue-flow/background@1.3.2':
resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/controls@1.1.3':
resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/core@1.48.2':
resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==}
peerDependencies:
vue: ^3.3.0
'@vue/compiler-core@3.5.26':
resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==}
@ -999,9 +1008,6 @@ packages:
resolution: {integrity: sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==}
engines: {node: '>=0.10.0'}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
clipboard@2.0.11:
resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==}
@ -1102,6 +1108,44 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
d@1.0.2:
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
engines: {node: '>=0.12'}
@ -1582,9 +1626,6 @@ 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==}
@ -1927,9 +1968,6 @@ packages:
mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
medium-editor@5.23.3:
resolution: {integrity: sha512-he9/TdjX8f8MGdXGfCs8AllrYnqXJJvjNkDKmPg3aPW/uoIrlRqtkFthrwvmd+u4QyzEiadhCCM0EwTiRdUCJw==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@ -1978,26 +2016,9 @@ packages:
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
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==}
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
mousetrap@1.6.5:
resolution: {integrity: sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@ -2162,14 +2183,6 @@ packages:
resolution: {integrity: sha512-spBB5sgC4cv2YcW03f/IAUN1pgDJWNWD8FzkyY4mArLUMJW+KlQhlmUdKAHQuPfb00Jl5xIfImeOsf6YL8QK7Q==}
engines: {node: '>=0.10.0'}
preact@10.29.7:
resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==}
peerDependencies:
preact-render-to-string: '>=5'
peerDependenciesMeta:
preact-render-to-string:
optional: true
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
@ -2200,9 +2213,6 @@ packages:
resolution: {integrity: sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==}
engines: {npm: '>=8.2.3'}
rangy@1.3.2:
resolution: {integrity: sha512-fS1C4MOyk8T+ZJZdLcgrukPWxkyDXa+Hd2Kj+Zg4wIK71yrWgmjzHubzPMY1G+WD9EgGxMp3fIL0zQ1ickmSWA==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@ -2758,10 +2768,6 @@ packages:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
vanilla-jsoneditor@3.13.0:
resolution: {integrity: sha512-u9C+D8Ocgr39Gnsfu7USjilfKssqbDqZehMdE0qbwIFnZFdNwkU4iKeEvJxvYbmlVi/sMLxMTrU14PhtXVyiZg==}
@ -2972,8 +2978,6 @@ snapshots:
'@antfu/utils@0.7.10': {}
'@antv/hierarchy@0.6.14': {}
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
@ -3199,33 +3203,6 @@ snapshots:
dependencies:
'@lezer/common': 1.5.2
'@logicflow/core@2.2.4':
dependencies:
classnames: 2.5.1
lodash-es: 4.18.1
mobx: 5.15.7
mobx-preact: 3.0.0(mobx@5.15.7)(preact@10.29.7)
mobx-utils: 5.6.2(mobx@5.15.7)
mousetrap: 1.6.5
preact: 10.29.7
uuid: 9.0.1
transitivePeerDependencies:
- preact-render-to-string
'@logicflow/extension@2.3.0(@logicflow/core@2.2.4)':
dependencies:
'@antv/hierarchy': 0.6.14
'@logicflow/core': 2.2.4
classnames: 2.5.1
lodash-es: 4.18.1
medium-editor: 5.23.3
mobx: 5.15.7
preact: 10.29.7
rangy: 1.3.2
vanilla-picker: 2.12.3
transitivePeerDependencies:
- preact-render-to-string
'@marijn/find-cluster-break@1.0.3': {}
'@nodelib/fs.scandir@2.1.5':
@ -3428,6 +3405,27 @@ snapshots:
vite: 6.4.1(@types/node@26.1.2)(sass-embedded@1.97.2)(sass@1.97.2)(yaml@2.9.0)
vue: 3.5.26
'@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.26))(vue@3.5.26)':
dependencies:
'@vue-flow/core': 1.48.2(vue@3.5.26)
vue: 3.5.26
'@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.26))(vue@3.5.26)':
dependencies:
'@vue-flow/core': 1.48.2(vue@3.5.26)
vue: 3.5.26
'@vue-flow/core@1.48.2(vue@3.5.26)':
dependencies:
'@vueuse/core': 10.11.1(vue@3.5.26)
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
vue: 3.5.26
transitivePeerDependencies:
- '@vue/composition-api'
'@vue/compiler-core@3.5.26':
dependencies:
'@babel/parser': 7.29.7
@ -3749,8 +3747,6 @@ snapshots:
isobject: 3.0.1
static-extend: 0.1.2
classnames@2.5.1: {}
clipboard@2.0.11:
dependencies:
good-listener: 1.2.2
@ -3847,6 +3843,42 @@ snapshots:
csstype@3.2.3: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
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
d3-zoom@3.0.0:
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)
d@1.0.2:
dependencies:
es5-ext: 0.10.64
@ -4443,8 +4475,6 @@ snapshots:
he@1.2.0: {}
hoist-non-react-statics@2.5.5: {}
hookable@5.5.3: {}
htmlparser2@3.10.1:
@ -4763,8 +4793,6 @@ snapshots:
mdn-data@2.0.14: {}
medium-editor@5.23.3: {}
memoize-one@6.0.0: {}
merge-options@1.0.1:
@ -4824,25 +4852,11 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.4
mobx-preact@3.0.0(mobx@5.15.7)(preact@10.29.7):
dependencies:
hoist-non-react-statics: 2.5.5
mobx: 5.15.7
preact: 10.29.7
mobx-utils@5.6.2(mobx@5.15.7):
dependencies:
mobx: 5.15.7
mobx@5.15.7: {}
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
marked: 14.0.0
mousetrap@1.6.5: {}
ms@2.0.0: {}
ms@2.1.3: {}
@ -5008,8 +5022,6 @@ snapshots:
posthtml-parser: 0.2.1
posthtml-render: 1.4.0
preact@10.29.7: {}
proto-list@1.2.4: {}
proxy-from-env@1.1.0: {}
@ -5044,8 +5056,6 @@ snapshots:
parchment: 3.0.0
quill-delta: 5.1.0
rangy@1.3.2: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@ -5732,8 +5742,6 @@ snapshots:
uuid@11.1.1: {}
uuid@9.0.1: {}
vanilla-jsoneditor@3.13.0:
dependencies:
'@codemirror/autocomplete': 6.20.3

View File

@ -62,7 +62,7 @@ export function getInst(query) {
}
// 查询任务实例日志
export function getLogicFlow(query) {
export function getFlowRuntimeLog(query) {
return request({
url: '/test/inst/log/query',
method: 'get',

View File

@ -44,6 +44,8 @@ import ImageUpload from "@/components/ImageUpload"
import ImagePreview from "@/components/ImagePreview"
// 字典标签组件
import DictTag from '@/components/DictTag'
import { JsonViewer } from 'vue3-json-viewer'
import 'vue3-json-viewer/dist/index.css'
import ace from 'ace-builds'
import modeJsonUrl from 'ace-builds/src-noconflict/mode-json?url'
@ -71,6 +73,7 @@ app.component('ImageUpload', ImageUpload)
app.component('ImagePreview', ImagePreview)
app.component('RightToolbar', RightToolbar)
app.component('Editor', Editor)
app.component('JsonViewer', JsonViewer)
app.use(router)
app.use(store)

View File

@ -135,18 +135,18 @@ export const constantRoutes = [
hidden: true,
},
{
//logicFlow
// 流程运行状态
path: "/logic_flow",
component: () =>
import("@/views/device/register/components/LogicFlow/index.vue"),
meta: { title: "logicFlow", noLogin: true },
import("@/views/device/register/components/VueFlow/index.vue"),
meta: { title: "流程运行状态", noLogin: true },
hidden: true,
},
{
//logicFlow
// 流程编排
path: "/flow",
component: () => import("@/views/flow/index.vue"),
meta: { title: "flow", noLogin: true },
meta: { title: "流程编排", noLogin: true },
hidden: true,
},
{

View File

@ -1,105 +0,0 @@
import { getFlowById } from '@/api/device/register'
const filterNodeRedData = async (id) => {
let nodeRedData = []
const data = await getFlowById(id)
if (data.nodes) {
nodeRedData = data.nodes
} else {
console.error('请求失败')
}
return nodeRedData
}
const mapNodeType = (nodeRedType) => {
const typeMap = {
'group': 'customGroup', // Node-RED的inject节点转为圆形
// 'inject': 'circle', // Node-RED的inject节点转为圆形
// 'debug': 'diamond', // debug节点转为菱形
// 'function': 'rect' // 函数节点转为矩形
};
return typeMap[nodeRedType] || 'CustomNode'; // 默认矩形
}
export const convertNodeRedToLogicFlow = async (id) => {
const nodeRedData = await filterNodeRedData(id)
// 节点数据
const nodes = nodeRedData.map((node) => {
return {
id: node.id, // 或生成新ID`node-${index}`
type: mapNodeType(node.type), // 映射节点类型
x: node.x * 2,
y: node.y * 2,
text: node.name || node.type, // 使用节点名称或类型作为文本
children: node.type === 'group' ? node.nodes : null,
properties: {
...node,
state: 'unexecuted',
}
};
});
const edges = []
// 连线数据
nodeRedData.forEach((item) => {
if (item.wires && item.wires.length > 0) {
item.wires.forEach((w, index) => {
w.forEach((id) => {
const obj = {
id: `edge-${item.id}-${id}`, // 或使用唯一标识生成方式
type: 'bezier',
sourceNodeId: item.id,
targetNodeId: id,
sourceAnchorId: item.id + '_1',
targetAnchorId: id + '_3'
}
edges.push(obj)
})
})
}
})
return { nodes, edges }
}
export const recursiveFilter = (data, id, result = [], type = 'customGroup') => {
data.forEach((item) => {
if (item.type === type && item.properties.nodes && item.properties.nodes.includes(id)) {
result.push(item)
recursiveFilter(data, item.id, result)
}
});
return result
}
export const changeOtherNodeState = (nodes, lf) => {
const filterArr = ['inject', 'cmvrLoop', 'startCamera', 'getImage', 'stopCamera', 'startVideo', 'stopVideo', 'end']
const loopNodes = nodes.filter(item => {
return !filterArr.includes(item.properties.type)
})
loopNodes.forEach(l => {
nodes.forEach(n => {
if (l.properties.wires[0].includes(n.id)) {
const { state } = lf.getProperties(n.id)
if (state !== 'unexecuted') {
lf.setProperties(l.id, {
state: 'success'
})
}
}
if (l.properties?.wires[1]?.includes(n.id)) {
const { state } = lf.getProperties(n.id)
if (state !== 'failed') {
lf.setProperties(l.id, {
state: 'running'
})
} else {
lf.setProperties(l.id, {
state: 'failed'
})
}
}
})
})
}

View File

@ -0,0 +1,76 @@
import { getFlowById } from "@/api/device/register";
const loadNodeRedData = async (id) => {
const data = await getFlowById(id);
return Array.isArray(data?.nodes) ? data.nodes : [];
};
export const convertNodeRedToVueFlow = async (id) => {
const source = await loadNodeRedData(id);
const nodes = source.map((node) => {
const isGroup = node.type === "group";
const width = isGroup ? Number(node.w || 300) + 80 : 200;
const height = isGroup ? Number(node.h || 180) + 80 : 100;
const center = { x: Number(node.x || 0) * 2, y: Number(node.y || 0) * 2 };
return {
id: node.id,
type: isGroup ? "runtime-group" : "runtime-node",
position: { x: center.x - width / 2, y: center.y - height / 2 },
width,
height,
draggable: false,
selectable: true,
connectable: false,
deletable: false,
zIndex: isGroup ? -1 : 1,
data: {
text: node.name || node.type,
properties: { ...node, state: "unexecuted" },
children: isGroup ? node.nodes || [] : [],
},
};
});
const edges = [];
source.forEach((node) => {
(node.wires || []).forEach((targets) => {
(targets || []).forEach((targetId) => {
edges.push({
id: `edge-${node.id}-${targetId}`,
source: node.id,
target: targetId,
sourceHandle: `${node.id}_1`,
targetHandle: `${targetId}_3`,
type: "bezier",
animated: true,
selectable: true,
focusable: true,
interactionWidth: 32,
markerEnd: {
type: "arrowclosed",
color: "#2f80ed",
width: 20,
height: 20,
},
style: { stroke: "#2f80ed", strokeWidth: 2 },
});
});
});
});
return { nodes, edges };
};
export const updateDerivedNodeStates = (nodes, getProperties, setProperties) => {
const ignored = ["inject", "cmvrLoop", "startCamera", "getImage", "stopCamera", "startVideo", "stopVideo", "end"];
nodes.filter((node) => !ignored.includes(node.data.properties.type)).forEach((current) => {
nodes.forEach((target) => {
if (current.data.properties.wires?.[0]?.includes(target.id) && getProperties(target.id).state !== "unexecuted") {
setProperties(current.id, { state: "success" });
}
if (current.data.properties.wires?.[1]?.includes(target.id)) {
setProperties(current.id, { state: getProperties(target.id).state === "failed" ? "failed" : "running" });
}
});
});
};

View File

@ -1,103 +0,0 @@
import { GroupNodeModel, GroupNode } from '@logicflow/extension'
import { recursiveFilter } from "@/utils/convertNodeRedToLogicFlow";
class CustomGroupModel extends GroupNodeModel {
initNodeData(data) {
super.initNodeData(data);
this.children = new Set(data.children || [])
this.zIndex = 0;
this.width = data.w + 80; // 增加内边距
this.height = data.h + 80;
this.x = data.x;
this.y = data.y;
setTimeout(() => {
this.updateSize()
const nodes = this.graphModel.nodes;
const arr = recursiveFilter(nodes, this.id)
arr.forEach((item) => {
item.updateSize()
})
}, 0)
}
setAttributes() {
this.text.editable = false;
}
getTextStyle() {
const style = super.getTextStyle();
style.fill = this.properties.style?.color || '#333';
style.fontSize = 16;
return style;
}
updateSize() {
const children = []
this.children.forEach((id) => {
children.push(this.graphModel.getNodeModelById(id))
});
if (children.length === 0) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
children.forEach((node) => {
minX = Math.min(minX, node.x - node.width / 2);
maxX = Math.max(maxX, node.x + node.width / 2);
minY = Math.min(minY, node.y - node.height / 2);
maxY = Math.max(maxY, node.y + node.height / 2);
});
this.width = maxX - minX + 80; // 增加内边距
this.height = maxY - minY + 80;
this.x = (minX + maxX) / 2;
this.y = (minY + maxY) / 2;
this.setTextCoordinate(this.width, this.height, this.x, this.y)
}
setTextCoordinate(width, height, x, y) {
const strategies = {
"n": () => {
this.text.x = x
this.text.y = y - height / 2 + 20
},
"ne": () => {
this.text.x = x + width / 2 - 20
this.text.y = y - height / 2 + 20
},
"sw": () => {
this.text.x = x - width / 2 + 20
this.text.y = y + height / 2 - 20
},
"s": () => {
this.text.x = x
this.text.y = y + height / 2 - 20
},
"se": () => {
this.text.x = x + width / 2 - 20
this.text.y = y + height / 2 - 20
},
"default": () => {
this.text.x = x - width / 2 + 20
this.text.y = y - height / 2 + 20
}
};
const labelPosition = this.properties.style?.['label-position'] || 'default'
const action = strategies[labelPosition]
action();
}
removeChild(id) {
this.children.delete(id) // 现在可安全调用
}
}
// 导出方法注册
export function registerCustomGroup(lf) {
lf.register({
type: "customGroup",
view: GroupNode,
model: CustomGroupModel
})
}

View File

@ -1,134 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from '@logicflow/core';
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h } from 'vue';
import ElementPlus from 'element-plus'
// 导入 Vue 组件
import CustomFlow from './customFlow.vue';
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class CustomFlowHtmlNode extends HtmlNode {
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.isMounted = false;
// 创建 元素的渲染函数
this.r = h(CustomFlow, {
model: props.model,
graphModel: props.graphModel,
properties: {
...props.model.getProperties(),
},
text: props.model.text.value,
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
setHtml(rootEl) {
if (!this.isMounted) {
this.isMounted = true;
const node = document.createElement('div');
node.style.width = '100%'
node.style.height = '100%'
rootEl.appendChild(node);
this.app.use(ElementPlus) // 关键单独注册ElementPlus
this.app.mount(node);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class CustomFlowHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
// 仅允许从底部锚点连出
this.sourceRules.push({
message: '只能从输出锚点连线',
validate: (sourceNode, targetNode, sourceAnchor) =>
// sourceAnchor.name === 'right'
sourceAnchor.properties.connectionType === 'source'
});
// 仅允许连接到顶部锚点
this.targetRules.push({
message: '只能连接到输入锚点',
validate: (sourceNode, targetNode, targetAnchor) =>
// targetAnchor.name === 'left'
targetAnchor.properties.connectionType === 'target'
});
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
this.width = 200;
this.height = 100;
this.text.editable = false;
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this, x = _a.x, y = _a.y, width = _a.width, height = _a.height;
return [
// { x: x, y: y - height / 2, id: "".concat(this.id, "_0") },
{ x: x + width / 2, y: y, name: 'left', id: "".concat(this.id, "_1"), properties: { connectionType: 'target' } },
// { x: x, y: y + height / 2, id: "".concat(this.id, "_2") },
{ x: x - width / 2, y: y, name: 'right', id: "".concat(this.id, "_3"), properties: { connectionType: 'source' } },
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = 'none';
// style.hover.stroke = 'none';
return style;
}
}
// 导出方法注册
export function registerCustomNode(lf) {
lf.register({
type: "CustomNode",
view: CustomFlowHtmlNode,
model: CustomFlowHtmlModel
})
}

View File

@ -1,294 +0,0 @@
<template>
<div class="flow_container">
<div class="btn_container">
<div class="instState">实例状态<span :class="instStatus">{{ stateMap[instStatus]}}</span></div>
<el-button key=" translateCenter" type="primary" @click="() => lfRef.translateCenter()">居中</el-button>
<el-button key="fitView" type="primary" @click="() => lfRef.fitView()">适应屏幕</el-button>
<el-button v-if="['RUNNING','PAUSED'].includes(instStatus)" type="primary" @click="stopFlow">停止</el-button>
<el-button v-if="instStatus === 'RUNNING'" type="primary" @click="pauseFlow">暂停</el-button>
<el-button v-if="instStatus === 'PAUSED'" type="primary" @click="restartFlow">恢复</el-button>
</div>
<div ref="containerRef" class="containerRef"></div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import LogicFlow from "@logicflow/core";
import { Group } from "@logicflow/extension";
import "@logicflow/core/es/index.css";
import { convertNodeRedToLogicFlow, recursiveFilter, changeOtherNodeState } from '@/utils/convertNodeRedToLogicFlow'
import { registerCustomNode } from "./customFlowNode";
import { registerCustomGroup } from "./CustomGroup";
import { getLogicFlow } from '@/api/test/log'
import { pauseInst, resumeInst, stopInst } from '@/api/device/terminal'
import { ElMessage } from 'element-plus'
const containerRef = ref(null);
const lfRef = ref(null);
//
const queryParams = new URLSearchParams(window.location.search);
const instId = queryParams.get('instId') || '';
const taskId = queryParams.get('taskId') || '';
const itemId = queryParams.get('itemId') || '03e0842c2fd7ab7575636a473aeefc89';
const data = {
nodes: [
{
id: "custom-node-1",
text: "node-1",
type: "CustomNode",
x: 100,
y: 100,
},
{
id: 'custom-node-2',
type: 'CustomNode',
x: 300,
y: 300,
text: 'node-2',
},
],
//
edges: [
{
type: 'polyline',
sourceNodeId: 'custom-node-1',
targetNodeId: 'custom-node-2',
},
],
};
const dataNodes = ref([])
const loadNodeRedData = async () => {
const {nodes, edges} = await convertNodeRedToLogicFlow(itemId)
lfRef.value.render({
nodes,
edges
})
dataNodes.value = nodes
setTimeout(() => {
lfRef.value.fitView()
}, 20)
getLog()
}
const getLog = async () => {
const res = await getLogicFlow({
instId,
taskId,
itemId
})
if (res.code === 200) {
const arr = res.data.filter(item => {
return item.nodeType
})
const map = new Map()
arr.forEach(item => {
if (['inject', 'start'].includes(item.nodeType)) { // , 'end'
map.set(item.nodeId, {
state: 'success',
nodeType: item.nodeType,
input: item.params
})
} else {
let obj
if (map.has(item.nodeId)) {
obj = map.get(item.nodeId)
} else {
obj = {
nodeType: item.nodeType,
}
}
if (item.paramsType === 'INPUT') {
obj.state = item.instStatus.toLowerCase(),
obj.input = item.instStatus === 'FAILED' ? item.params : item.message
} else {
obj.state = item.instStatus.toLowerCase() === 'running' ? 'success' : item.instStatus.toLowerCase(),
obj.output = item.instStatus === 'FAILED' ? item.message : item.params
}
map.set(item.nodeId, obj)
}
});
for (const [key, value] of map) {
lfRef.value.setProperties(key, value)
}
changeOtherNodeState(dataNodes.value, lfRef.value)
if (arr[arr.length - 1].nodeType !== 'end' && arr[arr.length - 1].instStatus !== 'FAILED') {
setTimeout(() => {
getLog()
}, 2000)
}
}
}
const stopFlow = async () => {
const res = await stopInst(instId)
if (res.code === 200) {
ElMessage.success('停止成功')
getInstDetail()
} else {
ElMessage.error(res.msg)
}
}
const pauseFlow = async () => {
const res = await pauseInst(instId)
if (res.code === 200) {
ElMessage.success('暂停成功')
getInstDetail()
} else {
ElMessage.error(res.msg)
}
}
const restartFlow = async () => {
const res = await resumeInst(instId)
if (res.code === 200) {
ElMessage.success('恢复成功')
getInstDetail()
} else {
ElMessage.error(res.msg)
}
}
const instStatus = ref('RUNNING')
const stateMap = {
SUCCESS: "已完成",
RUNNING: "执行中",
FAILED: "失败",
PAUSED: "已暂停",
STOPPED: "已终止"
};
const getInstDetail = async () => {
const res = await getLogicFlow({
instId,
taskId,
itemId
})
if (res.code === 200) {
const item = res.data[res.data.length - 1]
instStatus.value = item.instStatus
}
}
onMounted(() => {
if (containerRef.value) {
const lf = new LogicFlow({
container: containerRef.value,
isSilentMode: true,
adjustNodePosition: true,
stopZoomGraph: false,
stopScrollGraph: false,
stopMoveGraph: false,
autoExpand: true,
adjustEdgeStartAndEnd: true,
allowRotate: false,
edgeTextEdit: false,
keyboard: {
enabled: true,
},
partial: true,
background: {
color: "#FFFFFF",
},
grid: true,
edgeTextDraggable: false,
nodeTextEdit: false, //false
textEdit: false, //
style: {
inputText: {
background: "black",
color: "white",
},
},
idGenerator(type) {
return type + "_" + Math.random();
},
plugins: [Group],
group: {
foldable: true, //
foldSize: 30, //
},
});
//
registerCustomNode(lf)
registerCustomGroup(lf)
// data
lf.render(data);
lf.on('node:dragstart', (data, e) => {
lfRef.value.setProperties(data.data.id, {
nowTime: new Date().getTime()
})
})
//
lf.on("node:mousemove", ({ data, e }) => {
const { nodes } = lf.getGraphData();
const arr = recursiveFilter(nodes, data.id)
arr.forEach(item => {
lf.getNodeModelById(item.id).updateSize()
})
});
//
lf.on("blank:drop", () => {
getLog()
});
lfRef.value = lf;
}
loadNodeRedData()
getInstDetail()
});
</script>
<style lang="scss" scoped>
.flow_container {
width: 100%;
height: 100%;
position: relative;
.btn_container {
height: 60px;
display: flex;
align-items: center;
justify-content: end;
position: absolute;
right: 10px;
top: 0;
z-index: 2;
.instState {
margin-right: 12px;
font-size: 16px;
font-weight: bold;
.RUNNING, .PAUSED, .STOPPED {
color: #8bbf86;
}
.SUCCESS {
color: green;
}
.FAILED {
color: rgb(128, 41, 0);
}
}
}
:deep(.containerRef) {
width: 100%;
height: 100%;
.lf-graph {
width: 100% !important;
height: 100% !important;
}
}
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="runtime-group">
<span>{{ data.text || data.properties?.name || "分组" }}</span>
</div>
</template>
<script setup>
defineProps({ data: Object });
</script>
<style scoped lang="scss">
.runtime-group {
width: 100%;
height: 100%;
padding: 14px 18px;
box-sizing: border-box;
border: 1px dashed #91a4be;
border-radius: 8px;
background: rgba(234, 241, 250, 0.45);
color: #40536d;
font-size: 14px;
font-weight: 600;
}
</style>

View File

@ -0,0 +1,47 @@
<template>
<div class="runtime-node">
<Handle
:id="`${id}_3`"
type="target"
:position="Position.Left"
:connectable="false"
class="runtime-handle runtime-handle--target"
/>
<CustomFlow :properties="properties" :text="data.text" />
<Handle
:id="`${id}_1`"
type="source"
:position="Position.Right"
:connectable="false"
class="runtime-handle runtime-handle--source"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import { Handle, Position } from "@vue-flow/core";
import CustomFlow from "./customFlow.vue";
const props = defineProps({ id: String, data: Object });
const properties = computed(() => props.data.properties || {});
</script>
<style scoped lang="scss">
.runtime-node {
width: 100%;
height: 100%;
position: relative;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.12);
}
.runtime-handle {
width: 12px;
height: 12px;
border: 3px solid #fff;
background: #2f80ed;
&--target { left: -6px; }
&--source { right: -6px; }
}
</style>

View File

@ -0,0 +1,206 @@
<template>
<div class="runtime-flow-page">
<div class="runtime-toolbar">
<div class="instance-state">
实例状态<span :class="instStatus">{{ stateMap[instStatus] || instStatus }}</span>
</div>
<el-button @click="centerView">居中</el-button>
<el-button @click="fitGraph">适应屏幕</el-button>
<el-button v-if="['RUNNING', 'PAUSED'].includes(instStatus)" type="danger" plain @click="stopFlow">停止</el-button>
<el-button v-if="instStatus === 'RUNNING'" type="warning" plain @click="pauseFlow">暂停</el-button>
<el-button v-if="instStatus === 'PAUSED'" type="success" plain @click="restartFlow">恢复</el-button>
</div>
<VueFlow
id="runtime-flow"
class="runtime-canvas"
:nodes-draggable="false"
:nodes-connectable="false"
:edges-updatable="false"
:elements-selectable="true"
:delete-key-code="null"
:min-zoom="0.1"
:max-zoom="3"
:zoom-on-double-click="false"
@init="loadFlow"
@pane-click="closeNodeDetails"
>
<template #node-runtime-node="nodeProps">
<RuntimeNode v-bind="nodeProps" />
</template>
<template #node-runtime-group="nodeProps">
<RuntimeGroup v-bind="nodeProps" />
</template>
<Background color="#d9dee8" :gap="24" :size="1" />
<Controls position="bottom-right" :show-interactive="false" />
</VueFlow>
</div>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls";
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
import RuntimeNode from "./RuntimeNode.vue";
import RuntimeGroup from "./RuntimeGroup.vue";
import { convertNodeRedToVueFlow, updateDerivedNodeStates } from "@/utils/convertNodeRedToVueFlow";
import { getFlowRuntimeLog } from "@/api/test/log";
import { pauseInst, resumeInst, stopInst } from "@/api/device/terminal";
import { ElMessage } from "element-plus";
const runtimeFlow = useVueFlow("runtime-flow");
const queryParams = new URLSearchParams(window.location.search);
const instId = queryParams.get("instId") || "";
const taskId = queryParams.get("taskId") || "";
const itemId = queryParams.get("itemId") || "03e0842c2fd7ab7575636a473aeefc89";
const instStatus = ref("RUNNING");
const stateMap = {
SUCCESS: "已完成",
RUNNING: "执行中",
FAILED: "失败",
PAUSED: "已暂停",
STOPPED: "已终止",
};
let pollTimer = null;
let initialized = false;
const query = () => ({ instId, taskId, itemId });
const getProperties = (id) => runtimeFlow.findNode(id)?.data?.properties || {};
const setProperties = (id, patch) => {
const node = runtimeFlow.findNode(id);
if (!node) return;
runtimeFlow.updateNodeData(id, {
...node.data,
properties: { ...node.data.properties, ...patch },
}, { replace: true });
};
const schedulePoll = () => {
clearTimeout(pollTimer);
pollTimer = window.setTimeout(getLog, 2000);
};
const getLog = async () => {
const res = await getFlowRuntimeLog(query());
if (res.code !== 200) return;
const records = (res.data || []).filter((item) => item.nodeType);
if (!records.length) {
if (["RUNNING", "PAUSED"].includes(instStatus.value)) schedulePoll();
return;
}
const states = new Map();
records.forEach((item) => {
if (["inject", "start"].includes(item.nodeType)) {
states.set(item.nodeId, { state: "success", nodeType: item.nodeType, input: item.params });
return;
}
const current = states.get(item.nodeId) || { nodeType: item.nodeType };
if (item.paramsType === "INPUT") {
current.state = item.instStatus.toLowerCase();
current.input = item.instStatus === "FAILED" ? item.params : item.message;
} else {
current.state = item.instStatus === "RUNNING" ? "success" : item.instStatus.toLowerCase();
current.output = item.instStatus === "FAILED" ? item.message : item.params;
}
states.set(item.nodeId, current);
});
states.forEach((value, id) => setProperties(id, value));
updateDerivedNodeStates(runtimeFlow.getNodes.value, getProperties, setProperties);
const last = records.at(-1);
instStatus.value = last.instStatus || instStatus.value;
if (last.nodeType !== "end" && !["FAILED", "STOPPED", "SUCCESS"].includes(last.instStatus)) schedulePoll();
};
const loadFlow = async () => {
if (initialized) return;
initialized = true;
const { nodes, edges } = await convertNodeRedToVueFlow(itemId);
runtimeFlow.setNodes(nodes);
runtimeFlow.setEdges(edges);
await nextTick();
await runtimeFlow.fitView({ padding: 0.16, duration: 300 });
await getLog();
};
const getInstDetail = async () => {
const res = await getFlowRuntimeLog(query());
const last = res.code === 200 ? (res.data || []).at(-1) : null;
if (last?.instStatus) instStatus.value = last.instStatus;
};
const runControl = async (request, successMessage) => {
const res = await request(instId);
if (res.code === 200) {
ElMessage.success(successMessage);
await getInstDetail();
if (["RUNNING", "PAUSED"].includes(instStatus.value)) schedulePoll();
} else {
ElMessage.error(res.msg);
}
};
const stopFlow = () => runControl(stopInst, "停止成功");
const pauseFlow = () => runControl(pauseInst, "暂停成功");
const restartFlow = () => runControl(resumeInst, "恢复成功");
const fitGraph = () => runtimeFlow.fitView({ padding: 0.16, duration: 300 });
const centerView = () => runtimeFlow.fitView({ padding: 0.3, duration: 300 });
const closeNodeDetails = () => {
const nowTime = Date.now();
runtimeFlow.getNodes.value.forEach((node) => setProperties(node.id, { nowTime }));
};
onMounted(getInstDetail);
onUnmounted(() => clearTimeout(pollTimer));
</script>
<style scoped lang="scss">
.runtime-flow-page {
width: 100%;
height: 100%;
min-height: 620px;
position: relative;
overflow: hidden;
background: #f5f7fa;
}
.runtime-toolbar {
position: absolute;
z-index: 10;
top: 16px;
right: 16px;
min-height: 48px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #e1e6ee;
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.1);
:deep(.el-button + .el-button) { margin-left: 0; }
}
.instance-state {
margin-right: 8px;
color: #5a6472;
font-size: 14px;
font-weight: 600;
.RUNNING, .PAUSED { color: #d48806; }
.SUCCESS { color: #2b8a3e; }
.FAILED, .STOPPED { color: #c92a2a; }
}
.runtime-canvas {
width: 100%;
height: 100%;
}
</style>

View File

@ -86,7 +86,7 @@ const showState = ref('NORMAL')
watch(() => props.state, (newVal) => {
showState.value = newVal;
});
}, { immediate: true });
const closePopover = () => {
showState.value = 'NORMAL';

View File

@ -150,14 +150,14 @@ const execute = () => {
}
const handleInputFocus = (e) => {
// LogicFlow
//
e.stopPropagation()
e.preventDefault()
}
const handleInputKeydown = (e) => {
// Logic Flow
//
e.stopPropagation();
// Ctrl+V
if ((e.ctrlKey || e.metaKey) && e.key === "v") {

View File

@ -1,8 +1,3 @@
import { Group, SelectionSelect } from "@logicflow/extension";
import { registerCommon } from "./nodes/common/index";
import { registerFunction } from './nodes/function'
import cameraSvg from './icon/camera.svg'
import videoSvg from './icon/video.svg'
import loopSvg from './icon/loop.svg'
@ -19,66 +14,6 @@ import audioSvg from './icon/audio.svg'
import touchSvg from './icon/touch.svg'
import expressionSvg from './icon/expression.svg'
import { v4 as randomUUID } from 'uuid'
export const lfConfig = {
idGenerator: () => {
// 生成标准UUID并移除连字符
// return crypto.randomUUID().replace(/-/g, '');
return randomUUID().replace(/-/g, '')
},
background: {
backgroundColor: "#f6f8fa",
},
grid: false,
plugins: [Group, SelectionSelect],
animation: true,
adjustEdgeStartAndEnd: false,
// multipleSelectKey: "ctrl",
// disabledTools: ["multipleSelect"],
partial: true,
// group: {
// foldable: true, // 启用折叠功能
// foldSize: 30, // 折叠后显示的图标尺寸
// },
edgeType: "bezier",
zoomConfig: {
min: 0.05, // 最小缩放比例(支持更小值,如 0.01,但不建议过小)
max: 3, // 最大缩放比例(可自定义)
step: 0.1 // 每次滚轮缩放的步长(默认 0.1
},
style: {
anchor: {
show: true, // 强制全局锚点显示
hoverOn: false,
visibility: "visible",
fill: "#FF5722", // 实心绿色
stroke: "#FF5722", // 边框同色
strokeWidth: 0, // 消除边框
r: 7, // 锚点半径
hover: { fill: "#FF5722" }, // 禁用悬停变色
},
anchorLine: {
stroke: "#FF5722",
strokeWidth: 2,
strokeDasharray: "3,2",
},
bezier: {
stroke: "#FF5722",
strokeWidth: 2,
arrow: false, // 关闭箭头显示
animation: true, // 开启动画效果
animationSpeed: 3,
},
},
};
// 注册自定义节点
export const registerCustomizeNode = (lf) => {
registerCommon(lf)
registerFunction(lf)
};
const deviceOptions = () => {
return [{
value: 'cam1',

View File

@ -71,12 +71,45 @@
<Aside />
</el-aside>
<el-main>
<div
<VueFlow
id="main-flow"
class="flow__container"
ref="flowContainerRef"
:min-zoom="0.1"
:max-zoom="3"
:node-drag-threshold="0"
:nodes-draggable="!flowStore.disableForm"
:nodes-connectable="!flowStore.disableForm"
:edges-updatable="!flowStore.disableForm"
:elements-selectable="true"
:delete-key-code="null"
:multi-selection-key-code="['Control', 'Meta']"
:selection-key-code="groupSelectionPending ? true : 'Shift'"
:pan-on-drag="!groupSelectionPending"
:zoom-on-double-click="false"
:default-edge-options="defaultEdgeOptions"
@connect="handleConnect"
@node-double-click="handleNodeDoubleClick"
@node-drag-start="handleNodeDragStart"
@node-drag="handleNodeDrag"
@node-drag-stop="handleNodeDragStop"
@nodes-change="handleNodesChange"
@edges-change="handleEdgesChange"
@edge-click="handleEdgeClick"
@pane-click="handlePaneClick"
@selection-end="handleSelectionEnd"
@dragover="dragover"
@drop="drop"
></div>
>
<template #node-flow-node="nodeProps">
<FlowNode v-bind="nodeProps" />
</template>
<template #edge-flow-edge="edgeProps">
<FlowEdge v-bind="edgeProps" />
</template>
<Background color="#d9dee8" :gap="24" :size="1" />
<Controls position="bottom-right" />
</VueFlow>
</el-main>
</el-container>
</el-container>
@ -170,12 +203,16 @@
</el-dialog>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from "vue";
import LogicFlow from "@logicflow/core";
import "@logicflow/core/es/index.css";
import "@logicflow/core/lib/style/index.css";
import "@logicflow/extension/lib/style/index.css";
import { lfConfig, registerCustomizeNode } from "./config";
import { computed, reactive, ref, onMounted, onUnmounted, nextTick } from "vue";
import { MarkerType, VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls";
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
import FlowNode from "./vue-flow/FlowNode.vue";
import FlowEdge from "./vue-flow/FlowEdge.vue";
import { createFlowFacade } from "./vue-flow/adapter";
import TestRun from "./components/TestRun.vue";
import ParamsDrawer from "./components/ParamsDrawer.vue";
import Aside from "./components/Aside.vue";
@ -196,6 +233,26 @@ import { Location } from "@element-plus/icons-vue";
const flowContainerRef = ref(null);
const lfRef = ref(null);
const vueFlow = useVueFlow("main-flow");
const lf = createFlowFacade(vueFlow);
const loopChildDragContexts = new Map();
window.lf = lf;
const defaultEdgeOptions = {
type: "flow-edge",
animated: true,
selectable: true,
focusable: true,
deletable: true,
interactionWidth: 32,
markerEnd: {
type: MarkerType.ArrowClosed,
color: "#ff6b35",
width: 20,
height: 20,
},
style: { stroke: "#ff6b35", strokeWidth: 2 },
};
const dragover = (e) => {
e.preventDefault();
@ -203,12 +260,14 @@ const dragover = (e) => {
const drop = (e) => {
let node = e.dataTransfer.getData("flowNode");
if (!node) return;
node = JSON.parse(node);
if (["stopLoop", "subEnd", "subStart", "currentLoop"].includes(node.type)) {
const point = lf.getPointByClient(e.clientX, e.clientY);
const loopNode = findLoopAtPoint(point.canvasOverlayPosition);
if (["stopLoop", "subEnd", "subStart", "currentLoop"].includes(node.type) && !loopNode) {
ElMessage.warning("此节点只能拖拽到循环节点的循环体中");
return;
}
const point = lf.getPointByClient(e.clientX, e.clientY);
const { type, ...other } = node;
const newNode = lf.addNode({
type,
@ -218,6 +277,9 @@ const drop = (e) => {
...other,
},
});
if (loopNode && !["start", "end", "loop", "selectArea"].includes(newNode.type)) {
lf.addToGroup(loopNode.id, newNode.id, point.canvasOverlayPosition);
}
if (
!["selectArea", "branch", "stopLoop", "subStart", "subEnd"].includes(newNode.type)
@ -227,6 +289,112 @@ const drop = (e) => {
}
};
const handleConnect = (connection) => {
if (!connection.source || !connection.target) return;
const edge = lf.addEdge({
type: "bezier",
sourceNodeId: connection.source,
targetNodeId: connection.target,
sourceAnchorId: connection.sourceHandle || `${connection.source}_1`,
targetAnchorId: connection.targetHandle || `${connection.target}_3`,
});
lf.emit("edge:add", { data: edge });
};
const handleNodeDoubleClick = ({ node }) => {
const data = lf.getGraphData().nodes.find((item) => item.id === node.id);
if (!data || ["selectArea", "branch", "stopLoop", "subStart", "subEnd", "end"].includes(data.type)) return;
showParamsDrawer.value = true;
paramsDrawerData.value = data;
};
const findLoopAtPoint = (point) => {
return (vueFlow.getNodes.value || []).find((candidate) => {
if (candidate.data?.flowType !== "loop") return false;
const groupPosition = candidate.computedPosition || candidate.position;
const groupWidth = Number(candidate.dimensions?.width || candidate.data?.size?.width || 0);
const groupHeight = Number(candidate.dimensions?.height || candidate.data?.size?.height || 0);
return point.x >= groupPosition.x + 24
&& point.x <= groupPosition.x + groupWidth - 24
&& point.y >= groupPosition.y + 112
&& point.y <= groupPosition.y + groupHeight - 24;
});
};
const findContainingLoop = (node) => {
const position = node.computedPosition || node.position;
const width = Number(node.dimensions?.width || node.data?.size?.width || 0);
const height = Number(node.dimensions?.height || node.data?.size?.height || 0);
return findLoopAtPoint({ x: position.x + width / 2, y: position.y + height / 2 });
};
const eventClientPoint = (event) => {
const pointer = event?.touches?.[0] || event?.changedTouches?.[0] || event;
if (!Number.isFinite(pointer?.clientX) || !Number.isFinite(pointer?.clientY)) return null;
return { x: pointer.clientX, y: pointer.clientY };
};
const handleNodeDragStart = ({ node, event }) => {
const drag = lf.prepareLoopChildDrag(node.id);
const clientPoint = eventClientPoint(event);
if (!drag || !clientPoint) return;
const pointer = lf.getPointByClient(clientPoint.x, clientPoint.y).canvasOverlayPosition;
loopChildDragContexts.set(node.id, {
groupId: drag.groupId,
offset: { x: pointer.x - drag.position.x, y: pointer.y - drag.position.y },
});
};
const updateLoopChildDrag = ({ node, event }) => {
const drag = loopChildDragContexts.get(node.id);
const clientPoint = eventClientPoint(event);
if (!drag || !clientPoint) return;
const pointer = lf.getPointByClient(clientPoint.x, clientPoint.y).canvasOverlayPosition;
lf.layoutLoopDuringDrag(drag.groupId, node.id, {
x: pointer.x - drag.offset.x,
y: pointer.y - drag.offset.y,
});
};
const handleNodeDrag = (payload) => updateLoopChildDrag(payload);
const handleNodeDragStop = (payload) => {
const { node } = payload;
updateLoopChildDrag(payload);
loopChildDragContexts.delete(node.id);
const vueNode = vueFlow.findNode(node.id);
if (
vueNode
&& !vueNode.parentNode
&& !["start", "end", "loop", "selectArea"].includes(vueNode.data?.flowType)
) {
const loopNode = findContainingLoop(vueNode);
if (loopNode) lf.addToGroup(loopNode.id, vueNode.id);
}
const graph = lf.getGraphData();
const groups = recursiveFilter(graph.nodes, node.id, ["loop", "selectArea"]);
operationEdge(groups);
lf.markChanged();
};
const handleNodesChange = (changes) => {
if (changes.some((change) => ["add", "remove"].includes(change.type) || (change.type === "position" && change.dragging === false))) {
lf.markChanged();
}
};
const handleEdgesChange = (changes) => {
if (changes.some((change) => ["add", "remove"].includes(change.type))) lf.markChanged();
};
const handleEdgeClick = ({ event, edge }) => {
event?.stopPropagation?.();
lf.selectElementById(edge.id);
};
const handlePaneClick = () => lf.clearSelectElements();
const flowStore = useFlowStore();
flowStore.getDeviceList();
@ -525,170 +693,77 @@ const justLoadFlow = () => {
}
};
let flowInitialized = false;
const initFlow = () => {
if (flowContainerRef.value) {
const lf = new LogicFlow({
container: flowContainerRef.value,
...lfConfig,
zoomOptions: {
initScale: 0.5, // 50%
minScale: 0.1, // 10%
maxScale: 3, // 300%
},
keyboard: {
enabled: true,
shortcuts: [
{
keys: ["backspace", "delete"], //
callback: () => {
const activeElem = document.activeElement;
if (["INPUT", "TEXTAREA"].includes(activeElem.tagName)) {
return false
}
if (flowInitialized) return;
flowInitialized = true;
lfRef.value = lf;
loadFlowData(flowInfoData.value.itemId, lf);
const { nodes, edges } = lf.getSelectElements();
if (nodes.length > 0) {
//
const hasProtectedNode = nodes.some((node) =>
["start", "end"].includes(node.type)
);
if (hasProtectedNode) {
lf.selectElementById(null); //
return false; //
}
//
nodes.forEach((node) => {
if (node.type === "selectArea") {
lf.getNodeModelById(node.id).children = [];
node.children.forEach((item) => {
lf.getNodeModelById(item).draggable = true;
});
}
lf.deleteNode(node.id);
//
lf.deleteEdgeByNodeId(node.id);
});
return false;
}
if (nodes.length === 0 && edges.length > 0) {
edges.forEach((node) => {
//
lf.deleteEdge(node.id);
});
}
},
},
{
keys: ["ctrl+z"], //
callback: () => {
return true;
},
},
{
keys: ["ctrl+shift+z"],
callback: () => true,
},
],
},
});
registerCustomizeNode(lf);
loadFlowData(flowInfoData.value.itemId, lf);
lf.on("edge:add", ({ data }) => {
validEdge(data);
});
//
lf.on("history:change", ({ data }) => {
if (flowStore.disableForm) {
return;
}
const flowData = lf.getGraphData();
if (JSON.stringify(oldFlowData) === JSON.stringify(flowData)) {
return;
}
lf.on("edge:add", ({ data }) => validEdge(data));
lf.on("history:change", () => {
if (flowStore.disableForm) return;
const flowData = lf.getGraphData();
if (JSON.stringify(oldFlowData) !== JSON.stringify(flowData)) {
flowState.value = "updateData";
});
//
lf.on("node:mousemove", ({ data, e }) => {
const { nodes } = lf.getGraphData();
const arr = recursiveFilter(nodes, data.id, "loop");
arr.forEach((item) => {
const node = lf.getNodeModelById(item.id);
node.updateSize();
});
operationEdge(arr);
});
lf.on("node:mouseleave", ({ data }) => {
const node = lf.getNodeModelById(data.id);
if (node.setCustomProperties) {
node.setCustomProperties();
}
});
lf.on("node:dbclick", ({ data, e }) => {
if (
["selectArea", "branch", "stopLoop", "subStart", "subEnd", "end"].includes(data.type)
) {
return;
}
showParamsDrawer.value = true;
paramsDrawerData.value = data;
});
lfRef.value = lf;
window.lf = lf;
}
}
});
};
const group = () => {
const groupSelectionPending = ref(false);
const createGroup = (selectedNodes) => {
const nodes = selectedNodes.filter((item) => !["selectArea", "loop"].includes(item.type));
if (!nodes.length) {
groupSelectionPending.value = false;
return;
}
const PADDING = 200;
lf.extension.selectionSelect.openSelectionSelect();
lf.once("selection:selected", (data) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const children = [];
let children = [];
data.elements.forEach((item) => {
if (item.BaseType === "node") {
minX = Math.min(minX, item.x - item.width / 2);
minY = Math.min(minY, item.y - item.height / 2);
maxX = Math.max(maxX, item.x + item.width / 2);
maxY = Math.max(maxY, item.y + item.height / 2);
children.push(item.id);
lf.getNodeModelById(item.id).draggable = false;
}
});
if (children.length === 0) {
lf.clearSelectElements();
lf.extension.selectionSelect.closeSelectionSelect();
return;
}
let width = maxX - minX + PADDING;
let height = maxY - minY + PADDING;
lf.addNode({
type: "selectArea",
x: minX + (maxX - minX) / 2,
y: minY + (maxY - minY) / 2,
properties: {
width,
height,
},
children,
});
lf.clearSelectElements();
lf.extension.selectionSelect.closeSelectionSelect();
nodes.forEach((item) => {
const model = lf.getNodeModelById(item.id);
const width = model?.width || 372;
const height = model?.height || 180;
minX = Math.min(minX, item.x - width / 2);
minY = Math.min(minY, item.y - height / 2);
maxX = Math.max(maxX, item.x + width / 2);
maxY = Math.max(maxY, item.y + height / 2);
children.push(item.id);
if (model) model.draggable = false;
});
lf.addNode({
type: "selectArea",
x: minX + (maxX - minX) / 2,
y: minY + (maxY - minY) / 2,
properties: {
name: "分组",
action: "NONE",
width: maxX - minX + PADDING,
height: maxY - minY + PADDING,
},
children,
});
lf.clearSelectElements();
groupSelectionPending.value = false;
nextTick(() => lf.fitView());
};
const group = () => {
const { nodes } = lf.getSelectElements();
if (nodes.length) createGroup(nodes);
else groupSelectionPending.value = true;
};
const handleSelectionEnd = () => {
if (!groupSelectionPending.value) return;
createGroup(lf.getSelectElements().nodes);
};
const treeData = ref(null);
@ -774,6 +849,48 @@ const clearRun = () => {
});
}
const handleDeleteKey = (event) => {
const activeElement = document.activeElement;
if (["INPUT", "TEXTAREA"].includes(activeElement?.tagName) || activeElement?.isContentEditable) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
if (flowStore.disableForm) return;
event.preventDefault();
if (event.shiftKey) lf.redo();
else lf.undo();
return;
}
if (!["Backspace", "Delete"].includes(event.key) || flowStore.disableForm) return;
const { nodes, edges } = lf.getSelectElements();
if (nodes.some((node) => ["start", "end"].includes(node.type))) {
lf.clearSelectElements();
return;
}
if (nodes.length) {
event.preventDefault();
nodes.forEach((node) => {
if (node.type === "selectArea") {
const model = lf.getNodeModelById(node.id);
const children = Array.from(model?.children || []);
model.children = [];
children.forEach((childId) => {
const child = lf.getNodeModelById(childId);
if (child) child.draggable = true;
});
}
lf.deleteNode(node.id);
emitter.emit("deleteNode", node.id);
});
return;
}
if (edges.length) {
event.preventDefault();
edges.forEach((edge) => lf.deleteEdge(edge.id));
}
};
const nodeErrorList = ref([]);
onMounted(() => {
@ -781,6 +898,7 @@ onMounted(() => {
initFlow();
document.addEventListener("singleNodeExecution", singleNodeExecution);
registerBeforeUnload();
window.addEventListener("keydown", handleDeleteKey);
emitter.on("openParamsDrawer", (node) => {
showParamsDrawer.value = true;
@ -809,11 +927,11 @@ onMounted(() => {
if (hasError) {
if (!nodeErrorList.value.includes(id)) {
nodeErrorList.value.push(id);
element.style.background = "#ff00001a";
if (element) element.style.background = "#ff00001a";
}
} else {
nodeErrorList.value = nodeErrorList.value.filter((item) => item !== id);
element.style.background = "#fff";
if (element) element.style.background = "#fff";
}
});
@ -832,16 +950,20 @@ const operationEdge = (arr) => {
};
onUnmounted(() => {
loopChildDragContexts.clear();
emitter.off("changeNodeState");
emitter.off("openParamsDrawer");
emitter.off("throwAnError");
emitter.off("deleteNode");
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("keydown", handleDeleteKey);
document.removeEventListener("singleNodeExecution", singleNodeExecution);
});
</script>
<style lang="scss" scoped>
$flow-canvas-cursor: url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20width=%2724%27%20height=%2724%27%20viewBox=%270%200%2024%2024%27%3E%3Cpath%20d=%27M4%202.5v16l4.2-4%203.1%207.1%203.4-1.5-3.1-7.1h5.8z%27%20fill=%27%23fff%27%20stroke=%27%231f2937%27%20stroke-width=%271.8%27%20stroke-linejoin=%27round%27/%3E%3C/svg%3E") 4 3, default;
.page {
width: 100%;
height: 100%;
@ -856,7 +978,9 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #ddd;
padding: 0 16px;
border-bottom: 1px solid #e3e7ee;
background: #fff;
.btn__container {
display: flex;
@ -879,39 +1003,68 @@ onUnmounted(() => {
margin: 0;
width: 260px;
padding: 0;
border-right: 1px solid #e3e7ee;
background: #f8fafc;
}
.flow__container {
width: 100%;
height: 100%;
cursor: $flow-canvas-cursor;
:deep(.lf-base) {
foreignObject {
overflow: visible !important;
}
:deep(.vue-flow__node) {
border: 0;
background: transparent;
cursor: $flow-canvas-cursor;
}
.lf-node-content {
.lf-node-anchor {
display: block !important; /* 强制显示 */
opacity: 1 !important; /* 取消透明度 */
}
}
:deep(.vue-flow__node .flow-node-shell),
:deep(.vue-flow__node .flow-node-content),
:deep(.vue-flow__node .node__container),
:deep(.vue-flow__node .right),
:deep(.vue-flow__node .editIcon),
:deep(.vue-flow__node .resultText) {
cursor: $flow-canvas-cursor;
}
.lf-node-selected {
.node__box {
border-color: #8dc99d;
}
:deep(.vue-flow__edge-path),
:deep(.vue-flow__connection-path) {
stroke: #ff6b35;
stroke-width: 2;
}
.node__container {
border-color: #8dc99d;
// pointer-events: none;
}
:deep(.vue-flow__pane) {
cursor: $flow-canvas-cursor;
}
.group__container {
border-color: #8dc99d;
// pointer-events: none;
}
}
:deep(.vue-flow__pane.dragging) {
cursor: $flow-canvas-cursor;
}
:deep(.vue-flow__handle) {
cursor: crosshair;
}
:deep(.vue-flow__edge) {
cursor: pointer;
}
:deep(.vue-flow__edge-interaction) {
cursor: pointer;
pointer-events: stroke;
}
:deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke: #1677ff;
stroke-width: 4;
filter: drop-shadow(0 0 3px rgba(22, 119, 255, 0.45));
}
:deep(.vue-flow__controls) {
overflow: hidden;
border: 1px solid #dfe4ec;
border-radius: 6px;
box-shadow: 0 6px 18px rgba(20, 33, 61, 0.12);
}
}
}

View File

@ -1,196 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
import EndNode from "./endNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class EndNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(EndNode, {
model: props.model,
graphModel: props.graphModel,
properties: {
...props.model.getProperties(),
},
text: props.model.text.value,
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this),
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus); // 关键单独注册ElementPlus
this.app.use(JsonViewer)
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data);
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class EndNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
// 验证表单
async validateForm() {
return { valid: true, message: "无验证方法" };
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x - width / 2,
y: y - 25,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerEndNode(lf) {
lf.register({
type: "end",
view: EndNodeHtmlNode,
model: EndNodeHtmlModel,
});
}

View File

@ -1,11 +0,0 @@
import { registerStartNode } from "./startNode";
import { registerEndNode } from "./endNode";
import { registerSelectAreaNode } from './selectAreaNode.js'
import { registerServiceNode } from './serviceNode.js'
export const registerCommon = (lf) => {
registerStartNode(lf)
registerEndNode(lf)
registerSelectAreaNode(lf)
registerServiceNode(lf)
}

View File

@ -1,102 +0,0 @@
import { GroupNodeModel } from '@logicflow/extension';
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import { HtmlNode, h as svgH } from "@logicflow/core";
import ElementPlus from "element-plus";
// @ts-ignore
import SelectAreaNode from './selectAreaNode.vue';
class SelectAreaModel extends GroupNodeModel {
initNodeData(data) {
super.initNodeData(data);
this.children = new Set(data.children || [])
this.zIndex = 0;
this.isGroup = true;
this.resizable = false;
}
getDefaultAnchor() {
return []
}
isAllowAppendIn() {
return false
}
}
class SelectAreaView extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(SelectAreaNode, {
model: props.model,
graphModel: props.graphModel,
properties: {
...props.model.getProperties(),
}
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (SelectAreaView.reusePool.has(nodeId)) {
rootEl.appendChild(SelectAreaView.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.width = "100%"; // 关键:确保容器自适应内容
this.container.style.height = "100%"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus); // 关键单独注册ElementPlus
this.app.mount(this.container);
SelectAreaView.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
}
// 导出方法注册
export function registerSelectAreaNode(lf) {
lf.register({
type: "selectArea",
view: SelectAreaView,
model: SelectAreaModel,
});
}

View File

@ -1,213 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
// @ts-ignore
import ServiceNode from "./serviceNode.vue";
import OuterNode from "../../components/OuterNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class ServiceNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(OuterNode, {
model: props.model,
component: ServiceNode,
properties: {
...props.model.getProperties(),
},
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (ServiceNodeHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(ServiceNodeHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus);
this.app.use(JsonViewer);
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
ServiceNodeHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class ServiceNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
closePopover() {
this.componentInstance.closePopover()
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 + 4,
y: y,
name: "right",
id: "".concat(this.id, "_1"),
properties: { connectionType: "source" },
},
{
x: x - width / 2,
y: y,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerServiceNode(lf) {
lf.register({
type: "serviceNode",
view: ServiceNodeHtmlNode,
model: ServiceNodeHtmlModel,
});
}

View File

@ -7,7 +7,7 @@
<template #output>
<div v-if="nodeOperatingStatus != 'FAILED'">
<JsonViewer
v-if="props.properties.outputType === 'json'"
v-if="!['img', 'video'].includes(props.properties.outputType)"
:value="outputJsonData"
copyable
boxed

View File

@ -1,204 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from '@logicflow/core';
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from 'vue';
import ElementPlus from 'element-plus'
// 导入 Vue 组件
import StartNode from './startNode.vue';
import OuterNode from "../../components/OuterNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class StartNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props)
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(OuterNode, {
model: props.model,
component: StartNode,
properties: {
...props.model.getProperties()
},
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (StartNodeHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(StartNodeHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement('div');
this.container.style.display = 'inline-block'; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus) // 关键单独注册ElementPlus
this.app.use(JsonViewer)
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
StartNodeHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize () {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector('.node__box')
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class StartNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
closePopover() {
this.componentInstance.closePopover()
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
this.deletable = false; // 关键代码:禁止删除节点
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this, x = _a.x, y = _a.y, width = _a.width, height = _a.height;
return [
{ x: x + width / 2 - 24, y: y - 25, name: 'right', id: "".concat(this.id, "_1"), properties: { connectionType: 'source' }, onlyAsSource: true },
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = 'none';
// style.hover.stroke = 'none';
return style;
}
}
// 导出方法注册
export function registerStartNode(lf) {
lf.register({
type: "start",
view: StartNodeHtmlNode,
model: StartNodeHtmlModel,
effect: ['status'],
events: {
remove: (event) => {
// 阻止默认的删除事件
event.preventDefault();
// 可以在此处添加自定义的删除逻辑,例如弹出提示框
alert('此节点不可删除');
},
}
})
}

View File

@ -1,213 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
// @ts-ignore
import CodeNode from "./codeNode.vue";
import OuterNode from "../../components/OuterNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class CodeNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(OuterNode, {
model: props.model,
component: CodeNode,
properties: {
...props.model.getProperties(),
},
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (CodeNodeHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(CodeNodeHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus);
this.app.use(JsonViewer);
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
CodeNodeHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class CodeNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
closePopover() {
this.componentInstance.closePopover()
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 + 4,
y: y,
name: "right",
id: "".concat(this.id, "_1"),
properties: { connectionType: "source" },
},
{
x: x - width / 2,
y: y,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerCodeNode(lf) {
lf.register({
type: "code",
view: CodeNodeHtmlNode,
model: CodeNodeHtmlModel,
});
}

View File

@ -7,7 +7,7 @@
<template #output>
<div v-if="nodeOperatingStatus != 'FAILED'">
<JsonViewer
v-if="props.properties.outputType === 'json'"
v-if="!['img', 'video'].includes(props.properties.outputType)"
:value="outputJsonData"
copyable
boxed

View File

@ -5,7 +5,7 @@
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<JsonViewer v-if="props.properties.outputType === 'json'" :value="outputJsonData" copyable boxed sort theme="light" />
<JsonViewer v-if="!['img', 'video'].includes(props.properties.outputType)" :value="outputJsonData" copyable boxed sort theme="light" />
<el-image
v-if="props.properties.outputType === 'img'"
v-for="item in outputJsonData.imageUrl"

View File

@ -1,219 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
// @ts-ignore
import CurrentLoop from "./currentLoop.vue";
import OuterNode from "../../components/OuterNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class CurrentLoopNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(OuterNode, {
model: props.model,
component: CurrentLoop,
properties: {
...props.model.getProperties(),
},
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (CurrentLoopNodeHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(CurrentLoopNodeHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus);
this.app.use(JsonViewer);
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
CurrentLoopNodeHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class CurrentLoopNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
// 验证表单
async validateForm() {
const result = this.componentInstance.validateForm()
return result
}
closePopover() {
this.componentInstance.closePopover()
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 + 4,
y: y,
name: "right",
id: "".concat(this.id, "_1"),
properties: { connectionType: "source" },
},
{
x: x - width / 2,
y: y,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerCurrentLoopNode(lf) {
lf.register({
type: "currentLoop",
view: CurrentLoopNodeHtmlNode,
model: CurrentLoopNodeHtmlModel
});
}

View File

@ -1,213 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
// @ts-ignore
import HttpNode from "./httpNode.vue";
import OuterNode from "../../components/OuterNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class HttpNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(OuterNode, {
model: props.model,
component: HttpNode,
properties: {
...props.model.getProperties(),
},
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (HttpNodeHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(HttpNodeHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus);
this.app.use(JsonViewer);
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
HttpNodeHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class HttpNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
closePopover() {
this.componentInstance.closePopover()
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 + 4,
y: y,
name: "right",
id: "".concat(this.id, "_1"),
properties: { connectionType: "source" },
},
{
x: x - width / 2,
y: y,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerHttpNode(lf) {
lf.register({
type: "http",
view: HttpNodeHtmlNode,
model: HttpNodeHtmlModel,
});
}

View File

@ -7,7 +7,7 @@
<template #output>
<div v-if="nodeOperatingStatus != 'FAILED'">
<JsonViewer
v-if="props.properties.outputType === 'json'"
v-if="!['img', 'video'].includes(props.properties.outputType)"
:value="outputJsonData"
copyable
boxed

View File

@ -1,25 +0,0 @@
import { registerLoopBodyNode } from './loop'
import { registerStopLoopNode } from './stopLoop'
import { registerSwitch } from './switch'
import { registerSubEndNode } from './subEnd'
import { registerSubStartNode } from './subStart'
import { registerSleepNode } from './sleep'
import { registerHttpNode } from './httpNode'
import { registerCodeNode } from './codeNode'
import { registerCurrentLoopNode } from './currentLoopNode'
import { registerSdAgentNode } from './sdAgent'
import { registerRecognizeNode } from './recognize'
export const registerFunction = (lf) => {
registerLoopBodyNode(lf)
registerStopLoopNode(lf)
registerSwitch(lf)
registerSubEndNode(lf)
registerSubStartNode(lf)
registerSleepNode(lf)
registerHttpNode(lf)
registerCodeNode(lf)
registerCurrentLoopNode(lf)
registerSdAgentNode(lf)
registerRecognizeNode(lf)
}

View File

@ -1,294 +0,0 @@
import { GroupNodeModel } from '@logicflow/extension';
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import { HtmlNode, h as svgH } from "@logicflow/core";
import ElementPlus from "element-plus";
import Loop from './loop.vue';
class loopBodyModel extends GroupNodeModel {
initNodeData(data) {
super.initNodeData(data);
this.children = new Set(data.children || [])
this.zIndex = 0;
this.isGroup = true;
this.resizable = false;
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
// 递归获取所有有效子节点(包含多层嵌套 children
getAllValidChildren = (nodeIds, graphModel) => {
const validNodes = [];
// 遍历当前层节点 ID
Array.from(nodeIds).forEach((id) => {
const node = graphModel.getNodeModelById(id);
if (node) {
// 当前节点有效,加入结果
validNodes.push(node);
// 如果节点还有 children递归获取下级节点
if (node.children && node.children.size > 0) {
const childNodes = this.getAllValidChildren(node.children, graphModel);
validNodes.push(...childNodes);
}
} else {
// 无效 ID从集合中删除
nodeIds.delete(id);
}
});
return validNodes;
};
// 计算子节点的包围盒(包含所有子节点的最小矩形区域)
getChildrenBBox() {
const children = this.getAllValidChildren(this.children, this.graphModel)
// Array.from(this.children).forEach((id) => {
// const node = this.graphModel.getNodeModelById(id)
// if (node) {
// children.push(this.graphModel.getNodeModelById(id))
// } else {
// this.children.delete(id)
// }
// });
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
nextTick(() => {
children.forEach((node) => {
let _width = node.type === 'loop' ? 500 : 320
let _height = node.type === 'loop' ? 300 : 200
let width = node.width === 0 ? _width : node.width
let height = node.height === 0 ? _height : node.height
minX = Math.min(minX, node.x - width / 2);
maxX = Math.max(maxX, node.x + width / 2);
minY = Math.min(minY, node.y - height / 2);
maxY = Math.max(maxY, node.y + height / 2);
});
this.width = maxX - minX + 180; // 增加内边距
this.height = maxY - minY + 300;
this.x = (minX + maxX) / 2;
this.y = (minY + maxY) / 2;
const properties = lf.getProperties(this.id)
this.setProperties({
...properties,
width: this.width,
height: this.height
})
this.getDefaultAnchor()
})
}
updateSize() {
const children = Array.from(this.children);
if (children.length > 0) {
this.getChildrenBBox();
}
}
// 添加子节点时立即更新大小
addChild(childId) {
super.addChild(childId);
this.updateSize();
}
// 移除子节点时更新大小
removeChild(childId) {
return false
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 ,
// y: y + height / 2,
y:y,
name: "right",
id: "".concat(this.id, "_1"),
properties: { connectionType: "source" },
},
{
x: x - width / 2,
// y: y + height / 2,
y:y,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
isAllowAppendIn() {
return false;
}
}
class loopBodyView extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(Loop, {
model: props.model,
graphModel: props.graphModel,
properties: {
...props.model.getProperties(),
},
onAddToGroup: this.addChild.bind(this),
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
addChild(data) {
this.addToGroup(data);
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.props.model.updateSize();
}
// 关键修改重写getShape方法
getShape() {
const { model } = this.props;
const { x, y, width, height } = model;
// 创建自定义HTML内容
const html = super.getShape();
// 创建SVG组包含HTML内容和锚点
return svgH('g', {}, [
// 绘制组节点背景
svgH('rect', {
x: x - width / 2,
y: y - height / 2,
width,
height,
stroke: '#88f',
strokeWidth: 0,
strokeDasharray: '5,5',
fill: 'rgba(136, 136, 255, 0.1)',
rx: 4,
ry: 4
}),
// 添加HTML内容
html,
// 渲染锚点
this.getAnchorShapes()
]);
}
// 创建锚点元素
getAnchorShapes() {
const { model } = this.props;
const anchors = model.anchors || [];
const anchorShapes = anchors.map((offset, index) => {
const {x, y } = offset
return svgH('circle', {
cx: x,
cy: y,
r: 7,
hover: { fill: "#FF5722" }, // 禁用悬停变色
fill: '#FF5722',
stroke: '#FF5722',
strokeWidth: 0,
className: 'lf-node-anchor',
cursor: 'crosshair',
'data-anchor-id': `anchor_${index}`
});
});
return svgH('g', {}, anchorShapes);
}
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (loopBodyView.reusePool.has(nodeId)) {
rootEl.appendChild(loopBodyView.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
// this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
this.container.style.width = "100%"; // 关键:确保容器自适应内容
this.container.style.height = "100%"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus); // 关键单独注册ElementPlus
this.app.mount(this.container);
loopBodyView.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
addToGroup(childId) {
const groupModel = lf.getNodeModelById(this.props.model.id);
groupModel.addChild(childId);
groupModel.updateSize()
}
}
// 导出方法注册
export function registerLoopBodyNode(lf) {
lf.register({
type: "loop",
view: loopBodyView,
model: loopBodyModel,
});
}

View File

@ -1,5 +1,10 @@
<template>
<div class="group__container" :class="props.model.id" @mouseleave="setNodeProperties">
<div
class="group__container"
:class="[props.model.id, { 'group__container--dragover': isDragOver }]"
@dragenter.prevent="isDragOver = true"
@dragleave="handleDragleave"
>
<NodeTitle
:icon="loop"
:nodeId="props.model.id"
@ -9,7 +14,10 @@
:showZoom="false"
@setNodeName="setNodeName"
/>
<div>循环体</div>
<div class="loop-body-title">
<span>循环体</span>
<span class="loop-body-count">{{ childCount }} 个节点</span>
</div>
<div
class="child__container"
@drop="handleDrop"
@ -24,7 +32,7 @@ import NodeTitle from "../../components/NodeTitle.vue";
import loop from "../../icon/loop.svg";
import { addNewEdge } from "@/utils/flow";
import { emitter } from "@/utils/eventBus";
import { onUnmounted } from "vue";
import { computed, onUnmounted, ref } from "vue";
const props = defineProps({
model: Object,
@ -35,17 +43,27 @@ const props = defineProps({
const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]);
const isDragOver = ref(false);
const childCount = computed(() => props.model?.children?.size || 0);
const handleDragover = (e) => {
e.preventDefault();
isDragOver.value = true;
};
const handleDragleave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) isDragOver.value = false;
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
isDragOver.value = false;
let flowNode = e.dataTransfer.getData("flowNode");
if (!flowNode) return;
flowNode = JSON.parse(flowNode);
if (["start", "end", "loop", "selectArea"].includes(flowNode.type)) return;
const point = lf.getPointByClient(e.clientX, e.clientY);
const { type, ...other } = flowNode;
const node = lf.addNode({
@ -63,7 +81,7 @@ const handleDrop = (e) => {
emitter.emit("openParamsDrawer", node);
}
emits("addToGroup", node.id);
lf.addToGroup(props.model.id, node.id, point.canvasOverlayPosition);
setTimeout(() => {
addNewEdge(props.model.id);
}, 50);
@ -87,26 +105,71 @@ onUnmounted(() => {
.group__container {
width: 100%;
height: 100%;
// min-height: 420px;
background: #fff;
padding: 12px;
padding: 16px;
box-sizing: border-box;
cursor: default;
border-radius: 12px;
border: 2px solid white;
// box-shadow: 0 5px 15px 0#00000008;
border-radius: 8px;
border: 1px solid #cfd7e6;
box-shadow: none;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 5px;
height: 100%;
border-radius: 8px 0 0 8px;
background: #ff6b35;
}
&--dragover {
border-color: #ff6b35;
box-shadow: inset 0 0 0 2px rgba(255, 107, 53, 0.14);
}
.title {
height: 40px;
}
.loop-body-title {
display: flex;
align-items: center;
justify-content: space-between;
height: 28px;
margin-top: 2px;
color: #344054;
font-size: 13px;
font-weight: 600;
}
.loop-body-count {
padding: 2px 8px;
color: #667085;
background: #eef1f6;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
}
.child__container {
min-height: 100px;
min-height: 300px;
flex: 1;
background-color: #f6f8fa;
border-radius: 8px;
background-color: #f8fafc;
border: 1px dashed #b8c2d2;
border-radius: 6px;
transition: background-color 0.18s ease, border-color 0.18s ease;
}
&--dragover .child__container {
background-color: #fff7f2;
border-color: #ff6b35;
}
.el-row {

View File

@ -1,213 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
// @ts-ignore
import Recognize from "./recognize.vue";
import OuterNode from "../../components/OuterNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class RecognizeNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(OuterNode, {
model: props.model,
component: Recognize,
properties: {
...props.model.getProperties(),
},
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (RecognizeNodeHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(RecognizeNodeHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus);
this.app.use(JsonViewer);
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
RecognizeNodeHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class RecognizeNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
closePopover() {
this.componentInstance.closePopover()
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 + 4,
y: y,
name: "right",
id: "".concat(this.id, "_1"),
properties: { connectionType: "source" },
},
{
x: x - width / 2,
y: y,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerRecognizeNode(lf) {
lf.register({
type: "recognize",
view: RecognizeNodeHtmlNode,
model: RecognizeNodeHtmlModel
});
}

View File

@ -5,7 +5,7 @@
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<JsonViewer v-if="props.properties.outputType === 'json'" :value="outputJsonData" copyable boxed sort theme="light" />
<JsonViewer v-if="!['img', 'video'].includes(props.properties.outputType)" :value="outputJsonData" copyable boxed sort theme="light" />
<el-image
v-if="props.properties.outputType === 'img'"
v-for="item in outputJsonData.imageUrl"

View File

@ -1,213 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
// @ts-ignore
import SdAgent from "./sdAgent.vue";
import OuterNode from "../../components/OuterNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class SdAgentNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(OuterNode, {
model: props.model,
component: SdAgent,
properties: {
...props.model.getProperties(),
},
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (SdAgentNodeHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(SdAgentNodeHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus);
this.app.use(JsonViewer);
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
SdAgentNodeHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class SdAgentNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
closePopover() {
this.componentInstance.closePopover()
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 + 4,
y: y,
name: "right",
id: "".concat(this.id, "_1"),
properties: { connectionType: "source" },
},
{
x: x - width / 2,
y: y,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerSdAgentNode(lf) {
lf.register({
type: "sdAgent",
view: SdAgentNodeHtmlNode,
model: SdAgentNodeHtmlModel
});
}

View File

@ -5,7 +5,7 @@
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<JsonViewer v-if="props.properties.outputType === 'json'" :value="outputJsonData" copyable boxed sort theme="light" />
<JsonViewer v-if="!['img', 'video'].includes(props.properties.outputType)" :value="outputJsonData" copyable boxed sort theme="light" />
<el-image
v-if="props.properties.outputType === 'img'"
v-for="item in outputJsonData.imageUrl"

View File

@ -1,213 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
// @ts-ignore
import Sleep from "./sleep.vue";
import OuterNode from "../../components/OuterNode.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class SleepNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(OuterNode, {
model: props.model,
component: Sleep,
properties: {
...props.model.getProperties(),
},
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (SleepNodeHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(SleepNodeHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus);
this.app.use(JsonViewer);
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
SleepNodeHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class SleepNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
closePopover() {
this.componentInstance.closePopover()
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 + 4,
y: y,
name: "right",
id: "".concat(this.id, "_1"),
properties: { connectionType: "source" },
},
{
x: x - width / 2,
y: y,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerSleepNode(lf) {
lf.register({
type: "sleep",
view: SleepNodeHtmlNode,
model: SleepNodeHtmlModel
});
}

View File

@ -5,7 +5,7 @@
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<JsonViewer v-if="props.properties.outputType === 'json'" :value="outputJsonData" copyable boxed sort theme="light" />
<JsonViewer v-if="!['img', 'video'].includes(props.properties.outputType)" :value="outputJsonData" copyable boxed sort theme="light" />
<el-image
v-if="props.properties.outputType === 'img'"
v-for="item in outputJsonData.imageUrl"

View File

@ -1,191 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
import StopLoop from "./stopLoop.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class StopLoopHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(StopLoop, {
model: props.model,
graphModel: props.graphModel,
properties: {
...props.model.getProperties(),
},
text: props.model.text.value,
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this),
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus); // 关键单独注册ElementPlus
this.app.use(JsonViewer)
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data);
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class StopLoopHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x - width / 2,
y: y - 25,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerStopLoopNode(lf) {
lf.register({
type: "stopLoop",
view: StopLoopHtmlNode,
model: StopLoopHtmlModel,
});
}

View File

@ -1,186 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
import SubEndNode from "./subEnd.vue";
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class SubEndNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(SubEndNode, {
model: props.model,
graphModel: props.graphModel,
properties: {
...props.model.getProperties(),
}
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus); // 关键单独注册ElementPlus
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data);
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class SubEndNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x - width / 2,
y: y - 25,
name: "left",
id: "".concat(this.id, "_3"),
properties: { connectionType: "target" },
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerSubEndNode(lf) {
lf.register({
type: "subEnd",
view: SubEndNodeHtmlNode,
model: SubEndNodeHtmlModel,
});
}

View File

@ -1,185 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
import SubStartNode from "./subStart.vue";
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class SubStartNodeHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(SubStartNode, {
model: props.model,
graphModel: props.graphModel,
properties: {
...props.model.getProperties(),
}
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus); // 关键单独注册ElementPlus
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data);
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class SubStartNodeHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
return [
{
x: x + width / 2 - 24,
y: y - 25,
name: "right",
id: "".concat(this.id, "_1")
},
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerSubStartNode(lf) {
lf.register({
type: "subStart",
view: SubStartNodeHtmlNode,
model: SubStartNodeHtmlModel,
});
}

View File

@ -1,318 +0,0 @@
// 导入 HtmlNode 及其模型,为后续继承做准备
import { HtmlNode, HtmlNodeModel, h as svgH } from "@logicflow/core";
// 导入 Vue 相关方法,用于渲染组件
import { createApp, h, nextTick } from "vue";
import ElementPlus from "element-plus";
// 导入 Vue 组件
import Switch from "./switch.vue";
import JsonViewer from 'vue3-json-viewer'
/**
* 定义一个 元素的 HTML 节点类继承自 HtmlNode
* 该类负责在 HTML 中渲染 元素并处理其交互逻辑
*/
class SwitchHtmlNode extends HtmlNode {
resizeObserver = null;
isMounted; // 标记组件是否已挂载
r; // 渲染函数
app; // Vue 应用实例
container = null;
static reusePool = new Map(); // 节点复用池
/**
* 构造函数
* @param props 传递给节点的属性包括模型图模型等
*/
constructor(props) {
super(props);
this.initVueApp(props);
}
initVueApp(props) {
this.isMounted = false;
this.hideAnchor = false;
this.autoExpand = true; // 防止锚点被折叠
this.anchorsPreset = "default"; // 重置锚点预设
// 创建 元素的渲染函数
this.r = h(Switch, {
model: props.model,
graphModel: props.graphModel,
properties: {
...props.model.getProperties(),
},
text: props.model.text.value,
onContentChange: this.handleContentChange.bind(this),
onBindRef: this.handleComponentInstance.bind(this),
// 添加锚点相关回调
onAddAnchor: this.handleAddAnchor.bind(this),
onRemoveAnchor: this.handleRemoveAnchor.bind(this),
onChangeAnchor: this.handleChangeAnchor.bind(this)
});
// 创建 Vue 应用实例,并指定渲染函数
this.app = createApp({
render: () => this.r,
});
}
/**
* HTML 内容设置到指定的根元素上
* @param rootEl 根元素
*/
async setHtml(rootEl) {
const nodeId = this.props.model.id;
if (SwitchHtmlNode.reusePool.has(nodeId)) {
rootEl.appendChild(SwitchHtmlNode.reusePool.get(nodeId));
return;
}
if (!this.isMounted) {
this.isMounted = true;
this.container = document.createElement("div");
this.container.style.display = "inline-block"; // 关键:确保容器自适应内容
rootEl.appendChild(this.container);
this.app.use(ElementPlus);
this.app.use(JsonViewer);
this.app.mount(this.container);
await nextTick();
this.setupSizeObserver();
SwitchHtmlNode.reusePool.set(nodeId, this.container);
} else {
this.r.component.props.properties = this.props.model.getProperties();
}
}
/**
* 获取节点文本内容
* 对于元素返回 null因为其内容由特定组件渲染
* @returns {null}
*/
getText() {
return null;
}
handleComponentInstance(data) {
// 将组件实例保存到节点模型
this.props.model.setComponentInstance(data)
}
handleContentChange() {
// 内容变化时强制更新尺寸
this.updateNodeSize();
}
// 处理添加锚点
handleAddAnchor(height, anchorId) {
const { model } = this.props
model.addAnchor(height, anchorId)
}
// 处理删除锚点
handleRemoveAnchor(anchorId) {
this.props.model.removeAnchor(anchorId);
}
handleChangeAnchor(height, anchorId) {
const { model } = this.props
model.changeAnchorHeight(height, anchorId)
}
// 渲染完成后获取实际尺寸
updateNodeSize() {
if (this.container) {
const { SCALE_X, SCALE_Y } = this.props.graphModel.transformModel;
const node = this.container.querySelector(".node__container");
const rect = node.getBoundingClientRect();
this.props.model.updateSize(rect.width / SCALE_X, rect.height / SCALE_Y);
}
}
setupSizeObserver() {
// 首次渲染立即检测
requestAnimationFrame(() => {
this.updateNodeSize();
// 持续监听变化
this.resizeObserver = new ResizeObserver(() => {
this.updateNodeSize();
});
this.resizeObserver.observe(this.container);
});
}
// 组件卸载时移除监听
onDestroy() {
this.resizeObserver?.disconnect();
}
}
/**
* 定义一个元素的 HTML 模型类继承自 HtmlNodeModel
* 该类主要设置节点的属性和样式
*/
class SwitchHtmlModel extends HtmlNodeModel {
initNodeData(data) {
super.initNodeData(data);
}
// 返回动态锚点
addAnchor(height, anchorId) {
const properties = this.getProperties()
const anchor = properties?.anchor || []
anchor.push({
id: anchorId,
name: 'right',
x: this.x,
y: this.y,
height
})
this.setProperties({
...properties,
anchor
})
this.getDefaultAnchor()
this.initNodeData(this)
}
removeAnchor(anchorId) {
const properties = this.getProperties()
const anchor = properties?.anchor || []
let nweAnchor = anchor.filter(item => item.id !== anchorId);
this.setProperties({
...properties,
anchor: nweAnchor
})
}
changeAnchorHeight(height, anchorId) {
const properties = this.getProperties()
const anchor = properties?.anchor || []
anchor.forEach(item => {
if (item.id === anchorId) {
item.height = height
}
})
this.setProperties({
...properties,
anchor
})
this.getDefaultAnchor()
this.initNodeData(this)
}
// 保存组件实例引用
setComponentInstance(instance) {
this.componentInstance = instance;
}
closePopover() {
this.componentInstance.closePopover()
}
// 验证表单
async validateForm() {
if (this.componentInstance && this.componentInstance.validate) {
try {
const result = await this.componentInstance.validate();
if (result) {
return { valid: true, message: "验证通过" };
} else {
return { valid: false, message: "验证失败" };
}
} catch {
return { valid: false, message: "验证失败" };
}
}
return { valid: true, message: "无验证方法" };
}
/**
* 设置节点属性
* 包括宽度高度文本编辑属性等
*/
setAttributes() {
// 初始设置为0后续动态更新
this.width = 0;
this.height = 0;
this.text.editable = false;
}
updateSize(width, height) {
this.width = width + 24;
this.height = height + 50;
// const properties = lf.getProperties(this.id)
// this.setProperties({
// ...properties,
// width: this.width,
// height: this.height
// })
this.initNodeData(this); // 触发节点重绘
}
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
getDefaultAnchor() {
let _a = this,
x = _a.x,
y = _a.y,
width = _a.width,
height = _a.height;
const properties = this.getProperties()
const anchor = properties?.anchor || []
// const { SCALE_Y } = this.graphModel.transformModel;
const ids = []
anchor.forEach(item => {
ids.push(item.id)
if (item.id !== "".concat(this.id, "_else")) {
item.x = x + width / 2 - 40
// item.y = (y - height / 2 + item.height + 60) / SCALE_Y
item.y = y - height / 2 + item.height + 60
} else {
item.x = x + width / 2 - 40
item.y = y + height / 2 - 80
}
})
return [
// {
// x: x + width / 2 - 40,
// y: y + height / 2 - 80,
// name: "right",
// id: "".concat(this.id, "_else")
// },
{
x: x - width / 2,
y: y - 25,
name: "left",
id: "".concat(this.id, "_entry")
},
...anchor
];
}
/**
* 获取节点轮廓样式
* 覆盖父类方法设置 stroke 属性为 none以适应特定的视觉效果
* @returns {object} 节点轮廓样式
*/
getOutlineStyle() {
const style = super.getOutlineStyle();
style.stroke = "none";
// style.hover.stroke = "none";
return style;
}
}
// 导出方法注册
export function registerSwitch(lf) {
lf.register({
type: "branch",
view: SwitchHtmlNode,
model: SwitchHtmlModel,
});
}

View File

@ -455,14 +455,14 @@ const closePopover = () => {
const isEditing = ref(false)
const handleInputFocus = (e) => {
// LogicFlow
//
e.stopPropagation()
e.preventDefault()
}
const handleInputKeydown = (e) => {
// Logic Flow
//
e.stopPropagation();
// Ctrl+V
if ((e.ctrlKey || e.metaKey) && e.key === "v") {

View File

@ -0,0 +1,86 @@
<template>
<BaseEdge
:id="id"
:path="edgePath"
:marker-end="markerEnd"
:interaction-width="interactionWidth || 32"
:style="style"
/>
<EdgeLabelRenderer v-if="selected && !flowStore.disableForm">
<div class="edge-delete-action nodrag nopan" :style="deleteActionStyle">
<el-tooltip content="删除连线" placement="top">
<button type="button" aria-label="删除连线" @click.stop="deleteEdge">
<el-icon><Delete /></el-icon>
</button>
</el-tooltip>
</div>
</EdgeLabelRenderer>
</template>
<script setup>
import { computed } from "vue";
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from "@vue-flow/core";
import { Delete } from "@element-plus/icons-vue";
import { useFlowStore } from "@/store/modules/flow";
const props = defineProps({
id: String,
sourceX: Number,
sourceY: Number,
targetX: Number,
targetY: Number,
sourcePosition: String,
targetPosition: String,
markerEnd: String,
interactionWidth: Number,
selected: Boolean,
style: Object,
});
const flowStore = useFlowStore();
const pathData = computed(() => getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition,
}));
const edgePath = computed(() => pathData.value[0]);
const deleteActionStyle = computed(() => ({
transform: `translate(-50%, -50%) translate(${pathData.value[1]}px, ${pathData.value[2] - 24}px)`,
}));
const deleteEdge = () => {
window.lf?.deleteEdge(props.id);
window.lf?.clearSelectElements();
};
</script>
<style scoped lang="scss">
.edge-delete-action {
position: absolute;
z-index: 20;
pointer-events: all;
button {
display: grid;
width: 30px;
height: 30px;
padding: 0;
place-items: center;
border: 1px solid #f2b8b5;
border-radius: 50%;
background: #fff;
color: #d93025;
cursor: pointer;
box-shadow: 0 4px 12px rgba(20, 33, 61, 0.18);
&:hover {
border-color: #d93025;
background: #fff1f0;
}
}
}
</style>

View File

@ -0,0 +1,202 @@
<template>
<div
class="flow-node-shell"
:class="[`flow-node-shell--${flowType}`, { 'flow-node-shell--selected': selected }]"
>
<Handle
v-if="showTarget"
:id="targetHandleId"
type="target"
:position="Position.Left"
:connectable="connectable && !flowStore.disableForm"
class="flow-handle flow-handle--target"
/>
<div class="flow-node-content" :class="{ 'flow-node-card': !isGroup && flowType !== 'branch' }">
<component
:is="nodeComponent"
ref="componentRef"
:model="model"
:graph-model="model"
:properties="properties"
@content-change="contentChanged"
@bind-ref="bindComponent"
@add-anchor="addAnchor"
@remove-anchor="removeAnchor"
@change-anchor="changeAnchor"
@add-to-group="addToGroup"
/>
</div>
<template v-if="flowType === 'branch'">
<template v-for="anchor in branchAnchors" :key="anchor.id">
<Handle
:id="anchor.id"
type="source"
:position="Position.Right"
:connectable="connectable && !flowStore.disableForm"
class="flow-handle flow-handle--source flow-handle--branch"
:style="{ top: `${Math.max(76, Number(anchor.height || 0) + 60)}px` }"
/>
</template>
</template>
<Handle
v-else-if="showSource"
:id="`${id}_1`"
type="source"
:position="Position.Right"
:connectable="connectable && !flowStore.disableForm"
class="flow-handle flow-handle--source"
/>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from "vue";
import { Handle, Position, useVueFlow } from "@vue-flow/core";
import { useFlowStore } from "@/store/modules/flow";
import StartNode from "../nodes/common/startNode.vue";
import EndNode from "../nodes/common/endNode.vue";
import ServiceNode from "../nodes/common/serviceNode.vue";
import SelectAreaNode from "../nodes/common/selectAreaNode.vue";
import LoopNode from "../nodes/function/loop.vue";
import BranchNode from "../nodes/function/switch.vue";
import StopLoopNode from "../nodes/function/stopLoop.vue";
import SubStartNode from "../nodes/function/subStart.vue";
import SubEndNode from "../nodes/function/subEnd.vue";
import SleepNode from "../nodes/function/sleep.vue";
import HttpNode from "../nodes/function/httpNode.vue";
import CodeNode from "../nodes/function/codeNode.vue";
import CurrentLoopNode from "../nodes/function/currentLoop.vue";
import SdAgentNode from "../nodes/function/sdAgent.vue";
import RecognizeNode from "../nodes/function/recognize.vue";
const props = defineProps({
id: String,
data: Object,
selected: Boolean,
connectable: [Boolean, Number, String, Function],
});
const componentMap = {
start: StartNode,
end: EndNode,
serviceNode: ServiceNode,
selectArea: SelectAreaNode,
loop: LoopNode,
branch: BranchNode,
stopLoop: StopLoopNode,
subStart: SubStartNode,
subEnd: SubEndNode,
sleep: SleepNode,
http: HttpNode,
code: CodeNode,
currentLoop: CurrentLoopNode,
sdAgent: SdAgentNode,
recognize: RecognizeNode,
};
const flowStore = useFlowStore();
const vueFlow = useVueFlow("main-flow");
const componentRef = ref();
const flowType = computed(() => props.data.flowType);
const properties = computed(() => props.data.properties || {});
const nodeComponent = computed(() => componentMap[flowType.value] || ServiceNode);
const isGroup = computed(() => ["loop", "selectArea"].includes(flowType.value));
const showTarget = computed(() => !["start", "subStart", "selectArea"].includes(flowType.value));
const showSource = computed(() => !["end", "stopLoop", "subEnd", "selectArea", "branch"].includes(flowType.value));
const targetHandleId = computed(() => flowType.value === "branch" ? `${props.id}_entry` : `${props.id}_3`);
const branchAnchors = computed(() => {
const reservedIds = new Set([`${props.id}_1`, `${props.id}_3`, `${props.id}_entry`]);
const conditionDefs = properties.value.nodeParams || properties.value.conditions || [];
const conditionIds = new Set(conditionDefs.map((condition) => condition?.id).filter(Boolean));
const elseId = `${props.id}_else`;
const seen = new Set();
return (properties.value.anchor || []).filter((anchor) => {
if (!anchor?.id || reservedIds.has(anchor.id) || seen.has(anchor.id)) return false;
if (conditionIds.size && anchor.id !== elseId && !conditionIds.has(anchor.id)) return false;
seen.add(anchor.id);
return true;
});
});
const model = computed(() => window.lf?.getNodeModelById(props.id));
const syncAnchors = (anchors) => {
window.lf?.setProperties(props.id, { ...window.lf.getProperties(props.id), anchor: anchors });
vueFlow.updateNodeInternals([props.id]);
};
const addAnchor = (height, anchorId) => {
const anchors = [...branchAnchors.value];
if (!anchors.some((anchor) => anchor.id === anchorId)) {
anchors.push({ id: anchorId, name: "right", height });
syncAnchors(anchors);
}
};
const removeAnchor = (anchorId) => syncAnchors(branchAnchors.value.filter((anchor) => anchor.id !== anchorId));
const changeAnchor = (height, anchorId) => syncAnchors(branchAnchors.value.map((anchor) => anchor.id === anchorId ? { ...anchor, height } : anchor));
const contentChanged = () => {
vueFlow.updateNodeInternals([props.id]);
window.lf?.markChanged();
};
const bindComponent = (instance) => window.lf?.registerComponentInstance(props.id, instance);
const addToGroup = (childId, dropCenter) => window.lf?.addToGroup(props.id, childId, dropCenter);
onMounted(() => bindComponent(componentRef.value));
</script>
<style scoped lang="scss">
.flow-node-shell {
width: 100%;
min-height: 100%;
position: relative;
box-sizing: border-box;
&--loop,
&--selectArea {
height: 100%;
}
&--loop.flow-node-shell--selected .flow-node-content {
box-shadow: none;
}
&--selected .flow-node-content {
outline: none;
border: 2px solid #2f80ed;
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.1);
border-radius: 8px;
}
}
.flow-node-content {
width: 100%;
height: 100%;
box-sizing: border-box;
}
.flow-node-card {
width: 100%;
height: auto;
padding: 12px;
background: #fff;
border: 1px solid #e4e8ef;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.08);
box-sizing: border-box;
}
.flow-handle {
width: 14px;
height: 14px;
border: 3px solid #fff;
background: #ff6b35;
box-shadow: 0 0 0 1px #ff6b35;
z-index: 8;
&--target { left: -7px; }
&--source { right: -7px; }
&--branch { transform: translate(50%, -50%); }
}
</style>

View File

@ -0,0 +1,578 @@
import { nextTick } from "vue";
import { v4 as uuid } from "uuid";
import { MarkerType } from "@vue-flow/core";
const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
const GROUP_TYPES = new Set(["loop", "selectArea"]);
const TARGET_ONLY_TYPES = new Set(["end", "stopLoop", "subEnd"]);
const SOURCE_ONLY_TYPES = new Set(["start", "subStart"]);
const LOOP_BODY_INSET = { left: 24, top: 112, right: 24, bottom: 24 };
const LOOP_MIN_SIZE = { width: 520, height: 360 };
const loopChildExtent = () => ({
range: "parent",
padding: [
LOOP_BODY_INSET.top,
LOOP_BODY_INSET.right,
LOOP_BODY_INSET.bottom,
LOOP_BODY_INSET.left,
],
});
const nodeSize = (node) => {
const width = Number(node.properties?.width || node.width);
const height = Number(node.properties?.height || node.height);
if (Number.isFinite(width) && Number.isFinite(height)) {
return { width, height };
}
if (node.type === "branch") return { width: 748, height: 420 };
if (GROUP_TYPES.has(node.type)) return { width: 720, height: 460 };
if (TARGET_ONLY_TYPES.has(node.type) || SOURCE_ONLY_TYPES.has(node.type)) {
return { width: 328, height: 120 };
}
return { width: 372, height: 180 };
};
const edgeId = () => `edge_${uuid().replace(/-/g, "")}`;
const nodeId = () => uuid().replace(/-/g, "");
const toVueNode = (node) => {
const normalized = clone(node);
normalized.id ||= nodeId();
normalized.properties ||= {};
const size = nodeSize(normalized);
const center = {
x: Number(normalized.x) || 0,
y: Number(normalized.y) || 0,
};
const position = {
x: center.x - size.width / 2,
y: center.y - size.height / 2,
};
const dimensions = GROUP_TYPES.has(normalized.type)
? { width: size.width, height: size.height }
: { width: size.width };
return {
id: normalized.id,
type: "flow-node",
position,
...dimensions,
draggable: normalized.draggable !== false,
selectable: true,
deletable: !SOURCE_ONLY_TYPES.has(normalized.type) && normalized.type !== "end",
connectable: true,
zIndex: GROUP_TYPES.has(normalized.type) ? 0 : 2,
data: {
flowType: normalized.type,
properties: normalized.properties,
children: Array.isArray(normalized.children) ? normalized.children : [],
text: normalized.text,
backend: normalized,
originalCenter: center,
originalPosition: position,
size,
},
};
};
const toVueEdge = (edge) => {
const normalized = clone(edge);
normalized.id ||= edgeId();
return {
id: normalized.id,
source: normalized.sourceNodeId,
target: normalized.targetNodeId,
sourceHandle: normalized.sourceAnchorId || null,
targetHandle: normalized.targetAnchorId || null,
type: "flow-edge",
animated: true,
selectable: true,
focusable: true,
deletable: true,
interactionWidth: 32,
markerEnd: {
type: MarkerType.ArrowClosed,
color: "#ff6b35",
width: 20,
height: 20,
},
style: { stroke: "#ff6b35", strokeWidth: 2 },
data: { backend: normalized },
};
};
const attachChildrenToGroups = (nodes) => {
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
nodes.forEach((child) => {
const parentId = child.data.properties?.parentId;
const parent = nodeLookup.get(parentId);
if (!parent || !GROUP_TYPES.has(parent.data.flowType) || GROUP_TYPES.has(child.data.flowType)) return;
const isLoop = parent.data.flowType === "loop";
const relativePosition = {
x: isLoop
? Math.max(child.position.x - parent.position.x, LOOP_BODY_INSET.left)
: child.position.x - parent.position.x,
y: isLoop
? Math.max(child.position.y - parent.position.y, LOOP_BODY_INSET.top)
: child.position.y - parent.position.y,
};
child.parentNode = parentId;
child.extent = isLoop ? loopChildExtent() : "parent";
child.expandParent = false;
child.position = relativePosition;
child.zIndex = 2;
});
nodes.filter((node) => node.data.flowType === "loop").forEach((group) => {
const children = nodes.filter((node) => node.parentNode === group.id);
if (!children.length) return;
const requiredWidth = Math.max(...children.map((child) => (
child.position.x + Number(child.data.size?.width || 0) + LOOP_BODY_INSET.right
)));
const requiredHeight = Math.max(...children.map((child) => (
child.position.y + Number(child.data.size?.height || 0) + LOOP_BODY_INSET.bottom
)));
const width = Math.max(Number(group.data.size?.width || 0), LOOP_MIN_SIZE.width, requiredWidth);
const height = Math.max(Number(group.data.size?.height || 0), LOOP_MIN_SIZE.height, requiredHeight);
group.width = width;
group.height = height;
group.data.size = { width, height };
group.data.properties = { ...group.data.properties, width, height };
});
return nodes.sort((left, right) => Number(Boolean(left.parentNode)) - Number(Boolean(right.parentNode)));
};
export const toVueFlowData = (graph = {}) => ({
nodes: attachChildrenToGroups((graph.nodes || []).map(toVueNode)),
edges: (graph.edges || []).map(toVueEdge),
});
export const createFlowFacade = (store) => {
const handlers = new Map();
const componentInstances = new Map();
const loopLayoutStates = new Map();
let historySuspended = false;
let history = [];
let historyIndex = -1;
const getNodes = () => store.getNodes.value || [];
const getEdges = () => store.getEdges.value || [];
const backendNode = (node) => {
const result = clone(node.data.backend);
const size = {
width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0),
height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0),
};
const parent = node.parentNode ? store.findNode(node.parentNode) : null;
const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 };
const absolutePosition = parent
? { x: parentPosition.x + node.position.x, y: parentPosition.y + node.position.y }
: (node.position || node.computedPosition);
result.id = node.id;
result.type = node.data.flowType;
result.x = absolutePosition.x + size.width / 2;
result.y = absolutePosition.y + size.height / 2;
result.properties = clone(node.data.properties || {});
if (GROUP_TYPES.has(result.type)) {
result.properties.width = size.width;
result.properties.height = size.height;
}
if (node.data.children?.length || GROUP_TYPES.has(result.type)) {
result.children = [...(node.data.children || [])];
} else {
delete result.children;
}
return result;
};
const backendEdge = (edge) => ({
...clone(edge.data?.backend),
id: edge.id,
type: edge.data?.backend?.type || "bezier",
sourceNodeId: edge.source,
targetNodeId: edge.target,
sourceAnchorId: edge.sourceHandle || `${edge.source}_1`,
targetAnchorId: edge.targetHandle || `${edge.target}_3`,
});
const emit = (name, payload) => {
(handlers.get(name) || []).forEach((handler) => handler(payload));
};
const recordHistory = () => {
if (historySuspended) return;
const graph = facade.getGraphData();
const serialized = JSON.stringify(graph);
if (history[historyIndex]?.serialized === serialized) return;
history = history.slice(0, historyIndex + 1);
history.push({ serialized, graph: clone(graph) });
if (history.length > 100) history.shift();
historyIndex = history.length - 1;
};
const changed = () => {
if (historySuspended) return;
recordHistory();
emit("history:change", { data: facade.getGraphData() });
};
const getNodeRect = (node) => {
const parent = node.parentNode ? store.findNode(node.parentNode) : null;
const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 };
const position = parent
? { x: parentPosition.x + node.position.x, y: parentPosition.y + node.position.y }
: (node.position || node.computedPosition);
return {
x: Number(position?.x || 0),
y: Number(position?.y || 0),
width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0),
height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0),
};
};
const getLoopLayoutState = (group) => {
const current = getNodeRect(group);
const existing = loopLayoutStates.get(group.id);
if (!existing) {
const state = { base: { ...current }, last: { ...current } };
loopLayoutStates.set(group.id, state);
return state;
}
const sizeChanged = Math.abs(current.width - existing.last.width) > 1
|| Math.abs(current.height - existing.last.height) > 1;
if (sizeChanged) {
existing.base = { ...current };
} else {
const dx = current.x - existing.last.x;
const dy = current.y - existing.last.y;
existing.base.x += dx;
existing.base.y += dy;
}
existing.last = { ...current };
return existing;
};
const layoutLoop = (group, activeChildId, activePosition) => {
const state = getLoopLayoutState(group);
const children = getNodes().filter((node) => node.parentNode === group.id);
if (!children.length) return false;
const snapshots = children.map((child) => {
const rect = getNodeRect(child);
const position = child.id === activeChildId && activePosition
? { x: activePosition.x, y: activePosition.y }
: { x: rect.x, y: rect.y };
return { child, position, width: rect.width, height: rect.height };
});
const contentLeft = Math.min(...snapshots.map(({ position }) => position.x));
const contentTop = Math.min(...snapshots.map(({ position }) => position.y));
const contentRight = Math.max(...snapshots.map(({ position, width }) => position.x + width));
const contentBottom = Math.max(...snapshots.map(({ position, height }) => position.y + height));
const left = Math.min(state.base.x, contentLeft - LOOP_BODY_INSET.left);
const top = Math.min(state.base.y, contentTop - LOOP_BODY_INSET.top);
const right = Math.max(
state.base.x + Math.max(state.base.width, LOOP_MIN_SIZE.width),
contentRight + LOOP_BODY_INSET.right,
);
const bottom = Math.max(
state.base.y + Math.max(state.base.height, LOOP_MIN_SIZE.height),
contentBottom + LOOP_BODY_INSET.bottom,
);
const width = right - left;
const height = bottom - top;
const properties = { ...group.data.properties, width, height };
store.updateNode(group.id, {
position: { x: left, y: top },
computedPosition: { ...group.computedPosition, x: left, y: top },
dimensions: { width, height },
width,
height,
});
store.updateNodeData(group.id, {
...group.data,
properties,
size: { width, height },
}, { replace: true });
snapshots.forEach(({ child, position }) => {
store.updateNode(child.id, {
position: { x: position.x - left, y: position.y - top },
computedPosition: { ...child.computedPosition, x: position.x, y: position.y },
extent: loopChildExtent(),
expandParent: false,
});
});
state.last = { x: left, y: top, width, height };
nextTick(() => store.updateNodeInternals([group.id, ...children.map((child) => child.id)]));
return true;
};
const getAnchors = (node) => {
const type = node.data.flowType;
if (type === "branch") {
return [
{ id: `${node.id}_entry`, name: "left" },
...(node.data.properties?.anchor || []).map((item) => ({ ...item, name: "right" })),
];
}
if (TARGET_ONLY_TYPES.has(type)) return [{ id: `${node.id}_3`, name: "left" }];
if (SOURCE_ONLY_TYPES.has(type)) return [{ id: `${node.id}_1`, name: "right" }];
if (type === "selectArea") return [];
return [
{ id: `${node.id}_1`, name: "right" },
{ id: `${node.id}_3`, name: "left" },
];
};
const modelFor = (id) => {
const node = store.findNode(id);
if (!node) return null;
const model = {
id,
type: node.data.flowType,
get x() { return backendNode(node).x; },
get y() { return backendNode(node).y; },
get width() { return node.dimensions?.width || node.data.size?.width || 0; },
get height() { return node.dimensions?.height || node.data.size?.height || 0; },
get anchors() { return getAnchors(node); },
get children() { return new Set(node.data.children || []); },
set children(value) {
const children = Array.from(value || []);
store.updateNodeData(id, { ...node.data, children }, { replace: true });
changed();
},
get draggable() { return node.draggable !== false; },
set draggable(value) { store.updateNode(id, { draggable: Boolean(value) }); },
getProperties: () => facade.getProperties(id),
setProperties: (properties) => facade.setProperties(id, properties),
setComponentInstance: (instance) => componentInstances.set(id, instance),
closePopover: () => componentInstances.get(id)?.closePopover?.(),
updateSize: () => store.updateNodeInternals([id]),
setCustomProperties: () => store.updateNodeInternals([id]),
};
return model;
};
const facade = {
on(name, handler) {
const list = handlers.get(name) || [];
list.push(handler);
handlers.set(name, list);
return () => facade.off(name, handler);
},
once(name, handler) {
const wrapped = (payload) => {
facade.off(name, wrapped);
handler(payload);
};
facade.on(name, wrapped);
},
off(name, handler) {
if (!handler) handlers.delete(name);
else handlers.set(name, (handlers.get(name) || []).filter((item) => item !== handler));
},
emit,
markChanged: changed,
getGraphData() {
return {
nodes: getNodes().map(backendNode),
edges: getEdges().map(backendEdge),
};
},
render(graph, options = {}) {
historySuspended = true;
loopLayoutStates.clear();
const data = toVueFlowData(graph);
store.setNodes(data.nodes);
store.setEdges(data.edges);
nextTick(() => {
store.updateNodeInternals();
historySuspended = false;
if (options.resetHistory !== false) {
history = [];
historyIndex = -1;
recordHistory();
}
});
},
addNode(node) {
const vueNode = toVueNode(node);
store.addNodes(vueNode);
changed();
return backendNode(vueNode);
},
deleteNode(id) {
const target = store.findNode(id);
const parentId = target?.parentNode;
const childIds = target?.data?.children || [];
const removeChildren = target?.data?.flowType === "loop" && childIds.length > 0;
const nodeIds = removeChildren ? [id, ...childIds] : id;
const removedIds = new Set(Array.isArray(nodeIds) ? nodeIds : [nodeIds]);
store.removeNodes(nodeIds, true, removeChildren);
getNodes().forEach((node) => {
if (node.data.children?.includes(id) && !removedIds.has(node.id)) {
store.updateNodeData(node.id, {
...node.data,
children: node.data.children.filter((childId) => childId !== id),
}, { replace: true });
}
});
componentInstances.delete(id);
childIds.forEach((childId) => componentInstances.delete(childId));
changed();
if (parentId) nextTick(() => facade.resizeGroupToChildren(parentId));
},
addEdge(edge) {
const vueEdge = toVueEdge(edge);
store.addEdges(vueEdge);
changed();
return backendEdge(vueEdge);
},
deleteEdge(id) {
store.removeEdges(id);
changed();
},
deleteEdgeByNodeId(id) {
store.removeEdges(getEdges().filter((edge) => edge.source === id || edge.target === id));
changed();
},
getProperties(id) {
return clone(store.findNode(id)?.data?.properties || {});
},
setProperties(id, properties) {
const node = store.findNode(id);
if (!node) return;
store.updateNodeData(id, { ...node.data, properties: clone(properties) }, { replace: true });
nextTick(() => store.updateNodeInternals([id]));
changed();
},
getNodeModelById: modelFor,
getSelectElements() {
return {
nodes: (store.getSelectedNodes.value || []).map(backendNode),
edges: (store.getSelectedEdges.value || []).map(backendEdge),
};
},
clearSelectElements() {
store.removeSelectedElements();
},
selectElementById(id) {
store.removeSelectedElements();
if (!id) return;
const node = store.findNode(id);
if (node) store.addSelectedNodes([node]);
const edge = store.findEdge(id);
if (edge) store.addSelectedEdges([edge]);
},
focusOn({ id }) {
const node = store.findNode(id);
if (!node) return;
const center = backendNode(node);
store.setCenter(center.x, center.y, { zoom: Math.max(store.viewport.value?.zoom || 1, 0.7), duration: 300 });
},
fitView() {
return store.fitView({ padding: 0.18, duration: 300 });
},
translateCenter() {
return facade.fitView();
},
zoom(scale, center) {
if (Array.isArray(center) && center.length === 2) {
return store.setCenter(center[0], center[1], { zoom: scale, duration: 250 });
}
return store.zoomTo(scale, { duration: 250 });
},
getPointByClient(x, y) {
const point = store.screenToFlowCoordinate({ x, y });
return { canvasOverlayPosition: point, domOverlayPosition: point };
},
addToGroup(groupId, childId, dropCenter) {
const group = store.findNode(groupId);
const child = store.findNode(childId);
if (!group || !child || GROUP_TYPES.has(child.data.flowType)) return;
const graph = facade.getGraphData();
const groupData = graph.nodes.find((node) => node.id === groupId);
const childData = graph.nodes.find((node) => node.id === childId);
if (!groupData || !childData) return;
groupData.children = Array.from(new Set([...(groupData.children || []), childId]));
childData.properties = { ...(childData.properties || {}), parentId: groupId };
if (dropCenter) {
childData.x = dropCenter.x;
childData.y = dropCenter.y;
}
facade.render(graph, { resetHistory: false });
nextTick(() => {
store.updateNodeInternals([groupId, childId]);
changed();
});
},
prepareLoopChildDrag(childId) {
const child = store.findNode(childId);
const group = child?.parentNode ? store.findNode(child.parentNode) : null;
if (!group || group.data.flowType !== "loop") return;
store.updateNode(childId, {
extent: loopChildExtent(),
expandParent: false,
});
getLoopLayoutState(group);
const rect = getNodeRect(child);
return { groupId: group.id, position: { x: rect.x, y: rect.y } };
},
layoutLoopDuringDrag(groupId, childId, position) {
const group = store.findNode(groupId);
if (!group || group.data.flowType !== "loop" || !position) return;
layoutLoop(group, childId, position);
},
resizeGroupToChildren(groupId, options = {}) {
const group = store.findNode(groupId);
if (!group || group.data.flowType !== "loop") return;
if (layoutLoop(group) && options.markChanged !== false) changed();
},
moveNodeBy(id, dx, dy) {
const node = store.findNode(id);
if (!node) return;
store.updateNode(id, {
position: {
x: node.position.x + dx,
y: node.position.y + dy,
},
});
changed();
},
undo() {
if (historyIndex <= 0) return false;
historyIndex -= 1;
const snapshot = clone(history[historyIndex].graph);
facade.render(snapshot, { resetHistory: false });
nextTick(() => emit("history:change", { data: facade.getGraphData() }));
return true;
},
redo() {
if (historyIndex >= history.length - 1) return false;
historyIndex += 1;
const snapshot = clone(history[historyIndex].graph);
facade.render(snapshot, { resetHistory: false });
nextTick(() => emit("history:change", { data: facade.getGraphData() }));
return true;
},
registerComponentInstance(id, instance) {
if (instance) componentInstances.set(id, instance);
},
extension: {
selectionSelect: {
openSelectionSelect: () => emit("selection:open"),
closeSelectionSelect: () => emit("selection:close"),
},
},
};
return facade;
};

View File

@ -205,7 +205,7 @@
<div
class="flex align-center"
style="cursor: pointer"
@click="handleLogicFlow(item)"
@click="handleFlowView(item)"
>
<div>{{ item.name }}</div>
<div class="flex align-center" style="margin-left: 10px">
@ -410,7 +410,7 @@ function handleOpenLogDialog(row) {
const currentLog = ref({});
const handleLogicFlow = (item) => {
const handleFlowView = (item) => {
const params = {
instId: currentLog.value.id,
taskId: currentLog.value.taskId,

View File

@ -400,7 +400,7 @@
<div
class="flex align-center"
style="cursor: pointer"
@click="handleLogicFlow(item)"
@click="handleFlowView(item)"
>
<div>{{ item.name }}</div>
<div class="flex align-center" style="margin-left: 10px">
@ -685,7 +685,7 @@ function handleClose() {
openDashboard.value = false;
}
const handleLogicFlow = (item) => {
const handleFlowView = (item) => {
const params = {
instId: lastInstId.value,
taskId: currentLog.value.projectId,