feat
This commit is contained in:
@@ -10,28 +10,19 @@
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="service_identity" label="服务唯一标识">
|
||||
<a-input
|
||||
v-model="formData.service_identity"
|
||||
placeholder="输入为空系统自动生成 host:port:database"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="服务名称" required>
|
||||
<a-input v-model="formData.name" placeholder="请输入服务名称(唯一)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="server_identity" label="服务器标识">
|
||||
<a-input v-model="formData.server_identity" placeholder="关联服务器唯一标识" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="type" label="数据库类型" required>
|
||||
<a-select v-model="formData.type" placeholder="请选择数据库类型">
|
||||
@@ -54,13 +45,7 @@
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="port" label="端口" required>
|
||||
<a-input-number
|
||||
v-model="formData.port"
|
||||
placeholder="请输入端口"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-input-number v-model="formData.port" placeholder="请输入端口" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -73,10 +58,7 @@
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="password" label="密码">
|
||||
<a-input-password
|
||||
v-model="formData.password"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
<a-input-password v-model="formData.password" placeholder="请输入密码" />
|
||||
<template #extra v-if="isEdit">
|
||||
<span style="color: rgb(var(--warning-6))">编辑时密码不回显,如需修改请输入新密码</span>
|
||||
</template>
|
||||
@@ -92,23 +74,13 @@
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="interval" label="采集间隔(秒)">
|
||||
<a-input-number
|
||||
v-model="formData.interval"
|
||||
placeholder="默认60秒"
|
||||
:min="10"
|
||||
:max="3600"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-input-number v-model="formData.interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="description" label="描述信息">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入描述信息"
|
||||
:rows="2"
|
||||
/>
|
||||
<a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="2" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="tags" label="标签">
|
||||
@@ -131,13 +103,7 @@
|
||||
<a-row :gutter="20" v-if="formData.collect_on">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="collect_interval" label="进程内采集间隔(秒)">
|
||||
<a-input-number
|
||||
v-model="formData.collect_interval"
|
||||
placeholder="默认60秒"
|
||||
:min="10"
|
||||
:max="3600"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-input-number v-model="formData.collect_interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
@@ -148,11 +114,7 @@
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="extra" label="额外配置(JSON)">
|
||||
<a-textarea
|
||||
v-model="formData.extra"
|
||||
placeholder='JSON格式,如:{"charset":"utf8mb4"}'
|
||||
:rows="2"
|
||||
/>
|
||||
<a-textarea v-model="formData.extra" placeholder='JSON格式,如:{"charset":"utf8mb4"}' :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
@@ -162,13 +124,7 @@
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import {
|
||||
createDatabase,
|
||||
updateDatabase,
|
||||
type CreateDatabaseParams,
|
||||
type UpdateDatabaseParams,
|
||||
type DatabaseType,
|
||||
} from '@/api/ops/database'
|
||||
import { createDatabase, updateDatabase, type CreateDatabaseParams, type UpdateDatabaseParams, type DatabaseType } from '@/api/ops/database'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
|
||||
@@ -10,28 +10,19 @@
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="service_identity" label="服务唯一标识">
|
||||
<a-input
|
||||
v-model="formData.service_identity"
|
||||
placeholder="输入为空系统自动生成 ULID"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="服务名称">
|
||||
<a-input v-model="formData.name" placeholder="请输入服务名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="server_identity" label="服务器标识">
|
||||
<a-input v-model="formData.server_identity" placeholder="关联服务器唯一标识" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="type" label="中间件类型">
|
||||
<a-select v-model="formData.type" placeholder="请选择中间件类型">
|
||||
@@ -48,11 +39,7 @@
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="description" label="描述信息">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入描述信息"
|
||||
:rows="2"
|
||||
/>
|
||||
<a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="2" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="tags" label="标签">
|
||||
@@ -80,13 +67,7 @@
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="interval" label="采集间隔(秒)">
|
||||
<a-input-number
|
||||
v-model="formData.interval"
|
||||
placeholder="默认60秒"
|
||||
:min="10"
|
||||
:max="3600"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-input-number v-model="formData.interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -99,23 +80,13 @@
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item v-if="formData.collect_on" field="collect_interval" label="采集间隔(秒)">
|
||||
<a-input-number
|
||||
v-model="formData.collect_interval"
|
||||
placeholder="为0时使用interval"
|
||||
:min="0"
|
||||
:max="3600"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-input-number v-model="formData.collect_interval" placeholder="为0时使用interval" :min="0" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="extra" label="额外配置(JSON)">
|
||||
<a-textarea
|
||||
v-model="formData.extra"
|
||||
placeholder='JSON格式,如:{"config_path":"/etc/nginx/nginx.conf"}'
|
||||
:rows="2"
|
||||
/>
|
||||
<a-textarea v-model="formData.extra" placeholder='JSON格式,如:{"config_path":"/etc/nginx/nginx.conf"}' :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
@@ -125,12 +96,7 @@
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
import {
|
||||
createMiddleware,
|
||||
updateMiddleware,
|
||||
type MiddlewareCreateData,
|
||||
type MiddlewareUpdateData,
|
||||
} from '@/api/ops/middleware'
|
||||
import { createMiddleware, updateMiddleware, type MiddlewareCreateData, type MiddlewareUpdateData } from '@/api/ops/middleware'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
|
||||
@@ -10,23 +10,11 @@
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="unique_id" label="唯一标识">
|
||||
<a-input
|
||||
v-model="formData.unique_id"
|
||||
placeholder="输入为空系统自动生成 UUID"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="设备名称">
|
||||
<a-input v-model="formData.name" placeholder="请输入设备名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="type" label="设备类型">
|
||||
<a-select v-model="formData.type" placeholder="请选择设备类型">
|
||||
@@ -39,6 +27,9 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="manu" label="厂商">
|
||||
<a-select v-model="formData.manu" placeholder="请选择厂商">
|
||||
@@ -88,18 +79,11 @@
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="location" label="位置信息">
|
||||
<a-input
|
||||
v-model="formData.location"
|
||||
placeholder="请输入位置信息"
|
||||
/>
|
||||
<a-input v-model="formData.location" placeholder="请输入位置信息" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="remark" label="备注信息">
|
||||
<a-textarea
|
||||
v-model="formData.remark"
|
||||
placeholder="请输入备注信息"
|
||||
:rows="4"
|
||||
/>
|
||||
<a-textarea v-model="formData.remark" placeholder="请输入备注信息" :rows="4" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
@@ -108,7 +92,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { FormInstance } from '@arco-design/web-vue'
|
||||
|
||||
interface Props {
|
||||
@@ -174,13 +157,9 @@ const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (!formData.unique_id) {
|
||||
formData.unique_id = uuidv4()
|
||||
}
|
||||
|
||||
confirmLoading.value = true
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
Message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
emit('success')
|
||||
|
||||
@@ -10,28 +10,19 @@
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="server_identity" label="唯一标识">
|
||||
<a-input
|
||||
v-model="formData.server_identity"
|
||||
placeholder="输入为空系统自动生成ULID"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="服务器名称" required>
|
||||
<a-input v-model="formData.name" placeholder="请输入服务器名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="host" label="主机地址" required>
|
||||
<a-input v-model="formData.host" placeholder="请输入主机地址" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="ip_address" label="IP地址">
|
||||
<a-input v-model="formData.ip_address" placeholder="请输入IP地址" />
|
||||
@@ -140,11 +131,7 @@
|
||||
</a-row>
|
||||
|
||||
<a-form-item field="description" label="描述信息">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入描述信息"
|
||||
:rows="4"
|
||||
/>
|
||||
<a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="4" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
@@ -265,7 +252,7 @@ watch(
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const handleOk = async () => {
|
||||
|
||||
@@ -10,21 +10,17 @@
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
<a-form-item field="service_identity" label="服务唯一标识">
|
||||
<a-input v-model="formData.service_identity" placeholder="输入为空系统自动生成 ULID" :disabled="isEdit" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item field="name" label="服务名称">
|
||||
<a-input v-model="formData.name" placeholder="请输入服务名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-col :span="12">
|
||||
<a-form-item field="target_url" label="目标监控URL">
|
||||
<a-input v-model="formData.target_url" placeholder="请输入目标监控URL" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="20">
|
||||
<a-col :span="12">
|
||||
|
||||
@@ -1,154 +1,244 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['custom-node', { 'node-selected': selected }]"
|
||||
:style="nodeStyle"
|
||||
>
|
||||
<!-- 连接点 - 目标 -->
|
||||
<Handle
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
class="handle handle-top"
|
||||
/>
|
||||
<div :class="['custom-node', `status-${data.status || 'normal'}`, { '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 class="node-content">
|
||||
<div class="icon-wrapper" :style="iconWrapperStyle">
|
||||
<component :is="iconComponent" :size="32" :stroke-width="1.5" />
|
||||
</div>
|
||||
|
||||
<a-tag v-if="data.alerts && data.alerts > 0" color="danger" size="small">
|
||||
{{ data.alerts }}个告警
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<div class="node-info">
|
||||
<div class="node-label" :title="data.label">{{ data.label }}</div>
|
||||
<div v-if="data.ip" class="node-ip" :title="data.ip">{{ data.ip }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 连接点 - 源 -->
|
||||
<Handle
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
class="handle handle-bottom"
|
||||
/>
|
||||
<div v-if="data.alerts && data.alerts > 0" class="alert-badge">
|
||||
<span class="alert-count">{{ data.alerts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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';
|
||||
import { computed } from 'vue'
|
||||
import { Handle, Position, type NodeProps } from '@vue-flow/core'
|
||||
import { DEVICE_TYPE_CONFIG } from '../config'
|
||||
import type { NodeData } from '../types'
|
||||
import { IconServer } from '@tabler/icons-vue'
|
||||
|
||||
// 使用Vue Flow的NodeProps类型
|
||||
const props = defineProps<NodeProps<NodeData>>();
|
||||
const props = defineProps<NodeProps<NodeData>>()
|
||||
|
||||
// 从props中解构数据
|
||||
const data = computed(() => props.data);
|
||||
const selected = computed(() => props.selected);
|
||||
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(() => config.value.icon)
|
||||
|
||||
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, { border: string; bg: string; glow: string }> = {
|
||||
normal: { border: '#E5E7EB', bg: '#FFFFFF', glow: 'rgba(59, 130, 246, 0.1)' },
|
||||
warning: { border: '#FCD34D', bg: '#FFFBEB', glow: 'rgba(251, 191, 36, 0.3)' },
|
||||
error: { border: '#EF4444', bg: '#FEF2F2', glow: 'rgba(239, 68, 68, 0.4)' },
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
normal: '#52C41A',
|
||||
warning: '#FAAD14',
|
||||
error: '#F53F3F',
|
||||
};
|
||||
|
||||
const borderColor = computed(() => statusColors[data.value.status || 'normal']);
|
||||
const statusConfig = 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)',
|
||||
}));
|
||||
'--border-color': selected.value ? '#3B82F6' : statusConfig.value.border,
|
||||
'--bg-color': statusConfig.value.bg,
|
||||
'--node-color': config.value.color,
|
||||
'--glow-color': selected.value ? 'rgba(59, 130, 246, 0.3)' : statusConfig.value.glow,
|
||||
}))
|
||||
|
||||
const avatarStyle = computed(() => ({
|
||||
backgroundColor: `${config.value.color}1A`,
|
||||
const iconWrapperStyle = computed(() => ({
|
||||
background: `linear-gradient(135deg, ${config.value.color}15 0%, ${config.value.color}25 100%)`,
|
||||
color: config.value.color,
|
||||
}));
|
||||
boxShadow: `0 2px 8px ${config.value.color}30`,
|
||||
}))
|
||||
</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;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
min-width: 160px;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.02);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 16px;
|
||||
background: var(--glow-color);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.12),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
transform: translateY(-2px);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.node-selected {
|
||||
border-color: #165DFF;
|
||||
border-color: #3b82f6;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(59, 130, 246, 0.1),
|
||||
0 8px 24px rgba(59, 130, 246, 0.15);
|
||||
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.node-text {
|
||||
text-align: center;
|
||||
&.status-warning {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
&.status-error {
|
||||
border-left: 4px solid #ef4444;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
max-width: 120px;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.node-ip {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
max-width: 120px;
|
||||
color: var(--color-text-3);
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.alert-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
|
||||
.alert-count {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.handle {
|
||||
background: #165DFF;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
|
||||
transition: all 0.2s;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.3);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(59, 130, 246, 0.2),
|
||||
0 4px 8px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
&.handle-top {
|
||||
top: -5px;
|
||||
top: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
&.handle-bottom {
|
||||
bottom: -5px;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&:hover {
|
||||
transform: translateX(-50%) scale(1.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
<a-dropdown :popup-visible="visible" @update:popup-visible="handleClose">
|
||||
<slot></slot>
|
||||
<template #content>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'default' }"
|
||||
@click="handleSelect('default')"
|
||||
>
|
||||
<a-doption :class="{ 'selected-option': selectedType === 'default' }" @click="handleSelect('default')">
|
||||
<template #icon>
|
||||
<icon-minus />
|
||||
</template>
|
||||
@@ -14,10 +11,7 @@
|
||||
<div class="option-subtitle">贝塞尔曲线,平滑自然</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'straight' }"
|
||||
@click="handleSelect('straight')"
|
||||
>
|
||||
<a-doption :class="{ 'selected-option': selectedType === 'straight' }" @click="handleSelect('straight')">
|
||||
<template #icon>
|
||||
<icon-arrow-right />
|
||||
</template>
|
||||
@@ -26,10 +20,7 @@
|
||||
<div class="option-subtitle">直接连接,简洁明了</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'step' }"
|
||||
@click="handleSelect('step')"
|
||||
>
|
||||
<a-doption :class="{ 'selected-option': selectedType === 'step' }" @click="handleSelect('step')">
|
||||
<template #icon>
|
||||
<icon-minus />
|
||||
</template>
|
||||
@@ -38,24 +29,9 @@
|
||||
<div class="option-subtitle">直角转折,类似线路图</div>
|
||||
</div>
|
||||
</a-doption>
|
||||
<a-doption
|
||||
:class="{ 'selected-option': selectedType === 'smoothstep' }"
|
||||
@click="handleSelect('smoothstep')"
|
||||
>
|
||||
<a-doption :class="{ 'selected-option': selectedType === 'simplebezier' }" @click="handleSelect('simplebezier')">
|
||||
<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);" />
|
||||
<icon-minus style="transform: scaleY(0.8)" />
|
||||
</template>
|
||||
<div class="option-content">
|
||||
<div class="option-title">简单贝塞尔曲线</div>
|
||||
@@ -67,30 +43,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconMinus, IconArrowRight, IconArrowUp } from '@arco-design/web-vue/es/icon';
|
||||
import type { EdgeType } from '../types';
|
||||
import { IconMinus, IconArrowRight } from '@arco-design/web-vue/es/icon'
|
||||
import type { EdgeType } from '../types'
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
selectedType: EdgeType;
|
||||
visible: boolean
|
||||
selectedType: EdgeType
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'selectType', type: EdgeType): void;
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'selectType', type: EdgeType): void
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const handleSelect = (type: EdgeType) => {
|
||||
emit('selectType', type);
|
||||
emit('update:visible', false);
|
||||
};
|
||||
emit('selectType', type)
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
const handleClose = (value: boolean) => {
|
||||
emit('update:visible', value);
|
||||
};
|
||||
emit('update:visible', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div class="group-panel-wrapper">
|
||||
<div class="group-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-title">拓扑</div>
|
||||
<div class="header-title">
|
||||
<IconGitBranch :size="20" :stroke-width="1.5" />
|
||||
<span>拓扑视图</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动拓扑分组选择 -->
|
||||
@@ -25,9 +28,12 @@
|
||||
@click="handleTopologySelect(topology)"
|
||||
>
|
||||
<div class="topology-icon">
|
||||
<icon-apps />
|
||||
<IconGitBranch :size="18" :stroke-width="1.5" />
|
||||
</div>
|
||||
<div class="topology-info">
|
||||
<div class="topology-name">{{ topology.name }}</div>
|
||||
<div class="topology-meta">{{ topology.nodeCount || 0 }} 个节点</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,11 +44,11 @@
|
||||
<a-menu :selected-keys="selectedGroup === null ? ['all'] : []">
|
||||
<a-menu-item key="all" @click="handleSelectAll">
|
||||
<template #icon>
|
||||
<icon-share-alt />
|
||||
<IconLayout2 :size="18" :stroke-width="1.5" />
|
||||
</template>
|
||||
<template #default>全部</template>
|
||||
<template #default>全部节点</template>
|
||||
<template #extra>
|
||||
<span class="menu-subtitle">显示所有拓扑图</span>
|
||||
<span class="menu-count">{{ nodes.length }}</span>
|
||||
</template>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
@@ -68,36 +74,35 @@
|
||||
</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';
|
||||
import { ref, computed } from 'vue'
|
||||
import { Node } from '@vue-flow/core'
|
||||
import type { TopoGroup, NodeData } from '../types'
|
||||
import { IconGitBranch, IconLayout2 } from '@tabler/icons-vue'
|
||||
import GroupTreeItem from './GroupTreeItem.vue'
|
||||
|
||||
interface Props {
|
||||
groups: TopoGroup[];
|
||||
selectedGroup: string | null;
|
||||
expandedGroups: Set<string>;
|
||||
nodes: Node[];
|
||||
isAutoTopo?: boolean;
|
||||
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;
|
||||
(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 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[] => {
|
||||
@@ -106,51 +111,42 @@ const treeData = computed(() => {
|
||||
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);
|
||||
});
|
||||
}))
|
||||
}
|
||||
return convertToTree(topologyGroups.value)
|
||||
})
|
||||
|
||||
const handleSelectAll = () => {
|
||||
emit('selectGroup', null);
|
||||
};
|
||||
emit('selectGroup', null)
|
||||
}
|
||||
|
||||
const handleSelectGroup = (groupId: string) => {
|
||||
emit('selectGroup', groupId);
|
||||
};
|
||||
emit('selectGroup', groupId)
|
||||
}
|
||||
|
||||
const handleToggleGroup = (groupId: string) => {
|
||||
emit('toggleGroup', groupId);
|
||||
};
|
||||
emit('toggleGroup', groupId)
|
||||
}
|
||||
|
||||
const handleGroupChange = async (value: number | null) => {
|
||||
selectedGroupId.value = value;
|
||||
topologyList.value = [];
|
||||
selectedTopologyId.value = null;
|
||||
emit('groupChange', 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);
|
||||
console.error('获取拓扑列表失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTopologySelect = (topology: any) => {
|
||||
selectedTopologyId.value = topology.id;
|
||||
emit('groupChange', topology.id);
|
||||
};
|
||||
selectedTopologyId.value = topology.id
|
||||
emit('groupChange', topology.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -172,9 +168,12 @@ const handleTopologySelect = (topology: any) => {
|
||||
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border-2);
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
@@ -188,52 +187,68 @@ const handleTopologySelect = (topology: any) => {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topology-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid var(--color-border-2);
|
||||
border: 1px 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);
|
||||
border-color: rgb(var(--primary-6));
|
||||
background: var(--color-fill-2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgb(var(--primary-6));
|
||||
background: var(--color-primary-light-1);
|
||||
|
||||
.topology-icon {
|
||||
background: rgb(var(--primary-6));
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.topology-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-primary-light-3);
|
||||
color: rgb(var(--primary-6));
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-fill-3);
|
||||
color: var(--color-text-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.topology-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topology-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.topology-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,14 +259,12 @@ const handleTopologySelect = (topology: any) => {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.menu-subtitle {
|
||||
.menu-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-fill-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
<template>
|
||||
<div class="group-tree-item">
|
||||
<div
|
||||
:class="['group-item-content', { selected: isSelected }]"
|
||||
:style="{ paddingLeft: `${24 + level * 24}px` }"
|
||||
:class="['group-item-content', { selected: isSelected, 'has-children': hasChildren }]"
|
||||
:style="{ paddingLeft: `${16 + 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 v-if="hasChildren" class="toggle-icon" @click.stop="handleToggle">
|
||||
<IconChevronDown v-if="isExpanded" :size="16" :stroke-width="2" />
|
||||
<IconChevronRight v-else :size="16" :stroke-width="2" />
|
||||
</div>
|
||||
<div v-else class="toggle-placeholder" />
|
||||
|
||||
<!-- 设备图标 -->
|
||||
<div class="device-icon" :style="{ background: iconBg, color: iconColor }">
|
||||
<component :is="iconComponent" :size="16" />
|
||||
<div class="device-icon" :style="iconStyle">
|
||||
<component :is="iconComponent" :size="18" :stroke-width="1.5" />
|
||||
</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>
|
||||
{{ nodeData?.label || group.name }}
|
||||
<span v-if="nodeData?.status === 'error'" class="status-dot status-error" />
|
||||
<span v-else-if="nodeData?.status === 'warning'" class="status-dot status-warning" />
|
||||
</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 class="device-meta">
|
||||
<span v-if="nodeData?.ip" class="ip-address">{{ nodeData.ip }}</span>
|
||||
<span v-if="nodeData?.alerts && nodeData?.alerts > 0" class="alert-indicator">{{ nodeData.alerts }} 告警</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子节点数量 -->
|
||||
<div v-if="hasChildren" class="children-count">
|
||||
{{ group.children?.length || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子分组 -->
|
||||
<a-collapse :model-value="expandedGroups" :default-expanded-key="undefined" :bordered="false">
|
||||
<template v-if="hasChildren && isExpanded">
|
||||
<div v-if="hasChildren && isExpanded" class="children-container">
|
||||
<group-tree-item
|
||||
v-for="child in group.children"
|
||||
:key="child.id"
|
||||
@@ -47,78 +49,76 @@
|
||||
@select="$emit('select', $event)"
|
||||
@toggle="$emit('toggle', $event)"
|
||||
/>
|
||||
</template>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</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';
|
||||
import { computed } from 'vue'
|
||||
import { Node } from '@vue-flow/core'
|
||||
import { DEVICE_TYPE_CONFIG } from '../config'
|
||||
import type { TopoGroup, NodeData, DeviceType } from '../types'
|
||||
import { IconChevronDown, IconChevronRight, IconServer } from '@tabler/icons-vue'
|
||||
|
||||
interface Props {
|
||||
group: TopoGroup;
|
||||
level: number;
|
||||
selectedGroup: string | null;
|
||||
expandedGroups: Set<string>;
|
||||
nodes: Node[];
|
||||
group: TopoGroup
|
||||
level: number
|
||||
selectedGroup: string | null
|
||||
expandedGroups: Set<string>
|
||||
nodes: Node[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', groupId: string): void;
|
||||
(e: 'toggle', groupId: string): void;
|
||||
(e: 'select', groupId: string): void
|
||||
(e: 'toggle', groupId: string): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
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 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;
|
||||
});
|
||||
return props.group.children && props.group.children.length > 0
|
||||
})
|
||||
|
||||
const isExpanded = computed(() => {
|
||||
return props.expandedGroups.has(props.group.id);
|
||||
});
|
||||
return props.expandedGroups.has(props.group.id)
|
||||
})
|
||||
|
||||
const isSelected = computed(() => {
|
||||
return props.selectedGroup === props.group.id;
|
||||
});
|
||||
return props.selectedGroup === props.group.id
|
||||
})
|
||||
|
||||
const deviceConfig = computed(() => {
|
||||
if (!nodeData.value) return null;
|
||||
return DEVICE_TYPE_CONFIG[nodeData.value.type as DeviceType];
|
||||
});
|
||||
if (!nodeData.value) return null
|
||||
return DEVICE_TYPE_CONFIG[nodeData.value.type as DeviceType]
|
||||
})
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
return deviceConfig.value?.icon;
|
||||
});
|
||||
return deviceConfig.value?.icon || IconServer
|
||||
})
|
||||
|
||||
const iconBg = computed(() => {
|
||||
const color = deviceConfig.value?.color || '#888';
|
||||
return `${color}15`;
|
||||
});
|
||||
|
||||
const iconColor = computed(() => {
|
||||
return deviceConfig.value?.color || '#888';
|
||||
});
|
||||
const iconStyle = computed(() => {
|
||||
const color = deviceConfig.value?.color || '#6B7280'
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${color}15 0%, ${color}20 100%)`,
|
||||
color: color,
|
||||
}
|
||||
})
|
||||
|
||||
const handleSelect = () => {
|
||||
emit('select', props.group.id);
|
||||
};
|
||||
emit('select', props.group.id)
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
if (hasChildren.value) {
|
||||
emit('toggle', props.group.id);
|
||||
emit('toggle', props.group.id)
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -126,71 +126,138 @@ const handleToggle = () => {
|
||||
.group-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
margin: 4px 8px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
margin: 2px 8px;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-fill-2);
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-primary-light-1);
|
||||
background: var(--color-primary-light-1);
|
||||
border-left: 3px solid rgb(var(--primary-6));
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&.has-children {
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
|
||||
.placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
&:hover {
|
||||
background: var(--color-fill-3);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-placeholder {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.status-error {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
&.status-warning {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
.ip-address {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.alert-indicator {
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
padding: 1px 4px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.children-count {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-fill-2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.children-container {
|
||||
border-top: 1px solid var(--color-border-1);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { DeviceType } from './types';
|
||||
import { DeviceType } from './types'
|
||||
import {
|
||||
IconServer,
|
||||
IconRouter,
|
||||
IconDeviceDesktop,
|
||||
IconCloud,
|
||||
IconNotes,
|
||||
IconMap,
|
||||
IconShield,
|
||||
IconDatabase,
|
||||
IconDeviceMobile,
|
||||
} from '@tabler/icons-vue'
|
||||
|
||||
// 设备类型配置
|
||||
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 DEVICE_TYPE_CONFIG: Record<DeviceType, { icon: any; label: string; color: string }> = {
|
||||
server: { icon: IconServer, label: '服务器', color: '#3B82F6' },
|
||||
router: { icon: IconRouter, label: '路由器', color: '#F59E0B' },
|
||||
switch: { icon: IconDeviceDesktop, label: '交换机', color: '#10B981' },
|
||||
desktop: { icon: IconDeviceDesktop, label: '终端', color: '#8B5CF6' },
|
||||
cloud: { icon: IconCloud, label: '云端节点', color: '#06B6D4' },
|
||||
text: { icon: IconNotes, label: '文本标注', color: '#6B7280' },
|
||||
region: { icon: IconMap, label: '区域', color: '#EF4444' },
|
||||
}
|
||||
|
||||
// 侧边栏宽度
|
||||
export const DRAWER_WIDTH = 280;
|
||||
/** 扩展设备类型配置(包含更多设备) */
|
||||
export const EXTENDED_DEVICE_CONFIG = {
|
||||
...DEVICE_TYPE_CONFIG,
|
||||
firewall: { icon: IconShield, label: '防火墙', color: '#DC2626' },
|
||||
storage: { icon: IconDatabase, label: '存储设备', color: '#7C3AED' },
|
||||
mobile: { icon: IconDeviceMobile, label: '移动设备', color: '#EC4899' },
|
||||
}
|
||||
|
||||
// 初始节点数据 - 带层级关系
|
||||
/** 侧边栏宽度 */
|
||||
export const DRAWER_WIDTH = 280
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { computed, ComputedRef } from 'vue';
|
||||
import { Edge } from '@vue-flow/core';
|
||||
import { computed, ComputedRef, unref, Ref } from 'vue'
|
||||
import { Edge } from '@vue-flow/core'
|
||||
|
||||
type EdgeType = 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier';
|
||||
type EdgeType = 'default' | 'straight' | 'step' | 'simplebezier'
|
||||
|
||||
/**
|
||||
* 边样式计算Hook
|
||||
* 根据边类型、链路类型、标签等计算最终样式
|
||||
*/
|
||||
export function useEdgeStyles(edges: Edge[], edgeType: EdgeType): ComputedRef<Edge[]> {
|
||||
export function useEdgeStyles(edges: Ref<Edge[]> | Edge[], edgeType: Ref<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() !== '';
|
||||
const edgesValue = unref(edges)
|
||||
const edgeTypeValue = unref(edgeType)
|
||||
|
||||
return edgesValue.map((edge) => {
|
||||
const isVirtual = edge.data?.type === 'virtual'
|
||||
const hasLabel = edge.data?.label && edge.data.label.trim() !== ''
|
||||
|
||||
return {
|
||||
...edge,
|
||||
type: edgeType, // 使用全局设置的边类型
|
||||
type: edgeTypeValue,
|
||||
label: hasLabel ? edge.data?.label : undefined,
|
||||
animated: true, // 流动效果
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: isVirtual
|
||||
? '#F57C00' // 虚拟链路使用橙色
|
||||
: '#1976D2', // 物理链路使用蓝色
|
||||
stroke: isVirtual ? '#F57C00' : '#1976D2',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: isVirtual ? '5,5' : undefined, // 虚拟链路使用虚线
|
||||
strokeDasharray: isVirtual ? '5,5' : undefined,
|
||||
},
|
||||
labelStyle: hasLabel
|
||||
? {
|
||||
@@ -38,9 +39,9 @@ export function useEdgeStyles(edges: Edge[], edgeType: EdgeType): ComputedRef<Ed
|
||||
fillOpacity: 0.9,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return styledEdges;
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return styledEdges
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div ref="reactFlowWrapper" class="flow-wrapper">
|
||||
<vue-flow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:edges="styledEdges"
|
||||
:node-types="nodeTypes"
|
||||
:default-edge-options="defaultEdgeOptions"
|
||||
:fit-view-on-init="true"
|
||||
@@ -45,10 +45,7 @@
|
||||
@connect="onConnect"
|
||||
>
|
||||
<background pattern-color="#aaa" :gap="16" />
|
||||
<mini-map
|
||||
:node-color="getNodeColor"
|
||||
node-stroke-color="#555"
|
||||
/>
|
||||
<mini-map :node-color="getNodeColor" node-stroke-color="#555" />
|
||||
<controls />
|
||||
</vue-flow>
|
||||
</div>
|
||||
@@ -66,16 +63,9 @@
|
||||
@delete="handleDeleteNode"
|
||||
/>
|
||||
|
||||
<node-detail-dialog
|
||||
v-model:visible="nodeDetailDialogOpen"
|
||||
:node-data="selectedNode?.data"
|
||||
/>
|
||||
<node-detail-dialog v-model:visible="nodeDetailDialogOpen" :node-data="selectedNode?.data" />
|
||||
|
||||
<node-edit-dialog
|
||||
v-model:visible="nodeEditDialogOpen"
|
||||
:node="selectedNode"
|
||||
@save="handleSaveNode"
|
||||
/>
|
||||
<node-edit-dialog v-model:visible="nodeEditDialogOpen" :node="selectedNode" @save="handleSaveNode" />
|
||||
|
||||
<delete-confirm-dialog
|
||||
v-model:visible="deleteDialogOpen"
|
||||
@@ -85,11 +75,7 @@
|
||||
|
||||
<!-- ==================== 边对话框 ==================== -->
|
||||
|
||||
<edge-action-dialog
|
||||
v-model:visible="edgeActionDialogOpen"
|
||||
@edit="handleEditEdge"
|
||||
@delete="handleDeleteEdge"
|
||||
/>
|
||||
<edge-action-dialog v-model:visible="edgeActionDialogOpen" @edit="handleEditEdge" @delete="handleDeleteEdge" />
|
||||
|
||||
<edge-edit-dialog
|
||||
v-model:visible="edgeEditDialogOpen"
|
||||
@@ -99,11 +85,7 @@
|
||||
@save="handleSaveEdge"
|
||||
/>
|
||||
|
||||
<delete-confirm-dialog
|
||||
v-model:visible="deleteEdgeDialogOpen"
|
||||
node-name="链路"
|
||||
@confirm="handleDeleteEdgeConfirm"
|
||||
/>
|
||||
<delete-confirm-dialog v-model:visible="deleteEdgeDialogOpen" node-name="链路" @confirm="handleDeleteEdgeConfirm" />
|
||||
|
||||
<a-modal
|
||||
v-model:visible="batchImportAssetsOpen"
|
||||
@@ -112,7 +94,11 @@
|
||||
@cancel="batchImportAssetsOpen = false"
|
||||
>
|
||||
<p class="text-muted" style="margin-bottom: 8px; font-size: 12px; color: var(--color-text-3)">
|
||||
调用 DC-Control <code>/topologies/:id/nodes/batch-import</code>,节点将绑定 <code>ref_type=asset</code>。
|
||||
调用 DC-Control
|
||||
<code>/topologies/:id/nodes/batch-import</code>
|
||||
,节点将绑定
|
||||
<code>ref_type=asset</code>
|
||||
。
|
||||
</p>
|
||||
<a-textarea
|
||||
v-model="batchImportAssetIdsText"
|
||||
@@ -124,190 +110,191 @@
|
||||
</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 { 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 { 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';
|
||||
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 { fitView, zoomIn, zoomOut } = useVueFlow()
|
||||
|
||||
const route = useRoute();
|
||||
const reactFlowWrapper = ref<HTMLDivElement>();
|
||||
const route = useRoute()
|
||||
const reactFlowWrapper = ref<HTMLDivElement>()
|
||||
|
||||
// ==================== 状态管理 ====================
|
||||
|
||||
// 图数据状态
|
||||
const nodes = ref<any[]>([]);
|
||||
const edges = ref<any[]>([]);
|
||||
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 selectedGroup = ref<string | null>(null)
|
||||
const expandedGroups = ref<Set<string>>(new Set())
|
||||
const edgeType = ref<'default' | 'straight' | 'step' | 'simplebezier'>('default')
|
||||
|
||||
// 节点操作状态
|
||||
const selectedNode = ref<any>(null);
|
||||
const nodeActionDialogOpen = ref(false);
|
||||
const nodeDetailDialogOpen = ref(false);
|
||||
const nodeEditDialogOpen = ref(false);
|
||||
const deleteDialogOpen = ref(false);
|
||||
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 selectedEdge = ref<any>(null)
|
||||
const edgeActionDialogOpen = ref(false)
|
||||
const edgeEditDialogOpen = ref(false)
|
||||
const deleteEdgeDialogOpen = ref(false)
|
||||
|
||||
const batchImportAssetsOpen = ref(false);
|
||||
const batchImportAssetIdsText = ref('');
|
||||
const batchImportAssetsOpen = ref(false)
|
||||
const batchImportAssetIdsText = ref('')
|
||||
|
||||
const openBatchImportAssets = () => {
|
||||
batchImportAssetIdsText.value = '';
|
||||
batchImportAssetsOpen.value = true;
|
||||
};
|
||||
batchImportAssetIdsText.value = ''
|
||||
batchImportAssetsOpen.value = true
|
||||
}
|
||||
|
||||
const handleBatchImportAssetsConfirm = async () => {
|
||||
const id = currentTopologyId.value;
|
||||
const id = currentTopologyId.value
|
||||
if (!id) {
|
||||
Message.warning('请先通过路由选择拓扑(?id=)');
|
||||
batchImportAssetsOpen.value = false;
|
||||
return;
|
||||
Message.warning('请先通过路由选择拓扑(?id=)')
|
||||
batchImportAssetsOpen.value = false
|
||||
return
|
||||
}
|
||||
const raw = batchImportAssetIdsText.value
|
||||
.split(/[\s,,;;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const assetIds = [...new Set(raw.map((x) => parseInt(x, 10)).filter((n) => !Number.isNaN(n)))];
|
||||
.filter(Boolean)
|
||||
const assetIds = [...new Set(raw.map((x) => parseInt(x, 10)).filter((n) => !Number.isNaN(n)))]
|
||||
if (assetIds.length === 0) {
|
||||
Message.warning('请输入至少一个有效的资产 ID');
|
||||
return;
|
||||
Message.warning('请输入至少一个有效的资产 ID')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await TopoAPI.batchImportAssetNodes(id, assetIds);
|
||||
const res: any = await TopoAPI.batchImportAssetNodes(id, assetIds)
|
||||
if (res?.code === 0) {
|
||||
Message.success(`已导入 ${res.details?.imported ?? assetIds.length} 个节点请求已提交`);
|
||||
batchImportAssetsOpen.value = false;
|
||||
await refreshTopology();
|
||||
Message.success(`已导入 ${res.details?.imported ?? assetIds.length} 个节点请求已提交`)
|
||||
batchImportAssetsOpen.value = false
|
||||
await refreshTopology()
|
||||
} else {
|
||||
Message.error(res?.message || '导入失败');
|
||||
Message.error(res?.message || '导入失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Message.error('导入请求失败');
|
||||
console.error(e)
|
||||
Message.error('导入请求失败')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 布局钩子
|
||||
const { applyLayout } = useTopoLayout();
|
||||
const { applyLayout } = useTopoLayout()
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
// 从URL参数获取拓扑ID
|
||||
const currentTopologyId = computed(() => {
|
||||
const id = route.query.id;
|
||||
return id ? parseInt(id as string) : null;
|
||||
});
|
||||
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';
|
||||
});
|
||||
return route.path.includes('/netarch/auto-topo') ? 'calc(100vh - 170px)' : '100vh'
|
||||
})
|
||||
|
||||
// 判断是否为自动拓扑路由
|
||||
const isAutoTopo = computed(() => {
|
||||
return route.path.includes('/netarch/auto-topo');
|
||||
});
|
||||
return route.path.includes('/netarch/auto-topo')
|
||||
})
|
||||
|
||||
// 从节点自动生成分组树
|
||||
const topoGroups = computed(() => buildGroupTreeFromNodes(nodes.value, edges.value));
|
||||
const topoGroups = computed(() => {
|
||||
const groups = buildGroupTreeFromNodes(nodes.value, edges.value)
|
||||
console.log('[topoGroups] 分组树:', {
|
||||
nodes: nodes.value.length,
|
||||
edges: edges.value.length,
|
||||
groups: groups.length,
|
||||
sampleGroup: groups[0],
|
||||
})
|
||||
return groups
|
||||
})
|
||||
|
||||
// 根据选中的分组筛选显示的节点和边
|
||||
const filteredResult = computed(() =>
|
||||
filterByGroup(nodes.value, edges.value, selectedGroup.value)
|
||||
);
|
||||
const filteredResult = computed(() => filterByGroup(nodes.value, edges.value, selectedGroup.value))
|
||||
|
||||
// 使用响应式计算
|
||||
const displayNodes = computed(() => filteredResult.value.nodes);
|
||||
const displayEdges = computed(() => filteredResult.value.edges);
|
||||
const displayNodes = computed(() => filteredResult.value.nodes)
|
||||
const displayEdges = computed(() => filteredResult.value.edges)
|
||||
|
||||
// 计算边样式
|
||||
const styledEdges = computed(() => {
|
||||
return useEdgeStyles(displayEdges.value, edgeType.value);
|
||||
});
|
||||
const styledEdges = useEdgeStyles(displayEdges, edgeType)
|
||||
|
||||
// 是否为新边
|
||||
const isNewEdge = computed(() => {
|
||||
return !edges.value.some((e: any) => e.id === selectedEdge.value?.id);
|
||||
});
|
||||
return !edges.value.some((e: any) => e.id === selectedEdge.value?.id)
|
||||
})
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
// 初始化数据
|
||||
const loadData = async () => {
|
||||
if (!currentTopologyId.value) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
try {
|
||||
let nodesData: any[] = [];
|
||||
let edgesData: any[] = [];
|
||||
let nodesData: any[] = []
|
||||
let edgesData: any[] = []
|
||||
|
||||
// 从 graph 接口获取节点和边数据
|
||||
const graphResponse: any = await TopoAPI.fetchTopologyGraph(currentTopologyId.value);
|
||||
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) => ({
|
||||
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 || [];
|
||||
const nodesFromGraph = graphResponse.details?.nodes || graphResponse.details?.data || graphResponse.data?.nodes || []
|
||||
|
||||
if (Array.isArray(nodesFromGraph)) {
|
||||
nodesData = nodesFromGraph.map((node: any) => ({
|
||||
@@ -329,121 +316,134 @@ const loadData = async () => {
|
||||
ref_id: node.ref_id,
|
||||
sub_topology_id: node.sub_topology_id,
|
||||
},
|
||||
}));
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// 设置数据
|
||||
nodes.value = nodesData;
|
||||
edges.value = edgesData;
|
||||
nodes.value = nodesData
|
||||
edges.value = edgesData
|
||||
|
||||
console.log('[loadData] 加载完成:', {
|
||||
nodes: nodes.value.length,
|
||||
edges: edges.value.length,
|
||||
sampleNode: nodes.value[0],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载拓扑数据失败:', error);
|
||||
Message.warning('加载拓扑数据失败,使用默认数据');
|
||||
nodes.value = [];
|
||||
edges.value = [];
|
||||
console.error('加载拓扑数据失败:', error)
|
||||
Message.warning('加载拓扑数据失败,使用默认数据')
|
||||
nodes.value = []
|
||||
edges.value = []
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
fitView({ duration: 500 });
|
||||
});
|
||||
};
|
||||
fitView({ duration: 500 })
|
||||
})
|
||||
}
|
||||
|
||||
// 自动保存节点位置到后端(防抖)
|
||||
let saveTimer: number | null = null;
|
||||
watch(nodes, () => {
|
||||
if (nodes.value.length === 0) return;
|
||||
let saveTimer: number | null = null
|
||||
watch(
|
||||
nodes,
|
||||
() => {
|
||||
if (nodes.value.length === 0) return
|
||||
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(async () => {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
const positions = nodes.value.map(node => ({
|
||||
if (!currentTopologyId.value) return
|
||||
const positions = nodes.value.map((node) => ({
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
}));
|
||||
await TopoAPI.updateNodesPositions(currentTopologyId.value, positions);
|
||||
}))
|
||||
await TopoAPI.updateNodesPositions(currentTopologyId.value, positions)
|
||||
} catch (error) {
|
||||
console.error('保存节点位置失败:', error);
|
||||
console.error('保存节点位置失败:', error)
|
||||
}
|
||||
}, 1000);
|
||||
}, { deep: true });
|
||||
}, 1000)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 自动展开所有一级分组
|
||||
watch(topoGroups, (newGroups) => {
|
||||
watch(
|
||||
topoGroups,
|
||||
(newGroups) => {
|
||||
if (newGroups.length > 0) {
|
||||
const rootGroupIds = newGroups.map((g: any) => g.id);
|
||||
expandedGroups.value = new Set(rootGroupIds);
|
||||
const rootGroupIds = newGroups.map((g: any) => g.id)
|
||||
expandedGroups.value = new Set(rootGroupIds)
|
||||
}
|
||||
}, { immediate: true });
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
loadData()
|
||||
})
|
||||
|
||||
// ==================== 事件处理 ====================
|
||||
|
||||
// 连接处理 - 创建新链路
|
||||
const onConnect = async (connection: any) => {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
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('链路创建成功');
|
||||
await loadData()
|
||||
Message.success('链路创建成功')
|
||||
} else {
|
||||
Message.error('链路创建失败');
|
||||
Message.error('链路创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建链路失败:', error);
|
||||
Message.error('创建链路失败');
|
||||
console.error('创建链路失败:', error)
|
||||
Message.error('创建链路失败')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 节点点击
|
||||
const onNodeClick = (event: any) => {
|
||||
selectedNode.value = event.node;
|
||||
nodeActionDialogOpen.value = true;
|
||||
};
|
||||
selectedNode.value = event.node
|
||||
nodeActionDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 边点击
|
||||
const onEdgeClick = (event: any) => {
|
||||
selectedEdge.value = event.edge;
|
||||
edgeActionDialogOpen.value = true;
|
||||
};
|
||||
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;
|
||||
const layoutType = value as 'grid' | 'hierarchical' | 'circular'
|
||||
const updatedNodes = applyLayout(nodes.value, layoutType)
|
||||
nodes.value = updatedNodes
|
||||
nextTick(() => {
|
||||
fitView({ duration: 500 });
|
||||
});
|
||||
};
|
||||
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 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 };
|
||||
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;
|
||||
if (!currentTopologyId.value) return
|
||||
await TopoAPI.createNode(currentTopologyId.value, {
|
||||
label: config.label,
|
||||
type,
|
||||
@@ -452,26 +452,26 @@ const handleAddDevice = async (value: string | number | Record<string, any> | un
|
||||
alerts: 0,
|
||||
level: 0,
|
||||
position,
|
||||
});
|
||||
})
|
||||
|
||||
await loadData();
|
||||
Message.success('设备添加成功');
|
||||
await loadData()
|
||||
Message.success('设备添加成功')
|
||||
} catch (error) {
|
||||
console.error('添加设备失败:', error);
|
||||
Message.error('添加设备失败');
|
||||
console.error('添加设备失败:', error)
|
||||
Message.error('添加设备失败')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义添加
|
||||
const handleCustomAdd = () => {
|
||||
selectedNode.value = null;
|
||||
nodeEditDialogOpen.value = true;
|
||||
};
|
||||
selectedNode.value = null
|
||||
nodeEditDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 保存节点
|
||||
const handleSaveNode = async (nodeId: string | null, nodeData: Partial<NodeData>) => {
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
if (!currentTopologyId.value) return
|
||||
if (nodeId) {
|
||||
await TopoAPI.updateNode(currentTopologyId.value, nodeId, {
|
||||
label: nodeData.label!,
|
||||
@@ -483,11 +483,9 @@ const handleSaveNode = async (nodeId: string | null, nodeData: Partial<NodeData>
|
||||
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('节点更新成功');
|
||||
})
|
||||
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!,
|
||||
@@ -500,184 +498,181 @@ const handleSaveNode = async (nodeId: string | null, nodeData: Partial<NodeData>
|
||||
parentId: nodeData.parentId,
|
||||
level: nodeData.level ?? 0,
|
||||
position: { x: 400, y: 300 },
|
||||
});
|
||||
})
|
||||
|
||||
await loadData();
|
||||
Message.success('节点创建成功');
|
||||
await loadData()
|
||||
Message.success('节点创建成功')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存节点失败:', error);
|
||||
Message.error('保存节点失败');
|
||||
console.error('保存节点失败:', error)
|
||||
Message.error('保存节点失败')
|
||||
}
|
||||
nodeEditDialogOpen.value = false
|
||||
selectedNode.value = null
|
||||
}
|
||||
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('删除节点失败');
|
||||
nodeActionDialogOpen.value = false
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = false;
|
||||
selectedNode.value = null;
|
||||
};
|
||||
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;
|
||||
};
|
||||
nodeActionDialogOpen.value = false
|
||||
nodeDetailDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = () => {
|
||||
nodeActionDialogOpen.value = false;
|
||||
nodeEditDialogOpen.value = true;
|
||||
};
|
||||
nodeActionDialogOpen.value = false
|
||||
nodeEditDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 保存边
|
||||
const handleSaveEdge = async () => {
|
||||
if (!selectedEdge.value) return;
|
||||
if (!selectedEdge.value) return
|
||||
|
||||
try {
|
||||
if (!currentTopologyId.value) return;
|
||||
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('链路创建成功');
|
||||
edges.value.push(newEdge)
|
||||
// 强制触发响应式更新
|
||||
edges.value = [...edges.value]
|
||||
|
||||
await nextTick()
|
||||
} else {
|
||||
const linkId = Number(selectedEdge.value.id);
|
||||
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.map((e: any) => (e.id === selectedEdge.value.id ? { ...e, data: selectedEdge.value.data } : e))
|
||||
// 强制触发响应式更新
|
||||
edges.value = [...edges.value];
|
||||
edges.value = [...edges.value]
|
||||
|
||||
await nextTick();
|
||||
Message.success('链路更新成功');
|
||||
await nextTick()
|
||||
Message.success('链路更新成功')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存链路失败:', error);
|
||||
Message.error('保存链路失败');
|
||||
console.error('保存链路失败:', error)
|
||||
Message.error('保存链路失败')
|
||||
}
|
||||
|
||||
edgeEditDialogOpen.value = false;
|
||||
selectedEdge.value = null;
|
||||
};
|
||||
edgeEditDialogOpen.value = false
|
||||
selectedEdge.value = null
|
||||
}
|
||||
|
||||
// 删除边
|
||||
const handleDeleteEdge = () => {
|
||||
edgeActionDialogOpen.value = false;
|
||||
deleteEdgeDialogOpen.value = true;
|
||||
};
|
||||
edgeActionDialogOpen.value = false
|
||||
deleteEdgeDialogOpen.value = true
|
||||
}
|
||||
|
||||
const handleDeleteEdgeConfirm = async () => {
|
||||
if (!selectedEdge.value) return;
|
||||
if (!selectedEdge.value) return
|
||||
|
||||
try {
|
||||
const linkId = selectedEdge.value.id;
|
||||
const linkId = selectedEdge.value.id
|
||||
if (linkId && currentTopologyId.value) {
|
||||
await TopoAPI.deleteLink(currentTopologyId.value, linkId);
|
||||
await TopoAPI.deleteLink(currentTopologyId.value, linkId)
|
||||
}
|
||||
// 只删除边,不重新加载节点,保持节点位置
|
||||
edges.value = edges.value.filter((e: any) => e.id !== selectedEdge.value.id);
|
||||
edges.value = edges.value.filter((e: any) => e.id !== selectedEdge.value.id)
|
||||
// 强制触发响应式更新
|
||||
edges.value = [...edges.value];
|
||||
edges.value = [...edges.value]
|
||||
|
||||
await nextTick();
|
||||
Message.success('链路删除成功');
|
||||
await nextTick()
|
||||
Message.success('链路删除成功')
|
||||
} catch (error) {
|
||||
console.error('删除链路失败:', error);
|
||||
Message.error('删除链路失败');
|
||||
console.error('删除链路失败:', error)
|
||||
Message.error('删除链路失败')
|
||||
}
|
||||
|
||||
deleteEdgeDialogOpen.value = false;
|
||||
selectedEdge.value = null;
|
||||
};
|
||||
deleteEdgeDialogOpen.value = false
|
||||
selectedEdge.value = null
|
||||
}
|
||||
|
||||
// 编辑边
|
||||
const handleEditEdge = () => {
|
||||
edgeActionDialogOpen.value = false;
|
||||
edgeEditDialogOpen.value = true;
|
||||
};
|
||||
|
||||
edgeActionDialogOpen.value = false
|
||||
edgeEditDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 设置选中的边
|
||||
const setSelectedEdge = (edge: any) => {
|
||||
selectedEdge.value = edge;
|
||||
};
|
||||
selectedEdge.value = edge
|
||||
}
|
||||
|
||||
// 刷新拓扑
|
||||
const refreshTopology = () => {
|
||||
fitView({ duration: 500 });
|
||||
};
|
||||
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 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 (!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) => ({
|
||||
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,
|
||||
@@ -686,14 +681,15 @@ const resetTopology = async () => {
|
||||
description: edge.description,
|
||||
...edge,
|
||||
},
|
||||
})) || [];
|
||||
})) || []
|
||||
|
||||
let nodesData: any[] = [];
|
||||
const nodesFromGraph = graphResponse.details?.data || graphResponse.data?.nodes || [];
|
||||
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) => ({
|
||||
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 },
|
||||
@@ -702,55 +698,55 @@ const resetTopology = async () => {
|
||||
type: node.type,
|
||||
...node.data,
|
||||
},
|
||||
})) || [];
|
||||
})) || []
|
||||
} catch (e) {
|
||||
console.error('解析节点数据失败:', e);
|
||||
console.error('解析节点数据失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
nodes.value = nodesData;
|
||||
edges.value = edgesData;
|
||||
Message.success('重置成功');
|
||||
nodes.value = nodesData
|
||||
edges.value = edgesData
|
||||
Message.success('重置成功')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置失败:', error);
|
||||
Message.error('重置失败');
|
||||
console.error('重置失败:', error)
|
||||
Message.error('重置失败')
|
||||
}
|
||||
nextTick(() => {
|
||||
fitView({ duration: 500 });
|
||||
});
|
||||
fitView({ duration: 500 })
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 分组操作
|
||||
const handleSelectGroup = (groupId: string | null) => {
|
||||
selectedGroup.value = groupId;
|
||||
};
|
||||
selectedGroup.value = groupId
|
||||
}
|
||||
|
||||
const toggleGroup = (groupId: string) => {
|
||||
const next = new Set(expandedGroups.value);
|
||||
const next = new Set(expandedGroups.value)
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
next.delete(groupId)
|
||||
} else {
|
||||
next.add(groupId);
|
||||
next.add(groupId)
|
||||
}
|
||||
expandedGroups.value = next
|
||||
}
|
||||
expandedGroups.value = next;
|
||||
};
|
||||
|
||||
// 拓扑分组变化处理
|
||||
const handleTopologyGroupChange = async (topologyId: number | null) => {
|
||||
nodes.value = [];
|
||||
edges.value = [];
|
||||
nodes.value = []
|
||||
edges.value = []
|
||||
if (topologyId) {
|
||||
await loadData();
|
||||
await loadData()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取节点颜色
|
||||
const getNodeColor = (node: any) => {
|
||||
const config = DEVICE_TYPE_CONFIG[node.data?.type as DeviceType];
|
||||
return config?.color || '#888';
|
||||
};
|
||||
const config = DEVICE_TYPE_CONFIG[node.data?.type as DeviceType]
|
||||
return config?.color || '#888'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
// 设备类型定义
|
||||
export type DeviceType = 'server' | 'router' | 'switch' | 'desktop' | 'cloud' | 'text' | 'region';
|
||||
export type DeviceType = 'server' | 'router' | 'switch' | 'desktop' | 'cloud' | 'text' | 'region'
|
||||
|
||||
// 设备状态类型
|
||||
export type DeviceStatus = 'normal' | 'warning' | 'error';
|
||||
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; // 节点描述
|
||||
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 }; // 节点位置坐标
|
||||
parentId?: string | null // 父节点ID,null表示根节点
|
||||
level?: number // 层级(0为一级节点)
|
||||
position?: { x: number; y: number } // 节点位置坐标
|
||||
/** 与 dc-control 拓扑节点 ref 一致(资产导入等) */
|
||||
ref_type?: string;
|
||||
ref_id?: number;
|
||||
sub_topology_id?: number | null;
|
||||
ref_type?: string
|
||||
ref_id?: number
|
||||
sub_topology_id?: number | null
|
||||
}
|
||||
|
||||
// 拓扑分组类型(从节点自动生成)
|
||||
export interface TopoGroup {
|
||||
id: string; // 对应节点ID
|
||||
name: string; // 对应节点名称
|
||||
nodeId: string; // 关联的节点ID
|
||||
children?: TopoGroup[]; // 子分组(对应子节点)
|
||||
parentId?: string; // 父分组ID
|
||||
level: number; // 层级
|
||||
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;
|
||||
type?: 'physical' | 'virtual'
|
||||
bandwidth?: string
|
||||
traffic?: string
|
||||
}
|
||||
|
||||
// 链路类型(用于边样式)
|
||||
export type EdgeType = 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier';
|
||||
export type EdgeType = 'default' | 'straight' | 'step' | 'simplebezier'
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user