feat
This commit is contained in:
@@ -19,6 +19,10 @@
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.57.0",
|
||||
"@tabler/icons-vue": "^3.40.0",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.3",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue-flow/minimap": "^1.5.4",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.13.6",
|
||||
"dayjs": "^1.11.19",
|
||||
|
||||
249
pnpm-lock.yaml
generated
249
pnpm-lock.yaml
generated
@@ -11,9 +11,21 @@ importers:
|
||||
'@arco-design/web-vue':
|
||||
specifier: ^2.57.0
|
||||
version: 2.57.0(vue@3.5.29(typescript@5.9.3))
|
||||
'@types/mockjs':
|
||||
specifier: ^1.0.10
|
||||
version: 1.0.10
|
||||
'@tabler/icons-vue':
|
||||
specifier: ^3.40.0
|
||||
version: 3.40.0(vue@3.5.29(typescript@5.9.3))
|
||||
'@vue-flow/background':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))
|
||||
'@vue-flow/controls':
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))
|
||||
'@vue-flow/core':
|
||||
specifier: ^1.48.2
|
||||
version: 1.48.2(vue@3.5.29(typescript@5.9.3))
|
||||
'@vue-flow/minimap':
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4(@vue-flow/core@1.48.2(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^14.2.1
|
||||
version: 14.2.1(vue@3.5.29(typescript@5.9.3))
|
||||
@@ -26,36 +38,27 @@ importers:
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
install:
|
||||
specifier: ^0.13.0
|
||||
version: 0.13.0
|
||||
lodash:
|
||||
specifier: ^4.17.23
|
||||
version: 4.17.23
|
||||
mitt:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
mockjs:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
nprogress:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3))
|
||||
pnpm:
|
||||
specifier: ^10.30.3
|
||||
version: 10.30.3
|
||||
query-string:
|
||||
specifier: ^9.3.1
|
||||
version: 9.3.1
|
||||
sortablejs:
|
||||
specifier: ^1.15.7
|
||||
version: 1.15.7
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
vue:
|
||||
specifier: ^3.5.29
|
||||
version: 3.5.29(typescript@5.9.3)
|
||||
@@ -81,6 +84,9 @@ importers:
|
||||
'@types/lodash':
|
||||
specifier: ^4.17.24
|
||||
version: 4.17.24
|
||||
'@types/mockjs':
|
||||
specifier: ^1.0.10
|
||||
version: 1.0.10
|
||||
'@types/nprogress':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3
|
||||
@@ -141,6 +147,9 @@ importers:
|
||||
lint-staged:
|
||||
specifier: ^16.3.2
|
||||
version: 16.3.2
|
||||
mockjs:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
postcss-html:
|
||||
specifier: ^1.8.1
|
||||
version: 1.8.1
|
||||
@@ -171,6 +180,9 @@ importers:
|
||||
stylelint-order:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(stylelint@17.4.0(typescript@5.9.3))
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
unplugin-vue-components:
|
||||
specifier: ^31.0.0
|
||||
version: 31.0.0(vue@3.5.29(typescript@5.9.3))
|
||||
@@ -856,6 +868,14 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tabler/icons-vue@3.40.0':
|
||||
resolution: {integrity: sha512-DyAOsnL6p3THhI8/74WvCFu86vlNI8U6DKtNHnYYjQpljtENLIy6i/bLiug3Lr04S71yKTWNxYyfd/8K8YFzQw==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
'@tabler/icons@3.40.0':
|
||||
resolution: {integrity: sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==}
|
||||
|
||||
'@trysound/sax@0.2.0':
|
||||
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -916,6 +936,9 @@ packages:
|
||||
'@types/vfile@3.0.2':
|
||||
resolution: {integrity: sha512-b3nLFGaGkJ9rzOcuXRfHkZMdjsawuDD0ENL9fzTophtBg8FJHSGbH7daXkEpcwy3v7Xol3pAvsmlYyFhR4pqJw==}
|
||||
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
@@ -1107,6 +1130,29 @@ packages:
|
||||
'@volar/typescript@2.4.28':
|
||||
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
||||
|
||||
'@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-flow/minimap@1.5.4':
|
||||
resolution: {integrity: sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-macros/common@3.1.2':
|
||||
resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -1185,14 +1231,23 @@ packages:
|
||||
'@vue/shared@3.5.29':
|
||||
resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==}
|
||||
|
||||
'@vueuse/core@10.11.1':
|
||||
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||
|
||||
'@vueuse/core@14.2.1':
|
||||
resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/metadata@10.11.1':
|
||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||
|
||||
'@vueuse/metadata@14.2.1':
|
||||
resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==}
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
'@vueuse/shared@14.2.1':
|
||||
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
|
||||
peerDependencies:
|
||||
@@ -1673,6 +1728,44 @@ packages:
|
||||
resolution: {integrity: sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
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'}
|
||||
|
||||
dargs@8.1.0:
|
||||
resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2472,10 +2565,6 @@ packages:
|
||||
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
install@0.13.0:
|
||||
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3280,11 +3369,6 @@ packages:
|
||||
pkg-types@2.3.0:
|
||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||
|
||||
pnpm@10.30.3:
|
||||
resolution: {integrity: sha512-yWHR4KLY41TsqlFmuCJRZmi39Ey1vZUSLVkN2Bki9gb1RzttI+xKW+Bef80Y6EiNR9l4u+mBhy8RRdBumnQAFw==}
|
||||
engines: {node: '>=18.12'}
|
||||
hasBin: true
|
||||
|
||||
posix-character-classes@0.1.1:
|
||||
resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4155,6 +4239,10 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@13.0.0:
|
||||
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
||||
hasBin: true
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
@@ -4227,6 +4315,17 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: ^3.0.0-0 || ^2.6.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-echarts@8.0.1:
|
||||
resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==}
|
||||
peerDependencies:
|
||||
@@ -5009,6 +5108,13 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@tabler/icons-vue@3.40.0(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tabler/icons': 3.40.0
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@tabler/icons@3.40.0': {}
|
||||
|
||||
'@trysound/sax@0.2.0': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
@@ -5066,6 +5172,8 @@ snapshots:
|
||||
'@types/unist': 2.0.11
|
||||
'@types/vfile-message': 2.0.0
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@8.57.0)(typescript@5.9.3))(eslint@8.57.0)(typescript@5.9.3)':
|
||||
@@ -5250,6 +5358,34 @@ snapshots:
|
||||
path-browserify: 1.0.1
|
||||
vscode-uri: 3.1.0
|
||||
|
||||
'@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.29(typescript@5.9.3))
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.29(typescript@5.9.3))
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vue-flow/core@1.48.2(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vueuse/core': 10.11.1(vue@3.5.29(typescript@5.9.3))
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
'@vue-flow/minimap@1.5.4(@vue-flow/core@1.48.2(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.29(typescript@5.9.3))
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vue-macros/common@3.1.2(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vue/compiler-sfc': 3.5.29
|
||||
@@ -5386,6 +5522,16 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.5.29': {}
|
||||
|
||||
'@vueuse/core@10.11.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
'@vueuse/metadata': 10.11.1
|
||||
'@vueuse/shared': 10.11.1(vue@3.5.29(typescript@5.9.3))
|
||||
vue-demi: 0.14.10(vue@3.5.29(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
@@ -5393,8 +5539,17 @@ snapshots:
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3))
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vueuse/metadata@10.11.1': {}
|
||||
|
||||
'@vueuse/metadata@14.2.1': {}
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.29(typescript@5.9.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/shared@14.2.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
@@ -5904,6 +6059,42 @@ snapshots:
|
||||
dependencies:
|
||||
array-find-index: 1.0.2
|
||||
|
||||
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)
|
||||
|
||||
dargs@8.1.0: {}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
@@ -6819,8 +7010,6 @@ snapshots:
|
||||
|
||||
ini@4.1.1: {}
|
||||
|
||||
install@0.13.0: {}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -7605,8 +7794,6 @@ snapshots:
|
||||
exsolve: 1.0.8
|
||||
pathe: 2.0.3
|
||||
|
||||
pnpm@10.30.3: {}
|
||||
|
||||
posix-character-classes@0.1.1: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
@@ -8674,6 +8861,8 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@13.0.0: {}
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
dependencies:
|
||||
spdx-correct: 3.2.0
|
||||
@@ -8736,6 +8925,10 @@ snapshots:
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.29(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
vue-echarts@8.0.1(echarts@6.0.0)(vue@3.5.29(typescript@5.9.3)):
|
||||
dependencies:
|
||||
echarts: 6.0.0
|
||||
|
||||
174
src/api/ops/netarchTopo.ts
Normal file
174
src/api/ops/netarchTopo.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { request } from '@/api/request'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/** 拓扑对象 */
|
||||
export interface Topology {
|
||||
id: number
|
||||
name: string
|
||||
type: 'layer2' | 'layer3' | 'physical'
|
||||
description?: string
|
||||
group_id?: number
|
||||
group_name?: string
|
||||
node_data?: string
|
||||
collector_id?: number
|
||||
status?: 'pending' | 'discovering' | 'active' | 'error'
|
||||
discovery_config?: string
|
||||
last_discovery?: string
|
||||
next_discovery?: string
|
||||
node_count?: number
|
||||
link_count?: number
|
||||
enable?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 链路对象 */
|
||||
export interface Link {
|
||||
id?: number
|
||||
topology_id?: number
|
||||
source: string
|
||||
target: string
|
||||
type?: 'physical' | 'virtual'
|
||||
label?: string
|
||||
bandwidth?: number
|
||||
latency?: number
|
||||
packet_loss?: number
|
||||
status?: 'up' | 'down' | 'unknown'
|
||||
in_traffic?: number
|
||||
out_traffic?: number
|
||||
metadata?: string
|
||||
description?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 拓扑分组 */
|
||||
export interface TopologyGroup {
|
||||
id: number
|
||||
name: string
|
||||
parent_id?: number
|
||||
level?: number
|
||||
path?: string
|
||||
description?: string
|
||||
sort?: number
|
||||
enable?: boolean
|
||||
topology_count?: number
|
||||
total_link_count?: number
|
||||
total_topology_count?: number
|
||||
children?: TopologyGroup[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 拓扑节点对象 */
|
||||
export interface TopologyNode {
|
||||
id: string
|
||||
topology_id: number
|
||||
label: string
|
||||
type: string
|
||||
ip?: string
|
||||
status?: string
|
||||
alerts?: number
|
||||
traffic?: string
|
||||
description?: string
|
||||
parentId?: string | null
|
||||
level?: number
|
||||
position?: { x: number; y: number }
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// ==================== 分组管理接口 ====================
|
||||
|
||||
/** 获取分组列表 */
|
||||
export const fetchTopologyGroups = (data?: {
|
||||
parent_id?: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
keyword?: string;
|
||||
enable?: boolean
|
||||
}) =>
|
||||
request.get('/DC-Control/v1/topology-groups', { params: data })
|
||||
|
||||
/** 创建分组 */
|
||||
export const createTopologyGroup = (data: Partial<TopologyGroup>) =>
|
||||
request.post('/DC-Control/v1/topology-groups', data)
|
||||
|
||||
/** 更新分组 */
|
||||
export const updateTopologyGroup = (data: Partial<TopologyGroup> & { id: number }) =>
|
||||
request.put(`/DC-Control/v1/topology-groups/${data.id}`, data)
|
||||
|
||||
/** 删除分组 */
|
||||
export const deleteTopologyGroup = (id: number) =>
|
||||
request.delete(`/DC-Control/v1/topology-groups/${id}`)
|
||||
|
||||
// ==================== 拓扑管理接口 ====================
|
||||
|
||||
/** 获取拓扑列表 */
|
||||
export const fetchTopologies = (data: { page: number; size: number; keyword?: string; group_id?: number }) =>
|
||||
request.get('/DC-Control/v1/topologies', { params: data })
|
||||
|
||||
/** 获取拓扑详情 */
|
||||
export const fetchTopologyDetail = (id: number) =>
|
||||
request.get(`/DC-Control/v1/topologies/${id}`)
|
||||
|
||||
/** 创建拓扑 */
|
||||
export const createTopology = (data: Partial<Topology>) =>
|
||||
request.post('/DC-Control/v1/topologies', data)
|
||||
|
||||
/** 更新拓扑 */
|
||||
export const updateTopology = (data: Partial<Topology> & { id: number }) =>
|
||||
request.put(`/DC-Control/v1/topologies/${data.id}`, data)
|
||||
|
||||
/** 删除拓扑 */
|
||||
export const deleteTopology = (id: number) =>
|
||||
request.delete(`/DC-Control/v1/topologies/${id}`)
|
||||
|
||||
/** 触发拓扑发现 */
|
||||
export const discoverTopology = (id: number) =>
|
||||
request.post(`/DC-Control/v1/topologies/${id}/discover`)
|
||||
|
||||
/** 获取拓扑视图(包含拓扑和链路) */
|
||||
export const fetchTopologyView = (id: number) =>
|
||||
request.get(`/DC-Control/v1/topologies/${id}/view`)
|
||||
|
||||
/** 获取拓扑图数据(用于前端可视化) */
|
||||
export const fetchTopologyGraph = (id: number) =>
|
||||
request.get(`/DC-Control/v1/topologies/${id}/graph`)
|
||||
|
||||
// ==================== 节点管理接口 ====================
|
||||
|
||||
/** 创建节点 */
|
||||
export const createNode = (topologyId: number, data: Partial<TopologyNode>) =>
|
||||
request.post(`/DC-Control/v1/topologies/${topologyId}/nodes`, data)
|
||||
|
||||
/** 更新节点 */
|
||||
export const updateNode = (topologyId: number, nodeId: string, data: Partial<TopologyNode>) =>
|
||||
request.put(`/DC-Control/v1/topologies/${topologyId}/nodes/${nodeId}`, data)
|
||||
|
||||
/** 删除节点 */
|
||||
export const deleteNode = (topologyId: number, nodeId: string) =>
|
||||
request.delete(`/DC-Control/v1/topologies/${topologyId}/nodes/${nodeId}`)
|
||||
|
||||
/** 批量更新节点位置 */
|
||||
export const updateNodesPositions = (topologyId: number, positions: Array<{ id: string; position: { x: number; y: number } }>) =>
|
||||
request.put(`/DC-Control/v1/topologies/${topologyId}/nodes/positions`, { positions })
|
||||
|
||||
// ==================== 链路管理接口 ====================
|
||||
|
||||
/** 获取链路列表 */
|
||||
export const fetchLinks = (topologyId: number) =>
|
||||
request.get(`/DC-Control/v1/topologies/${topologyId}/links`)
|
||||
|
||||
/** 创建链路 */
|
||||
export const createLink = (topologyId: number, data: Partial<Link>) =>
|
||||
request.post(`/DC-Control/v1/topologies/${topologyId}/links`, data)
|
||||
|
||||
/** 更新链路 */
|
||||
export const updateLink = (topologyId: number, linkId: number, data: Partial<Link>) =>
|
||||
request.put(`/DC-Control/v1/topologies/${topologyId}/links/${linkId}`, data)
|
||||
|
||||
/** 删除链路 */
|
||||
export const deleteLink = (topologyId: number, linkId: string) =>
|
||||
request.delete(`/DC-Control/v1/topologies/${topologyId}/links/${linkId}`)
|
||||
@@ -43,7 +43,14 @@
|
||||
<a-layout class="layout-content" :style="paddingStyle">
|
||||
<!-- <TabBar v-if="appStore.tabBar" /> -->
|
||||
<a-layout-content>
|
||||
<PageLayout />
|
||||
<div v-if="showIframe" class="iframe-container">
|
||||
<iframe
|
||||
:src="iframeUrl"
|
||||
frameborder="0"
|
||||
class="web-iframe"
|
||||
></iframe>
|
||||
</div>
|
||||
<PageLayout v-else />
|
||||
</a-layout-content>
|
||||
<Footer v-if="footer" />
|
||||
</a-layout>
|
||||
@@ -63,6 +70,7 @@ import { useAppStore, useUserStore } from '@/store'
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PageLayout from './page-layout.vue'
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
const isInit = ref(false)
|
||||
const appStore = useAppStore()
|
||||
@@ -82,6 +90,47 @@ const menuWidth = computed(() => {
|
||||
})
|
||||
|
||||
console.log('route', route)
|
||||
const showIframe = ref(false)
|
||||
const iframeUrl = ref('')
|
||||
|
||||
const updateShowIframe = () => {
|
||||
const isWebPage = route?.meta?.is_web_page === true
|
||||
const webUrl = route?.meta?.web_url
|
||||
|
||||
if (isWebPage && webUrl && typeof webUrl === 'string') {
|
||||
showIframe.value = true
|
||||
// Add token as query parameter to the URL
|
||||
const token = getToken()
|
||||
if (token && typeof token === 'string') {
|
||||
// Parse the URL and add token parameter
|
||||
try {
|
||||
const url = new URL(webUrl)
|
||||
url.searchParams.set('token', token)
|
||||
iframeUrl.value = url.toString()
|
||||
} catch (error) {
|
||||
// If URL is invalid, use it as is
|
||||
console.warn('Invalid URL provided for iframe:', webUrl)
|
||||
iframeUrl.value = webUrl
|
||||
}
|
||||
} else {
|
||||
// No token available, use URL as is
|
||||
iframeUrl.value = webUrl
|
||||
}
|
||||
} else {
|
||||
showIframe.value = false
|
||||
iframeUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for route changes to update iframe display
|
||||
watch(
|
||||
() => [route.fullPath, route.meta],
|
||||
() => {
|
||||
updateShowIframe()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const paddingStyle = computed(() => {
|
||||
const paddingLeft = renderMenu.value && !hideMenu.value ? { paddingLeft: `${menuWidth.value}px` } : {}
|
||||
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {}
|
||||
@@ -221,5 +270,17 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.iframe-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100vh - @nav-size-height);
|
||||
|
||||
.web-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,9 +26,13 @@ export default {
|
||||
'menu.server.monitor': 'Monitor-Server',
|
||||
'menu.list': 'List',
|
||||
'menu.ops': 'Operations',
|
||||
'menu.ops.netarch': 'Network Architecture',
|
||||
'menu.ops.netarch.topoGroup': 'Topology Group Management',
|
||||
'menu.ops.netarch.topo': 'Topology',
|
||||
'menu.ops.systemSettings': 'System Settings',
|
||||
'menu.ops.systemSettings.menuManagement': 'Menu Management',
|
||||
'menu.ops.systemSettings.systemLogs': 'System Logs',
|
||||
'menu.ops.webTest': 'Web Test',
|
||||
'menu.management': 'Menu Management',
|
||||
'menu.addRoot': 'Add Root Menu',
|
||||
'menu.tip': 'Click a menu item to edit, hover to show action buttons',
|
||||
@@ -83,6 +87,7 @@ export default {
|
||||
'menu.user': 'User Center',
|
||||
'menu.arcoWebsite': '外链',
|
||||
'menu.faq': 'FAQ',
|
||||
'common.comingSoon': 'Feature under development, please stay tuned...',
|
||||
'navbar.docs': 'Docs',
|
||||
'navbar.action.locale': 'Switch to English',
|
||||
...localeSettings,
|
||||
|
||||
@@ -26,9 +26,13 @@ export default {
|
||||
'menu.server.monitor': '实时监控-服务端',
|
||||
'menu.list': '列表页',
|
||||
'menu.ops': '运维管理',
|
||||
'menu.ops.netarch': '网络架构',
|
||||
'menu.ops.netarch.topoGroup': '拓扑组管理',
|
||||
'menu.ops.netarch.topo': '拓扑图',
|
||||
'menu.ops.systemSettings': '系统设置',
|
||||
'menu.ops.systemSettings.menuManagement': '菜单管理',
|
||||
'menu.ops.systemSettings.systemLogs': '系统日志',
|
||||
'menu.ops.webTest': '网页测试',
|
||||
'menu.management': '菜单管理',
|
||||
'menu.addRoot': '添加根菜单',
|
||||
'menu.tip': '点击菜单项可编辑,悬停显示操作按钮',
|
||||
@@ -83,6 +87,7 @@ export default {
|
||||
'menu.user': '个人中心',
|
||||
'menu.arcoWebsite': '外链',
|
||||
'menu.faq': '常见问题',
|
||||
'common.comingSoon': '功能开发中,敬请期待...',
|
||||
'navbar.docs': '文档中心',
|
||||
'navbar.action.locale': '切换为中文',
|
||||
...localeSettings,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ServerMenuItem extends TreeNodeBase {
|
||||
// 预定义的视图模块映射(用于 Vite 动态导入)
|
||||
const viewModules = import.meta.glob('@/views/**/*.vue')
|
||||
|
||||
console.log('viewModules', viewModules)
|
||||
/**
|
||||
* 动态加载视图组件
|
||||
* @param componentPath 组件路径,如 'ops/pages/overview' 或 'ops/pages/overview/index'
|
||||
@@ -36,24 +37,33 @@ const viewModules = import.meta.glob('@/views/**/*.vue')
|
||||
*/
|
||||
export function loadViewComponent(componentPath: string) {
|
||||
// 将路径转换为完整的视图路径
|
||||
// 如果路径不以 /index 结尾,自动补全
|
||||
let fullPath = componentPath
|
||||
if (!fullPath.endsWith('/index') && !fullPath.endsWith('.vue')) {
|
||||
fullPath = `${fullPath}/index`
|
||||
// 如果路径不以 /index 结尾且不以 .vue 结尾,自动补全
|
||||
let normalizedPath = componentPath
|
||||
if (!normalizedPath.endsWith('/index') && !normalizedPath.endsWith('.vue')) {
|
||||
normalizedPath = `${normalizedPath}/index`
|
||||
}
|
||||
|
||||
// 构建完整的文件路径
|
||||
const filePath = `/src/views/${fullPath}.vue`
|
||||
const filePath = `/src/views/${normalizedPath}.vue`
|
||||
console.log('filePath', filePath)
|
||||
|
||||
// 从预加载的模块中查找
|
||||
const modulePath = Object.keys(viewModules).find((path) => path.endsWith(filePath) || path === filePath)
|
||||
|
||||
console.log('modulePath', modulePath)
|
||||
if (modulePath && viewModules[modulePath]) {
|
||||
return viewModules[modulePath]
|
||||
}
|
||||
|
||||
// 如果找不到,返回一个默认组件或抛出错误
|
||||
console.warn(`View component not found: ${filePath}`)
|
||||
// 如果找不到,尝试不带 /index 的路径
|
||||
const directFilePath = `/src/views/${componentPath}.vue`
|
||||
const directModulePath = Object.keys(viewModules).find((path) => path.endsWith(directFilePath) || path === directFilePath)
|
||||
|
||||
if (directModulePath && viewModules[directModulePath]) {
|
||||
return viewModules[directModulePath]
|
||||
}
|
||||
|
||||
// 如果都找不到,返回一个默认组件或抛出错误
|
||||
console.warn(`View component not found: ${filePath} or ${directFilePath}`)
|
||||
return () => import('@/views/redirect/index.vue')
|
||||
}
|
||||
|
||||
@@ -70,6 +80,7 @@ export function transformMenuToRoutes(menuItems: ServerMenuItem[]): AppRouteReco
|
||||
path: item.menu_path || '',
|
||||
name: item.title || item.name || `menu_${item.id}`,
|
||||
meta: {
|
||||
// ...item,
|
||||
locale: item.locale || item.title,
|
||||
requiresAuth: item.requiresAuth !== false,
|
||||
icon: item.icon || item?.menu_icon,
|
||||
@@ -87,6 +98,7 @@ export function transformMenuToRoutes(menuItems: ServerMenuItem[]): AppRouteReco
|
||||
// 传递父级的 component 和 path 给子路由处理函数
|
||||
route.children = transformChildRoutes(item.children, item.component, item.menu_path)
|
||||
} else if (item.component) {
|
||||
console.log('menu item component:', item.component)
|
||||
// 一级菜单没有 children 但有 component,创建一个空路径的子路由
|
||||
const routeName = route.name
|
||||
route.children = [
|
||||
@@ -97,7 +109,7 @@ export function transformMenuToRoutes(menuItems: ServerMenuItem[]): AppRouteReco
|
||||
meta: {
|
||||
locale: item.locale || item.title,
|
||||
requiresAuth: item.requiresAuth !== false,
|
||||
isNewTab: item.is_new_tab
|
||||
isNewTab: item.is_new_tab,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -159,7 +171,7 @@ function transformChildRoutes(
|
||||
return children.map((child) => {
|
||||
// 优先使用子菜单自己的 component,否则继承父级的 component
|
||||
const componentPath = child.component || parentComponent
|
||||
|
||||
console.log('child component:', componentPath)
|
||||
// 计算子路由的相对路径
|
||||
const childFullPath = child.menu_path || child.path || ''
|
||||
const relativePath = extractRelativePath(childFullPath, parentPath || '')
|
||||
@@ -168,6 +180,7 @@ function transformChildRoutes(
|
||||
path: relativePath,
|
||||
name: child.title || child.name || `menu_${child.id}`,
|
||||
meta: {
|
||||
...child,
|
||||
locale: child.locale || child.title,
|
||||
requiresAuth: child.requiresAuth !== false,
|
||||
roles: child.roles,
|
||||
|
||||
@@ -32,6 +32,38 @@ const OPS: AppRouteRecordRaw = {
|
||||
roles: ['*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'web-test',
|
||||
name: 'WebTest',
|
||||
component: () => import('@/views/ops/pages/help/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.ops.webTest',
|
||||
requiresAuth: true,
|
||||
roles: ['*'],
|
||||
is_web_page: true,
|
||||
web_url: 'https://www.baidu.com'
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'netarch/topo-group',
|
||||
name: 'TopologyGroup',
|
||||
component: () => import('@/views/ops/pages/netarch/topo-group/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.ops.netarch.topoGroup',
|
||||
requiresAuth: true,
|
||||
roles: ['*'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'netarch/topo',
|
||||
name: 'Topology',
|
||||
component: () => import('@/views/ops/pages/netarch/topo/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.ops.netarch.topo',
|
||||
requiresAuth: true,
|
||||
roles: ['*'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
2
src/router/typings.d.ts
vendored
2
src/router/typings.d.ts
vendored
@@ -12,5 +12,7 @@ declare module 'vue-router' {
|
||||
order?: number // Sort routing menu items. If set key, the higher the value, the more forward it is
|
||||
noAffix?: boolean // if set true, the tag will not affix in the tab-bar
|
||||
ignoreCache?: boolean // if set true, the page will not be cached
|
||||
is_web_page?: boolean // If true and web_url exists, display as iframe
|
||||
web_url?: string // The URL to display in iframe when is_web_page is true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,19 +81,19 @@ const useAppStore = defineStore('app', {
|
||||
this.serverMenu = routes as unknown as RouteRecordNormalized[]
|
||||
} else {
|
||||
// 如果接口返回数据为空,使用本地数据
|
||||
localMenuData.forEach((route) => {
|
||||
router.addRoute(route as any)
|
||||
})
|
||||
this.serverMenu = localMenuData as unknown as RouteRecordNormalized[]
|
||||
// localMenuData.forEach((route) => {
|
||||
// router.addRoute(route as any)
|
||||
// })
|
||||
// this.serverMenu = localMenuData as unknown as RouteRecordNormalized[]
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// 接口失败时使用本地数据
|
||||
console.error('fetchServerMenuConfig error:', error)
|
||||
localMenuData.forEach((route) => {
|
||||
router.addRoute(route as any)
|
||||
})
|
||||
this.serverMenu = localMenuData as unknown as RouteRecordNormalized[]
|
||||
// localMenuData.forEach((route) => {
|
||||
// router.addRoute(route as any)
|
||||
// })
|
||||
// this.serverMenu = localMenuData as unknown as RouteRecordNormalized[]
|
||||
}
|
||||
},
|
||||
clearServerMenu() {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="添加子分组"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-form
|
||||
:model="formData"
|
||||
layout="vertical"
|
||||
:label-col-props="{ span: 24 }"
|
||||
:wrapper-col-props="{ span: 24 }"
|
||||
>
|
||||
<!-- 父分组提示 -->
|
||||
<a-alert
|
||||
v-if="parentGroup"
|
||||
type="info"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<template #icon><icon-info-circle /></template>
|
||||
<div>
|
||||
<div style="color: var(--color-text-3); font-size: 12px; margin-bottom: 4px;">
|
||||
将作为以下分组的子分组:
|
||||
</div>
|
||||
<div style="font-weight: 600; color: rgb(var(--primary-6));">
|
||||
{{ parentGroup.name }}
|
||||
</div>
|
||||
</div>
|
||||
</a-alert>
|
||||
|
||||
<a-form-item label="分组名称" required>
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入子分组名称"
|
||||
:max-length="100"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="排序权重">
|
||||
<a-input-number
|
||||
v-model="formData.sort"
|
||||
placeholder="请输入排序权重"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
<template #extra>
|
||||
<span style="color: var(--color-text-3); font-size: 12px;">数字越小越靠前</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否启用">
|
||||
<a-switch v-model="formData.enable" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="分组描述">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入分组描述(可选)"
|
||||
:max-length="500"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, reactive } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconInfoCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { createTopologyGroup } from '@/api/ops/netarchTopo'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
parentGroup?: { id: number; name: string } | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parentGroup: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
parent_id: 0,
|
||||
description: '',
|
||||
sort: 0,
|
||||
enable: true,
|
||||
})
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val && props.parentGroup) {
|
||||
formData.name = ''
|
||||
formData.parent_id = props.parentGroup.id
|
||||
formData.description = ''
|
||||
formData.sort = 0
|
||||
formData.enable = true
|
||||
}
|
||||
})
|
||||
|
||||
// 更新可见性
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
Message.warning('请输入分组名称')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const response = await createTopologyGroup(formData)
|
||||
if (response.code === 0) {
|
||||
Message.success('子分组创建成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} else {
|
||||
Message.error(response.message || '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建子分组失败:', error)
|
||||
Message.error('创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'SubGroupFormDialog',
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="mode === 'create' ? '新增拓扑' : '编辑拓扑'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-form
|
||||
:model="formData"
|
||||
layout="vertical"
|
||||
:label-col-props="{ span: 24 }"
|
||||
:wrapper-col-props="{ span: 24 }"
|
||||
>
|
||||
<a-form-item label="拓扑名称" required>
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入拓扑名称"
|
||||
:max-length="100"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否启用">
|
||||
<a-select v-model="formData.enable" placeholder="请选择">
|
||||
<a-option :value="true">是</a-option>
|
||||
<a-option :value="false">否</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入描述(可选)"
|
||||
:max-length="500"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, reactive } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { Topology } from '@/api/ops/netarchTopo'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
mode: 'create' | 'edit'
|
||||
initialValues?: Topology | null
|
||||
groupId?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'submit', values: any): Promise<void>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
initialValues: null,
|
||||
groupId: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
type: 'layer2' as 'layer2' | 'layer3' | 'physical',
|
||||
description: '',
|
||||
enable: true,
|
||||
})
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
if (props.mode === 'edit' && props.initialValues) {
|
||||
formData.name = props.initialValues.name || ''
|
||||
formData.type = props.initialValues.type || 'layer2'
|
||||
formData.description = props.initialValues.description || ''
|
||||
formData.enable = props.initialValues.enable ?? true
|
||||
} else {
|
||||
formData.name = ''
|
||||
formData.type = 'layer2'
|
||||
formData.description = ''
|
||||
formData.enable = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 更新可见性
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
Message.warning('请输入拓扑名称')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await emit('submit', { ...formData })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'TopologyFormDialog',
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="拓扑列表"
|
||||
:footer="false"
|
||||
width="80%"
|
||||
@cancel="handleClose"
|
||||
@update:visible="handleUpdateVisible"
|
||||
>
|
||||
<template #title>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<span>拓扑列表 {{ groupName ? `- ${groupName}` : '' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-button type="primary" style="margin-bottom: 16px;" @click="handleOpenCreate">
|
||||
<template #icon><icon-plus /></template>
|
||||
新增拓扑
|
||||
</a-button>
|
||||
|
||||
<a-spin :loading="loading" style="width: 100%;">
|
||||
<a-alert v-if="error" type="error" :content="error" style="margin-bottom: 16px;" />
|
||||
<a-empty v-else-if="!loading && data.length === 0" description="暂无拓扑数据" />
|
||||
<div v-else>
|
||||
<a-table
|
||||
:data="data"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: true,
|
||||
}"
|
||||
size="small"
|
||||
@page-change="handlePageChange"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="序号" data-index="id" :width="80">
|
||||
<template #cell="{ rowIndex }">
|
||||
{{ (page - 1) * pageSize + rowIndex + 1 }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="拓扑名称" data-index="name" />
|
||||
<a-table-column title="类型" data-index="type" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="getTypeColor(record.type)">
|
||||
{{ getTypeLabel(record.type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" data-index="status" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="节点数" data-index="node_count" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
{{ record.node_count || 0 }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="链路数" data-index="link_count" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
{{ record.link_count || 0 }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="启用" data-index="enable" :width="80" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="record.enable ? 'blue' : 'gray'" bordered>
|
||||
{{ record.enable ? '是' : '否' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" data-index="created_at" :width="160" align="center">
|
||||
<template #cell="{ record }">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="200" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space>
|
||||
<a-button type="text" size="small" @click="handleViewTopology(record)">
|
||||
拓扑
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleOpenEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<!-- 表单弹窗 -->
|
||||
<TopologyFormDialog
|
||||
v-model:visible="formDialogVisible"
|
||||
:mode="formMode"
|
||||
:initial-values="currentEditItem"
|
||||
:group-id="groupId"
|
||||
@submit="handleFormSubmit"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Topology } from '@/api/ops/netarchTopo'
|
||||
import {
|
||||
fetchTopologies,
|
||||
createTopology,
|
||||
updateTopology,
|
||||
deleteTopology,
|
||||
fetchTopologyDetail,
|
||||
} from '@/api/ops/netarchTopo'
|
||||
import TopologyFormDialog from './TopologyFormDialog.vue'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
groupId: number
|
||||
groupName?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:open', value: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
groupName: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<Topology[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 表单弹窗状态
|
||||
const formDialogVisible = ref(false)
|
||||
const formMode = ref<'create' | 'edit'>('create')
|
||||
const currentEditItem = ref<Topology | null>(null)
|
||||
|
||||
// 监听 open 变化
|
||||
watch(() => props.open, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
|
||||
// 更新可见性
|
||||
const handleUpdateVisible = (val: boolean) => {
|
||||
visible.value = val
|
||||
if (!val) {
|
||||
emit('update:open', false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (!props.groupId) {
|
||||
return
|
||||
}
|
||||
const response = await fetchTopologies({
|
||||
group_id: props.groupId,
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
})
|
||||
|
||||
if (response.code === 0) {
|
||||
data.value = response.details?.data || []
|
||||
total.value = response.details?.total || 0
|
||||
} else {
|
||||
error.value = response.message || '加载失败'
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理页码变更
|
||||
const handlePageChange = (current: number) => {
|
||||
page.value = current
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 跳转到拓扑详情页
|
||||
const handleViewTopology = (topology: Topology) => {
|
||||
window.open(`/#/netarch/topo?id=${topology.id}`, '_blank')
|
||||
}
|
||||
|
||||
// 打开新增弹窗
|
||||
const handleOpenCreate = () => {
|
||||
formMode.value = 'create'
|
||||
currentEditItem.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 打开编辑弹窗
|
||||
const handleOpenEdit = async (topology: Topology) => {
|
||||
try {
|
||||
const response = await fetchTopologyDetail(topology.id)
|
||||
if (response.code === 0) {
|
||||
formMode.value = 'edit'
|
||||
currentEditItem.value = response.details
|
||||
formDialogVisible.value = true
|
||||
} else {
|
||||
Message.error(response.message || '获取详情失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
Message.error(err.message || '获取详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = (topology: Topology) => {
|
||||
Modal.confirm({
|
||||
title: '删除确认',
|
||||
content: `确定要删除拓扑「${topology.name}」吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await deleteTopology(topology.id)
|
||||
if (response.code === 0) {
|
||||
Message.success('删除成功')
|
||||
loadData()
|
||||
} else {
|
||||
Message.error(response.message || '删除失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
Message.error(err.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 表单提交
|
||||
const handleFormSubmit = async (values: any) => {
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
group_id: props.groupId,
|
||||
}
|
||||
|
||||
if (formMode.value === 'create') {
|
||||
const response = await createTopology(payload)
|
||||
if (response.code === 0) {
|
||||
Message.success('新增成功')
|
||||
formDialogVisible.value = false
|
||||
loadData()
|
||||
} else {
|
||||
throw new Error(response.message || '新增失败')
|
||||
}
|
||||
} else {
|
||||
const response = await updateTopology({ ...payload, id: currentEditItem.value!.id })
|
||||
if (response.code === 0) {
|
||||
Message.success('编辑成功')
|
||||
formDialogVisible.value = false
|
||||
loadData()
|
||||
} else {
|
||||
throw new Error(response.message || '编辑失败')
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
Message.error(err.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
pending: 'gray',
|
||||
discovering: 'blue',
|
||||
active: 'green',
|
||||
error: 'red',
|
||||
}
|
||||
return colorMap[status || ''] || 'gray'
|
||||
}
|
||||
|
||||
// 获取状态标签
|
||||
const getStatusLabel = (status?: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
discovering: '发现中',
|
||||
active: '活跃',
|
||||
error: '错误',
|
||||
}
|
||||
return labelMap[status || ''] || status || '-'
|
||||
}
|
||||
|
||||
// 获取类型标签
|
||||
const getTypeLabel = (type?: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
layer2: '二层',
|
||||
layer3: '三层',
|
||||
physical: '物理',
|
||||
}
|
||||
return typeMap[type || ''] || type || '-'
|
||||
}
|
||||
|
||||
// 获取类型颜色
|
||||
const getTypeColor = (type?: string) => {
|
||||
return 'arcoblue'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time?: string) => {
|
||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'TopologyListDialog',
|
||||
}
|
||||
</script>
|
||||
88
src/views/ops/pages/netarch/topo-group/config/columns.ts
Normal file
88
src/views/ops/pages/netarch/topo-group/config/columns.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 表格列配置 - 网络拓扑分组
|
||||
*/
|
||||
import dayjs from 'dayjs'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
|
||||
export const getColumns = (): TableColumnData[] => [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tree',
|
||||
width: 20,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '分组名称',
|
||||
dataIndex: 'name',
|
||||
width: 250,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'parent_id',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
slotName: 'level',
|
||||
},
|
||||
{
|
||||
title: '分组路径',
|
||||
dataIndex: 'path',
|
||||
width: 300,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: '拓扑数量',
|
||||
dataIndex: 'topology_count',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
slotName: 'topology_count',
|
||||
},
|
||||
{
|
||||
title: '总链路数',
|
||||
dataIndex: 'total_link_count',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
slotName: 'total_link_count',
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enable',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
slotName: 'enable',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 160,
|
||||
align: 'center',
|
||||
render: ({ record }: any) => {
|
||||
return record.created_at ? dayjs(record.created_at).format('YYYY-MM-DD HH:mm') : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'operation',
|
||||
width: 300,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
slotName: 'operation',
|
||||
},
|
||||
]
|
||||
26
src/views/ops/pages/netarch/topo-group/config/filters.ts
Normal file
26
src/views/ops/pages/netarch/topo-group/config/filters.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 筛选项配置 - 网络拓扑分组
|
||||
*/
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import { enableOptions } from './options'
|
||||
|
||||
export const getFilters = (): FormItem[] => [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '搜索分组名称或描述',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'enable',
|
||||
label: '启用状态',
|
||||
type: 'select',
|
||||
placeholder: '请选择启用状态',
|
||||
span: 6,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
...enableOptions,
|
||||
],
|
||||
},
|
||||
]
|
||||
9
src/views/ops/pages/netarch/topo-group/config/options.ts
Normal file
9
src/views/ops/pages/netarch/topo-group/config/options.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 选项配置
|
||||
*/
|
||||
|
||||
/** 启用状态选项 */
|
||||
export const enableOptions = [
|
||||
{ label: '启用', value: 'true' },
|
||||
{ label: '禁用', value: 'false' },
|
||||
]
|
||||
493
src/views/ops/pages/netarch/topo-group/index.vue
Normal file
493
src/views/ops/pages/netarch/topo-group/index.vue
Normal file
@@ -0,0 +1,493 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<Breadcrumb :items="['运维管理', '网络架构', '拓扑分组']" />
|
||||
|
||||
<SearchTable
|
||||
title="拓扑分组管理"
|
||||
:form-model="searchForm"
|
||||
:form-items="filters"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
}"
|
||||
@page-change="handlePageChange"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@refresh="fetchData"
|
||||
>
|
||||
<!-- 工具栏左侧 - 新增按钮 -->
|
||||
<template #toolbar-left>
|
||||
<a-button type="primary" @click="handleAddGroup">
|
||||
<template #icon><icon-plus /></template>
|
||||
新增分组
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #operation="{ record }">
|
||||
<a-space>
|
||||
<!-- 添加子分组:只在顶层分组时显示 (order: 50) -->
|
||||
<a-button
|
||||
v-if="!record.parent_id || record.parent_id === 0"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleAddSubGroup(record)"
|
||||
>
|
||||
添加子分组
|
||||
</a-button>
|
||||
<!-- 详情:只在有父分组时显示 (order: 100) -->
|
||||
<a-button
|
||||
v-if="!!record.parent_id && record.parent_id !== 0"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleViewDetail(record)"
|
||||
>
|
||||
详情
|
||||
</a-button>
|
||||
<!-- 拓扑:只在有父分组时显示 (order: 150) -->
|
||||
<a-button
|
||||
v-if="!!record.parent_id && record.parent_id !== 0"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleViewTopologies(record)"
|
||||
>
|
||||
拓扑
|
||||
</a-button>
|
||||
<!-- 编辑:始终显示 (order: 200) -->
|
||||
<a-button type="text" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<!-- 删除:始终显示 (order: 300) -->
|
||||
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 层级列 -->
|
||||
<template #level="{ record }">
|
||||
<a-tag
|
||||
:color="record.parent_id === 0 ? 'blue' : 'arcoblue'"
|
||||
bordered
|
||||
>
|
||||
{{ record.parent_id === 0 ? '顶层' : `${record.level || 1}级` }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 拓扑数量列 -->
|
||||
<template #topology_count="{ record }">
|
||||
<a-tag
|
||||
:color="(record.parent_id ? record.total_topology_count : record.topology_count) > 0 ? 'green' : 'gray'"
|
||||
>
|
||||
{{ record.parent_id ? record.total_topology_count : record.topology_count || 0 }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 总链路数列 -->
|
||||
<template #total_link_count="{ record }">
|
||||
<a-tag
|
||||
:color="record.total_link_count > 0 ? 'green' : 'gray'"
|
||||
>
|
||||
{{ record.total_link_count || 0 }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 启用列 -->
|
||||
<template #enable="{ record }">
|
||||
<a-tag
|
||||
:color="record.enable ? 'blue' : 'gray'"
|
||||
bordered
|
||||
>
|
||||
{{ record.enable ? '是' : '否' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</SearchTable>
|
||||
|
||||
<!-- 分组表单弹窗 -->
|
||||
<a-modal
|
||||
v-model:visible="formModalVisible"
|
||||
:title="formModalTitle"
|
||||
:mask-closable="false"
|
||||
:width="520"
|
||||
@ok="handleFormModalOk"
|
||||
@cancel="handleFormModalCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col-props="{ span: 6 }"
|
||||
:wrapper-col-props="{ span: 16 }"
|
||||
>
|
||||
<a-form-item field="name" label="分组名称" validate-trigger="blur">
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入分组名称"
|
||||
:max-length="100"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item field="description" label="分组描述">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入分组描述(可选)"
|
||||
:max-length="500"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item field="sort" label="排序权重">
|
||||
<a-input-number
|
||||
v-model="formData.sort"
|
||||
placeholder="请输入排序权重"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
<template #extra>
|
||||
<span style="color: var(--color-text-3); font-size: 12px;">数字越小越靠前</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item field="enable" label="是否启用">
|
||||
<a-switch v-model="formData.enable" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="deleteConfirmVisible"
|
||||
title="删除确认"
|
||||
@ok="handleConfirmDelete"
|
||||
@cancel="deleteConfirmVisible = false"
|
||||
>
|
||||
<p>确定要删除分组 "{{ groupToDelete?.name }}" 吗?</p>
|
||||
<p v-if="groupToDelete?.topology_count && groupToDelete.topology_count > 0" style="color: rgb(var(--warning-6));">
|
||||
该分组下还有 {{ groupToDelete.topology_count }} 个拓扑,删除分组不会删除拓扑。
|
||||
</p>
|
||||
</a-modal>
|
||||
|
||||
<!-- 拓扑列表弹窗 -->
|
||||
<TopologyListDialog
|
||||
v-model:open="topologyListDialogVisible"
|
||||
:group-id="currentGroupId"
|
||||
:group-name="currentGroupName"
|
||||
/>
|
||||
|
||||
<!-- 添加子分组弹窗 -->
|
||||
<SubGroupFormDialog
|
||||
v-model:visible="subGroupDialogVisible"
|
||||
:parent-group="currentParentGroup"
|
||||
@success="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
import type { TopologyGroup } from '@/api/ops/netarchTopo'
|
||||
import {
|
||||
fetchTopologyGroups,
|
||||
createTopologyGroup,
|
||||
updateTopologyGroup,
|
||||
deleteTopologyGroup,
|
||||
} from '@/api/ops/netarchTopo'
|
||||
import { getColumns } from './config/columns'
|
||||
import { getFilters } from './config/filters'
|
||||
import TopologyListDialog from './components/TopologyListDialog.vue'
|
||||
import SubGroupFormDialog from './components/SubGroupFormDialog.vue'
|
||||
|
||||
// 表格列配置
|
||||
const columns = getColumns()
|
||||
|
||||
// 搜索表单配置
|
||||
const filters = getFilters()
|
||||
|
||||
// 搜索表单数据
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
enable: '',
|
||||
})
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref<TopologyGroup[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 分页
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
// 表单弹窗相关
|
||||
const formModalVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const formData = reactive<Partial<TopologyGroup> & { parent_id: number }>({
|
||||
id: undefined,
|
||||
name: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
enable: true,
|
||||
parent_id: 0,
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入分组名称' },
|
||||
{ minLength: 1, message: '分组名称不能为空' },
|
||||
],
|
||||
}
|
||||
|
||||
// 弹窗标题
|
||||
const formModalTitle = computed(() => (isEdit.value ? '编辑分组' : '新增分组'))
|
||||
|
||||
// 删除确认
|
||||
const deleteConfirmVisible = ref(false)
|
||||
const groupToDelete = ref<TopologyGroup | null>(null)
|
||||
|
||||
// 拓扑列表弹窗
|
||||
const topologyListDialogVisible = ref(false)
|
||||
const currentGroupId = ref(0)
|
||||
const currentGroupName = ref('')
|
||||
|
||||
// 添加子分组弹窗
|
||||
const subGroupDialogVisible = ref(false)
|
||||
const currentParentGroup = ref<{ id: number; name: string } | null>(null)
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await fetchTopologyGroups({
|
||||
keyword: searchForm.keyword || undefined,
|
||||
enable: searchForm.enable ? searchForm.enable === 'true' : undefined,
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
})
|
||||
if (res?.code === 0) {
|
||||
// API返回结构: { details: { count: number, list: array } }
|
||||
const { count = 0, list = [] } = res.details || {}
|
||||
total.value = count
|
||||
|
||||
// 构建树形结构
|
||||
tableData.value = list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch topology groups:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 构建树形结构
|
||||
const buildTree = (flatData: TopologyGroup[]): TopologyGroup[] => {
|
||||
const map = new Map<number, TopologyGroup>()
|
||||
const roots: TopologyGroup[] = []
|
||||
|
||||
// 先创建所有节点的映射
|
||||
flatData.forEach((item) => {
|
||||
map.set(item.id!, { ...item, children: [] })
|
||||
})
|
||||
|
||||
// 构建树形结构
|
||||
flatData.forEach((item) => {
|
||||
const node = map.get(item.id!)!
|
||||
if (item.parent_id === 0 || item.parent_id === undefined) {
|
||||
roots.push(node)
|
||||
} else {
|
||||
const parent = map.get(item.parent_id)
|
||||
if (parent) {
|
||||
if (!parent.children) {
|
||||
parent.children = []
|
||||
}
|
||||
parent.children.push(node)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 计算每个分组的总拓扑数和链路数
|
||||
const calculateTotals = (node: TopologyGroup): void => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
node.total_topology_count = node.topology_count || 0
|
||||
node.total_link_count = node.total_link_count || 0
|
||||
return
|
||||
}
|
||||
|
||||
let totalTopo = node.topology_count || 0
|
||||
let totalLinks = node.total_link_count || 0
|
||||
|
||||
node.children.forEach((child) => {
|
||||
calculateTotals(child)
|
||||
totalTopo += child.total_topology_count || 0
|
||||
totalLinks += child.total_link_count || 0
|
||||
})
|
||||
|
||||
node.total_topology_count = totalTopo
|
||||
node.total_link_count = totalLinks
|
||||
}
|
||||
|
||||
roots.forEach((root) => calculateTotals(root))
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.enable = ''
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 页码变化
|
||||
const handlePageChange = (current: number) => {
|
||||
page.value = current
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 新增分组
|
||||
const handleAddGroup = () => {
|
||||
isEdit.value = false
|
||||
resetFormData()
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑分组
|
||||
const handleEdit = (record: TopologyGroup) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
sort: record.sort || 0,
|
||||
enable: record.enable ?? true,
|
||||
})
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 删除分组
|
||||
const handleDelete = (record: TopologyGroup) => {
|
||||
groupToDelete.value = record
|
||||
deleteConfirmVisible.value = true
|
||||
}
|
||||
|
||||
// 查看拓扑列表
|
||||
const handleViewTopologies = (record: TopologyGroup) => {
|
||||
currentGroupId.value = record.id!
|
||||
currentGroupName.value = record.name
|
||||
topologyListDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (record: TopologyGroup) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
sort: record.sort || 0,
|
||||
enable: record.enable ?? true,
|
||||
})
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 添加子分组
|
||||
const handleAddSubGroup = (record: TopologyGroup) => {
|
||||
currentParentGroup.value = {
|
||||
id: record.id!,
|
||||
name: record.name,
|
||||
}
|
||||
subGroupDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!groupToDelete.value?.id) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
await deleteTopologyGroup(groupToDelete.value.id)
|
||||
Message.success('删除成功')
|
||||
deleteConfirmVisible.value = false
|
||||
groupToDelete.value = null
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete group:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗确认
|
||||
const handleFormModalOk = async () => {
|
||||
try {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
|
||||
loading.value = true
|
||||
if (isEdit.value && formData.id) {
|
||||
await updateTopologyGroup({ ...formData, id: formData.id })
|
||||
Message.success('修改成功')
|
||||
} else {
|
||||
await createTopologyGroup({ ...formData, parent_id: 0 })
|
||||
Message.success('创建成功')
|
||||
}
|
||||
formModalVisible.value = false
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
console.error('Failed to save group:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗取消
|
||||
const handleFormModalCancel = () => {
|
||||
formModalVisible.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 重置表单数据
|
||||
const resetFormData = () => {
|
||||
Object.assign(formData, {
|
||||
id: undefined,
|
||||
name: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
enable: true,
|
||||
parent_id: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'TopologyGroup',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
</style>
|
||||
70
src/views/ops/pages/netarch/topo/components/AddNodeMenu.vue
Normal file
70
src/views/ops/pages/netarch/topo/components/AddNodeMenu.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<a-dropdown :popup-visible="visible" @update:popup-visible="handleClose">
|
||||
<slot></slot>
|
||||
<template #content>
|
||||
<a-doption @click="handleCustomAdd">
|
||||
<template #icon>
|
||||
<icon-plus-circle />
|
||||
</template>
|
||||
<span>自定义添加</span>
|
||||
</a-doption>
|
||||
<a-divider style="margin: 4px 0;" />
|
||||
<a-doption @click="handleAddDevice('server')">
|
||||
<template #icon>
|
||||
<icon-desktop />
|
||||
</template>
|
||||
<span>快速添加服务器</span>
|
||||
</a-doption>
|
||||
<a-doption @click="handleAddDevice('router')">
|
||||
<template #icon>
|
||||
<icon-file />
|
||||
</template>
|
||||
<span>快速添加路由器</span>
|
||||
</a-doption>
|
||||
<a-doption @click="handleAddDevice('switch')">
|
||||
<template #icon>
|
||||
<icon-safe />
|
||||
</template>
|
||||
<span>快速添加交换机</span>
|
||||
</a-doption>
|
||||
<a-doption @click="handleAddDevice('cloud')">
|
||||
<template #icon>
|
||||
<icon-cloud />
|
||||
</template>
|
||||
<span>快速添加云端节点</span>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconPlusCircle, IconDesktop, IconFile, IconSafe, IconCloud } from '@arco-design/web-vue/es/icon';
|
||||
import type { DeviceType } from '../types';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'addDevice', type: DeviceType): void;
|
||||
(e: 'customAdd'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleAddDevice = (type: DeviceType) => {
|
||||
emit('addDevice', type);
|
||||
handleClose(false);
|
||||
};
|
||||
|
||||
const handleCustomAdd = () => {
|
||||
emit('customAdd');
|
||||
handleClose(false);
|
||||
};
|
||||
|
||||
const handleClose = (value: boolean) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
</script>
|
||||
154
src/views/ops/pages/netarch/topo/components/CustomNode.vue
Normal file
154
src/views/ops/pages/netarch/topo/components/CustomNode.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['custom-node', { 'node-selected': selected }]"
|
||||
:style="nodeStyle"
|
||||
>
|
||||
<!-- 连接点 - 目标 -->
|
||||
<Handle
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
class="handle handle-top"
|
||||
/>
|
||||
|
||||
<!-- 节点内容 -->
|
||||
<a-space direction="vertical" align="center" :size="4">
|
||||
<a-avatar :size="48" :style="avatarStyle">
|
||||
<component :is="iconComponent" :size="28" />
|
||||
</a-avatar>
|
||||
|
||||
<div class="node-text">
|
||||
<div class="node-label">{{ data.label }}</div>
|
||||
<div v-if="data.ip" class="node-ip">{{ data.ip }}</div>
|
||||
</div>
|
||||
|
||||
<a-tag v-if="data.alerts && data.alerts > 0" color="danger" size="small">
|
||||
{{ data.alerts }}个告警
|
||||
</a-tag>
|
||||
</a-space>
|
||||
|
||||
<!-- 连接点 - 源 -->
|
||||
<Handle
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
class="handle handle-bottom"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Handle, Position, type NodeProps } from '@vue-flow/core';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import type { DeviceType, DeviceStatus, NodeData } from '../types';
|
||||
import {
|
||||
IconDesktop,
|
||||
IconCloud,
|
||||
IconStorage,
|
||||
IconMore,
|
||||
IconSafe,
|
||||
IconFile,
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
|
||||
// 使用Vue Flow的NodeProps类型
|
||||
const props = defineProps<NodeProps<NodeData>>();
|
||||
|
||||
// 从props中解构数据
|
||||
const data = computed(() => props.data);
|
||||
const selected = computed(() => props.selected);
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
server: IconDesktop,
|
||||
switch: IconSafe,
|
||||
router: IconFile,
|
||||
firewall: IconSafe,
|
||||
storage: IconStorage,
|
||||
cloud: IconCloud,
|
||||
desktop: IconDesktop,
|
||||
mobile: IconDesktop,
|
||||
};
|
||||
|
||||
const config = computed(() => DEVICE_TYPE_CONFIG[data.value.type] || DEVICE_TYPE_CONFIG.server);
|
||||
const iconComponent = computed(() => iconMap[data.value.type] || IconDesktop);
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
normal: '#52C41A',
|
||||
warning: '#FAAD14',
|
||||
error: '#F53F3F',
|
||||
};
|
||||
|
||||
const borderColor = computed(() => statusColors[data.value.status || 'normal']);
|
||||
|
||||
const nodeStyle = computed(() => ({
|
||||
'--border-color': selected.value ? '#165DFF' : borderColor.value,
|
||||
'--bg-color': '#fff',
|
||||
'--shadow': selected.value ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.1)',
|
||||
}));
|
||||
|
||||
const avatarStyle = computed(() => ({
|
||||
backgroundColor: `${config.value.color}1A`,
|
||||
color: config.value.color,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.custom-node {
|
||||
background: var(--bg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
min-width: 140px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&.node-selected {
|
||||
border-color: #165DFF;
|
||||
}
|
||||
|
||||
.node-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-ip {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.handle {
|
||||
background: #165DFF;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
|
||||
&.handle-top {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&.handle-bottom {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="确认删除"
|
||||
@ok="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<p>{{ message }}</p>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'confirm'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
message: '确定要删除吗?',
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm');
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="链路操作"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
:footer="false"
|
||||
width="400px"
|
||||
>
|
||||
<div class="edge-actions">
|
||||
<a-button long @click="handleEdit">编辑链路</a-button>
|
||||
<a-button long status="danger" @click="handleDelete">删除链路</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'edit'): void;
|
||||
(e: 'delete'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit');
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete');
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.edge-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="isNewEdge ? '添加链路' : '编辑链路'"
|
||||
@ok="handleSave"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-form :model="formData" layout="vertical">
|
||||
<a-form-item label="链路类型 *" required>
|
||||
<a-select
|
||||
v-model="formData.type"
|
||||
placeholder="请选择链路类型"
|
||||
>
|
||||
<a-option value="physical">物理链路 (蓝色实线)</a-option>
|
||||
<a-option value="virtual">虚拟链路 (橙色虚线)</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="链路标签">
|
||||
<a-input
|
||||
v-model="formData.label"
|
||||
placeholder="选填,如: 主干网络, 备用链路等"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue';
|
||||
import type { Edge } from '@vue-flow/core';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
edge: Edge | null;
|
||||
isNewEdge: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'change', edge: Edge): void;
|
||||
(e: 'save'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const formData = reactive({
|
||||
type: 'physical',
|
||||
label: '',
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.edge,
|
||||
(edge) => {
|
||||
if (edge) {
|
||||
formData.type = edge.data?.type || 'physical';
|
||||
formData.label = edge.data?.label || '';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [formData.type, formData.label],
|
||||
() => {
|
||||
if (props.edge) {
|
||||
emit('change', {
|
||||
...props.edge,
|
||||
data: { ...props.edge.data, type: formData.type, label: formData.label },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
emit('save');
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
116
src/views/ops/pages/netarch/topo/components/EdgeStyleMenu.vue
Normal file
116
src/views/ops/pages/netarch/topo/components/EdgeStyleMenu.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<a-dropdown :popup-visible="visible" @update:popup-visible="handleClose">
|
||||
<slot></slot>
|
||||
<template #content>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'default' }"
|
||||
@click="handleSelect('default')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-minus />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">默认曲线</div>
|
||||
<div class="option-subtitle">贝塞尔曲线,平滑自然</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'straight' }"
|
||||
@click="handleSelect('straight')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-arrow-right />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">直线</div>
|
||||
<div class="option-subtitle">直接连接,简洁明了</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'step' }"
|
||||
@click="handleSelect('step')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-minus />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">阶梯线</div>
|
||||
<div class="option-subtitle">直角转折,类似线路图</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'smoothstep' }"
|
||||
@click="handleSelect('smoothstep')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-arrow-up />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">平滑阶梯线</div>
|
||||
<div class="option-subtitle">圆角转折,平滑过渡</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'simplebezier' }"
|
||||
@click="handleSelect('simplebezier')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-minus style="transform: scaleY(0.8);" />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">简单贝塞尔曲线</div>
|
||||
<div class="option-subtitle">轻微弯曲,柔和过渡</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconMinus, IconArrowRight, IconArrowUp } from '@arco-design/web-vue/es/icon';
|
||||
import type { EdgeType } from '../types';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
selectedType: EdgeType;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'selectType', type: EdgeType): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleSelect = (type: EdgeType) => {
|
||||
emit('selectType', type);
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleClose = (value: boolean) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.selected-option {
|
||||
background-color: var(--color-primary-light-1);
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.option-title {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.option-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
</style>
|
||||
257
src/views/ops/pages/netarch/topo/components/GroupPanel.vue
Normal file
257
src/views/ops/pages/netarch/topo/components/GroupPanel.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div class="group-panel-wrapper">
|
||||
<div class="group-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-title">拓扑</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动拓扑分组选择 -->
|
||||
<div v-if="isAutoTopo" class="topo-selector">
|
||||
<a-tree-select
|
||||
v-model="selectedGroupId"
|
||||
placeholder="选择分组"
|
||||
:data="treeData"
|
||||
allow-clear
|
||||
allow-search
|
||||
default-expand-all
|
||||
:field-names="{ key: 'key', title: 'title', children: 'children' }"
|
||||
@change="handleGroupChange"
|
||||
/>
|
||||
<div v-if="topologyList.length > 0" class="topology-list">
|
||||
<div
|
||||
v-for="topology in topologyList"
|
||||
:key="topology.id"
|
||||
:class="['topology-item', { active: selectedTopologyId === topology.id }]"
|
||||
@click="handleTopologySelect(topology)"
|
||||
>
|
||||
<div class="topology-icon">
|
||||
<icon-apps />
|
||||
</div>
|
||||
<div class="topology-name">{{ topology.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 全部分组选项 -->
|
||||
<a-menu :selected-keys="selectedGroup === null ? ['all'] : []">
|
||||
<a-menu-item key="all" @click="handleSelectAll">
|
||||
<template #icon>
|
||||
<icon-share-alt />
|
||||
</template>
|
||||
<template #default>全部</template>
|
||||
<template #extra>
|
||||
<span class="menu-subtitle">显示所有拓扑图</span>
|
||||
</template>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 分组树 -->
|
||||
<div class="group-tree">
|
||||
<template v-for="group in groups" :key="group.id">
|
||||
<group-tree-item
|
||||
:group="group"
|
||||
:level="0"
|
||||
:selected-group="selectedGroup"
|
||||
:expanded-groups="expandedGroups"
|
||||
:nodes="nodes"
|
||||
@select="handleSelectGroup"
|
||||
@toggle="handleToggleGroup"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Node } from '@vue-flow/core';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import type { TopoGroup, NodeData } from '../types';
|
||||
import { IconShareAlt, IconApps } from '@arco-design/web-vue/es/icon';
|
||||
import GroupTreeItem from './GroupTreeItem.vue';
|
||||
|
||||
interface Props {
|
||||
groups: TopoGroup[];
|
||||
selectedGroup: string | null;
|
||||
expandedGroups: Set<string>;
|
||||
nodes: Node[];
|
||||
isAutoTopo?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'selectGroup', groupId: string | null): void;
|
||||
(e: 'toggleGroup', groupId: string): void;
|
||||
(e: 'groupChange', groupId: number | null): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isAutoTopo: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
const topologyGroups = ref<any[]>([]);
|
||||
const selectedGroupId = ref<number | null>(null);
|
||||
const topologyList = ref<any[]>([]);
|
||||
const selectedTopologyId = ref<number | null>(null);
|
||||
|
||||
const treeData = computed(() => {
|
||||
const convertToTree = (groups: any[]): any[] => {
|
||||
return groups.map((group) => ({
|
||||
key: String(group.id),
|
||||
title: group.name,
|
||||
disabled: group.children && group.children.length > 0,
|
||||
children: group.children && group.children.length > 0 ? convertToTree(group.children) : undefined,
|
||||
}));
|
||||
};
|
||||
return convertToTree(topologyGroups.value);
|
||||
});
|
||||
|
||||
const handleSelectAll = () => {
|
||||
emit('selectGroup', null);
|
||||
};
|
||||
|
||||
const handleSelectGroup = (groupId: string) => {
|
||||
emit('selectGroup', groupId);
|
||||
};
|
||||
|
||||
const handleToggleGroup = (groupId: string) => {
|
||||
emit('toggleGroup', groupId);
|
||||
};
|
||||
|
||||
const handleGroupChange = async (value: number | null) => {
|
||||
selectedGroupId.value = value;
|
||||
topologyList.value = [];
|
||||
selectedTopologyId.value = null;
|
||||
emit('groupChange', null);
|
||||
|
||||
if (value) {
|
||||
try {
|
||||
// 这里应该调用API获取拓扑列表
|
||||
// const response = await fetchTopologies({ group_id: value, page: 1, size: 9999 });
|
||||
// if (response.code === 0) {
|
||||
// const list = response.details?.data || [];
|
||||
// topologyList.value = list;
|
||||
// if (list.length > 0) {
|
||||
// selectedTopologyId.value = list[0].id;
|
||||
// emit('groupChange', list[0].id);
|
||||
// }
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error('获取拓扑列表失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTopologySelect = (topology: any) => {
|
||||
selectedTopologyId.value = topology.id;
|
||||
emit('groupChange', topology.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.group-panel-wrapper {
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--color-border-2);
|
||||
background: var(--color-bg-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.group-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
.topo-selector {
|
||||
padding: 16px;
|
||||
|
||||
.topology-list {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topology-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid var(--color-border-2);
|
||||
background: var(--color-bg-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-light-3);
|
||||
background: var(--color-fill-2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgb(var(--primary-6));
|
||||
background: var(--color-primary-light-1);
|
||||
}
|
||||
|
||||
.topology-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-primary-light-3);
|
||||
color: rgb(var(--primary-6));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topology-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.menu-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
196
src/views/ops/pages/netarch/topo/components/GroupTreeItem.vue
Normal file
196
src/views/ops/pages/netarch/topo/components/GroupTreeItem.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="group-tree-item">
|
||||
<div
|
||||
:class="['group-item-content', { selected: isSelected }]"
|
||||
:style="{ paddingLeft: `${24 + level * 24}px` }"
|
||||
@click="handleSelect"
|
||||
>
|
||||
<!-- 展开/折叠按钮 -->
|
||||
<div class="toggle-icon" @click.stop="handleToggle">
|
||||
<icon-down v-if="hasChildren && isExpanded" />
|
||||
<icon-right v-else-if="hasChildren" />
|
||||
<div v-else class="placeholder" />
|
||||
</div>
|
||||
|
||||
<!-- 设备图标 -->
|
||||
<div class="device-icon" :style="{ background: iconBg, color: iconColor }">
|
||||
<component :is="iconComponent" :size="16" />
|
||||
</div>
|
||||
|
||||
<!-- 设备信息 -->
|
||||
<div class="device-info">
|
||||
<div class="device-name">
|
||||
{{ nodeData.label }}
|
||||
<a-tag v-if="nodeData.status === 'error'" color="red" size="small">异常</a-tag>
|
||||
<a-tag v-else-if="nodeData.status === 'warning'" color="orange" size="small">告警</a-tag>
|
||||
</div>
|
||||
<div class="device-details">
|
||||
<span v-if="nodeData.ip" class="ip-address">{{ nodeData.ip }}</span>
|
||||
<a-tag v-if="nodeData.alerts && nodeData.alerts > 0" color="red" size="small">
|
||||
{{ nodeData.alerts }}个告警
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子分组 -->
|
||||
<a-collapse :model-value="expandedGroups" :default-expanded-key="undefined" :bordered="false">
|
||||
<template v-if="hasChildren && isExpanded">
|
||||
<group-tree-item
|
||||
v-for="child in group.children"
|
||||
:key="child.id"
|
||||
:group="child"
|
||||
:level="level + 1"
|
||||
:selected-group="selectedGroup"
|
||||
:expanded-groups="expandedGroups"
|
||||
:nodes="nodes"
|
||||
@select="$emit('select', $event)"
|
||||
@toggle="$emit('toggle', $event)"
|
||||
/>
|
||||
</template>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Node } from '@vue-flow/core';
|
||||
import { IconDown, IconRight } from '@arco-design/web-vue/es/icon';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import type { TopoGroup, NodeData, DeviceType } from '../types';
|
||||
|
||||
interface Props {
|
||||
group: TopoGroup;
|
||||
level: number;
|
||||
selectedGroup: string | null;
|
||||
expandedGroups: Set<string>;
|
||||
nodes: Node[];
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', groupId: string): void;
|
||||
(e: 'toggle', groupId: string): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const nodeData = computed(() => {
|
||||
const node = props.nodes.find((n) => n.id === props.group.nodeId);
|
||||
return (node?.data as NodeData) || null;
|
||||
});
|
||||
|
||||
const hasChildren = computed(() => {
|
||||
return props.group.children && props.group.children.length > 0;
|
||||
});
|
||||
|
||||
const isExpanded = computed(() => {
|
||||
return props.expandedGroups.has(props.group.id);
|
||||
});
|
||||
|
||||
const isSelected = computed(() => {
|
||||
return props.selectedGroup === props.group.id;
|
||||
});
|
||||
|
||||
const deviceConfig = computed(() => {
|
||||
if (!nodeData.value) return null;
|
||||
return DEVICE_TYPE_CONFIG[nodeData.value.type as DeviceType];
|
||||
});
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
return deviceConfig.value?.icon;
|
||||
});
|
||||
|
||||
const iconBg = computed(() => {
|
||||
const color = deviceConfig.value?.color || '#888';
|
||||
return `${color}15`;
|
||||
});
|
||||
|
||||
const iconColor = computed(() => {
|
||||
return deviceConfig.value?.color || '#888';
|
||||
});
|
||||
|
||||
const handleSelect = () => {
|
||||
emit('select', props.group.id);
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
if (hasChildren.value) {
|
||||
emit('toggle', props.group.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.group-tree-item {
|
||||
.group-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
margin: 4px 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-fill-2);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-primary-light-1);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
|
||||
.placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
50
src/views/ops/pages/netarch/topo/components/LayoutMenu.vue
Normal file
50
src/views/ops/pages/netarch/topo/components/LayoutMenu.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<a-dropdown :popup-visible="visible" @update:popup-visible="handleClose">
|
||||
<slot></slot>
|
||||
<template #content>
|
||||
<a-doption @click="handleSelect('grid')">
|
||||
<template #icon>
|
||||
<icon-apps />
|
||||
</template>
|
||||
网格布局
|
||||
</a-doption>
|
||||
<a-doption @click="handleSelect('hierarchical')">
|
||||
<template #icon>
|
||||
<icon-list />
|
||||
</template>
|
||||
层次布局
|
||||
</a-doption>
|
||||
<a-doption @click="handleSelect('circular')">
|
||||
<template #icon>
|
||||
<icon-minus-circle />
|
||||
</template>
|
||||
环形布局
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconApps, IconList, IconMinusCircle } from '@arco-design/web-vue/es/icon';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'selectLayout', layoutType: 'grid' | 'hierarchical' | 'circular'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleSelect = (layoutType: 'grid' | 'hierarchical' | 'circular') => {
|
||||
emit('selectLayout', layoutType);
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleClose = (value: boolean) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
</script>
|
||||
203
src/views/ops/pages/netarch/topo/components/NodeActionDialog.vue
Normal file
203
src/views/ops/pages/netarch/topo/components/NodeActionDialog.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
title="设备操作"
|
||||
:footer="false"
|
||||
width="400px"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<template v-if="node">
|
||||
<div class="node-action-content">
|
||||
<!-- 设备信息概览 -->
|
||||
<div class="device-info">
|
||||
<div
|
||||
class="device-icon-wrapper"
|
||||
:style="{ backgroundColor: `${config.color}20`, color: config.color }"
|
||||
>
|
||||
<component :is="config.icon" :size="40" />
|
||||
</div>
|
||||
<div class="device-name">{{ node.data?.label || '未命名' }}</div>
|
||||
<div class="device-type">{{ config.label }}</div>
|
||||
<div v-if="node.data?.ip" class="device-ip">IP: {{ node.data.ip }}</div>
|
||||
<a-tag
|
||||
:color="statusColor[node.data?.status || 'normal']"
|
||||
class="device-status"
|
||||
size="small"
|
||||
>
|
||||
{{ statusText[node.data?.status || 'normal'] }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<a-button long @click="handleViewDetail">
|
||||
<template #icon>
|
||||
<icon-info-circle />
|
||||
</template>
|
||||
查看详情
|
||||
</a-button>
|
||||
<a-button long @click="handleEdit">
|
||||
<template #icon>
|
||||
<icon-edit />
|
||||
</template>
|
||||
编辑设备
|
||||
</a-button>
|
||||
<a-button long status="danger" @click="handleDelete">
|
||||
<template #icon>
|
||||
<icon-delete />
|
||||
</template>
|
||||
删除设备
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="handleClose">关闭</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconDesktop,
|
||||
IconCloud,
|
||||
IconStorage,
|
||||
IconSafe,
|
||||
IconFile,
|
||||
IconMore
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import { DeviceType } from '../types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
node: any;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'viewDetail'): void;
|
||||
(e: 'edit'): void;
|
||||
(e: 'delete'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
});
|
||||
|
||||
const iconMap: Record<DeviceType, any> = {
|
||||
server: IconDesktop,
|
||||
router: IconFile,
|
||||
switch: IconSafe,
|
||||
desktop: IconDesktop,
|
||||
cloud: IconCloud,
|
||||
text: IconMore,
|
||||
region: IconStorage,
|
||||
};
|
||||
|
||||
const config = computed(() => {
|
||||
const type = props.node?.data?.type as DeviceType || 'server';
|
||||
const deviceConfig = DEVICE_TYPE_CONFIG[type];
|
||||
return {
|
||||
...deviceConfig,
|
||||
icon: iconMap[type] || iconMap.server,
|
||||
};
|
||||
});
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
normal: 'green',
|
||||
warning: 'orange',
|
||||
error: 'red',
|
||||
};
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
normal: '正常',
|
||||
warning: '警告',
|
||||
error: '错误',
|
||||
};
|
||||
|
||||
const handleViewDetail = () => {
|
||||
emit('viewDetail');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:open', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.node-action-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-icon-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-type {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-ip {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
193
src/views/ops/pages/netarch/topo/components/NodeDetailDialog.vue
Normal file
193
src/views/ops/pages/netarch/topo/components/NodeDetailDialog.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:footer="false"
|
||||
title="设备详情"
|
||||
@cancel="handleClose"
|
||||
width="700px"
|
||||
>
|
||||
<template v-if="nodeData">
|
||||
<a-space direction="vertical" :size="16" fill>
|
||||
<!-- 基本信息 -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="label">设备名称</div>
|
||||
<div class="value">{{ nodeData.label }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="label">设备类型</div>
|
||||
<div class="value">{{ nodeData.type }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="label">IP地址</div>
|
||||
<div class="value">{{ nodeData.ip || '未配置' }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="label">设备状态</div>
|
||||
<a-tag :color="statusColor">{{ statusText }}</a-tag>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-card :bordered="true" class="stat-card">
|
||||
<div class="stat-label">链路流量</div>
|
||||
<div class="stat-value">{{ nodeData.traffic || '0 Mbps' }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-card :bordered="true" class="stat-card">
|
||||
<div class="stat-label">告警数量</div>
|
||||
<div class="stat-value" :class="{ 'alert-count': nodeData.alerts && nodeData.alerts > 0 }">
|
||||
{{ nodeData.alerts || 0 }}
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 告警列表 -->
|
||||
<template v-if="nodeData.alerts && nodeData.alerts > 0">
|
||||
<a-divider />
|
||||
<div class="section-title">当前告警</div>
|
||||
<a-list :bordered="false">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #avatar>
|
||||
<icon-exclamation-circle-fill style="font-size: 20px; color: #FF7D00;" />
|
||||
</template>
|
||||
<template #title>CPU使用率过高</template>
|
||||
<template #description>当前CPU使用率: 92%, 触发时间: 2025-12-11 09:30</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #avatar>
|
||||
<icon-exclamation-circle-fill style="font-size: 20px; color: #F53F3F;" />
|
||||
</template>
|
||||
<template #title>内存不足</template>
|
||||
<template #description>当前可用内存: 512MB, 触发时间: 2025-12-11 09:25</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<!-- 描述信息 -->
|
||||
<template v-if="nodeData.description">
|
||||
<a-divider />
|
||||
<div class="info-item">
|
||||
<div class="label">设备描述</div>
|
||||
<div class="value">{{ nodeData.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="handleClose">关闭</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { IconExclamationCircleFill } from '@arco-design/web-vue/es/icon';
|
||||
import type { NodeData } from '../types';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
nodeData: NodeData | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'close'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!props.nodeData) return '';
|
||||
switch (props.nodeData.status) {
|
||||
case 'normal':
|
||||
return '正常';
|
||||
case 'warning':
|
||||
return '警告';
|
||||
case 'error':
|
||||
return '错误';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (!props.nodeData) return 'gray';
|
||||
switch (props.nodeData.status) {
|
||||
case 'normal':
|
||||
return 'green';
|
||||
case 'warning':
|
||||
return 'orange';
|
||||
case 'error':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false);
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.info-item {
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
|
||||
&.alert-count {
|
||||
color: #f53f3f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
}
|
||||
</style>
|
||||
151
src/views/ops/pages/netarch/topo/components/NodeEditDialog.vue
Normal file
151
src/views/ops/pages/netarch/topo/components/NodeEditDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="isEdit ? '编辑设备' : '添加设备'"
|
||||
@cancel="handleClose"
|
||||
@ok="handleSave"
|
||||
:ok-text="isEdit ? '保存' : '添加'"
|
||||
:ok-button-props="{ disabled: !formData.label }"
|
||||
width="600px"
|
||||
>
|
||||
<a-form :model="formData" layout="vertical" class="node-edit-form">
|
||||
<a-form-item label="设备类型" required>
|
||||
<a-select v-model="formData.type">
|
||||
<a-option
|
||||
v-for="(config, key) in DEVICE_TYPE_CONFIG"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
<a-space align="center">
|
||||
<component :is="getIconComponent(key)" :size="18" />
|
||||
<span>{{ config.label }}</span>
|
||||
</a-space>
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="设备名称" required>
|
||||
<a-input
|
||||
v-model="formData.label"
|
||||
placeholder="请输入设备名称"
|
||||
:max-length="50"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="IP地址">
|
||||
<a-input
|
||||
v-model="formData.ip"
|
||||
placeholder="如: 192.168.1.1"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="流量信息">
|
||||
<a-input
|
||||
v-model="formData.traffic"
|
||||
placeholder="如: 100Mbps"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="设备描述">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入设备描述"
|
||||
:max-length="200"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { Node } from '@vue-flow/core';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import type { DeviceType, NodeData } from '../types';
|
||||
import {
|
||||
IconDesktop,
|
||||
IconSafe,
|
||||
IconFile,
|
||||
IconStorage,
|
||||
IconCloud,
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
node: Node | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'save', nodeId: string | null, data: Partial<NodeData>): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const formData = ref({
|
||||
label: '',
|
||||
type: 'server' as DeviceType,
|
||||
ip: '',
|
||||
traffic: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
server: IconDesktop,
|
||||
switch: IconSafe,
|
||||
router: IconFile,
|
||||
firewall: IconSafe,
|
||||
storage: IconStorage,
|
||||
cloud: IconCloud,
|
||||
desktop: IconDesktop,
|
||||
mobile: IconDesktop,
|
||||
};
|
||||
|
||||
const getIconComponent = (type: string) => {
|
||||
return iconMap[type] || IconDesktop;
|
||||
};
|
||||
|
||||
const isEdit = computed(() => !!props.node);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (props.node) {
|
||||
formData.value = {
|
||||
label: props.node.data?.label || '',
|
||||
type: props.node.data?.type || 'server',
|
||||
ip: props.node.data?.ip || '',
|
||||
traffic: props.node.data?.traffic || '',
|
||||
description: props.node.data?.description || '',
|
||||
};
|
||||
} else {
|
||||
formData.value = {
|
||||
label: '',
|
||||
type: 'server',
|
||||
ip: '',
|
||||
traffic: '',
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
emit('save', props.node?.id || null, formData.value);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.node-edit-form {
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
147
src/views/ops/pages/netarch/topo/components/Toolbar.vue
Normal file
147
src/views/ops/pages/netarch/topo/components/Toolbar.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="topo-toolbar">
|
||||
<!-- 缩放控制 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-tooltip content="放大">
|
||||
<a-button @click="props.onZoomIn">
|
||||
<icon-zoom-in :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="缩小">
|
||||
<a-button @click="props.onZoomOut">
|
||||
<icon-zoom-out :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="适应窗口">
|
||||
<a-button @click="props.onFitView">
|
||||
<icon-fullscreen :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-button-group>
|
||||
|
||||
<!-- 添加设备 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-dropdown trigger="click" @select="props.onAddDevice">
|
||||
<a-button>
|
||||
<icon-plus :size="18" />
|
||||
<span class="btn-text">设备</span>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption value="server">服务器</a-doption>
|
||||
<a-doption value="switch">交换机</a-doption>
|
||||
<a-doption value="router">路由器</a-doption>
|
||||
<a-doption value="firewall">防火墙</a-doption>
|
||||
<a-doption value="storage">存储</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-button-group>
|
||||
|
||||
<!-- 布局 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-dropdown trigger="click" @select="props.onLayout">
|
||||
<a-button>
|
||||
<icon-apps :size="18" />
|
||||
<span class="btn-text">布局</span>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption value="grid">网格布局</a-doption>
|
||||
<a-doption value="hierarchical">层次布局</a-doption>
|
||||
<a-doption value="circular">环形布局</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-button-group>
|
||||
|
||||
<!-- 链路样式 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-dropdown trigger="click" @select="props.onEdgeStyle">
|
||||
<a-button>
|
||||
<icon-minus :size="18" />
|
||||
<span class="btn-text">链路</span>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption value="default">默认</a-doption>
|
||||
<a-doption value="straight">直线</a-doption>
|
||||
<a-doption value="step">阶梯</a-doption>
|
||||
<a-doption value="smoothstep">平滑阶梯</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-button-group>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<!-- 操作 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-tooltip content="刷新">
|
||||
<a-button @click="props.onRefresh">
|
||||
<icon-refresh :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="导出">
|
||||
<a-button @click="props.onExport">
|
||||
<icon-download :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="props.onReset" content="重置">
|
||||
<a-button @click="props.onReset" status="warning">
|
||||
<icon-rotate-left :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-button-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
IconZoomIn,
|
||||
IconZoomOut,
|
||||
IconFullscreen,
|
||||
IconPlus,
|
||||
IconApps,
|
||||
IconMinus,
|
||||
IconRefresh,
|
||||
IconDownload,
|
||||
IconRotateLeft,
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
|
||||
interface Props {
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onFitView: () => void;
|
||||
onAddDevice: (value: string | number | Record<string, any> | undefined) => void;
|
||||
onLayout: (value: string | number | Record<string, any> | undefined) => void;
|
||||
onEdgeStyle: (value: string | number | Record<string, any> | undefined) => void;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
onReset: undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.topo-toolbar {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
src/views/ops/pages/netarch/topo/components/index.ts
Normal file
12
src/views/ops/pages/netarch/topo/components/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as CustomNode } from './CustomNode.vue';
|
||||
export { default as NodeDetailDialog } from './NodeDetailDialog.vue';
|
||||
export { default as NodeEditDialog } from './NodeEditDialog.vue';
|
||||
export { default as NodeActionDialog } from './NodeActionDialog.vue';
|
||||
export { default as DeleteConfirmDialog } from './DeleteConfirmDialog.vue';
|
||||
export { default as GroupPanel } from './GroupPanel.vue';
|
||||
export { default as TopoToolbar } from './Toolbar.vue';
|
||||
export { default as LayoutMenu } from './LayoutMenu.vue';
|
||||
export { default as EdgeStyleMenu } from './EdgeStyleMenu.vue';
|
||||
export { default as AddNodeMenu } from './AddNodeMenu.vue';
|
||||
export { default as EdgeActionDialog } from './EdgeActionDialog.vue';
|
||||
export { default as EdgeEditDialog } from './EdgeEditDialog.vue';
|
||||
17
src/views/ops/pages/netarch/topo/config.ts
Normal file
17
src/views/ops/pages/netarch/topo/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { DeviceType } from './types';
|
||||
|
||||
// 设备类型配置
|
||||
export const DEVICE_TYPE_CONFIG: Record<DeviceType, { icon: string; label: string; color: string }> = {
|
||||
server: { icon: 'icon-server', label: '服务器', color: '#2196F3' },
|
||||
router: { icon: 'icon-router', label: '路由器', color: '#FF9800' },
|
||||
switch: { icon: 'icon-desktop', label: '交换机', color: '#4CAF50' },
|
||||
desktop: { icon: 'icon-desktop', label: '终端', color: '#9C27B0' },
|
||||
cloud: { icon: 'icon-cloud', label: '云端节点', color: '#00BCD4' },
|
||||
text: { icon: 'icon-text', label: '文本标注', color: '#757575' },
|
||||
region: { icon: 'icon-rectangle', label: '区域', color: '#FF5722' },
|
||||
};
|
||||
|
||||
// 侧边栏宽度
|
||||
export const DRAWER_WIDTH = 280;
|
||||
|
||||
// 初始节点数据 - 带层级关系
|
||||
3
src/views/ops/pages/netarch/topo/hooks/index.ts
Normal file
3
src/views/ops/pages/netarch/topo/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useTopoStorage } from './useTopoStorage';
|
||||
export { useTopoLayout } from './useTopoLayout';
|
||||
export { useEdgeStyles } from './useEdgeStyles';
|
||||
46
src/views/ops/pages/netarch/topo/hooks/useEdgeStyles.ts
Normal file
46
src/views/ops/pages/netarch/topo/hooks/useEdgeStyles.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { computed, ComputedRef } from 'vue';
|
||||
import { Edge } from '@vue-flow/core';
|
||||
|
||||
type EdgeType = 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier';
|
||||
|
||||
/**
|
||||
* 边样式计算Hook
|
||||
* 根据边类型、链路类型、标签等计算最终样式
|
||||
*/
|
||||
export function useEdgeStyles(edges: Edge[], edgeType: EdgeType): ComputedRef<Edge[]> {
|
||||
const styledEdges = computed(() => {
|
||||
return edges.map((edge) => {
|
||||
const isVirtual = edge.data?.type === 'virtual';
|
||||
const hasLabel = edge.data?.label && edge.data.label.trim() !== '';
|
||||
|
||||
return {
|
||||
...edge,
|
||||
type: edgeType, // 使用全局设置的边类型
|
||||
label: hasLabel ? edge.data?.label : undefined,
|
||||
animated: true, // 流动效果
|
||||
style: {
|
||||
stroke: isVirtual
|
||||
? '#F57C00' // 虚拟链路使用橙色
|
||||
: '#1976D2', // 物理链路使用蓝色
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: isVirtual ? '5,5' : undefined, // 虚拟链路使用虚线
|
||||
},
|
||||
labelStyle: hasLabel
|
||||
? {
|
||||
fill: '#333',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
}
|
||||
: undefined,
|
||||
labelBgStyle: hasLabel
|
||||
? {
|
||||
fill: '#fff',
|
||||
fillOpacity: 0.9,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return styledEdges;
|
||||
}
|
||||
83
src/views/ops/pages/netarch/topo/hooks/useTopoLayout.ts
Normal file
83
src/views/ops/pages/netarch/topo/hooks/useTopoLayout.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Node } from '@vue-flow/core';
|
||||
|
||||
/**
|
||||
* 拓扑布局算法Hook
|
||||
* 提供网格、层次、环形三种布局方式
|
||||
*/
|
||||
export function useTopoLayout() {
|
||||
/**
|
||||
* 应用布局算法
|
||||
* @param nodes 当前节点列表
|
||||
* @param layoutType 布局类型
|
||||
* @returns 更新后的节点列表
|
||||
*/
|
||||
const applyLayout = (
|
||||
nodes: Node[],
|
||||
layoutType: 'grid' | 'hierarchical' | 'circular'
|
||||
): Node[] => {
|
||||
const nodesCopy = [...nodes];
|
||||
|
||||
switch (layoutType) {
|
||||
case 'grid': {
|
||||
// 网格布局
|
||||
const cols = Math.ceil(Math.sqrt(nodesCopy.length));
|
||||
nodesCopy.forEach((node, idx) => {
|
||||
node.position = {
|
||||
x: (idx % cols) * 200 + 100,
|
||||
y: Math.floor(idx / cols) * 180 + 100,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hierarchical': {
|
||||
// 层次布局
|
||||
const levels = new Map<number, Node[]>();
|
||||
|
||||
// 按level分组
|
||||
nodesCopy.forEach((node) => {
|
||||
const level = (node.data?.level as number) || 0;
|
||||
if (!levels.has(level)) {
|
||||
levels.set(level, []);
|
||||
}
|
||||
levels.get(level)!.push(node);
|
||||
});
|
||||
|
||||
// 按层级排列
|
||||
Array.from(levels.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.forEach(([level, levelNodes], levelIdx) => {
|
||||
const startX = (levelNodes.length - 1) * -150;
|
||||
levelNodes.forEach((node, nodeIdx) => {
|
||||
node.position = {
|
||||
x: startX + nodeIdx * 300 + 400,
|
||||
y: levelIdx * 200 + 100,
|
||||
};
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'circular': {
|
||||
// 环形布局
|
||||
const radius = 300;
|
||||
const centerX = 400;
|
||||
const centerY = 300;
|
||||
const angleStep = (2 * Math.PI) / nodesCopy.length;
|
||||
|
||||
nodesCopy.forEach((node, idx) => {
|
||||
const angle = idx * angleStep - Math.PI / 2;
|
||||
node.position = {
|
||||
x: centerX + radius * Math.cos(angle),
|
||||
y: centerY + radius * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return nodesCopy;
|
||||
};
|
||||
|
||||
return { applyLayout };
|
||||
}
|
||||
61
src/views/ops/pages/netarch/topo/hooks/useTopoStorage.ts
Normal file
61
src/views/ops/pages/netarch/topo/hooks/useTopoStorage.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Node, Edge } from '@vue-flow/core';
|
||||
|
||||
const STORAGE_KEY = 'topo_graph_data';
|
||||
|
||||
interface TopoData {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拓扑图本地存储Hook
|
||||
* 自动保存和加载拓扑图的节点和边数据
|
||||
*/
|
||||
export function useTopoStorage() {
|
||||
// 保存到本地存储
|
||||
const saveTopoData = (nodes: Node[], edges: Edge[]) => {
|
||||
try {
|
||||
const data: TopoData = {
|
||||
nodes,
|
||||
edges,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
console.log('拓扑图数据已保存到本地', data.timestamp);
|
||||
} catch (error) {
|
||||
console.error('保存拓扑图数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 从本地存储加载
|
||||
const loadTopoData = (): TopoData | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored) as TopoData;
|
||||
console.log('从本地加载拓扑图数据', data.timestamp);
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载拓扑图数据失败:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 清除本地存储
|
||||
const clearTopoData = () => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
console.log('已清除本地拓扑图数据');
|
||||
} catch (error) {
|
||||
console.error('清除拓扑图数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
saveTopoData,
|
||||
loadTopoData,
|
||||
clearTopoData,
|
||||
};
|
||||
}
|
||||
738
src/views/ops/pages/netarch/topo/index.vue
Normal file
738
src/views/ops/pages/netarch/topo/index.vue
Normal file
@@ -0,0 +1,738 @@
|
||||
<template>
|
||||
<div class="topo-container" :style="{ height: containerHeight }">
|
||||
<!-- 左侧分组面板 -->
|
||||
<div class="topo-sidebar">
|
||||
<group-panel
|
||||
:groups="topoGroups"
|
||||
:selected-group="selectedGroup"
|
||||
:expanded-groups="expandedGroups"
|
||||
:nodes="nodes"
|
||||
:is-auto-topo="isAutoTopo"
|
||||
@select-group="handleSelectGroup"
|
||||
@toggle-group="toggleGroup"
|
||||
@group-change="handleTopologyGroupChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 主拓扑区域 -->
|
||||
<div class="topo-main">
|
||||
<!-- 工具栏 -->
|
||||
<toolbar
|
||||
@zoom-in="zoomIn"
|
||||
@zoom-out="zoomOut"
|
||||
@fit-view="fitView"
|
||||
@add-device="handleAddDevice"
|
||||
@layout="handleLayout"
|
||||
@edge-style="setEdgeType"
|
||||
@refresh="refreshTopology"
|
||||
@export="exportTopology"
|
||||
@reset="resetTopology"
|
||||
/>
|
||||
|
||||
<!-- Vue Flow 画布 -->
|
||||
<div ref="reactFlowWrapper" class="flow-wrapper">
|
||||
<vue-flow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:node-types="nodeTypes"
|
||||
:default-edge-options="defaultEdgeOptions"
|
||||
:fit-view-on-init="true"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="2"
|
||||
@node-click="onNodeClick"
|
||||
@edge-click="onEdgeClick"
|
||||
@connect="onConnect"
|
||||
>
|
||||
<background pattern-color="#aaa" :gap="16" />
|
||||
<mini-map
|
||||
:node-color="getNodeColor"
|
||||
node-stroke-color="#555"
|
||||
/>
|
||||
<controls />
|
||||
</vue-flow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 对话框组件 ==================== -->
|
||||
|
||||
<!-- ==================== 节点对话框 ==================== -->
|
||||
|
||||
<node-action-dialog
|
||||
v-model:open="nodeActionDialogOpen"
|
||||
:node="selectedNode"
|
||||
@view-detail="handleViewDetail"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDeleteNode"
|
||||
/>
|
||||
|
||||
<node-detail-dialog
|
||||
v-model:visible="nodeDetailDialogOpen"
|
||||
:node-data="selectedNode?.data"
|
||||
/>
|
||||
|
||||
<node-edit-dialog
|
||||
v-model:visible="nodeEditDialogOpen"
|
||||
:node="selectedNode"
|
||||
@save="handleSaveNode"
|
||||
/>
|
||||
|
||||
<delete-confirm-dialog
|
||||
v-model:visible="deleteDialogOpen"
|
||||
:node-name="selectedNode?.data?.label || '未命名'"
|
||||
@confirm="handleDeleteNodeConfirm"
|
||||
/>
|
||||
|
||||
<!-- ==================== 边对话框 ==================== -->
|
||||
|
||||
<edge-action-dialog
|
||||
v-model:visible="edgeActionDialogOpen"
|
||||
@edit="handleEditEdge"
|
||||
@delete="handleDeleteEdge"
|
||||
/>
|
||||
|
||||
<edge-edit-dialog
|
||||
v-model:visible="edgeEditDialogOpen"
|
||||
:edge="selectedEdge"
|
||||
:is-new-edge="isNewEdge"
|
||||
@change="setSelectedEdge"
|
||||
@save="handleSaveEdge"
|
||||
/>
|
||||
|
||||
<delete-confirm-dialog
|
||||
v-model:visible="deleteEdgeDialogOpen"
|
||||
node-name="链路"
|
||||
@confirm="handleDeleteEdgeConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { VueFlow, useVueFlow } from '@vue-flow/core';
|
||||
import { Background } from '@vue-flow/background';
|
||||
import { MiniMap } from '@vue-flow/minimap';
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
import '@vue-flow/core/dist/style.css';
|
||||
import '@vue-flow/core/dist/theme-default.css';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import * as TopoAPI from '@/api/ops/netarchTopo';
|
||||
|
||||
import { NodeData, DeviceType } from './types';
|
||||
import { DEVICE_TYPE_CONFIG } from './config';
|
||||
import { CustomNode } from './components';
|
||||
import { useTopoLayout, useEdgeStyles } from './hooks';
|
||||
import { buildGroupTreeFromNodes, filterByGroup } from './utils/buildGroupTree';
|
||||
|
||||
// 导入所有组件
|
||||
import GroupPanel from './components/GroupPanel.vue';
|
||||
import Toolbar from './components/Toolbar.vue';
|
||||
import AddNodeMenu from './components/AddNodeMenu.vue';
|
||||
import LayoutMenu from './components/LayoutMenu.vue';
|
||||
import EdgeStyleMenu from './components/EdgeStyleMenu.vue';
|
||||
import NodeActionDialog from './components/NodeActionDialog.vue';
|
||||
import NodeDetailDialog from './components/NodeDetailDialog.vue';
|
||||
import NodeEditDialog from './components/NodeEditDialog.vue';
|
||||
import DeleteConfirmDialog from './components/DeleteConfirmDialog.vue';
|
||||
import EdgeActionDialog from './components/EdgeActionDialog.vue';
|
||||
import EdgeEditDialog from './components/EdgeEditDialog.vue';
|
||||
|
||||
// 注册自定义节点类型
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
};
|
||||
|
||||
// 默认边样式
|
||||
const defaultEdgeOptions = {
|
||||
style: { strokeWidth: 2 },
|
||||
type: 'smoothstep',
|
||||
};
|
||||
|
||||
// Vue Flow 实例
|
||||
const { fitView, zoomIn, zoomOut } = useVueFlow();
|
||||
|
||||
const route = useRoute();
|
||||
const reactFlowWrapper = ref<HTMLDivElement>();
|
||||
|
||||
// ==================== 状态管理 ====================
|
||||
|
||||
// 图数据状态
|
||||
const nodes = ref<any[]>([]);
|
||||
const edges = ref<any[]>([]);
|
||||
|
||||
// UI控制状态
|
||||
const selectedGroup = ref<string | null>(null);
|
||||
const expandedGroups = ref<Set<string>>(new Set());
|
||||
const edgeType = ref<'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier'>('smoothstep');
|
||||
|
||||
|
||||
// 节点操作状态
|
||||
const selectedNode = ref<any>(null);
|
||||
const nodeActionDialogOpen = ref(false);
|
||||
const nodeDetailDialogOpen = ref(false);
|
||||
const nodeEditDialogOpen = ref(false);
|
||||
const deleteDialogOpen = ref(false);
|
||||
|
||||
// 边操作状态
|
||||
const selectedEdge = ref<any>(null);
|
||||
const edgeActionDialogOpen = ref(false);
|
||||
const edgeEditDialogOpen = ref(false);
|
||||
const deleteEdgeDialogOpen = ref(false);
|
||||
|
||||
// 布局钩子
|
||||
const { applyLayout } = useTopoLayout();
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
// 从URL参数获取拓扑ID
|
||||
const currentTopologyId = computed(() => {
|
||||
const id = route.query.id;
|
||||
return id ? parseInt(id as string) : null;
|
||||
});
|
||||
|
||||
// 根据路由判断高度
|
||||
const containerHeight = computed(() => {
|
||||
return route.path.includes('/netarch/auto-topo')
|
||||
? 'calc(100vh - 170px)'
|
||||
: '100vh';
|
||||
});
|
||||
|
||||
// 判断是否为自动拓扑路由
|
||||
const isAutoTopo = computed(() => {
|
||||
return route.path.includes('/netarch/auto-topo');
|
||||
});
|
||||
|
||||
// 从节点自动生成分组树
|
||||
const topoGroups = computed(() => buildGroupTreeFromNodes(nodes.value, edges.value));
|
||||
|
||||
// 根据选中的分组筛选显示的节点和边
|
||||
const filteredResult = computed(() =>
|
||||
filterByGroup(nodes.value, edges.value, selectedGroup.value)
|
||||
);
|
||||
|
||||
// 使用响应式计算
|
||||
const displayNodes = computed(() => filteredResult.value.nodes);
|
||||
const displayEdges = computed(() => filteredResult.value.edges);
|
||||
|
||||
// 计算边样式
|
||||
const styledEdges = computed(() => {
|
||||
return useEdgeStyles(displayEdges.value, edgeType.value);
|
||||
});
|
||||
|
||||
// 是否为新边
|
||||
const isNewEdge = computed(() => {
|
||||
return !edges.value.some((e: any) => e.id === selectedEdge.value?.id);
|
||||
});
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
// 初始化数据
|
||||
const loadData = async () => {
|
||||
if (!currentTopologyId.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let nodesData: any[] = [];
|
||||
let edgesData: any[] = [];
|
||||
|
||||
// 从 graph 接口获取节点和边数据
|
||||
const graphResponse: any = await TopoAPI.fetchTopologyGraph(currentTopologyId.value);
|
||||
|
||||
if (graphResponse.code === 0) {
|
||||
// 获取边数据 - res.details.edges
|
||||
const edgesFromGraph = graphResponse.details?.edges || graphResponse.data?.edges || [];
|
||||
edgesData = edgesFromGraph.map((edge: any) => ({
|
||||
id: String(edge.id),
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.type || 'smoothstep',
|
||||
label: edge.label || '',
|
||||
data: { ...edge },
|
||||
})) || [];
|
||||
|
||||
// 获取节点数据 - res.details.nodes 或 res.details.data
|
||||
const nodesFromGraph = graphResponse.details?.nodes || graphResponse.details?.data || graphResponse.data?.nodes || [];
|
||||
|
||||
if (Array.isArray(nodesFromGraph)) {
|
||||
nodesData = nodesFromGraph.map((node: any) => ({
|
||||
id: node.id,
|
||||
type: 'custom',
|
||||
position: node.position || { x: Math.random() * 800, y: Math.random() * 600 },
|
||||
data: {
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
ip: node.ip,
|
||||
status: node.status || 'normal',
|
||||
alerts: node.alerts || 0,
|
||||
traffic: node.traffic,
|
||||
description: node.description,
|
||||
parentId: node.parentId,
|
||||
level: node.level ?? 0,
|
||||
position: node.position,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 设置数据
|
||||
nodes.value = nodesData;
|
||||
edges.value = edgesData;
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载拓扑数据失败:', error);
|
||||
Message.warning('加载拓扑数据失败,使用默认数据');
|
||||
nodes.value = [];
|
||||
edges.value = [];
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
fitView({ duration: 500 });
|
||||
});
|
||||
};
|
||||
|
||||
// 自动保存节点位置到后端(防抖)
|
||||
let saveTimer: number | null = null;
|
||||
watch(nodes, () => {
|
||||
if (nodes.value.length === 0) return;
|
||||
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(async () => {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
const positions = nodes.value.map(node => ({
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
}));
|
||||
await TopoAPI.updateNodesPositions(currentTopologyId.value, positions);
|
||||
} catch (error) {
|
||||
console.error('保存节点位置失败:', error);
|
||||
}
|
||||
}, 1000);
|
||||
}, { deep: true });
|
||||
|
||||
// 自动展开所有一级分组
|
||||
watch(topoGroups, (newGroups) => {
|
||||
if (newGroups.length > 0) {
|
||||
const rootGroupIds = newGroups.map((g: any) => g.id);
|
||||
expandedGroups.value = new Set(rootGroupIds);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
// 连接处理 - 创建新链路
|
||||
const onConnect = async (connection: any) => {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
const response: any = await TopoAPI.createLink(currentTopologyId.value, {
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
type: 'physical',
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
// 创建成功后刷新接口
|
||||
await loadData();
|
||||
Message.success('链路创建成功');
|
||||
} else {
|
||||
Message.error('链路创建失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建链路失败:', error);
|
||||
Message.error('创建链路失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 节点点击
|
||||
const onNodeClick = (event: any) => {
|
||||
selectedNode.value = event.node;
|
||||
nodeActionDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// 边点击
|
||||
const onEdgeClick = (event: any) => {
|
||||
selectedEdge.value = event.edge;
|
||||
edgeActionDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// ==================== 业务操作 ====================
|
||||
|
||||
// 布局处理
|
||||
const handleLayout = (value: string | number | Record<string, any> | undefined) => {
|
||||
const layoutType = value as 'grid' | 'hierarchical' | 'circular';
|
||||
const updatedNodes = applyLayout(nodes.value, layoutType);
|
||||
nodes.value = updatedNodes;
|
||||
nextTick(() => {
|
||||
fitView({ duration: 500 });
|
||||
});
|
||||
};
|
||||
|
||||
// 设置边类型
|
||||
const setEdgeType = (value: string | number | Record<string, any> | undefined) => {
|
||||
const type = value as 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier';
|
||||
edgeType.value = type;
|
||||
};
|
||||
|
||||
// 添加设备
|
||||
const handleAddDevice = async (value: string | number | Record<string, any> | undefined) => {
|
||||
const type = value as DeviceType;
|
||||
const config = DEVICE_TYPE_CONFIG[type];
|
||||
const position = { x: Math.random() * 400 + 200, y: Math.random() * 300 + 100 };
|
||||
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
await TopoAPI.createNode(currentTopologyId.value, {
|
||||
label: config.label,
|
||||
type,
|
||||
ip: '',
|
||||
status: 'normal',
|
||||
alerts: 0,
|
||||
level: 0,
|
||||
position,
|
||||
});
|
||||
|
||||
await loadData();
|
||||
Message.success('设备添加成功');
|
||||
} catch (error) {
|
||||
console.error('添加设备失败:', error);
|
||||
Message.error('添加设备失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义添加
|
||||
const handleCustomAdd = () => {
|
||||
selectedNode.value = null;
|
||||
nodeEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// 保存节点
|
||||
const handleSaveNode = async (nodeId: string | null, nodeData: Partial<NodeData>) => {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
if (nodeId) {
|
||||
await TopoAPI.updateNode(currentTopologyId.value, nodeId, {
|
||||
label: nodeData.label!,
|
||||
type: nodeData.type!,
|
||||
ip: nodeData.ip,
|
||||
status: nodeData.status,
|
||||
alerts: nodeData.alerts,
|
||||
traffic: nodeData.traffic,
|
||||
description: nodeData.description,
|
||||
parentId: nodeData.parentId,
|
||||
level: nodeData.level,
|
||||
});
|
||||
nodes.value = nodes.value.map((n: any) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, ...nodeData } } : n
|
||||
);
|
||||
Message.success('节点更新成功');
|
||||
} else {
|
||||
await TopoAPI.createNode(currentTopologyId.value, {
|
||||
label: nodeData.label!,
|
||||
type: nodeData.type!,
|
||||
ip: nodeData.ip,
|
||||
status: nodeData.status || 'normal',
|
||||
alerts: nodeData.alerts || 0,
|
||||
traffic: nodeData.traffic,
|
||||
description: nodeData.description,
|
||||
parentId: nodeData.parentId,
|
||||
level: nodeData.level ?? 0,
|
||||
position: { x: 400, y: 300 },
|
||||
});
|
||||
|
||||
await loadData();
|
||||
Message.success('节点创建成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存节点失败:', error);
|
||||
Message.error('保存节点失败');
|
||||
}
|
||||
nodeEditDialogOpen.value = false;
|
||||
selectedNode.value = null;
|
||||
};
|
||||
|
||||
// 删除节点
|
||||
const handleDeleteNode = () => {
|
||||
nodeActionDialogOpen.value = false;
|
||||
deleteDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const handleDeleteNodeConfirm = async () => {
|
||||
if (!selectedNode.value) return;
|
||||
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
await TopoAPI.deleteNode(currentTopologyId.value, selectedNode.value.id);
|
||||
await loadData();
|
||||
Message.success('节点删除成功');
|
||||
} catch (error) {
|
||||
console.error('删除节点失败:', error);
|
||||
Message.error('删除节点失败');
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = false;
|
||||
selectedNode.value = null;
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = () => {
|
||||
nodeActionDialogOpen.value = false;
|
||||
nodeDetailDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = () => {
|
||||
nodeActionDialogOpen.value = false;
|
||||
nodeEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// 保存边
|
||||
const handleSaveEdge = async () => {
|
||||
if (!selectedEdge.value) return;
|
||||
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
if (isNewEdge.value) {
|
||||
const response: any = await TopoAPI.createLink(currentTopologyId.value, {
|
||||
source: selectedEdge.value.source,
|
||||
target: selectedEdge.value.target,
|
||||
type: selectedEdge.value.data?.type || 'physical',
|
||||
label: selectedEdge.value.data?.label || `${selectedEdge.value.source}-${selectedEdge.value.target}`,
|
||||
});
|
||||
if (response.code === 0 && response.data?.id) {
|
||||
const newEdge = {
|
||||
...selectedEdge.value,
|
||||
id: String(response.data.id),
|
||||
data: { ...selectedEdge.value.data, ...response.data },
|
||||
};
|
||||
edges.value.push(newEdge);
|
||||
// 强制触发响应式更新
|
||||
edges.value = [...edges.value];
|
||||
|
||||
await nextTick();
|
||||
} else {
|
||||
edges.value.push(selectedEdge.value);
|
||||
edges.value = [...edges.value];
|
||||
|
||||
await nextTick();
|
||||
}
|
||||
Message.success('链路创建成功');
|
||||
} else {
|
||||
const linkId = Number(selectedEdge.value.id);
|
||||
if (linkId) {
|
||||
await TopoAPI.updateLink(currentTopologyId.value, linkId, {
|
||||
type: selectedEdge.value.data?.type,
|
||||
label: selectedEdge.value.data?.label,
|
||||
});
|
||||
}
|
||||
// 只更新边数据,不重新加载节点
|
||||
edges.value = edges.value.map((e: any) =>
|
||||
e.id === selectedEdge.value.id ? { ...e, data: selectedEdge.value.data } : e
|
||||
);
|
||||
// 强制触发响应式更新
|
||||
edges.value = [...edges.value];
|
||||
|
||||
await nextTick();
|
||||
Message.success('链路更新成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存链路失败:', error);
|
||||
Message.error('保存链路失败');
|
||||
}
|
||||
|
||||
edgeEditDialogOpen.value = false;
|
||||
selectedEdge.value = null;
|
||||
};
|
||||
|
||||
// 删除边
|
||||
const handleDeleteEdge = () => {
|
||||
edgeActionDialogOpen.value = false;
|
||||
deleteEdgeDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const handleDeleteEdgeConfirm = async () => {
|
||||
if (!selectedEdge.value) return;
|
||||
|
||||
try {
|
||||
const linkId = selectedEdge.value.id;
|
||||
if (linkId && currentTopologyId.value) {
|
||||
await TopoAPI.deleteLink(currentTopologyId.value, linkId);
|
||||
}
|
||||
// 只删除边,不重新加载节点,保持节点位置
|
||||
edges.value = edges.value.filter((e: any) => e.id !== selectedEdge.value.id);
|
||||
// 强制触发响应式更新
|
||||
edges.value = [...edges.value];
|
||||
|
||||
await nextTick();
|
||||
Message.success('链路删除成功');
|
||||
} catch (error) {
|
||||
console.error('删除链路失败:', error);
|
||||
Message.error('删除链路失败');
|
||||
}
|
||||
|
||||
deleteEdgeDialogOpen.value = false;
|
||||
selectedEdge.value = null;
|
||||
};
|
||||
|
||||
// 编辑边
|
||||
const handleEditEdge = () => {
|
||||
edgeActionDialogOpen.value = false;
|
||||
edgeEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
|
||||
// 设置选中的边
|
||||
const setSelectedEdge = (edge: any) => {
|
||||
selectedEdge.value = edge;
|
||||
};
|
||||
|
||||
// 刷新拓扑
|
||||
const refreshTopology = () => {
|
||||
fitView({ duration: 500 });
|
||||
};
|
||||
|
||||
// 导出拓扑
|
||||
const exportTopology = () => {
|
||||
const data = { nodes: nodes.value, edges: edges.value };
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `topology-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 重置拓扑
|
||||
const resetTopology = async () => {
|
||||
if (confirm('确定要重置拓扑图吗?这将重新加载服务器数据!')) {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
const graphResponse: any = await TopoAPI.fetchTopologyGraph(currentTopologyId.value);
|
||||
if (graphResponse.code === 0) {
|
||||
const edgesFromGraph = graphResponse.details?.edges || graphResponse.data?.edges || [];
|
||||
const edgesData = edgesFromGraph.map((edge: any) => ({
|
||||
id: String(edge.id),
|
||||
source: edge.source_node_id || edge.source,
|
||||
target: edge.target_node_id || edge.target,
|
||||
type: 'smoothstep',
|
||||
label: edge.name || edge.label || '',
|
||||
data: {
|
||||
linkId: edge.id,
|
||||
linkType: edge.type,
|
||||
bandwidth: edge.bandwidth,
|
||||
description: edge.description,
|
||||
...edge,
|
||||
},
|
||||
})) || [];
|
||||
|
||||
let nodesData: any[] = [];
|
||||
const nodesFromGraph = graphResponse.details?.data || graphResponse.data?.nodes || [];
|
||||
if (typeof nodesFromGraph === 'string') {
|
||||
try {
|
||||
const parsedData = JSON.parse(nodesFromGraph);
|
||||
nodesData = parsedData.nodes?.map((node: any) => ({
|
||||
id: node.id,
|
||||
type: 'custom',
|
||||
position: node.data?.position || { x: Math.random() * 800, y: Math.random() * 600 },
|
||||
data: {
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
...node.data,
|
||||
},
|
||||
})) || [];
|
||||
} catch (e) {
|
||||
console.error('解析节点数据失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
nodes.value = nodesData;
|
||||
edges.value = edgesData;
|
||||
Message.success('重置成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置失败:', error);
|
||||
Message.error('重置失败');
|
||||
}
|
||||
nextTick(() => {
|
||||
fitView({ duration: 500 });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 分组操作
|
||||
const handleSelectGroup = (groupId: string | null) => {
|
||||
selectedGroup.value = groupId;
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
const next = new Set(expandedGroups.value);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
expandedGroups.value = next;
|
||||
};
|
||||
|
||||
// 拓扑分组变化处理
|
||||
const handleTopologyGroupChange = async (topologyId: number | null) => {
|
||||
nodes.value = [];
|
||||
edges.value = [];
|
||||
if (topologyId) {
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取节点颜色
|
||||
const getNodeColor = (node: any) => {
|
||||
const config = DEVICE_TYPE_CONFIG[node.data?.type as DeviceType];
|
||||
return config?.color || '#888';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.topo-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topo-sidebar {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topo-main {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--color-border-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flow-wrapper {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.vue-flow) {
|
||||
background-color: var(--color-bg-2);
|
||||
}
|
||||
|
||||
:deep(.vue-flow__minimap) {
|
||||
background-color: var(--color-bg-2) !important;
|
||||
border: 1px solid var(--color-border-2) !important;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__controls) {
|
||||
background-color: var(--color-bg-2) !important;
|
||||
border: 1px solid var(--color-border-2) !important;
|
||||
}
|
||||
</style>
|
||||
205
src/views/ops/pages/netarch/topo/services/topoService.ts
Normal file
205
src/views/ops/pages/netarch/topo/services/topoService.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 拓扑服务层 - 整合本地存储和后端API
|
||||
* 支持离线和在线两种模式
|
||||
*/
|
||||
|
||||
import { Node, Edge } from '@vue-flow/core';
|
||||
import * as TopoAPI from '@/api/ops/netarchTopo';
|
||||
|
||||
// 配置:是否使用后端API(可通过环境变量控制)
|
||||
const USE_BACKEND_API = import.meta.env.VITE_USE_TOPO_API === 'true';
|
||||
|
||||
// 本地存储key
|
||||
const STORAGE_KEY = 'topo-data';
|
||||
|
||||
/**
|
||||
* 拓扑数据服务类
|
||||
*/
|
||||
export class TopoService {
|
||||
private currentTopologyId: number | null = null;
|
||||
|
||||
/**
|
||||
* 设置当前拓扑ID
|
||||
*/
|
||||
setCurrentTopologyId(id: number) {
|
||||
this.currentTopologyId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拓扑数据
|
||||
*/
|
||||
async getTopoData(): Promise<{ nodes: Node[]; edges: Edge[] }> {
|
||||
if (USE_BACKEND_API && this.currentTopologyId) {
|
||||
// 从后端获取
|
||||
const graphData = await TopoAPI.fetchTopologyGraph(this.currentTopologyId);
|
||||
return this.graphToReactFlowData(graphData);
|
||||
} else {
|
||||
// 从本地存储获取
|
||||
return this.loadFromStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存拓扑数据
|
||||
*/
|
||||
async saveTopoData(nodes: Node[], edges: Edge[]): Promise<void> {
|
||||
// 同时保存到本地(作为缓存)
|
||||
this.saveToStorage(nodes, edges);
|
||||
|
||||
// 后端API通过主组件直接调用,不在此服务层处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建链路
|
||||
*/
|
||||
async createEdge(edge: Edge): Promise<void> {
|
||||
if (USE_BACKEND_API && this.currentTopologyId) {
|
||||
const params = {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.data?.type || 'physical',
|
||||
label: edge.data?.label || '',
|
||||
};
|
||||
await TopoAPI.createLink(this.currentTopologyId, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新链路
|
||||
*/
|
||||
async updateEdge(edge: Edge): Promise<void> {
|
||||
if (USE_BACKEND_API && this.currentTopologyId && edge.id) {
|
||||
const linkId = parseInt(edge.id.replace(/\D/g, ''));
|
||||
if (!isNaN(linkId)) {
|
||||
await TopoAPI.updateLink(this.currentTopologyId, linkId, {
|
||||
type: edge.data?.type as 'physical' | 'virtual',
|
||||
label: edge.data?.label,
|
||||
bandwidth: edge.data?.bandwidth,
|
||||
description: edge.data?.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除链路
|
||||
*/
|
||||
async deleteEdge(edgeId: string): Promise<void> {
|
||||
if (USE_BACKEND_API && this.currentTopologyId) {
|
||||
await TopoAPI.deleteLink(this.currentTopologyId, edgeId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拓扑列表
|
||||
*/
|
||||
async getTopologies(params: { page: number; size: number; keyword?: string; group_id?: number }): Promise<{ list: TopoAPI.Topology[] }> {
|
||||
if (USE_BACKEND_API) {
|
||||
const result = await TopoAPI.fetchTopologies(params);
|
||||
return { list: result.data?.list || [] };
|
||||
}
|
||||
return { list: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建拓扑
|
||||
*/
|
||||
async createTopology(params: Partial<TopoAPI.Topology>): Promise<TopoAPI.Topology> {
|
||||
if (USE_BACKEND_API) {
|
||||
return await TopoAPI.createTopology(params);
|
||||
}
|
||||
throw new Error('Backend API not enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发拓扑发现
|
||||
*/
|
||||
async discoverTopology(id: number): Promise<void> {
|
||||
if (USE_BACKEND_API) {
|
||||
await TopoAPI.discoverTopology(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 数据转换方法 ====================
|
||||
|
||||
/**
|
||||
* 将后端图数据转换为Vue Flow数据
|
||||
*/
|
||||
private graphToReactFlowData(graphData: any): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
if (graphData.code === 0) {
|
||||
// 处理节点数据
|
||||
const nodesFromGraph = graphData.details?.nodes || graphData.details?.data || graphData.data?.nodes || [];
|
||||
|
||||
if (Array.isArray(nodesFromGraph)) {
|
||||
nodesFromGraph.forEach((node: any) => {
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
type: 'custom',
|
||||
position: node.position || { x: Math.random() * 800, y: Math.random() * 600 },
|
||||
data: {
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
ip: node.ip,
|
||||
status: node.status || 'normal',
|
||||
alerts: node.alerts || 0,
|
||||
traffic: node.traffic,
|
||||
description: node.description,
|
||||
parentId: node.parentId,
|
||||
level: node.level ?? 0,
|
||||
position: node.position,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 处理边数据
|
||||
const edgesFromGraph = graphData.details?.edges || graphData.data?.edges || [];
|
||||
|
||||
edgesFromGraph.forEach((edge: any) => {
|
||||
edges.push({
|
||||
id: String(edge.id),
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.type || 'smoothstep',
|
||||
label: edge.label || '',
|
||||
data: { ...edge },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ==================== 本地存储方法 ====================
|
||||
|
||||
private loadFromStorage(): { nodes: Node[]; edges: Edge[] } {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load topo data from storage:', error);
|
||||
}
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
private saveToStorage(nodes: Node[], edges: Edge[]): void {
|
||||
try {
|
||||
const data = { nodes, edges };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error('Failed to save topo data to storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
clearStorage(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const topoService = new TopoService();
|
||||
40
src/views/ops/pages/netarch/topo/types.ts
Normal file
40
src/views/ops/pages/netarch/topo/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 设备类型定义
|
||||
export type DeviceType = 'server' | 'router' | 'switch' | 'desktop' | 'cloud' | 'text' | 'region';
|
||||
|
||||
// 设备状态类型
|
||||
export type DeviceStatus = 'normal' | 'warning' | 'error';
|
||||
|
||||
// 节点数据接口
|
||||
export interface NodeData {
|
||||
label: string; // 节点标签/名称
|
||||
type: DeviceType; // 节点类型
|
||||
ip?: string; // 节点IP地址
|
||||
status?: DeviceStatus; // 节点状态
|
||||
alerts?: number; // 告警数量
|
||||
traffic?: string; // 流量信息(如"100Mbps")
|
||||
description?: string; // 节点描述
|
||||
// 节点层级关系
|
||||
parentId?: string | null; // 父节点ID,null表示根节点
|
||||
level?: number; // 层级(0为一级节点)
|
||||
position?: { x: number; y: number }; // 节点位置坐标
|
||||
}
|
||||
|
||||
// 拓扑分组类型(从节点自动生成)
|
||||
export interface TopoGroup {
|
||||
id: string; // 对应节点ID
|
||||
name: string; // 对应节点名称
|
||||
nodeId: string; // 关联的节点ID
|
||||
children?: TopoGroup[]; // 子分组(对应子节点)
|
||||
parentId?: string; // 父分组ID
|
||||
level: number; // 层级
|
||||
}
|
||||
|
||||
// 链路数据接口
|
||||
export interface LinkData {
|
||||
type?: 'physical' | 'virtual';
|
||||
bandwidth?: string;
|
||||
traffic?: string;
|
||||
}
|
||||
|
||||
// 链路类型(用于边样式)
|
||||
export type EdgeType = 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier';
|
||||
194
src/views/ops/pages/netarch/topo/utils/buildGroupTree.ts
Normal file
194
src/views/ops/pages/netarch/topo/utils/buildGroupTree.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Node, Edge } from '@vue-flow/core';
|
||||
import { TopoGroup, NodeData } from '../types';
|
||||
|
||||
/**
|
||||
* 从节点数据构建分组树状结构
|
||||
* 规则:
|
||||
* 1. 优先根据连接线(edges)的source->target关系构建树
|
||||
* 2. source节点为父节点,target节点为子节点
|
||||
* 3. 如果没有连接线,则所有节点都作为根节点
|
||||
*/
|
||||
export function buildGroupTreeFromNodes(nodes: Node[], edges: Edge[] = []): TopoGroup[] {
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
// 始终使用边关系构建树
|
||||
return buildFromEdges(nodes, edges);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方式1: 使用level和parentId信息构建分组树
|
||||
*/
|
||||
function buildFromLevelInfo(nodes: Node[]): TopoGroup[] {
|
||||
// 找出所有一级节点(level=0)
|
||||
const rootNodes = nodes.filter((node) => {
|
||||
const data = node.data as NodeData;
|
||||
return data.level === 0;
|
||||
});
|
||||
|
||||
// 为每个一级节点构建分组树
|
||||
return rootNodes.map((rootNode) => buildGroupFromNode(rootNode, nodes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方式2: 根据连接线自动推断层级关系
|
||||
*/
|
||||
function buildFromEdges(nodes: Node[], edges: Edge[]): TopoGroup[] {
|
||||
// 构建连接关系图: 找出每个节点的子节点
|
||||
const childrenMap = new Map<string, string[]>();
|
||||
const hasParent = new Set<string>();
|
||||
|
||||
edges.forEach((edge) => {
|
||||
const sourceId = edge.source;
|
||||
const targetId = edge.target;
|
||||
|
||||
if (!childrenMap.has(sourceId)) {
|
||||
childrenMap.set(sourceId, []);
|
||||
}
|
||||
childrenMap.get(sourceId)!.push(targetId);
|
||||
hasParent.add(targetId);
|
||||
});
|
||||
|
||||
// 找出根节点(没有父节点的)
|
||||
const rootNodes = nodes.filter((node) => !hasParent.has(node.id));
|
||||
|
||||
// 如果没有边,则所有节点都是根节点
|
||||
if (edges.length === 0) {
|
||||
return nodes.map((node) => {
|
||||
const data = node.data as NodeData;
|
||||
return {
|
||||
id: node.id,
|
||||
name: data.label || `节点${node.id}`,
|
||||
nodeId: node.id,
|
||||
level: 0,
|
||||
parentId: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 为每个根节点构建分组树
|
||||
return rootNodes.map((rootNode) => buildGroupFromEdges(rootNode, nodes, childrenMap, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据连接关系递归构建分组
|
||||
*/
|
||||
function buildGroupFromEdges(
|
||||
node: Node,
|
||||
allNodes: Node[],
|
||||
childrenMap: Map<string, string[]>,
|
||||
level: number
|
||||
): TopoGroup {
|
||||
const data = node.data as NodeData;
|
||||
const childIds = childrenMap.get(node.id) || [];
|
||||
|
||||
const children = childIds.length > 0
|
||||
? childIds
|
||||
.map((childId) => allNodes.find((n) => n.id === childId))
|
||||
.filter((n): n is Node => n !== undefined)
|
||||
.map((childNode) => buildGroupFromEdges(childNode, allNodes, childrenMap, level + 1))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: data.label || `节点${node.id}`,
|
||||
nodeId: node.id,
|
||||
level,
|
||||
parentId: undefined,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用level/parentId信息递归构建单个节点的分组树
|
||||
*/
|
||||
function buildGroupFromNode(node: Node, allNodes: Node[]): TopoGroup {
|
||||
const data = node.data as NodeData;
|
||||
|
||||
// 查找当前节点的所有子节点
|
||||
const childNodes = allNodes.filter((n) => {
|
||||
const nodeData = n.data as NodeData;
|
||||
return nodeData.parentId === node.id;
|
||||
});
|
||||
|
||||
// 递归构建子分组
|
||||
const children = childNodes.length > 0
|
||||
? childNodes.map((childNode) => buildGroupFromNode(childNode, allNodes))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: data.label,
|
||||
nodeId: node.id,
|
||||
level: data.level || 0,
|
||||
parentId: data.parentId || undefined,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组下的所有节点ID(包括子分组的节点)
|
||||
*/
|
||||
export function getGroupNodeIds(group: TopoGroup): string[] {
|
||||
const ids = [group.nodeId];
|
||||
|
||||
if (group.children) {
|
||||
group.children.forEach((child) => {
|
||||
ids.push(...getGroupNodeIds(child));
|
||||
});
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分组筛选节点和边
|
||||
*/
|
||||
export function filterByGroup(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
selectedGroup: string | null
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
if (!selectedGroup) {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// 找到选中的分组
|
||||
const allGroups = buildGroupTreeFromNodes(nodes, edges);
|
||||
const group = findGroupById(allGroups, selectedGroup);
|
||||
|
||||
if (!group) {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// 获取分组下的所有节点ID
|
||||
const nodeIds = getGroupNodeIds(group);
|
||||
|
||||
// 筛选节点
|
||||
const filteredNodes = nodes.filter((node) => nodeIds.includes(node.id));
|
||||
|
||||
// 筛选边(只保留两端都在分组内的边)
|
||||
const filteredEdges = edges.filter((edge) =>
|
||||
nodeIds.includes(edge.source) && nodeIds.includes(edge.target)
|
||||
);
|
||||
|
||||
return { nodes: filteredNodes, edges: filteredEdges };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在分组树中查找指定ID的分组
|
||||
*/
|
||||
function findGroupById(groups: TopoGroup[], id: string): TopoGroup | null {
|
||||
for (const group of groups) {
|
||||
if (group.id === id) {
|
||||
return group;
|
||||
}
|
||||
if (group.children) {
|
||||
const found = findGroupById(group.children, id);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user