feat
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="添加子分组"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-form
|
||||
:model="formData"
|
||||
layout="vertical"
|
||||
:label-col-props="{ span: 24 }"
|
||||
:wrapper-col-props="{ span: 24 }"
|
||||
>
|
||||
<!-- 父分组提示 -->
|
||||
<a-alert
|
||||
v-if="parentGroup"
|
||||
type="info"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<template #icon><icon-info-circle /></template>
|
||||
<div>
|
||||
<div style="color: var(--color-text-3); font-size: 12px; margin-bottom: 4px;">
|
||||
将作为以下分组的子分组:
|
||||
</div>
|
||||
<div style="font-weight: 600; color: rgb(var(--primary-6));">
|
||||
{{ parentGroup.name }}
|
||||
</div>
|
||||
</div>
|
||||
</a-alert>
|
||||
|
||||
<a-form-item label="分组名称" required>
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入子分组名称"
|
||||
:max-length="100"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="排序权重">
|
||||
<a-input-number
|
||||
v-model="formData.sort"
|
||||
placeholder="请输入排序权重"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
<template #extra>
|
||||
<span style="color: var(--color-text-3); font-size: 12px;">数字越小越靠前</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否启用">
|
||||
<a-switch v-model="formData.enable" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="分组描述">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入分组描述(可选)"
|
||||
:max-length="500"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, reactive } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconInfoCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { createTopologyGroup } from '@/api/ops/netarchTopo'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
parentGroup?: { id: number; name: string } | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parentGroup: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
parent_id: 0,
|
||||
description: '',
|
||||
sort: 0,
|
||||
enable: true,
|
||||
})
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val && props.parentGroup) {
|
||||
formData.name = ''
|
||||
formData.parent_id = props.parentGroup.id
|
||||
formData.description = ''
|
||||
formData.sort = 0
|
||||
formData.enable = true
|
||||
}
|
||||
})
|
||||
|
||||
// 更新可见性
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
Message.warning('请输入分组名称')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const response = await createTopologyGroup(formData)
|
||||
if (response.code === 0) {
|
||||
Message.success('子分组创建成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} else {
|
||||
Message.error(response.message || '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建子分组失败:', error)
|
||||
Message.error('创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'SubGroupFormDialog',
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="mode === 'create' ? '新增拓扑' : '编辑拓扑'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleClose"
|
||||
@update:visible="handleUpdateVisible"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-form
|
||||
:model="formData"
|
||||
layout="vertical"
|
||||
:label-col-props="{ span: 24 }"
|
||||
:wrapper-col-props="{ span: 24 }"
|
||||
>
|
||||
<a-form-item label="拓扑名称" required>
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入拓扑名称"
|
||||
:max-length="100"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否启用">
|
||||
<a-select v-model="formData.enable" placeholder="请选择">
|
||||
<a-option :value="true">是</a-option>
|
||||
<a-option :value="false">否</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入描述(可选)"
|
||||
:max-length="500"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, reactive } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { Topology } from '@/api/ops/netarchTopo'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
mode: 'create' | 'edit'
|
||||
initialValues?: Topology | null
|
||||
groupId?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'submit', values: any): Promise<void>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
initialValues: null,
|
||||
groupId: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
type: 'layer2' as 'layer2' | 'layer3' | 'physical',
|
||||
description: '',
|
||||
enable: true,
|
||||
})
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
if (props.mode === 'edit' && props.initialValues) {
|
||||
formData.name = props.initialValues.name || ''
|
||||
formData.type = props.initialValues.type || 'layer2'
|
||||
formData.description = props.initialValues.description || ''
|
||||
formData.enable = props.initialValues.enable ?? true
|
||||
} else {
|
||||
formData.name = ''
|
||||
formData.type = 'layer2'
|
||||
formData.description = ''
|
||||
formData.enable = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 更新可见性
|
||||
const handleUpdateVisible = (value: boolean) => {
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
Message.warning('请输入拓扑名称')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await emit('submit', { ...formData })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'TopologyFormDialog',
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="拓扑列表"
|
||||
:footer="false"
|
||||
width="80%"
|
||||
@cancel="handleClose"
|
||||
@update:visible="handleUpdateVisible"
|
||||
>
|
||||
<template #title>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<span>拓扑列表 {{ groupName ? `- ${groupName}` : '' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-button type="primary" style="margin-bottom: 16px;" @click="handleOpenCreate">
|
||||
<template #icon><icon-plus /></template>
|
||||
新增拓扑
|
||||
</a-button>
|
||||
|
||||
<a-spin :loading="loading" style="width: 100%;">
|
||||
<a-alert v-if="error" type="error" :content="error" style="margin-bottom: 16px;" />
|
||||
<a-empty v-else-if="!loading && data.length === 0" description="暂无拓扑数据" />
|
||||
<div v-else>
|
||||
<a-table
|
||||
:data="data"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: true,
|
||||
}"
|
||||
size="small"
|
||||
@page-change="handlePageChange"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="序号" data-index="id" :width="80">
|
||||
<template #cell="{ rowIndex }">
|
||||
{{ (page - 1) * pageSize + rowIndex + 1 }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="拓扑名称" data-index="name" />
|
||||
<a-table-column title="类型" data-index="type" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="getTypeColor(record.type)">
|
||||
{{ getTypeLabel(record.type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" data-index="status" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="节点数" data-index="node_count" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
{{ record.node_count || 0 }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="链路数" data-index="link_count" :width="100" align="center">
|
||||
<template #cell="{ record }">
|
||||
{{ record.link_count || 0 }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="启用" data-index="enable" :width="80" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="record.enable ? 'blue' : 'gray'" bordered>
|
||||
{{ record.enable ? '是' : '否' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="创建时间" data-index="created_at" :width="160" align="center">
|
||||
<template #cell="{ record }">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="200" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space>
|
||||
<a-button type="text" size="small" @click="handleViewTopology(record)">
|
||||
拓扑
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleOpenEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<!-- 表单弹窗 -->
|
||||
<TopologyFormDialog
|
||||
v-model:visible="formDialogVisible"
|
||||
:mode="formMode"
|
||||
:initial-values="currentEditItem"
|
||||
:group-id="groupId"
|
||||
@submit="handleFormSubmit"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Topology } from '@/api/ops/netarchTopo'
|
||||
import {
|
||||
fetchTopologies,
|
||||
createTopology,
|
||||
updateTopology,
|
||||
deleteTopology,
|
||||
fetchTopologyDetail,
|
||||
} from '@/api/ops/netarchTopo'
|
||||
import TopologyFormDialog from './TopologyFormDialog.vue'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
groupId: number
|
||||
groupName?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:open', value: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
groupName: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<Topology[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 表单弹窗状态
|
||||
const formDialogVisible = ref(false)
|
||||
const formMode = ref<'create' | 'edit'>('create')
|
||||
const currentEditItem = ref<Topology | null>(null)
|
||||
|
||||
// 监听 open 变化
|
||||
watch(() => props.open, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
|
||||
// 更新可见性
|
||||
const handleUpdateVisible = (val: boolean) => {
|
||||
visible.value = val
|
||||
if (!val) {
|
||||
emit('update:open', false)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (!props.groupId) {
|
||||
return
|
||||
}
|
||||
const response = await fetchTopologies({
|
||||
group_id: props.groupId,
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
})
|
||||
|
||||
if (response.code === 0) {
|
||||
data.value = response.details?.data || []
|
||||
total.value = response.details?.total || 0
|
||||
} else {
|
||||
error.value = response.message || '加载失败'
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理页码变更
|
||||
const handlePageChange = (current: number) => {
|
||||
page.value = current
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 跳转到拓扑详情页
|
||||
const handleViewTopology = (topology: Topology) => {
|
||||
window.open(`/#/netarch/topo?id=${topology.id}`, '_blank')
|
||||
}
|
||||
|
||||
// 打开新增弹窗
|
||||
const handleOpenCreate = () => {
|
||||
formMode.value = 'create'
|
||||
currentEditItem.value = null
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 打开编辑弹窗
|
||||
const handleOpenEdit = async (topology: Topology) => {
|
||||
try {
|
||||
const response = await fetchTopologyDetail(topology.id)
|
||||
if (response.code === 0) {
|
||||
formMode.value = 'edit'
|
||||
currentEditItem.value = response.details
|
||||
formDialogVisible.value = true
|
||||
} else {
|
||||
Message.error(response.message || '获取详情失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
Message.error(err.message || '获取详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = (topology: Topology) => {
|
||||
Modal.confirm({
|
||||
title: '删除确认',
|
||||
content: `确定要删除拓扑「${topology.name}」吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await deleteTopology(topology.id)
|
||||
if (response.code === 0) {
|
||||
Message.success('删除成功')
|
||||
loadData()
|
||||
} else {
|
||||
Message.error(response.message || '删除失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
Message.error(err.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 表单提交
|
||||
const handleFormSubmit = async (values: any) => {
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
group_id: props.groupId,
|
||||
}
|
||||
|
||||
if (formMode.value === 'create') {
|
||||
const response = await createTopology(payload)
|
||||
if (response.code === 0) {
|
||||
Message.success('新增成功')
|
||||
formDialogVisible.value = false
|
||||
loadData()
|
||||
} else {
|
||||
throw new Error(response.message || '新增失败')
|
||||
}
|
||||
} else {
|
||||
const response = await updateTopology({ ...payload, id: currentEditItem.value!.id })
|
||||
if (response.code === 0) {
|
||||
Message.success('编辑成功')
|
||||
formDialogVisible.value = false
|
||||
loadData()
|
||||
} else {
|
||||
throw new Error(response.message || '编辑失败')
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
Message.error(err.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
pending: 'gray',
|
||||
discovering: 'blue',
|
||||
active: 'green',
|
||||
error: 'red',
|
||||
}
|
||||
return colorMap[status || ''] || 'gray'
|
||||
}
|
||||
|
||||
// 获取状态标签
|
||||
const getStatusLabel = (status?: string) => {
|
||||
const labelMap: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
discovering: '发现中',
|
||||
active: '活跃',
|
||||
error: '错误',
|
||||
}
|
||||
return labelMap[status || ''] || status || '-'
|
||||
}
|
||||
|
||||
// 获取类型标签
|
||||
const getTypeLabel = (type?: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
layer2: '二层',
|
||||
layer3: '三层',
|
||||
physical: '物理',
|
||||
}
|
||||
return typeMap[type || ''] || type || '-'
|
||||
}
|
||||
|
||||
// 获取类型颜色
|
||||
const getTypeColor = (type?: string) => {
|
||||
return 'arcoblue'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time?: string) => {
|
||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'TopologyListDialog',
|
||||
}
|
||||
</script>
|
||||
88
src/views/ops/pages/netarch/topo-group/config/columns.ts
Normal file
88
src/views/ops/pages/netarch/topo-group/config/columns.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 表格列配置 - 网络拓扑分组
|
||||
*/
|
||||
import dayjs from 'dayjs'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
|
||||
export const getColumns = (): TableColumnData[] => [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tree',
|
||||
width: 20,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '分组名称',
|
||||
dataIndex: 'name',
|
||||
width: 250,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'parent_id',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
slotName: 'level',
|
||||
},
|
||||
{
|
||||
title: '分组路径',
|
||||
dataIndex: 'path',
|
||||
width: 300,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: '拓扑数量',
|
||||
dataIndex: 'topology_count',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
slotName: 'topology_count',
|
||||
},
|
||||
{
|
||||
title: '总链路数',
|
||||
dataIndex: 'total_link_count',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
slotName: 'total_link_count',
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enable',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
slotName: 'enable',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 160,
|
||||
align: 'center',
|
||||
render: ({ record }: any) => {
|
||||
return record.created_at ? dayjs(record.created_at).format('YYYY-MM-DD HH:mm') : '-'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'operation',
|
||||
width: 300,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
slotName: 'operation',
|
||||
},
|
||||
]
|
||||
26
src/views/ops/pages/netarch/topo-group/config/filters.ts
Normal file
26
src/views/ops/pages/netarch/topo-group/config/filters.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 筛选项配置 - 网络拓扑分组
|
||||
*/
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import { enableOptions } from './options'
|
||||
|
||||
export const getFilters = (): FormItem[] => [
|
||||
{
|
||||
field: 'keyword',
|
||||
label: '关键词',
|
||||
type: 'input',
|
||||
placeholder: '搜索分组名称或描述',
|
||||
span: 8,
|
||||
},
|
||||
{
|
||||
field: 'enable',
|
||||
label: '启用状态',
|
||||
type: 'select',
|
||||
placeholder: '请选择启用状态',
|
||||
span: 6,
|
||||
options: [
|
||||
{ label: '全部', value: '' },
|
||||
...enableOptions,
|
||||
],
|
||||
},
|
||||
]
|
||||
9
src/views/ops/pages/netarch/topo-group/config/options.ts
Normal file
9
src/views/ops/pages/netarch/topo-group/config/options.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 选项配置
|
||||
*/
|
||||
|
||||
/** 启用状态选项 */
|
||||
export const enableOptions = [
|
||||
{ label: '启用', value: 'true' },
|
||||
{ label: '禁用', value: 'false' },
|
||||
]
|
||||
493
src/views/ops/pages/netarch/topo-group/index.vue
Normal file
493
src/views/ops/pages/netarch/topo-group/index.vue
Normal file
@@ -0,0 +1,493 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<Breadcrumb :items="['运维管理', '网络架构', '拓扑分组']" />
|
||||
|
||||
<SearchTable
|
||||
title="拓扑分组管理"
|
||||
:form-model="searchForm"
|
||||
:form-items="filters"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
}"
|
||||
@page-change="handlePageChange"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@refresh="fetchData"
|
||||
>
|
||||
<!-- 工具栏左侧 - 新增按钮 -->
|
||||
<template #toolbar-left>
|
||||
<a-button type="primary" @click="handleAddGroup">
|
||||
<template #icon><icon-plus /></template>
|
||||
新增分组
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #operation="{ record }">
|
||||
<a-space>
|
||||
<!-- 添加子分组:只在顶层分组时显示 (order: 50) -->
|
||||
<a-button
|
||||
v-if="!record.parent_id || record.parent_id === 0"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleAddSubGroup(record)"
|
||||
>
|
||||
添加子分组
|
||||
</a-button>
|
||||
<!-- 详情:只在有父分组时显示 (order: 100) -->
|
||||
<a-button
|
||||
v-if="!!record.parent_id && record.parent_id !== 0"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleViewDetail(record)"
|
||||
>
|
||||
详情
|
||||
</a-button>
|
||||
<!-- 拓扑:只在有父分组时显示 (order: 150) -->
|
||||
<a-button
|
||||
v-if="!!record.parent_id && record.parent_id !== 0"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleViewTopologies(record)"
|
||||
>
|
||||
拓扑
|
||||
</a-button>
|
||||
<!-- 编辑:始终显示 (order: 200) -->
|
||||
<a-button type="text" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<!-- 删除:始终显示 (order: 300) -->
|
||||
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 层级列 -->
|
||||
<template #level="{ record }">
|
||||
<a-tag
|
||||
:color="record.parent_id === 0 ? 'blue' : 'arcoblue'"
|
||||
bordered
|
||||
>
|
||||
{{ record.parent_id === 0 ? '顶层' : `${record.level || 1}级` }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 拓扑数量列 -->
|
||||
<template #topology_count="{ record }">
|
||||
<a-tag
|
||||
:color="(record.parent_id ? record.total_topology_count : record.topology_count) > 0 ? 'green' : 'gray'"
|
||||
>
|
||||
{{ record.parent_id ? record.total_topology_count : record.topology_count || 0 }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 总链路数列 -->
|
||||
<template #total_link_count="{ record }">
|
||||
<a-tag
|
||||
:color="record.total_link_count > 0 ? 'green' : 'gray'"
|
||||
>
|
||||
{{ record.total_link_count || 0 }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 启用列 -->
|
||||
<template #enable="{ record }">
|
||||
<a-tag
|
||||
:color="record.enable ? 'blue' : 'gray'"
|
||||
bordered
|
||||
>
|
||||
{{ record.enable ? '是' : '否' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</SearchTable>
|
||||
|
||||
<!-- 分组表单弹窗 -->
|
||||
<a-modal
|
||||
v-model:visible="formModalVisible"
|
||||
:title="formModalTitle"
|
||||
:mask-closable="false"
|
||||
:width="520"
|
||||
@ok="handleFormModalOk"
|
||||
@cancel="handleFormModalCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col-props="{ span: 6 }"
|
||||
:wrapper-col-props="{ span: 16 }"
|
||||
>
|
||||
<a-form-item field="name" label="分组名称" validate-trigger="blur">
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入分组名称"
|
||||
:max-length="100"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item field="description" label="分组描述">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入分组描述(可选)"
|
||||
:max-length="500"
|
||||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item field="sort" label="排序权重">
|
||||
<a-input-number
|
||||
v-model="formData.sort"
|
||||
placeholder="请输入排序权重"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
<template #extra>
|
||||
<span style="color: var(--color-text-3); font-size: 12px;">数字越小越靠前</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
<a-form-item field="enable" label="是否启用">
|
||||
<a-switch v-model="formData.enable" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<a-modal
|
||||
v-model:visible="deleteConfirmVisible"
|
||||
title="删除确认"
|
||||
@ok="handleConfirmDelete"
|
||||
@cancel="deleteConfirmVisible = false"
|
||||
>
|
||||
<p>确定要删除分组 "{{ groupToDelete?.name }}" 吗?</p>
|
||||
<p v-if="groupToDelete?.topology_count && groupToDelete.topology_count > 0" style="color: rgb(var(--warning-6));">
|
||||
该分组下还有 {{ groupToDelete.topology_count }} 个拓扑,删除分组不会删除拓扑。
|
||||
</p>
|
||||
</a-modal>
|
||||
|
||||
<!-- 拓扑列表弹窗 -->
|
||||
<TopologyListDialog
|
||||
v-model:open="topologyListDialogVisible"
|
||||
:group-id="currentGroupId"
|
||||
:group-name="currentGroupName"
|
||||
/>
|
||||
|
||||
<!-- 添加子分组弹窗 -->
|
||||
<SubGroupFormDialog
|
||||
v-model:visible="subGroupDialogVisible"
|
||||
:parent-group="currentParentGroup"
|
||||
@success="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
import type { TopologyGroup } from '@/api/ops/netarchTopo'
|
||||
import {
|
||||
fetchTopologyGroups,
|
||||
createTopologyGroup,
|
||||
updateTopologyGroup,
|
||||
deleteTopologyGroup,
|
||||
} from '@/api/ops/netarchTopo'
|
||||
import { getColumns } from './config/columns'
|
||||
import { getFilters } from './config/filters'
|
||||
import TopologyListDialog from './components/TopologyListDialog.vue'
|
||||
import SubGroupFormDialog from './components/SubGroupFormDialog.vue'
|
||||
|
||||
// 表格列配置
|
||||
const columns = getColumns()
|
||||
|
||||
// 搜索表单配置
|
||||
const filters = getFilters()
|
||||
|
||||
// 搜索表单数据
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
enable: '',
|
||||
})
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref<TopologyGroup[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 分页
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
// 表单弹窗相关
|
||||
const formModalVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const formData = reactive<Partial<TopologyGroup> & { parent_id: number }>({
|
||||
id: undefined,
|
||||
name: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
enable: true,
|
||||
parent_id: 0,
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入分组名称' },
|
||||
{ minLength: 1, message: '分组名称不能为空' },
|
||||
],
|
||||
}
|
||||
|
||||
// 弹窗标题
|
||||
const formModalTitle = computed(() => (isEdit.value ? '编辑分组' : '新增分组'))
|
||||
|
||||
// 删除确认
|
||||
const deleteConfirmVisible = ref(false)
|
||||
const groupToDelete = ref<TopologyGroup | null>(null)
|
||||
|
||||
// 拓扑列表弹窗
|
||||
const topologyListDialogVisible = ref(false)
|
||||
const currentGroupId = ref(0)
|
||||
const currentGroupName = ref('')
|
||||
|
||||
// 添加子分组弹窗
|
||||
const subGroupDialogVisible = ref(false)
|
||||
const currentParentGroup = ref<{ id: number; name: string } | null>(null)
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await fetchTopologyGroups({
|
||||
keyword: searchForm.keyword || undefined,
|
||||
enable: searchForm.enable ? searchForm.enable === 'true' : undefined,
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
})
|
||||
if (res?.code === 0) {
|
||||
// API返回结构: { details: { count: number, list: array } }
|
||||
const { count = 0, list = [] } = res.details || {}
|
||||
total.value = count
|
||||
|
||||
// 构建树形结构
|
||||
tableData.value = list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch topology groups:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 构建树形结构
|
||||
const buildTree = (flatData: TopologyGroup[]): TopologyGroup[] => {
|
||||
const map = new Map<number, TopologyGroup>()
|
||||
const roots: TopologyGroup[] = []
|
||||
|
||||
// 先创建所有节点的映射
|
||||
flatData.forEach((item) => {
|
||||
map.set(item.id!, { ...item, children: [] })
|
||||
})
|
||||
|
||||
// 构建树形结构
|
||||
flatData.forEach((item) => {
|
||||
const node = map.get(item.id!)!
|
||||
if (item.parent_id === 0 || item.parent_id === undefined) {
|
||||
roots.push(node)
|
||||
} else {
|
||||
const parent = map.get(item.parent_id)
|
||||
if (parent) {
|
||||
if (!parent.children) {
|
||||
parent.children = []
|
||||
}
|
||||
parent.children.push(node)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 计算每个分组的总拓扑数和链路数
|
||||
const calculateTotals = (node: TopologyGroup): void => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
node.total_topology_count = node.topology_count || 0
|
||||
node.total_link_count = node.total_link_count || 0
|
||||
return
|
||||
}
|
||||
|
||||
let totalTopo = node.topology_count || 0
|
||||
let totalLinks = node.total_link_count || 0
|
||||
|
||||
node.children.forEach((child) => {
|
||||
calculateTotals(child)
|
||||
totalTopo += child.total_topology_count || 0
|
||||
totalLinks += child.total_link_count || 0
|
||||
})
|
||||
|
||||
node.total_topology_count = totalTopo
|
||||
node.total_link_count = totalLinks
|
||||
}
|
||||
|
||||
roots.forEach((root) => calculateTotals(root))
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.enable = ''
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 页码变化
|
||||
const handlePageChange = (current: number) => {
|
||||
page.value = current
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 新增分组
|
||||
const handleAddGroup = () => {
|
||||
isEdit.value = false
|
||||
resetFormData()
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑分组
|
||||
const handleEdit = (record: TopologyGroup) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
sort: record.sort || 0,
|
||||
enable: record.enable ?? true,
|
||||
})
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 删除分组
|
||||
const handleDelete = (record: TopologyGroup) => {
|
||||
groupToDelete.value = record
|
||||
deleteConfirmVisible.value = true
|
||||
}
|
||||
|
||||
// 查看拓扑列表
|
||||
const handleViewTopologies = (record: TopologyGroup) => {
|
||||
currentGroupId.value = record.id!
|
||||
currentGroupName.value = record.name
|
||||
topologyListDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (record: TopologyGroup) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
sort: record.sort || 0,
|
||||
enable: record.enable ?? true,
|
||||
})
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 添加子分组
|
||||
const handleAddSubGroup = (record: TopologyGroup) => {
|
||||
currentParentGroup.value = {
|
||||
id: record.id!,
|
||||
name: record.name,
|
||||
}
|
||||
subGroupDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!groupToDelete.value?.id) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
await deleteTopologyGroup(groupToDelete.value.id)
|
||||
Message.success('删除成功')
|
||||
deleteConfirmVisible.value = false
|
||||
groupToDelete.value = null
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete group:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗确认
|
||||
const handleFormModalOk = async () => {
|
||||
try {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (valid) return
|
||||
|
||||
loading.value = true
|
||||
if (isEdit.value && formData.id) {
|
||||
await updateTopologyGroup({ ...formData, id: formData.id })
|
||||
Message.success('修改成功')
|
||||
} else {
|
||||
await createTopologyGroup({ ...formData, parent_id: 0 })
|
||||
Message.success('创建成功')
|
||||
}
|
||||
formModalVisible.value = false
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
console.error('Failed to save group:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗取消
|
||||
const handleFormModalCancel = () => {
|
||||
formModalVisible.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 重置表单数据
|
||||
const resetFormData = () => {
|
||||
Object.assign(formData, {
|
||||
id: undefined,
|
||||
name: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
enable: true,
|
||||
parent_id: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'TopologyGroup',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
</style>
|
||||
70
src/views/ops/pages/netarch/topo/components/AddNodeMenu.vue
Normal file
70
src/views/ops/pages/netarch/topo/components/AddNodeMenu.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<a-dropdown :popup-visible="visible" @update:popup-visible="handleClose">
|
||||
<slot></slot>
|
||||
<template #content>
|
||||
<a-doption @click="handleCustomAdd">
|
||||
<template #icon>
|
||||
<icon-plus-circle />
|
||||
</template>
|
||||
<span>自定义添加</span>
|
||||
</a-doption>
|
||||
<a-divider style="margin: 4px 0;" />
|
||||
<a-doption @click="handleAddDevice('server')">
|
||||
<template #icon>
|
||||
<icon-desktop />
|
||||
</template>
|
||||
<span>快速添加服务器</span>
|
||||
</a-doption>
|
||||
<a-doption @click="handleAddDevice('router')">
|
||||
<template #icon>
|
||||
<icon-file />
|
||||
</template>
|
||||
<span>快速添加路由器</span>
|
||||
</a-doption>
|
||||
<a-doption @click="handleAddDevice('switch')">
|
||||
<template #icon>
|
||||
<icon-safe />
|
||||
</template>
|
||||
<span>快速添加交换机</span>
|
||||
</a-doption>
|
||||
<a-doption @click="handleAddDevice('cloud')">
|
||||
<template #icon>
|
||||
<icon-cloud />
|
||||
</template>
|
||||
<span>快速添加云端节点</span>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconPlusCircle, IconDesktop, IconFile, IconSafe, IconCloud } from '@arco-design/web-vue/es/icon';
|
||||
import type { DeviceType } from '../types';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'addDevice', type: DeviceType): void;
|
||||
(e: 'customAdd'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleAddDevice = (type: DeviceType) => {
|
||||
emit('addDevice', type);
|
||||
handleClose(false);
|
||||
};
|
||||
|
||||
const handleCustomAdd = () => {
|
||||
emit('customAdd');
|
||||
handleClose(false);
|
||||
};
|
||||
|
||||
const handleClose = (value: boolean) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
</script>
|
||||
154
src/views/ops/pages/netarch/topo/components/CustomNode.vue
Normal file
154
src/views/ops/pages/netarch/topo/components/CustomNode.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['custom-node', { 'node-selected': selected }]"
|
||||
:style="nodeStyle"
|
||||
>
|
||||
<!-- 连接点 - 目标 -->
|
||||
<Handle
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
class="handle handle-top"
|
||||
/>
|
||||
|
||||
<!-- 节点内容 -->
|
||||
<a-space direction="vertical" align="center" :size="4">
|
||||
<a-avatar :size="48" :style="avatarStyle">
|
||||
<component :is="iconComponent" :size="28" />
|
||||
</a-avatar>
|
||||
|
||||
<div class="node-text">
|
||||
<div class="node-label">{{ data.label }}</div>
|
||||
<div v-if="data.ip" class="node-ip">{{ data.ip }}</div>
|
||||
</div>
|
||||
|
||||
<a-tag v-if="data.alerts && data.alerts > 0" color="danger" size="small">
|
||||
{{ data.alerts }}个告警
|
||||
</a-tag>
|
||||
</a-space>
|
||||
|
||||
<!-- 连接点 - 源 -->
|
||||
<Handle
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
class="handle handle-bottom"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Handle, Position, type NodeProps } from '@vue-flow/core';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import type { DeviceType, DeviceStatus, NodeData } from '../types';
|
||||
import {
|
||||
IconDesktop,
|
||||
IconCloud,
|
||||
IconStorage,
|
||||
IconMore,
|
||||
IconSafe,
|
||||
IconFile,
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
|
||||
// 使用Vue Flow的NodeProps类型
|
||||
const props = defineProps<NodeProps<NodeData>>();
|
||||
|
||||
// 从props中解构数据
|
||||
const data = computed(() => props.data);
|
||||
const selected = computed(() => props.selected);
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
server: IconDesktop,
|
||||
switch: IconSafe,
|
||||
router: IconFile,
|
||||
firewall: IconSafe,
|
||||
storage: IconStorage,
|
||||
cloud: IconCloud,
|
||||
desktop: IconDesktop,
|
||||
mobile: IconDesktop,
|
||||
};
|
||||
|
||||
const config = computed(() => DEVICE_TYPE_CONFIG[data.value.type] || DEVICE_TYPE_CONFIG.server);
|
||||
const iconComponent = computed(() => iconMap[data.value.type] || IconDesktop);
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
normal: '#52C41A',
|
||||
warning: '#FAAD14',
|
||||
error: '#F53F3F',
|
||||
};
|
||||
|
||||
const borderColor = computed(() => statusColors[data.value.status || 'normal']);
|
||||
|
||||
const nodeStyle = computed(() => ({
|
||||
'--border-color': selected.value ? '#165DFF' : borderColor.value,
|
||||
'--bg-color': '#fff',
|
||||
'--shadow': selected.value ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.1)',
|
||||
}));
|
||||
|
||||
const avatarStyle = computed(() => ({
|
||||
backgroundColor: `${config.value.color}1A`,
|
||||
color: config.value.color,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.custom-node {
|
||||
background: var(--bg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
min-width: 140px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&.node-selected {
|
||||
border-color: #165DFF;
|
||||
}
|
||||
|
||||
.node-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-ip {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.handle {
|
||||
background: #165DFF;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
|
||||
&.handle-top {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&.handle-bottom {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="确认删除"
|
||||
@ok="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<p>{{ message }}</p>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'confirm'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
message: '确定要删除吗?',
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm');
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="链路操作"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
:footer="false"
|
||||
width="400px"
|
||||
>
|
||||
<div class="edge-actions">
|
||||
<a-button long @click="handleEdit">编辑链路</a-button>
|
||||
<a-button long status="danger" @click="handleDelete">删除链路</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'edit'): void;
|
||||
(e: 'delete'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit');
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete');
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.edge-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="isNewEdge ? '添加链路' : '编辑链路'"
|
||||
@ok="handleSave"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-form :model="formData" layout="vertical">
|
||||
<a-form-item label="链路类型 *" required>
|
||||
<a-select
|
||||
v-model="formData.type"
|
||||
placeholder="请选择链路类型"
|
||||
>
|
||||
<a-option value="physical">物理链路 (蓝色实线)</a-option>
|
||||
<a-option value="virtual">虚拟链路 (橙色虚线)</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="链路标签">
|
||||
<a-input
|
||||
v-model="formData.label"
|
||||
placeholder="选填,如: 主干网络, 备用链路等"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue';
|
||||
import type { Edge } from '@vue-flow/core';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
edge: Edge | null;
|
||||
isNewEdge: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'change', edge: Edge): void;
|
||||
(e: 'save'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const formData = reactive({
|
||||
type: 'physical',
|
||||
label: '',
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.edge,
|
||||
(edge) => {
|
||||
if (edge) {
|
||||
formData.type = edge.data?.type || 'physical';
|
||||
formData.label = edge.data?.label || '';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [formData.type, formData.label],
|
||||
() => {
|
||||
if (props.edge) {
|
||||
emit('change', {
|
||||
...props.edge,
|
||||
data: { ...props.edge.data, type: formData.type, label: formData.label },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
emit('save');
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
116
src/views/ops/pages/netarch/topo/components/EdgeStyleMenu.vue
Normal file
116
src/views/ops/pages/netarch/topo/components/EdgeStyleMenu.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<a-dropdown :popup-visible="visible" @update:popup-visible="handleClose">
|
||||
<slot></slot>
|
||||
<template #content>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'default' }"
|
||||
@click="handleSelect('default')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-minus />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">默认曲线</div>
|
||||
<div class="option-subtitle">贝塞尔曲线,平滑自然</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'straight' }"
|
||||
@click="handleSelect('straight')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-arrow-right />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">直线</div>
|
||||
<div class="option-subtitle">直接连接,简洁明了</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'step' }"
|
||||
@click="handleSelect('step')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-minus />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">阶梯线</div>
|
||||
<div class="option-subtitle">直角转折,类似线路图</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'smoothstep' }"
|
||||
@click="handleSelect('smoothstep')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-arrow-up />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">平滑阶梯线</div>
|
||||
<div class="option-subtitle">圆角转折,平滑过渡</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'simplebezier' }"
|
||||
@click="handleSelect('simplebezier')"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-minus style="transform: scaleY(0.8);" />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">简单贝塞尔曲线</div>
|
||||
<div class="option-subtitle">轻微弯曲,柔和过渡</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconMinus, IconArrowRight, IconArrowUp } from '@arco-design/web-vue/es/icon';
|
||||
import type { EdgeType } from '../types';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
selectedType: EdgeType;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'selectType', type: EdgeType): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleSelect = (type: EdgeType) => {
|
||||
emit('selectType', type);
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleClose = (value: boolean) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.selected-option {
|
||||
background-color: var(--color-primary-light-1);
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.option-title {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.option-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
</style>
|
||||
257
src/views/ops/pages/netarch/topo/components/GroupPanel.vue
Normal file
257
src/views/ops/pages/netarch/topo/components/GroupPanel.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div class="group-panel-wrapper">
|
||||
<div class="group-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-title">拓扑</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动拓扑分组选择 -->
|
||||
<div v-if="isAutoTopo" class="topo-selector">
|
||||
<a-tree-select
|
||||
v-model="selectedGroupId"
|
||||
placeholder="选择分组"
|
||||
:data="treeData"
|
||||
allow-clear
|
||||
allow-search
|
||||
default-expand-all
|
||||
:field-names="{ key: 'key', title: 'title', children: 'children' }"
|
||||
@change="handleGroupChange"
|
||||
/>
|
||||
<div v-if="topologyList.length > 0" class="topology-list">
|
||||
<div
|
||||
v-for="topology in topologyList"
|
||||
:key="topology.id"
|
||||
:class="['topology-item', { active: selectedTopologyId === topology.id }]"
|
||||
@click="handleTopologySelect(topology)"
|
||||
>
|
||||
<div class="topology-icon">
|
||||
<icon-apps />
|
||||
</div>
|
||||
<div class="topology-name">{{ topology.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 全部分组选项 -->
|
||||
<a-menu :selected-keys="selectedGroup === null ? ['all'] : []">
|
||||
<a-menu-item key="all" @click="handleSelectAll">
|
||||
<template #icon>
|
||||
<icon-share-alt />
|
||||
</template>
|
||||
<template #default>全部</template>
|
||||
<template #extra>
|
||||
<span class="menu-subtitle">显示所有拓扑图</span>
|
||||
</template>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 分组树 -->
|
||||
<div class="group-tree">
|
||||
<template v-for="group in groups" :key="group.id">
|
||||
<group-tree-item
|
||||
:group="group"
|
||||
:level="0"
|
||||
:selected-group="selectedGroup"
|
||||
:expanded-groups="expandedGroups"
|
||||
:nodes="nodes"
|
||||
@select="handleSelectGroup"
|
||||
@toggle="handleToggleGroup"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Node } from '@vue-flow/core';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import type { TopoGroup, NodeData } from '../types';
|
||||
import { IconShareAlt, IconApps } from '@arco-design/web-vue/es/icon';
|
||||
import GroupTreeItem from './GroupTreeItem.vue';
|
||||
|
||||
interface Props {
|
||||
groups: TopoGroup[];
|
||||
selectedGroup: string | null;
|
||||
expandedGroups: Set<string>;
|
||||
nodes: Node[];
|
||||
isAutoTopo?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'selectGroup', groupId: string | null): void;
|
||||
(e: 'toggleGroup', groupId: string): void;
|
||||
(e: 'groupChange', groupId: number | null): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isAutoTopo: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
const topologyGroups = ref<any[]>([]);
|
||||
const selectedGroupId = ref<number | null>(null);
|
||||
const topologyList = ref<any[]>([]);
|
||||
const selectedTopologyId = ref<number | null>(null);
|
||||
|
||||
const treeData = computed(() => {
|
||||
const convertToTree = (groups: any[]): any[] => {
|
||||
return groups.map((group) => ({
|
||||
key: String(group.id),
|
||||
title: group.name,
|
||||
disabled: group.children && group.children.length > 0,
|
||||
children: group.children && group.children.length > 0 ? convertToTree(group.children) : undefined,
|
||||
}));
|
||||
};
|
||||
return convertToTree(topologyGroups.value);
|
||||
});
|
||||
|
||||
const handleSelectAll = () => {
|
||||
emit('selectGroup', null);
|
||||
};
|
||||
|
||||
const handleSelectGroup = (groupId: string) => {
|
||||
emit('selectGroup', groupId);
|
||||
};
|
||||
|
||||
const handleToggleGroup = (groupId: string) => {
|
||||
emit('toggleGroup', groupId);
|
||||
};
|
||||
|
||||
const handleGroupChange = async (value: number | null) => {
|
||||
selectedGroupId.value = value;
|
||||
topologyList.value = [];
|
||||
selectedTopologyId.value = null;
|
||||
emit('groupChange', null);
|
||||
|
||||
if (value) {
|
||||
try {
|
||||
// 这里应该调用API获取拓扑列表
|
||||
// const response = await fetchTopologies({ group_id: value, page: 1, size: 9999 });
|
||||
// if (response.code === 0) {
|
||||
// const list = response.details?.data || [];
|
||||
// topologyList.value = list;
|
||||
// if (list.length > 0) {
|
||||
// selectedTopologyId.value = list[0].id;
|
||||
// emit('groupChange', list[0].id);
|
||||
// }
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error('获取拓扑列表失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTopologySelect = (topology: any) => {
|
||||
selectedTopologyId.value = topology.id;
|
||||
emit('groupChange', topology.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.group-panel-wrapper {
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--color-border-2);
|
||||
background: var(--color-bg-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.group-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
.header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
.topo-selector {
|
||||
padding: 16px;
|
||||
|
||||
.topology-list {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topology-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid var(--color-border-2);
|
||||
background: var(--color-bg-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-light-3);
|
||||
background: var(--color-fill-2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgb(var(--primary-6));
|
||||
background: var(--color-primary-light-1);
|
||||
}
|
||||
|
||||
.topology-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-primary-light-3);
|
||||
color: rgb(var(--primary-6));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topology-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.menu-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
196
src/views/ops/pages/netarch/topo/components/GroupTreeItem.vue
Normal file
196
src/views/ops/pages/netarch/topo/components/GroupTreeItem.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="group-tree-item">
|
||||
<div
|
||||
:class="['group-item-content', { selected: isSelected }]"
|
||||
:style="{ paddingLeft: `${24 + level * 24}px` }"
|
||||
@click="handleSelect"
|
||||
>
|
||||
<!-- 展开/折叠按钮 -->
|
||||
<div class="toggle-icon" @click.stop="handleToggle">
|
||||
<icon-down v-if="hasChildren && isExpanded" />
|
||||
<icon-right v-else-if="hasChildren" />
|
||||
<div v-else class="placeholder" />
|
||||
</div>
|
||||
|
||||
<!-- 设备图标 -->
|
||||
<div class="device-icon" :style="{ background: iconBg, color: iconColor }">
|
||||
<component :is="iconComponent" :size="16" />
|
||||
</div>
|
||||
|
||||
<!-- 设备信息 -->
|
||||
<div class="device-info">
|
||||
<div class="device-name">
|
||||
{{ nodeData.label }}
|
||||
<a-tag v-if="nodeData.status === 'error'" color="red" size="small">异常</a-tag>
|
||||
<a-tag v-else-if="nodeData.status === 'warning'" color="orange" size="small">告警</a-tag>
|
||||
</div>
|
||||
<div class="device-details">
|
||||
<span v-if="nodeData.ip" class="ip-address">{{ nodeData.ip }}</span>
|
||||
<a-tag v-if="nodeData.alerts && nodeData.alerts > 0" color="red" size="small">
|
||||
{{ nodeData.alerts }}个告警
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子分组 -->
|
||||
<a-collapse :model-value="expandedGroups" :default-expanded-key="undefined" :bordered="false">
|
||||
<template v-if="hasChildren && isExpanded">
|
||||
<group-tree-item
|
||||
v-for="child in group.children"
|
||||
:key="child.id"
|
||||
:group="child"
|
||||
:level="level + 1"
|
||||
:selected-group="selectedGroup"
|
||||
:expanded-groups="expandedGroups"
|
||||
:nodes="nodes"
|
||||
@select="$emit('select', $event)"
|
||||
@toggle="$emit('toggle', $event)"
|
||||
/>
|
||||
</template>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Node } from '@vue-flow/core';
|
||||
import { IconDown, IconRight } from '@arco-design/web-vue/es/icon';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import type { TopoGroup, NodeData, DeviceType } from '../types';
|
||||
|
||||
interface Props {
|
||||
group: TopoGroup;
|
||||
level: number;
|
||||
selectedGroup: string | null;
|
||||
expandedGroups: Set<string>;
|
||||
nodes: Node[];
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', groupId: string): void;
|
||||
(e: 'toggle', groupId: string): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const nodeData = computed(() => {
|
||||
const node = props.nodes.find((n) => n.id === props.group.nodeId);
|
||||
return (node?.data as NodeData) || null;
|
||||
});
|
||||
|
||||
const hasChildren = computed(() => {
|
||||
return props.group.children && props.group.children.length > 0;
|
||||
});
|
||||
|
||||
const isExpanded = computed(() => {
|
||||
return props.expandedGroups.has(props.group.id);
|
||||
});
|
||||
|
||||
const isSelected = computed(() => {
|
||||
return props.selectedGroup === props.group.id;
|
||||
});
|
||||
|
||||
const deviceConfig = computed(() => {
|
||||
if (!nodeData.value) return null;
|
||||
return DEVICE_TYPE_CONFIG[nodeData.value.type as DeviceType];
|
||||
});
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
return deviceConfig.value?.icon;
|
||||
});
|
||||
|
||||
const iconBg = computed(() => {
|
||||
const color = deviceConfig.value?.color || '#888';
|
||||
return `${color}15`;
|
||||
});
|
||||
|
||||
const iconColor = computed(() => {
|
||||
return deviceConfig.value?.color || '#888';
|
||||
});
|
||||
|
||||
const handleSelect = () => {
|
||||
emit('select', props.group.id);
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
if (hasChildren.value) {
|
||||
emit('toggle', props.group.id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.group-tree-item {
|
||||
.group-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
margin: 4px 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-fill-2);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-primary-light-1);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
|
||||
.placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
50
src/views/ops/pages/netarch/topo/components/LayoutMenu.vue
Normal file
50
src/views/ops/pages/netarch/topo/components/LayoutMenu.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<a-dropdown :popup-visible="visible" @update:popup-visible="handleClose">
|
||||
<slot></slot>
|
||||
<template #content>
|
||||
<a-doption @click="handleSelect('grid')">
|
||||
<template #icon>
|
||||
<icon-apps />
|
||||
</template>
|
||||
网格布局
|
||||
</a-doption>
|
||||
<a-doption @click="handleSelect('hierarchical')">
|
||||
<template #icon>
|
||||
<icon-list />
|
||||
</template>
|
||||
层次布局
|
||||
</a-doption>
|
||||
<a-doption @click="handleSelect('circular')">
|
||||
<template #icon>
|
||||
<icon-minus-circle />
|
||||
</template>
|
||||
环形布局
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconApps, IconList, IconMinusCircle } from '@arco-design/web-vue/es/icon';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'selectLayout', layoutType: 'grid' | 'hierarchical' | 'circular'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleSelect = (layoutType: 'grid' | 'hierarchical' | 'circular') => {
|
||||
emit('selectLayout', layoutType);
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleClose = (value: boolean) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
</script>
|
||||
203
src/views/ops/pages/netarch/topo/components/NodeActionDialog.vue
Normal file
203
src/views/ops/pages/netarch/topo/components/NodeActionDialog.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:visible="visible"
|
||||
title="设备操作"
|
||||
:footer="false"
|
||||
width="400px"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<template v-if="node">
|
||||
<div class="node-action-content">
|
||||
<!-- 设备信息概览 -->
|
||||
<div class="device-info">
|
||||
<div
|
||||
class="device-icon-wrapper"
|
||||
:style="{ backgroundColor: `${config.color}20`, color: config.color }"
|
||||
>
|
||||
<component :is="config.icon" :size="40" />
|
||||
</div>
|
||||
<div class="device-name">{{ node.data?.label || '未命名' }}</div>
|
||||
<div class="device-type">{{ config.label }}</div>
|
||||
<div v-if="node.data?.ip" class="device-ip">IP: {{ node.data.ip }}</div>
|
||||
<a-tag
|
||||
:color="statusColor[node.data?.status || 'normal']"
|
||||
class="device-status"
|
||||
size="small"
|
||||
>
|
||||
{{ statusText[node.data?.status || 'normal'] }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<a-button long @click="handleViewDetail">
|
||||
<template #icon>
|
||||
<icon-info-circle />
|
||||
</template>
|
||||
查看详情
|
||||
</a-button>
|
||||
<a-button long @click="handleEdit">
|
||||
<template #icon>
|
||||
<icon-edit />
|
||||
</template>
|
||||
编辑设备
|
||||
</a-button>
|
||||
<a-button long status="danger" @click="handleDelete">
|
||||
<template #icon>
|
||||
<icon-delete />
|
||||
</template>
|
||||
删除设备
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="handleClose">关闭</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconDesktop,
|
||||
IconCloud,
|
||||
IconStorage,
|
||||
IconSafe,
|
||||
IconFile,
|
||||
IconMore
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import { DeviceType } from '../types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
node: any;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'viewDetail'): void;
|
||||
(e: 'edit'): void;
|
||||
(e: 'delete'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value)
|
||||
});
|
||||
|
||||
const iconMap: Record<DeviceType, any> = {
|
||||
server: IconDesktop,
|
||||
router: IconFile,
|
||||
switch: IconSafe,
|
||||
desktop: IconDesktop,
|
||||
cloud: IconCloud,
|
||||
text: IconMore,
|
||||
region: IconStorage,
|
||||
};
|
||||
|
||||
const config = computed(() => {
|
||||
const type = props.node?.data?.type as DeviceType || 'server';
|
||||
const deviceConfig = DEVICE_TYPE_CONFIG[type];
|
||||
return {
|
||||
...deviceConfig,
|
||||
icon: iconMap[type] || iconMap.server,
|
||||
};
|
||||
});
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
normal: 'green',
|
||||
warning: 'orange',
|
||||
error: 'red',
|
||||
};
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
normal: '正常',
|
||||
warning: '警告',
|
||||
error: '错误',
|
||||
};
|
||||
|
||||
const handleViewDetail = () => {
|
||||
emit('viewDetail');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
emit('edit');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:open', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.node-action-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-icon-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-type {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.device-ip {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
193
src/views/ops/pages/netarch/topo/components/NodeDetailDialog.vue
Normal file
193
src/views/ops/pages/netarch/topo/components/NodeDetailDialog.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:footer="false"
|
||||
title="设备详情"
|
||||
@cancel="handleClose"
|
||||
width="700px"
|
||||
>
|
||||
<template v-if="nodeData">
|
||||
<a-space direction="vertical" :size="16" fill>
|
||||
<!-- 基本信息 -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="label">设备名称</div>
|
||||
<div class="value">{{ nodeData.label }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="label">设备类型</div>
|
||||
<div class="value">{{ nodeData.type }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="label">IP地址</div>
|
||||
<div class="value">{{ nodeData.ip || '未配置' }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="label">设备状态</div>
|
||||
<a-tag :color="statusColor">{{ statusText }}</a-tag>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-card :bordered="true" class="stat-card">
|
||||
<div class="stat-label">链路流量</div>
|
||||
<div class="stat-value">{{ nodeData.traffic || '0 Mbps' }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-card :bordered="true" class="stat-card">
|
||||
<div class="stat-label">告警数量</div>
|
||||
<div class="stat-value" :class="{ 'alert-count': nodeData.alerts && nodeData.alerts > 0 }">
|
||||
{{ nodeData.alerts || 0 }}
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 告警列表 -->
|
||||
<template v-if="nodeData.alerts && nodeData.alerts > 0">
|
||||
<a-divider />
|
||||
<div class="section-title">当前告警</div>
|
||||
<a-list :bordered="false">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #avatar>
|
||||
<icon-exclamation-circle-fill style="font-size: 20px; color: #FF7D00;" />
|
||||
</template>
|
||||
<template #title>CPU使用率过高</template>
|
||||
<template #description>当前CPU使用率: 92%, 触发时间: 2025-12-11 09:30</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #avatar>
|
||||
<icon-exclamation-circle-fill style="font-size: 20px; color: #F53F3F;" />
|
||||
</template>
|
||||
<template #title>内存不足</template>
|
||||
<template #description>当前可用内存: 512MB, 触发时间: 2025-12-11 09:25</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<!-- 描述信息 -->
|
||||
<template v-if="nodeData.description">
|
||||
<a-divider />
|
||||
<div class="info-item">
|
||||
<div class="label">设备描述</div>
|
||||
<div class="value">{{ nodeData.description }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="handleClose">关闭</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { IconExclamationCircleFill } from '@arco-design/web-vue/es/icon';
|
||||
import type { NodeData } from '../types';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
nodeData: NodeData | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'close'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!props.nodeData) return '';
|
||||
switch (props.nodeData.status) {
|
||||
case 'normal':
|
||||
return '正常';
|
||||
case 'warning':
|
||||
return '警告';
|
||||
case 'error':
|
||||
return '错误';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (!props.nodeData) return 'gray';
|
||||
switch (props.nodeData.status) {
|
||||
case 'normal':
|
||||
return 'green';
|
||||
case 'warning':
|
||||
return 'orange';
|
||||
case 'error':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false);
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.info-item {
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
|
||||
&.alert-count {
|
||||
color: #f53f3f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
}
|
||||
</style>
|
||||
151
src/views/ops/pages/netarch/topo/components/NodeEditDialog.vue
Normal file
151
src/views/ops/pages/netarch/topo/components/NodeEditDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="isEdit ? '编辑设备' : '添加设备'"
|
||||
@cancel="handleClose"
|
||||
@ok="handleSave"
|
||||
:ok-text="isEdit ? '保存' : '添加'"
|
||||
:ok-button-props="{ disabled: !formData.label }"
|
||||
width="600px"
|
||||
>
|
||||
<a-form :model="formData" layout="vertical" class="node-edit-form">
|
||||
<a-form-item label="设备类型" required>
|
||||
<a-select v-model="formData.type">
|
||||
<a-option
|
||||
v-for="(config, key) in DEVICE_TYPE_CONFIG"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
<a-space align="center">
|
||||
<component :is="getIconComponent(key)" :size="18" />
|
||||
<span>{{ config.label }}</span>
|
||||
</a-space>
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="设备名称" required>
|
||||
<a-input
|
||||
v-model="formData.label"
|
||||
placeholder="请输入设备名称"
|
||||
:max-length="50"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="IP地址">
|
||||
<a-input
|
||||
v-model="formData.ip"
|
||||
placeholder="如: 192.168.1.1"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="流量信息">
|
||||
<a-input
|
||||
v-model="formData.traffic"
|
||||
placeholder="如: 100Mbps"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="设备描述">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入设备描述"
|
||||
:max-length="200"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { Node } from '@vue-flow/core';
|
||||
import { DEVICE_TYPE_CONFIG } from '../config';
|
||||
import type { DeviceType, NodeData } from '../types';
|
||||
import {
|
||||
IconDesktop,
|
||||
IconSafe,
|
||||
IconFile,
|
||||
IconStorage,
|
||||
IconCloud,
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
node: Node | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'save', nodeId: string | null, data: Partial<NodeData>): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const formData = ref({
|
||||
label: '',
|
||||
type: 'server' as DeviceType,
|
||||
ip: '',
|
||||
traffic: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
server: IconDesktop,
|
||||
switch: IconSafe,
|
||||
router: IconFile,
|
||||
firewall: IconSafe,
|
||||
storage: IconStorage,
|
||||
cloud: IconCloud,
|
||||
desktop: IconDesktop,
|
||||
mobile: IconDesktop,
|
||||
};
|
||||
|
||||
const getIconComponent = (type: string) => {
|
||||
return iconMap[type] || IconDesktop;
|
||||
};
|
||||
|
||||
const isEdit = computed(() => !!props.node);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (props.node) {
|
||||
formData.value = {
|
||||
label: props.node.data?.label || '',
|
||||
type: props.node.data?.type || 'server',
|
||||
ip: props.node.data?.ip || '',
|
||||
traffic: props.node.data?.traffic || '',
|
||||
description: props.node.data?.description || '',
|
||||
};
|
||||
} else {
|
||||
formData.value = {
|
||||
label: '',
|
||||
type: 'server',
|
||||
ip: '',
|
||||
traffic: '',
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
emit('save', props.node?.id || null, formData.value);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.node-edit-form {
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
147
src/views/ops/pages/netarch/topo/components/Toolbar.vue
Normal file
147
src/views/ops/pages/netarch/topo/components/Toolbar.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="topo-toolbar">
|
||||
<!-- 缩放控制 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-tooltip content="放大">
|
||||
<a-button @click="props.onZoomIn">
|
||||
<icon-zoom-in :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="缩小">
|
||||
<a-button @click="props.onZoomOut">
|
||||
<icon-zoom-out :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="适应窗口">
|
||||
<a-button @click="props.onFitView">
|
||||
<icon-fullscreen :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-button-group>
|
||||
|
||||
<!-- 添加设备 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-dropdown trigger="click" @select="props.onAddDevice">
|
||||
<a-button>
|
||||
<icon-plus :size="18" />
|
||||
<span class="btn-text">设备</span>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption value="server">服务器</a-doption>
|
||||
<a-doption value="switch">交换机</a-doption>
|
||||
<a-doption value="router">路由器</a-doption>
|
||||
<a-doption value="firewall">防火墙</a-doption>
|
||||
<a-doption value="storage">存储</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-button-group>
|
||||
|
||||
<!-- 布局 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-dropdown trigger="click" @select="props.onLayout">
|
||||
<a-button>
|
||||
<icon-apps :size="18" />
|
||||
<span class="btn-text">布局</span>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption value="grid">网格布局</a-doption>
|
||||
<a-doption value="hierarchical">层次布局</a-doption>
|
||||
<a-doption value="circular">环形布局</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-button-group>
|
||||
|
||||
<!-- 链路样式 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-dropdown trigger="click" @select="props.onEdgeStyle">
|
||||
<a-button>
|
||||
<icon-minus :size="18" />
|
||||
<span class="btn-text">链路</span>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption value="default">默认</a-doption>
|
||||
<a-doption value="straight">直线</a-doption>
|
||||
<a-doption value="step">阶梯</a-doption>
|
||||
<a-doption value="smoothstep">平滑阶梯</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-button-group>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<!-- 操作 -->
|
||||
<a-button-group type="outline" size="small">
|
||||
<a-tooltip content="刷新">
|
||||
<a-button @click="props.onRefresh">
|
||||
<icon-refresh :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="导出">
|
||||
<a-button @click="props.onExport">
|
||||
<icon-download :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="props.onReset" content="重置">
|
||||
<a-button @click="props.onReset" status="warning">
|
||||
<icon-rotate-left :size="18" />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-button-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
IconZoomIn,
|
||||
IconZoomOut,
|
||||
IconFullscreen,
|
||||
IconPlus,
|
||||
IconApps,
|
||||
IconMinus,
|
||||
IconRefresh,
|
||||
IconDownload,
|
||||
IconRotateLeft,
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
|
||||
interface Props {
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onFitView: () => void;
|
||||
onAddDevice: (value: string | number | Record<string, any> | undefined) => void;
|
||||
onLayout: (value: string | number | Record<string, any> | undefined) => void;
|
||||
onEdgeStyle: (value: string | number | Record<string, any> | undefined) => void;
|
||||
onRefresh: () => void;
|
||||
onExport: () => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
onReset: undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.topo-toolbar {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
src/views/ops/pages/netarch/topo/components/index.ts
Normal file
12
src/views/ops/pages/netarch/topo/components/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { default as CustomNode } from './CustomNode.vue';
|
||||
export { default as NodeDetailDialog } from './NodeDetailDialog.vue';
|
||||
export { default as NodeEditDialog } from './NodeEditDialog.vue';
|
||||
export { default as NodeActionDialog } from './NodeActionDialog.vue';
|
||||
export { default as DeleteConfirmDialog } from './DeleteConfirmDialog.vue';
|
||||
export { default as GroupPanel } from './GroupPanel.vue';
|
||||
export { default as TopoToolbar } from './Toolbar.vue';
|
||||
export { default as LayoutMenu } from './LayoutMenu.vue';
|
||||
export { default as EdgeStyleMenu } from './EdgeStyleMenu.vue';
|
||||
export { default as AddNodeMenu } from './AddNodeMenu.vue';
|
||||
export { default as EdgeActionDialog } from './EdgeActionDialog.vue';
|
||||
export { default as EdgeEditDialog } from './EdgeEditDialog.vue';
|
||||
17
src/views/ops/pages/netarch/topo/config.ts
Normal file
17
src/views/ops/pages/netarch/topo/config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { DeviceType } from './types';
|
||||
|
||||
// 设备类型配置
|
||||
export const DEVICE_TYPE_CONFIG: Record<DeviceType, { icon: string; label: string; color: string }> = {
|
||||
server: { icon: 'icon-server', label: '服务器', color: '#2196F3' },
|
||||
router: { icon: 'icon-router', label: '路由器', color: '#FF9800' },
|
||||
switch: { icon: 'icon-desktop', label: '交换机', color: '#4CAF50' },
|
||||
desktop: { icon: 'icon-desktop', label: '终端', color: '#9C27B0' },
|
||||
cloud: { icon: 'icon-cloud', label: '云端节点', color: '#00BCD4' },
|
||||
text: { icon: 'icon-text', label: '文本标注', color: '#757575' },
|
||||
region: { icon: 'icon-rectangle', label: '区域', color: '#FF5722' },
|
||||
};
|
||||
|
||||
// 侧边栏宽度
|
||||
export const DRAWER_WIDTH = 280;
|
||||
|
||||
// 初始节点数据 - 带层级关系
|
||||
3
src/views/ops/pages/netarch/topo/hooks/index.ts
Normal file
3
src/views/ops/pages/netarch/topo/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useTopoStorage } from './useTopoStorage';
|
||||
export { useTopoLayout } from './useTopoLayout';
|
||||
export { useEdgeStyles } from './useEdgeStyles';
|
||||
46
src/views/ops/pages/netarch/topo/hooks/useEdgeStyles.ts
Normal file
46
src/views/ops/pages/netarch/topo/hooks/useEdgeStyles.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { computed, ComputedRef } from 'vue';
|
||||
import { Edge } from '@vue-flow/core';
|
||||
|
||||
type EdgeType = 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier';
|
||||
|
||||
/**
|
||||
* 边样式计算Hook
|
||||
* 根据边类型、链路类型、标签等计算最终样式
|
||||
*/
|
||||
export function useEdgeStyles(edges: Edge[], edgeType: EdgeType): ComputedRef<Edge[]> {
|
||||
const styledEdges = computed(() => {
|
||||
return edges.map((edge) => {
|
||||
const isVirtual = edge.data?.type === 'virtual';
|
||||
const hasLabel = edge.data?.label && edge.data.label.trim() !== '';
|
||||
|
||||
return {
|
||||
...edge,
|
||||
type: edgeType, // 使用全局设置的边类型
|
||||
label: hasLabel ? edge.data?.label : undefined,
|
||||
animated: true, // 流动效果
|
||||
style: {
|
||||
stroke: isVirtual
|
||||
? '#F57C00' // 虚拟链路使用橙色
|
||||
: '#1976D2', // 物理链路使用蓝色
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: isVirtual ? '5,5' : undefined, // 虚拟链路使用虚线
|
||||
},
|
||||
labelStyle: hasLabel
|
||||
? {
|
||||
fill: '#333',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
}
|
||||
: undefined,
|
||||
labelBgStyle: hasLabel
|
||||
? {
|
||||
fill: '#fff',
|
||||
fillOpacity: 0.9,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return styledEdges;
|
||||
}
|
||||
83
src/views/ops/pages/netarch/topo/hooks/useTopoLayout.ts
Normal file
83
src/views/ops/pages/netarch/topo/hooks/useTopoLayout.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Node } from '@vue-flow/core';
|
||||
|
||||
/**
|
||||
* 拓扑布局算法Hook
|
||||
* 提供网格、层次、环形三种布局方式
|
||||
*/
|
||||
export function useTopoLayout() {
|
||||
/**
|
||||
* 应用布局算法
|
||||
* @param nodes 当前节点列表
|
||||
* @param layoutType 布局类型
|
||||
* @returns 更新后的节点列表
|
||||
*/
|
||||
const applyLayout = (
|
||||
nodes: Node[],
|
||||
layoutType: 'grid' | 'hierarchical' | 'circular'
|
||||
): Node[] => {
|
||||
const nodesCopy = [...nodes];
|
||||
|
||||
switch (layoutType) {
|
||||
case 'grid': {
|
||||
// 网格布局
|
||||
const cols = Math.ceil(Math.sqrt(nodesCopy.length));
|
||||
nodesCopy.forEach((node, idx) => {
|
||||
node.position = {
|
||||
x: (idx % cols) * 200 + 100,
|
||||
y: Math.floor(idx / cols) * 180 + 100,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hierarchical': {
|
||||
// 层次布局
|
||||
const levels = new Map<number, Node[]>();
|
||||
|
||||
// 按level分组
|
||||
nodesCopy.forEach((node) => {
|
||||
const level = (node.data?.level as number) || 0;
|
||||
if (!levels.has(level)) {
|
||||
levels.set(level, []);
|
||||
}
|
||||
levels.get(level)!.push(node);
|
||||
});
|
||||
|
||||
// 按层级排列
|
||||
Array.from(levels.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.forEach(([level, levelNodes], levelIdx) => {
|
||||
const startX = (levelNodes.length - 1) * -150;
|
||||
levelNodes.forEach((node, nodeIdx) => {
|
||||
node.position = {
|
||||
x: startX + nodeIdx * 300 + 400,
|
||||
y: levelIdx * 200 + 100,
|
||||
};
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'circular': {
|
||||
// 环形布局
|
||||
const radius = 300;
|
||||
const centerX = 400;
|
||||
const centerY = 300;
|
||||
const angleStep = (2 * Math.PI) / nodesCopy.length;
|
||||
|
||||
nodesCopy.forEach((node, idx) => {
|
||||
const angle = idx * angleStep - Math.PI / 2;
|
||||
node.position = {
|
||||
x: centerX + radius * Math.cos(angle),
|
||||
y: centerY + radius * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return nodesCopy;
|
||||
};
|
||||
|
||||
return { applyLayout };
|
||||
}
|
||||
61
src/views/ops/pages/netarch/topo/hooks/useTopoStorage.ts
Normal file
61
src/views/ops/pages/netarch/topo/hooks/useTopoStorage.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Node, Edge } from '@vue-flow/core';
|
||||
|
||||
const STORAGE_KEY = 'topo_graph_data';
|
||||
|
||||
interface TopoData {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拓扑图本地存储Hook
|
||||
* 自动保存和加载拓扑图的节点和边数据
|
||||
*/
|
||||
export function useTopoStorage() {
|
||||
// 保存到本地存储
|
||||
const saveTopoData = (nodes: Node[], edges: Edge[]) => {
|
||||
try {
|
||||
const data: TopoData = {
|
||||
nodes,
|
||||
edges,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
console.log('拓扑图数据已保存到本地', data.timestamp);
|
||||
} catch (error) {
|
||||
console.error('保存拓扑图数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 从本地存储加载
|
||||
const loadTopoData = (): TopoData | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored) as TopoData;
|
||||
console.log('从本地加载拓扑图数据', data.timestamp);
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载拓扑图数据失败:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 清除本地存储
|
||||
const clearTopoData = () => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
console.log('已清除本地拓扑图数据');
|
||||
} catch (error) {
|
||||
console.error('清除拓扑图数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
saveTopoData,
|
||||
loadTopoData,
|
||||
clearTopoData,
|
||||
};
|
||||
}
|
||||
738
src/views/ops/pages/netarch/topo/index.vue
Normal file
738
src/views/ops/pages/netarch/topo/index.vue
Normal file
@@ -0,0 +1,738 @@
|
||||
<template>
|
||||
<div class="topo-container" :style="{ height: containerHeight }">
|
||||
<!-- 左侧分组面板 -->
|
||||
<div class="topo-sidebar">
|
||||
<group-panel
|
||||
:groups="topoGroups"
|
||||
:selected-group="selectedGroup"
|
||||
:expanded-groups="expandedGroups"
|
||||
:nodes="nodes"
|
||||
:is-auto-topo="isAutoTopo"
|
||||
@select-group="handleSelectGroup"
|
||||
@toggle-group="toggleGroup"
|
||||
@group-change="handleTopologyGroupChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 主拓扑区域 -->
|
||||
<div class="topo-main">
|
||||
<!-- 工具栏 -->
|
||||
<toolbar
|
||||
@zoom-in="zoomIn"
|
||||
@zoom-out="zoomOut"
|
||||
@fit-view="fitView"
|
||||
@add-device="handleAddDevice"
|
||||
@layout="handleLayout"
|
||||
@edge-style="setEdgeType"
|
||||
@refresh="refreshTopology"
|
||||
@export="exportTopology"
|
||||
@reset="resetTopology"
|
||||
/>
|
||||
|
||||
<!-- Vue Flow 画布 -->
|
||||
<div ref="reactFlowWrapper" class="flow-wrapper">
|
||||
<vue-flow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:node-types="nodeTypes"
|
||||
:default-edge-options="defaultEdgeOptions"
|
||||
:fit-view-on-init="true"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="2"
|
||||
@node-click="onNodeClick"
|
||||
@edge-click="onEdgeClick"
|
||||
@connect="onConnect"
|
||||
>
|
||||
<background pattern-color="#aaa" :gap="16" />
|
||||
<mini-map
|
||||
:node-color="getNodeColor"
|
||||
node-stroke-color="#555"
|
||||
/>
|
||||
<controls />
|
||||
</vue-flow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 对话框组件 ==================== -->
|
||||
|
||||
<!-- ==================== 节点对话框 ==================== -->
|
||||
|
||||
<node-action-dialog
|
||||
v-model:open="nodeActionDialogOpen"
|
||||
:node="selectedNode"
|
||||
@view-detail="handleViewDetail"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDeleteNode"
|
||||
/>
|
||||
|
||||
<node-detail-dialog
|
||||
v-model:visible="nodeDetailDialogOpen"
|
||||
:node-data="selectedNode?.data"
|
||||
/>
|
||||
|
||||
<node-edit-dialog
|
||||
v-model:visible="nodeEditDialogOpen"
|
||||
:node="selectedNode"
|
||||
@save="handleSaveNode"
|
||||
/>
|
||||
|
||||
<delete-confirm-dialog
|
||||
v-model:visible="deleteDialogOpen"
|
||||
:node-name="selectedNode?.data?.label || '未命名'"
|
||||
@confirm="handleDeleteNodeConfirm"
|
||||
/>
|
||||
|
||||
<!-- ==================== 边对话框 ==================== -->
|
||||
|
||||
<edge-action-dialog
|
||||
v-model:visible="edgeActionDialogOpen"
|
||||
@edit="handleEditEdge"
|
||||
@delete="handleDeleteEdge"
|
||||
/>
|
||||
|
||||
<edge-edit-dialog
|
||||
v-model:visible="edgeEditDialogOpen"
|
||||
:edge="selectedEdge"
|
||||
:is-new-edge="isNewEdge"
|
||||
@change="setSelectedEdge"
|
||||
@save="handleSaveEdge"
|
||||
/>
|
||||
|
||||
<delete-confirm-dialog
|
||||
v-model:visible="deleteEdgeDialogOpen"
|
||||
node-name="链路"
|
||||
@confirm="handleDeleteEdgeConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { VueFlow, useVueFlow } from '@vue-flow/core';
|
||||
import { Background } from '@vue-flow/background';
|
||||
import { MiniMap } from '@vue-flow/minimap';
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
import '@vue-flow/core/dist/style.css';
|
||||
import '@vue-flow/core/dist/theme-default.css';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import * as TopoAPI from '@/api/ops/netarchTopo';
|
||||
|
||||
import { NodeData, DeviceType } from './types';
|
||||
import { DEVICE_TYPE_CONFIG } from './config';
|
||||
import { CustomNode } from './components';
|
||||
import { useTopoLayout, useEdgeStyles } from './hooks';
|
||||
import { buildGroupTreeFromNodes, filterByGroup } from './utils/buildGroupTree';
|
||||
|
||||
// 导入所有组件
|
||||
import GroupPanel from './components/GroupPanel.vue';
|
||||
import Toolbar from './components/Toolbar.vue';
|
||||
import AddNodeMenu from './components/AddNodeMenu.vue';
|
||||
import LayoutMenu from './components/LayoutMenu.vue';
|
||||
import EdgeStyleMenu from './components/EdgeStyleMenu.vue';
|
||||
import NodeActionDialog from './components/NodeActionDialog.vue';
|
||||
import NodeDetailDialog from './components/NodeDetailDialog.vue';
|
||||
import NodeEditDialog from './components/NodeEditDialog.vue';
|
||||
import DeleteConfirmDialog from './components/DeleteConfirmDialog.vue';
|
||||
import EdgeActionDialog from './components/EdgeActionDialog.vue';
|
||||
import EdgeEditDialog from './components/EdgeEditDialog.vue';
|
||||
|
||||
// 注册自定义节点类型
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
};
|
||||
|
||||
// 默认边样式
|
||||
const defaultEdgeOptions = {
|
||||
style: { strokeWidth: 2 },
|
||||
type: 'smoothstep',
|
||||
};
|
||||
|
||||
// Vue Flow 实例
|
||||
const { fitView, zoomIn, zoomOut } = useVueFlow();
|
||||
|
||||
const route = useRoute();
|
||||
const reactFlowWrapper = ref<HTMLDivElement>();
|
||||
|
||||
// ==================== 状态管理 ====================
|
||||
|
||||
// 图数据状态
|
||||
const nodes = ref<any[]>([]);
|
||||
const edges = ref<any[]>([]);
|
||||
|
||||
// UI控制状态
|
||||
const selectedGroup = ref<string | null>(null);
|
||||
const expandedGroups = ref<Set<string>>(new Set());
|
||||
const edgeType = ref<'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier'>('smoothstep');
|
||||
|
||||
|
||||
// 节点操作状态
|
||||
const selectedNode = ref<any>(null);
|
||||
const nodeActionDialogOpen = ref(false);
|
||||
const nodeDetailDialogOpen = ref(false);
|
||||
const nodeEditDialogOpen = ref(false);
|
||||
const deleteDialogOpen = ref(false);
|
||||
|
||||
// 边操作状态
|
||||
const selectedEdge = ref<any>(null);
|
||||
const edgeActionDialogOpen = ref(false);
|
||||
const edgeEditDialogOpen = ref(false);
|
||||
const deleteEdgeDialogOpen = ref(false);
|
||||
|
||||
// 布局钩子
|
||||
const { applyLayout } = useTopoLayout();
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
// 从URL参数获取拓扑ID
|
||||
const currentTopologyId = computed(() => {
|
||||
const id = route.query.id;
|
||||
return id ? parseInt(id as string) : null;
|
||||
});
|
||||
|
||||
// 根据路由判断高度
|
||||
const containerHeight = computed(() => {
|
||||
return route.path.includes('/netarch/auto-topo')
|
||||
? 'calc(100vh - 170px)'
|
||||
: '100vh';
|
||||
});
|
||||
|
||||
// 判断是否为自动拓扑路由
|
||||
const isAutoTopo = computed(() => {
|
||||
return route.path.includes('/netarch/auto-topo');
|
||||
});
|
||||
|
||||
// 从节点自动生成分组树
|
||||
const topoGroups = computed(() => buildGroupTreeFromNodes(nodes.value, edges.value));
|
||||
|
||||
// 根据选中的分组筛选显示的节点和边
|
||||
const filteredResult = computed(() =>
|
||||
filterByGroup(nodes.value, edges.value, selectedGroup.value)
|
||||
);
|
||||
|
||||
// 使用响应式计算
|
||||
const displayNodes = computed(() => filteredResult.value.nodes);
|
||||
const displayEdges = computed(() => filteredResult.value.edges);
|
||||
|
||||
// 计算边样式
|
||||
const styledEdges = computed(() => {
|
||||
return useEdgeStyles(displayEdges.value, edgeType.value);
|
||||
});
|
||||
|
||||
// 是否为新边
|
||||
const isNewEdge = computed(() => {
|
||||
return !edges.value.some((e: any) => e.id === selectedEdge.value?.id);
|
||||
});
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
// 初始化数据
|
||||
const loadData = async () => {
|
||||
if (!currentTopologyId.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let nodesData: any[] = [];
|
||||
let edgesData: any[] = [];
|
||||
|
||||
// 从 graph 接口获取节点和边数据
|
||||
const graphResponse: any = await TopoAPI.fetchTopologyGraph(currentTopologyId.value);
|
||||
|
||||
if (graphResponse.code === 0) {
|
||||
// 获取边数据 - res.details.edges
|
||||
const edgesFromGraph = graphResponse.details?.edges || graphResponse.data?.edges || [];
|
||||
edgesData = edgesFromGraph.map((edge: any) => ({
|
||||
id: String(edge.id),
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.type || 'smoothstep',
|
||||
label: edge.label || '',
|
||||
data: { ...edge },
|
||||
})) || [];
|
||||
|
||||
// 获取节点数据 - res.details.nodes 或 res.details.data
|
||||
const nodesFromGraph = graphResponse.details?.nodes || graphResponse.details?.data || graphResponse.data?.nodes || [];
|
||||
|
||||
if (Array.isArray(nodesFromGraph)) {
|
||||
nodesData = nodesFromGraph.map((node: any) => ({
|
||||
id: node.id,
|
||||
type: 'custom',
|
||||
position: node.position || { x: Math.random() * 800, y: Math.random() * 600 },
|
||||
data: {
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
ip: node.ip,
|
||||
status: node.status || 'normal',
|
||||
alerts: node.alerts || 0,
|
||||
traffic: node.traffic,
|
||||
description: node.description,
|
||||
parentId: node.parentId,
|
||||
level: node.level ?? 0,
|
||||
position: node.position,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 设置数据
|
||||
nodes.value = nodesData;
|
||||
edges.value = edgesData;
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载拓扑数据失败:', error);
|
||||
Message.warning('加载拓扑数据失败,使用默认数据');
|
||||
nodes.value = [];
|
||||
edges.value = [];
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
fitView({ duration: 500 });
|
||||
});
|
||||
};
|
||||
|
||||
// 自动保存节点位置到后端(防抖)
|
||||
let saveTimer: number | null = null;
|
||||
watch(nodes, () => {
|
||||
if (nodes.value.length === 0) return;
|
||||
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(async () => {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
const positions = nodes.value.map(node => ({
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
}));
|
||||
await TopoAPI.updateNodesPositions(currentTopologyId.value, positions);
|
||||
} catch (error) {
|
||||
console.error('保存节点位置失败:', error);
|
||||
}
|
||||
}, 1000);
|
||||
}, { deep: true });
|
||||
|
||||
// 自动展开所有一级分组
|
||||
watch(topoGroups, (newGroups) => {
|
||||
if (newGroups.length > 0) {
|
||||
const rootGroupIds = newGroups.map((g: any) => g.id);
|
||||
expandedGroups.value = new Set(rootGroupIds);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
// 连接处理 - 创建新链路
|
||||
const onConnect = async (connection: any) => {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
const response: any = await TopoAPI.createLink(currentTopologyId.value, {
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
type: 'physical',
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
// 创建成功后刷新接口
|
||||
await loadData();
|
||||
Message.success('链路创建成功');
|
||||
} else {
|
||||
Message.error('链路创建失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建链路失败:', error);
|
||||
Message.error('创建链路失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 节点点击
|
||||
const onNodeClick = (event: any) => {
|
||||
selectedNode.value = event.node;
|
||||
nodeActionDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// 边点击
|
||||
const onEdgeClick = (event: any) => {
|
||||
selectedEdge.value = event.edge;
|
||||
edgeActionDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// ==================== 业务操作 ====================
|
||||
|
||||
// 布局处理
|
||||
const handleLayout = (value: string | number | Record<string, any> | undefined) => {
|
||||
const layoutType = value as 'grid' | 'hierarchical' | 'circular';
|
||||
const updatedNodes = applyLayout(nodes.value, layoutType);
|
||||
nodes.value = updatedNodes;
|
||||
nextTick(() => {
|
||||
fitView({ duration: 500 });
|
||||
});
|
||||
};
|
||||
|
||||
// 设置边类型
|
||||
const setEdgeType = (value: string | number | Record<string, any> | undefined) => {
|
||||
const type = value as 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier';
|
||||
edgeType.value = type;
|
||||
};
|
||||
|
||||
// 添加设备
|
||||
const handleAddDevice = async (value: string | number | Record<string, any> | undefined) => {
|
||||
const type = value as DeviceType;
|
||||
const config = DEVICE_TYPE_CONFIG[type];
|
||||
const position = { x: Math.random() * 400 + 200, y: Math.random() * 300 + 100 };
|
||||
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
await TopoAPI.createNode(currentTopologyId.value, {
|
||||
label: config.label,
|
||||
type,
|
||||
ip: '',
|
||||
status: 'normal',
|
||||
alerts: 0,
|
||||
level: 0,
|
||||
position,
|
||||
});
|
||||
|
||||
await loadData();
|
||||
Message.success('设备添加成功');
|
||||
} catch (error) {
|
||||
console.error('添加设备失败:', error);
|
||||
Message.error('添加设备失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义添加
|
||||
const handleCustomAdd = () => {
|
||||
selectedNode.value = null;
|
||||
nodeEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// 保存节点
|
||||
const handleSaveNode = async (nodeId: string | null, nodeData: Partial<NodeData>) => {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
if (nodeId) {
|
||||
await TopoAPI.updateNode(currentTopologyId.value, nodeId, {
|
||||
label: nodeData.label!,
|
||||
type: nodeData.type!,
|
||||
ip: nodeData.ip,
|
||||
status: nodeData.status,
|
||||
alerts: nodeData.alerts,
|
||||
traffic: nodeData.traffic,
|
||||
description: nodeData.description,
|
||||
parentId: nodeData.parentId,
|
||||
level: nodeData.level,
|
||||
});
|
||||
nodes.value = nodes.value.map((n: any) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, ...nodeData } } : n
|
||||
);
|
||||
Message.success('节点更新成功');
|
||||
} else {
|
||||
await TopoAPI.createNode(currentTopologyId.value, {
|
||||
label: nodeData.label!,
|
||||
type: nodeData.type!,
|
||||
ip: nodeData.ip,
|
||||
status: nodeData.status || 'normal',
|
||||
alerts: nodeData.alerts || 0,
|
||||
traffic: nodeData.traffic,
|
||||
description: nodeData.description,
|
||||
parentId: nodeData.parentId,
|
||||
level: nodeData.level ?? 0,
|
||||
position: { x: 400, y: 300 },
|
||||
});
|
||||
|
||||
await loadData();
|
||||
Message.success('节点创建成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存节点失败:', error);
|
||||
Message.error('保存节点失败');
|
||||
}
|
||||
nodeEditDialogOpen.value = false;
|
||||
selectedNode.value = null;
|
||||
};
|
||||
|
||||
// 删除节点
|
||||
const handleDeleteNode = () => {
|
||||
nodeActionDialogOpen.value = false;
|
||||
deleteDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const handleDeleteNodeConfirm = async () => {
|
||||
if (!selectedNode.value) return;
|
||||
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
await TopoAPI.deleteNode(currentTopologyId.value, selectedNode.value.id);
|
||||
await loadData();
|
||||
Message.success('节点删除成功');
|
||||
} catch (error) {
|
||||
console.error('删除节点失败:', error);
|
||||
Message.error('删除节点失败');
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = false;
|
||||
selectedNode.value = null;
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = () => {
|
||||
nodeActionDialogOpen.value = false;
|
||||
nodeDetailDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = () => {
|
||||
nodeActionDialogOpen.value = false;
|
||||
nodeEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
// 保存边
|
||||
const handleSaveEdge = async () => {
|
||||
if (!selectedEdge.value) return;
|
||||
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
if (isNewEdge.value) {
|
||||
const response: any = await TopoAPI.createLink(currentTopologyId.value, {
|
||||
source: selectedEdge.value.source,
|
||||
target: selectedEdge.value.target,
|
||||
type: selectedEdge.value.data?.type || 'physical',
|
||||
label: selectedEdge.value.data?.label || `${selectedEdge.value.source}-${selectedEdge.value.target}`,
|
||||
});
|
||||
if (response.code === 0 && response.data?.id) {
|
||||
const newEdge = {
|
||||
...selectedEdge.value,
|
||||
id: String(response.data.id),
|
||||
data: { ...selectedEdge.value.data, ...response.data },
|
||||
};
|
||||
edges.value.push(newEdge);
|
||||
// 强制触发响应式更新
|
||||
edges.value = [...edges.value];
|
||||
|
||||
await nextTick();
|
||||
} else {
|
||||
edges.value.push(selectedEdge.value);
|
||||
edges.value = [...edges.value];
|
||||
|
||||
await nextTick();
|
||||
}
|
||||
Message.success('链路创建成功');
|
||||
} else {
|
||||
const linkId = Number(selectedEdge.value.id);
|
||||
if (linkId) {
|
||||
await TopoAPI.updateLink(currentTopologyId.value, linkId, {
|
||||
type: selectedEdge.value.data?.type,
|
||||
label: selectedEdge.value.data?.label,
|
||||
});
|
||||
}
|
||||
// 只更新边数据,不重新加载节点
|
||||
edges.value = edges.value.map((e: any) =>
|
||||
e.id === selectedEdge.value.id ? { ...e, data: selectedEdge.value.data } : e
|
||||
);
|
||||
// 强制触发响应式更新
|
||||
edges.value = [...edges.value];
|
||||
|
||||
await nextTick();
|
||||
Message.success('链路更新成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存链路失败:', error);
|
||||
Message.error('保存链路失败');
|
||||
}
|
||||
|
||||
edgeEditDialogOpen.value = false;
|
||||
selectedEdge.value = null;
|
||||
};
|
||||
|
||||
// 删除边
|
||||
const handleDeleteEdge = () => {
|
||||
edgeActionDialogOpen.value = false;
|
||||
deleteEdgeDialogOpen.value = true;
|
||||
};
|
||||
|
||||
const handleDeleteEdgeConfirm = async () => {
|
||||
if (!selectedEdge.value) return;
|
||||
|
||||
try {
|
||||
const linkId = selectedEdge.value.id;
|
||||
if (linkId && currentTopologyId.value) {
|
||||
await TopoAPI.deleteLink(currentTopologyId.value, linkId);
|
||||
}
|
||||
// 只删除边,不重新加载节点,保持节点位置
|
||||
edges.value = edges.value.filter((e: any) => e.id !== selectedEdge.value.id);
|
||||
// 强制触发响应式更新
|
||||
edges.value = [...edges.value];
|
||||
|
||||
await nextTick();
|
||||
Message.success('链路删除成功');
|
||||
} catch (error) {
|
||||
console.error('删除链路失败:', error);
|
||||
Message.error('删除链路失败');
|
||||
}
|
||||
|
||||
deleteEdgeDialogOpen.value = false;
|
||||
selectedEdge.value = null;
|
||||
};
|
||||
|
||||
// 编辑边
|
||||
const handleEditEdge = () => {
|
||||
edgeActionDialogOpen.value = false;
|
||||
edgeEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
|
||||
// 设置选中的边
|
||||
const setSelectedEdge = (edge: any) => {
|
||||
selectedEdge.value = edge;
|
||||
};
|
||||
|
||||
// 刷新拓扑
|
||||
const refreshTopology = () => {
|
||||
fitView({ duration: 500 });
|
||||
};
|
||||
|
||||
// 导出拓扑
|
||||
const exportTopology = () => {
|
||||
const data = { nodes: nodes.value, edges: edges.value };
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `topology-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 重置拓扑
|
||||
const resetTopology = async () => {
|
||||
if (confirm('确定要重置拓扑图吗?这将重新加载服务器数据!')) {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
const graphResponse: any = await TopoAPI.fetchTopologyGraph(currentTopologyId.value);
|
||||
if (graphResponse.code === 0) {
|
||||
const edgesFromGraph = graphResponse.details?.edges || graphResponse.data?.edges || [];
|
||||
const edgesData = edgesFromGraph.map((edge: any) => ({
|
||||
id: String(edge.id),
|
||||
source: edge.source_node_id || edge.source,
|
||||
target: edge.target_node_id || edge.target,
|
||||
type: 'smoothstep',
|
||||
label: edge.name || edge.label || '',
|
||||
data: {
|
||||
linkId: edge.id,
|
||||
linkType: edge.type,
|
||||
bandwidth: edge.bandwidth,
|
||||
description: edge.description,
|
||||
...edge,
|
||||
},
|
||||
})) || [];
|
||||
|
||||
let nodesData: any[] = [];
|
||||
const nodesFromGraph = graphResponse.details?.data || graphResponse.data?.nodes || [];
|
||||
if (typeof nodesFromGraph === 'string') {
|
||||
try {
|
||||
const parsedData = JSON.parse(nodesFromGraph);
|
||||
nodesData = parsedData.nodes?.map((node: any) => ({
|
||||
id: node.id,
|
||||
type: 'custom',
|
||||
position: node.data?.position || { x: Math.random() * 800, y: Math.random() * 600 },
|
||||
data: {
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
...node.data,
|
||||
},
|
||||
})) || [];
|
||||
} catch (e) {
|
||||
console.error('解析节点数据失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
nodes.value = nodesData;
|
||||
edges.value = edgesData;
|
||||
Message.success('重置成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置失败:', error);
|
||||
Message.error('重置失败');
|
||||
}
|
||||
nextTick(() => {
|
||||
fitView({ duration: 500 });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 分组操作
|
||||
const handleSelectGroup = (groupId: string | null) => {
|
||||
selectedGroup.value = groupId;
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
const next = new Set(expandedGroups.value);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
expandedGroups.value = next;
|
||||
};
|
||||
|
||||
// 拓扑分组变化处理
|
||||
const handleTopologyGroupChange = async (topologyId: number | null) => {
|
||||
nodes.value = [];
|
||||
edges.value = [];
|
||||
if (topologyId) {
|
||||
await loadData();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取节点颜色
|
||||
const getNodeColor = (node: any) => {
|
||||
const config = DEVICE_TYPE_CONFIG[node.data?.type as DeviceType];
|
||||
return config?.color || '#888';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.topo-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topo-sidebar {
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topo-main {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--color-border-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flow-wrapper {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.vue-flow) {
|
||||
background-color: var(--color-bg-2);
|
||||
}
|
||||
|
||||
:deep(.vue-flow__minimap) {
|
||||
background-color: var(--color-bg-2) !important;
|
||||
border: 1px solid var(--color-border-2) !important;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__controls) {
|
||||
background-color: var(--color-bg-2) !important;
|
||||
border: 1px solid var(--color-border-2) !important;
|
||||
}
|
||||
</style>
|
||||
205
src/views/ops/pages/netarch/topo/services/topoService.ts
Normal file
205
src/views/ops/pages/netarch/topo/services/topoService.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 拓扑服务层 - 整合本地存储和后端API
|
||||
* 支持离线和在线两种模式
|
||||
*/
|
||||
|
||||
import { Node, Edge } from '@vue-flow/core';
|
||||
import * as TopoAPI from '@/api/ops/netarchTopo';
|
||||
|
||||
// 配置:是否使用后端API(可通过环境变量控制)
|
||||
const USE_BACKEND_API = import.meta.env.VITE_USE_TOPO_API === 'true';
|
||||
|
||||
// 本地存储key
|
||||
const STORAGE_KEY = 'topo-data';
|
||||
|
||||
/**
|
||||
* 拓扑数据服务类
|
||||
*/
|
||||
export class TopoService {
|
||||
private currentTopologyId: number | null = null;
|
||||
|
||||
/**
|
||||
* 设置当前拓扑ID
|
||||
*/
|
||||
setCurrentTopologyId(id: number) {
|
||||
this.currentTopologyId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拓扑数据
|
||||
*/
|
||||
async getTopoData(): Promise<{ nodes: Node[]; edges: Edge[] }> {
|
||||
if (USE_BACKEND_API && this.currentTopologyId) {
|
||||
// 从后端获取
|
||||
const graphData = await TopoAPI.fetchTopologyGraph(this.currentTopologyId);
|
||||
return this.graphToReactFlowData(graphData);
|
||||
} else {
|
||||
// 从本地存储获取
|
||||
return this.loadFromStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存拓扑数据
|
||||
*/
|
||||
async saveTopoData(nodes: Node[], edges: Edge[]): Promise<void> {
|
||||
// 同时保存到本地(作为缓存)
|
||||
this.saveToStorage(nodes, edges);
|
||||
|
||||
// 后端API通过主组件直接调用,不在此服务层处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建链路
|
||||
*/
|
||||
async createEdge(edge: Edge): Promise<void> {
|
||||
if (USE_BACKEND_API && this.currentTopologyId) {
|
||||
const params = {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.data?.type || 'physical',
|
||||
label: edge.data?.label || '',
|
||||
};
|
||||
await TopoAPI.createLink(this.currentTopologyId, params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新链路
|
||||
*/
|
||||
async updateEdge(edge: Edge): Promise<void> {
|
||||
if (USE_BACKEND_API && this.currentTopologyId && edge.id) {
|
||||
const linkId = parseInt(edge.id.replace(/\D/g, ''));
|
||||
if (!isNaN(linkId)) {
|
||||
await TopoAPI.updateLink(this.currentTopologyId, linkId, {
|
||||
type: edge.data?.type as 'physical' | 'virtual',
|
||||
label: edge.data?.label,
|
||||
bandwidth: edge.data?.bandwidth,
|
||||
description: edge.data?.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除链路
|
||||
*/
|
||||
async deleteEdge(edgeId: string): Promise<void> {
|
||||
if (USE_BACKEND_API && this.currentTopologyId) {
|
||||
await TopoAPI.deleteLink(this.currentTopologyId, edgeId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拓扑列表
|
||||
*/
|
||||
async getTopologies(params: { page: number; size: number; keyword?: string; group_id?: number }): Promise<{ list: TopoAPI.Topology[] }> {
|
||||
if (USE_BACKEND_API) {
|
||||
const result = await TopoAPI.fetchTopologies(params);
|
||||
return { list: result.data?.list || [] };
|
||||
}
|
||||
return { list: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建拓扑
|
||||
*/
|
||||
async createTopology(params: Partial<TopoAPI.Topology>): Promise<TopoAPI.Topology> {
|
||||
if (USE_BACKEND_API) {
|
||||
return await TopoAPI.createTopology(params);
|
||||
}
|
||||
throw new Error('Backend API not enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发拓扑发现
|
||||
*/
|
||||
async discoverTopology(id: number): Promise<void> {
|
||||
if (USE_BACKEND_API) {
|
||||
await TopoAPI.discoverTopology(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 数据转换方法 ====================
|
||||
|
||||
/**
|
||||
* 将后端图数据转换为Vue Flow数据
|
||||
*/
|
||||
private graphToReactFlowData(graphData: any): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
if (graphData.code === 0) {
|
||||
// 处理节点数据
|
||||
const nodesFromGraph = graphData.details?.nodes || graphData.details?.data || graphData.data?.nodes || [];
|
||||
|
||||
if (Array.isArray(nodesFromGraph)) {
|
||||
nodesFromGraph.forEach((node: any) => {
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
type: 'custom',
|
||||
position: node.position || { x: Math.random() * 800, y: Math.random() * 600 },
|
||||
data: {
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
ip: node.ip,
|
||||
status: node.status || 'normal',
|
||||
alerts: node.alerts || 0,
|
||||
traffic: node.traffic,
|
||||
description: node.description,
|
||||
parentId: node.parentId,
|
||||
level: node.level ?? 0,
|
||||
position: node.position,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 处理边数据
|
||||
const edgesFromGraph = graphData.details?.edges || graphData.data?.edges || [];
|
||||
|
||||
edgesFromGraph.forEach((edge: any) => {
|
||||
edges.push({
|
||||
id: String(edge.id),
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.type || 'smoothstep',
|
||||
label: edge.label || '',
|
||||
data: { ...edge },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ==================== 本地存储方法 ====================
|
||||
|
||||
private loadFromStorage(): { nodes: Node[]; edges: Edge[] } {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load topo data from storage:', error);
|
||||
}
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
private saveToStorage(nodes: Node[], edges: Edge[]): void {
|
||||
try {
|
||||
const data = { nodes, edges };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error('Failed to save topo data to storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
clearStorage(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const topoService = new TopoService();
|
||||
40
src/views/ops/pages/netarch/topo/types.ts
Normal file
40
src/views/ops/pages/netarch/topo/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 设备类型定义
|
||||
export type DeviceType = 'server' | 'router' | 'switch' | 'desktop' | 'cloud' | 'text' | 'region';
|
||||
|
||||
// 设备状态类型
|
||||
export type DeviceStatus = 'normal' | 'warning' | 'error';
|
||||
|
||||
// 节点数据接口
|
||||
export interface NodeData {
|
||||
label: string; // 节点标签/名称
|
||||
type: DeviceType; // 节点类型
|
||||
ip?: string; // 节点IP地址
|
||||
status?: DeviceStatus; // 节点状态
|
||||
alerts?: number; // 告警数量
|
||||
traffic?: string; // 流量信息(如"100Mbps")
|
||||
description?: string; // 节点描述
|
||||
// 节点层级关系
|
||||
parentId?: string | null; // 父节点ID,null表示根节点
|
||||
level?: number; // 层级(0为一级节点)
|
||||
position?: { x: number; y: number }; // 节点位置坐标
|
||||
}
|
||||
|
||||
// 拓扑分组类型(从节点自动生成)
|
||||
export interface TopoGroup {
|
||||
id: string; // 对应节点ID
|
||||
name: string; // 对应节点名称
|
||||
nodeId: string; // 关联的节点ID
|
||||
children?: TopoGroup[]; // 子分组(对应子节点)
|
||||
parentId?: string; // 父分组ID
|
||||
level: number; // 层级
|
||||
}
|
||||
|
||||
// 链路数据接口
|
||||
export interface LinkData {
|
||||
type?: 'physical' | 'virtual';
|
||||
bandwidth?: string;
|
||||
traffic?: string;
|
||||
}
|
||||
|
||||
// 链路类型(用于边样式)
|
||||
export type EdgeType = 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier';
|
||||
194
src/views/ops/pages/netarch/topo/utils/buildGroupTree.ts
Normal file
194
src/views/ops/pages/netarch/topo/utils/buildGroupTree.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Node, Edge } from '@vue-flow/core';
|
||||
import { TopoGroup, NodeData } from '../types';
|
||||
|
||||
/**
|
||||
* 从节点数据构建分组树状结构
|
||||
* 规则:
|
||||
* 1. 优先根据连接线(edges)的source->target关系构建树
|
||||
* 2. source节点为父节点,target节点为子节点
|
||||
* 3. 如果没有连接线,则所有节点都作为根节点
|
||||
*/
|
||||
export function buildGroupTreeFromNodes(nodes: Node[], edges: Edge[] = []): TopoGroup[] {
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
// 始终使用边关系构建树
|
||||
return buildFromEdges(nodes, edges);
|
||||
}
|
||||
|
||||
/**
|
||||
* 方式1: 使用level和parentId信息构建分组树
|
||||
*/
|
||||
function buildFromLevelInfo(nodes: Node[]): TopoGroup[] {
|
||||
// 找出所有一级节点(level=0)
|
||||
const rootNodes = nodes.filter((node) => {
|
||||
const data = node.data as NodeData;
|
||||
return data.level === 0;
|
||||
});
|
||||
|
||||
// 为每个一级节点构建分组树
|
||||
return rootNodes.map((rootNode) => buildGroupFromNode(rootNode, nodes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 方式2: 根据连接线自动推断层级关系
|
||||
*/
|
||||
function buildFromEdges(nodes: Node[], edges: Edge[]): TopoGroup[] {
|
||||
// 构建连接关系图: 找出每个节点的子节点
|
||||
const childrenMap = new Map<string, string[]>();
|
||||
const hasParent = new Set<string>();
|
||||
|
||||
edges.forEach((edge) => {
|
||||
const sourceId = edge.source;
|
||||
const targetId = edge.target;
|
||||
|
||||
if (!childrenMap.has(sourceId)) {
|
||||
childrenMap.set(sourceId, []);
|
||||
}
|
||||
childrenMap.get(sourceId)!.push(targetId);
|
||||
hasParent.add(targetId);
|
||||
});
|
||||
|
||||
// 找出根节点(没有父节点的)
|
||||
const rootNodes = nodes.filter((node) => !hasParent.has(node.id));
|
||||
|
||||
// 如果没有边,则所有节点都是根节点
|
||||
if (edges.length === 0) {
|
||||
return nodes.map((node) => {
|
||||
const data = node.data as NodeData;
|
||||
return {
|
||||
id: node.id,
|
||||
name: data.label || `节点${node.id}`,
|
||||
nodeId: node.id,
|
||||
level: 0,
|
||||
parentId: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 为每个根节点构建分组树
|
||||
return rootNodes.map((rootNode) => buildGroupFromEdges(rootNode, nodes, childrenMap, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据连接关系递归构建分组
|
||||
*/
|
||||
function buildGroupFromEdges(
|
||||
node: Node,
|
||||
allNodes: Node[],
|
||||
childrenMap: Map<string, string[]>,
|
||||
level: number
|
||||
): TopoGroup {
|
||||
const data = node.data as NodeData;
|
||||
const childIds = childrenMap.get(node.id) || [];
|
||||
|
||||
const children = childIds.length > 0
|
||||
? childIds
|
||||
.map((childId) => allNodes.find((n) => n.id === childId))
|
||||
.filter((n): n is Node => n !== undefined)
|
||||
.map((childNode) => buildGroupFromEdges(childNode, allNodes, childrenMap, level + 1))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: data.label || `节点${node.id}`,
|
||||
nodeId: node.id,
|
||||
level,
|
||||
parentId: undefined,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用level/parentId信息递归构建单个节点的分组树
|
||||
*/
|
||||
function buildGroupFromNode(node: Node, allNodes: Node[]): TopoGroup {
|
||||
const data = node.data as NodeData;
|
||||
|
||||
// 查找当前节点的所有子节点
|
||||
const childNodes = allNodes.filter((n) => {
|
||||
const nodeData = n.data as NodeData;
|
||||
return nodeData.parentId === node.id;
|
||||
});
|
||||
|
||||
// 递归构建子分组
|
||||
const children = childNodes.length > 0
|
||||
? childNodes.map((childNode) => buildGroupFromNode(childNode, allNodes))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: data.label,
|
||||
nodeId: node.id,
|
||||
level: data.level || 0,
|
||||
parentId: data.parentId || undefined,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组下的所有节点ID(包括子分组的节点)
|
||||
*/
|
||||
export function getGroupNodeIds(group: TopoGroup): string[] {
|
||||
const ids = [group.nodeId];
|
||||
|
||||
if (group.children) {
|
||||
group.children.forEach((child) => {
|
||||
ids.push(...getGroupNodeIds(child));
|
||||
});
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分组筛选节点和边
|
||||
*/
|
||||
export function filterByGroup(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
selectedGroup: string | null
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
if (!selectedGroup) {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// 找到选中的分组
|
||||
const allGroups = buildGroupTreeFromNodes(nodes, edges);
|
||||
const group = findGroupById(allGroups, selectedGroup);
|
||||
|
||||
if (!group) {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// 获取分组下的所有节点ID
|
||||
const nodeIds = getGroupNodeIds(group);
|
||||
|
||||
// 筛选节点
|
||||
const filteredNodes = nodes.filter((node) => nodeIds.includes(node.id));
|
||||
|
||||
// 筛选边(只保留两端都在分组内的边)
|
||||
const filteredEdges = edges.filter((edge) =>
|
||||
nodeIds.includes(edge.source) && nodeIds.includes(edge.target)
|
||||
);
|
||||
|
||||
return { nodes: filteredNodes, edges: filteredEdges };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在分组树中查找指定ID的分组
|
||||
*/
|
||||
function findGroupById(groups: TopoGroup[], id: string): TopoGroup | null {
|
||||
for (const group of groups) {
|
||||
if (group.id === id) {
|
||||
return group;
|
||||
}
|
||||
if (group.children) {
|
||||
const found = findGroupById(group.children, id);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user