This commit is contained in:
ygx
2026-03-14 18:55:23 +08:00
parent 3fdb03cc60
commit 18072d75a4
40 changed files with 4886 additions and 47 deletions

View File

@@ -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
View File

@@ -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
View 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}`)

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: ['*'],
},
},
],
}

View File

@@ -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
}
}

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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',
},
]

View 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,
],
},
]

View File

@@ -0,0 +1,9 @@
/**
* 选项配置
*/
/** 启用状态选项 */
export const enableOptions = [
{ label: '启用', value: 'true' },
{ label: '禁用', value: 'false' },
]

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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';

View 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;
// 初始节点数据 - 带层级关系

View File

@@ -0,0 +1,3 @@
export { useTopoStorage } from './useTopoStorage';
export { useTopoLayout } from './useTopoLayout';
export { useEdgeStyles } from './useEdgeStyles';

View 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;
}

View 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 };
}

View 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,
};
}

View 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>

View 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();

View 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';

View 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;
}