This commit is contained in:
zxr
2026-04-11 20:03:10 +08:00
14 changed files with 913 additions and 869 deletions

View File

@@ -10,28 +10,19 @@
> >
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical"> <a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20"> <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-col :span="12">
<a-form-item field="name" label="服务名称" required> <a-form-item field="name" label="服务名称" required>
<a-input v-model="formData.name" placeholder="请输入服务名称(唯一)" /> <a-input v-model="formData.name" placeholder="请输入服务名称(唯一)" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="server_identity" label="服务器标识"> <a-form-item field="server_identity" label="服务器标识">
<a-input v-model="formData.server_identity" placeholder="关联服务器唯一标识" /> <a-input v-model="formData.server_identity" placeholder="关联服务器唯一标识" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="type" label="数据库类型" required> <a-form-item field="type" label="数据库类型" required>
<a-select v-model="formData.type" placeholder="请选择数据库类型"> <a-select v-model="formData.type" placeholder="请选择数据库类型">
@@ -54,13 +45,7 @@
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item field="port" label="端口" required> <a-form-item field="port" label="端口" required>
<a-input-number <a-input-number v-model="formData.port" placeholder="请输入端口" :min="1" :max="65535" style="width: 100%" />
v-model="formData.port"
placeholder="请输入端口"
:min="1"
:max="65535"
style="width: 100%"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@@ -73,10 +58,7 @@
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item field="password" label="密码"> <a-form-item field="password" label="密码">
<a-input-password <a-input-password v-model="formData.password" placeholder="请输入密码" />
v-model="formData.password"
placeholder="请输入密码"
/>
<template #extra v-if="isEdit"> <template #extra v-if="isEdit">
<span style="color: rgb(var(--warning-6))">编辑时密码不回显如需修改请输入新密码</span> <span style="color: rgb(var(--warning-6))">编辑时密码不回显如需修改请输入新密码</span>
</template> </template>
@@ -92,23 +74,13 @@
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item field="interval" label="采集间隔(秒)"> <a-form-item field="interval" label="采集间隔(秒)">
<a-input-number <a-input-number v-model="formData.interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
v-model="formData.interval"
placeholder="默认60秒"
:min="10"
:max="3600"
style="width: 100%"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-form-item field="description" label="描述信息"> <a-form-item field="description" label="描述信息">
<a-textarea <a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="2" />
v-model="formData.description"
placeholder="请输入描述信息"
:rows="2"
/>
</a-form-item> </a-form-item>
<a-form-item field="tags" label="标签"> <a-form-item field="tags" label="标签">
@@ -131,13 +103,7 @@
<a-row :gutter="20" v-if="formData.collect_on"> <a-row :gutter="20" v-if="formData.collect_on">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="collect_interval" label="进程内采集间隔(秒)"> <a-form-item field="collect_interval" label="进程内采集间隔(秒)">
<a-input-number <a-input-number v-model="formData.collect_interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
v-model="formData.collect_interval"
placeholder="默认60秒"
:min="10"
:max="3600"
style="width: 100%"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
@@ -148,11 +114,7 @@
</a-row> </a-row>
<a-form-item field="extra" label="额外配置(JSON)"> <a-form-item field="extra" label="额外配置(JSON)">
<a-textarea <a-textarea v-model="formData.extra" placeholder='JSON格式{"charset":"utf8mb4"}' :rows="2" />
v-model="formData.extra"
placeholder='JSON格式{"charset":"utf8mb4"}'
:rows="2"
/>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
@@ -162,13 +124,7 @@
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue' import type { FormInstance } from '@arco-design/web-vue'
import { import { createDatabase, updateDatabase, type CreateDatabaseParams, type UpdateDatabaseParams, type DatabaseType } from '@/api/ops/database'
createDatabase,
updateDatabase,
type CreateDatabaseParams,
type UpdateDatabaseParams,
type DatabaseType,
} from '@/api/ops/database'
interface Props { interface Props {
visible: boolean visible: boolean

View File

@@ -10,28 +10,19 @@
> >
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical"> <a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20"> <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-col :span="12">
<a-form-item field="name" label="服务名称"> <a-form-item field="name" label="服务名称">
<a-input v-model="formData.name" placeholder="请输入服务名称" /> <a-input v-model="formData.name" placeholder="请输入服务名称" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="server_identity" label="服务器标识"> <a-form-item field="server_identity" label="服务器标识">
<a-input v-model="formData.server_identity" placeholder="关联服务器唯一标识" /> <a-input v-model="formData.server_identity" placeholder="关联服务器唯一标识" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="type" label="中间件类型"> <a-form-item field="type" label="中间件类型">
<a-select v-model="formData.type" placeholder="请选择中间件类型"> <a-select v-model="formData.type" placeholder="请选择中间件类型">
@@ -48,17 +39,13 @@
</a-row> </a-row>
<a-form-item field="description" label="描述信息"> <a-form-item field="description" label="描述信息">
<a-textarea <a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="2" />
v-model="formData.description"
placeholder="请输入描述信息"
:rows="2"
/>
</a-form-item> </a-form-item>
<a-form-item field="tags" label="标签"> <a-form-item field="tags" label="标签">
<a-input v-model="formData.tags" placeholder="多个标签,逗号间隔" /> <a-input v-model="formData.tags" placeholder="多个标签,逗号间隔" />
</a-form-item> </a-form-item>
<a-row :gutter="20"> <a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="status_url" label="状态检查URL"> <a-form-item field="status_url" label="状态检查URL">
@@ -80,13 +67,7 @@
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item field="interval" label="采集间隔(秒)"> <a-form-item field="interval" label="采集间隔(秒)">
<a-input-number <a-input-number v-model="formData.interval" placeholder="默认60秒" :min="10" :max="3600" style="width: 100%" />
v-model="formData.interval"
placeholder="默认60秒"
:min="10"
:max="3600"
style="width: 100%"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
@@ -99,23 +80,13 @@
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item v-if="formData.collect_on" field="collect_interval" label="采集间隔(秒)"> <a-form-item v-if="formData.collect_on" field="collect_interval" label="采集间隔(秒)">
<a-input-number <a-input-number v-model="formData.collect_interval" placeholder="为0时使用interval" :min="0" :max="3600" style="width: 100%" />
v-model="formData.collect_interval"
placeholder="为0时使用interval"
:min="0"
:max="3600"
style="width: 100%"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-form-item field="extra" label="额外配置(JSON)"> <a-form-item field="extra" label="额外配置(JSON)">
<a-textarea <a-textarea v-model="formData.extra" placeholder='JSON格式{"config_path":"/etc/nginx/nginx.conf"}' :rows="2" />
v-model="formData.extra"
placeholder='JSON格式{"config_path":"/etc/nginx/nginx.conf"}'
:rows="2"
/>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
@@ -125,12 +96,7 @@
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue' import type { FormInstance } from '@arco-design/web-vue'
import { import { createMiddleware, updateMiddleware, type MiddlewareCreateData, type MiddlewareUpdateData } from '@/api/ops/middleware'
createMiddleware,
updateMiddleware,
type MiddlewareCreateData,
type MiddlewareUpdateData,
} from '@/api/ops/middleware'
interface Props { interface Props {
visible: boolean visible: boolean
@@ -220,9 +186,9 @@ watch(
const handleOk = async () => { const handleOk = async () => {
try { try {
await formRef.value?.validate() await formRef.value?.validate()
confirmLoading.value = true confirmLoading.value = true
if (isEdit.value) { if (isEdit.value) {
const updateData: MiddlewareUpdateData = { const updateData: MiddlewareUpdateData = {
service_identity: formData.service_identity, service_identity: formData.service_identity,
@@ -264,7 +230,7 @@ const handleOk = async () => {
await createMiddleware(createData) await createMiddleware(createData)
Message.success('创建成功') Message.success('创建成功')
} }
emit('success') emit('success')
handleCancel() handleCancel()
} catch (error) { } catch (error) {

View File

@@ -10,23 +10,11 @@
> >
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical"> <a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20"> <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-col :span="12">
<a-form-item field="name" label="设备名称"> <a-form-item field="name" label="设备名称">
<a-input v-model="formData.name" placeholder="请输入设备名称" /> <a-input v-model="formData.name" placeholder="请输入设备名称" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="type" label="设备类型"> <a-form-item field="type" label="设备类型">
<a-select v-model="formData.type" placeholder="请选择设备类型"> <a-select v-model="formData.type" placeholder="请选择设备类型">
@@ -39,6 +27,9 @@
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="manu" label="厂商"> <a-form-item field="manu" label="厂商">
<a-select v-model="formData.manu" placeholder="请选择厂商"> <a-select v-model="formData.manu" placeholder="请选择厂商">
@@ -88,18 +79,11 @@
</a-row> </a-row>
<a-form-item field="location" label="位置信息"> <a-form-item field="location" label="位置信息">
<a-input <a-input v-model="formData.location" placeholder="请输入位置信息" />
v-model="formData.location"
placeholder="请输入位置信息"
/>
</a-form-item> </a-form-item>
<a-form-item field="remark" label="备注信息"> <a-form-item field="remark" label="备注信息">
<a-textarea <a-textarea v-model="formData.remark" placeholder="请输入备注信息" :rows="4" />
v-model="formData.remark"
placeholder="请输入备注信息"
:rows="4"
/>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
@@ -108,7 +92,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { v4 as uuidv4 } from 'uuid'
import type { FormInstance } from '@arco-design/web-vue' import type { FormInstance } from '@arco-design/web-vue'
interface Props { interface Props {
@@ -173,15 +156,11 @@ watch(
const handleOk = async () => { const handleOk = async () => {
try { try {
await formRef.value?.validate() await formRef.value?.validate()
if (!formData.unique_id) {
formData.unique_id = uuidv4()
}
confirmLoading.value = true confirmLoading.value = true
await new Promise(resolve => setTimeout(resolve, 1000)) await new Promise((resolve) => setTimeout(resolve, 1000))
Message.success(isEdit.value ? '更新成功' : '创建成功') Message.success(isEdit.value ? '更新成功' : '创建成功')
emit('success') emit('success')
handleCancel() handleCancel()
@@ -200,4 +179,4 @@ const handleCancel = () => {
emit('update:visible', false) emit('update:visible', false)
formRef.value?.resetFields() formRef.value?.resetFields()
} }
</script> </script>

View File

@@ -10,28 +10,19 @@
> >
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical"> <a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20"> <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-col :span="12">
<a-form-item field="name" label="服务器名称" required> <a-form-item field="name" label="服务器名称" required>
<a-input v-model="formData.name" placeholder="请输入服务器名称" /> <a-input v-model="formData.name" placeholder="请输入服务器名称" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="host" label="主机地址" required> <a-form-item field="host" label="主机地址" required>
<a-input v-model="formData.host" placeholder="请输入主机地址" /> <a-input v-model="formData.host" placeholder="请输入主机地址" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row>
<a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="ip_address" label="IP地址"> <a-form-item field="ip_address" label="IP地址">
<a-input v-model="formData.ip_address" placeholder="请输入IP地址" /> <a-input v-model="formData.ip_address" placeholder="请输入IP地址" />
@@ -140,11 +131,7 @@
</a-row> </a-row>
<a-form-item field="description" label="描述信息"> <a-form-item field="description" label="描述信息">
<a-textarea <a-textarea v-model="formData.description" placeholder="请输入描述信息" :rows="4" />
v-model="formData.description"
placeholder="请输入描述信息"
:rows="4"
/>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
@@ -265,7 +252,7 @@ watch(
}) })
} }
} }
}, }
) )
const handleOk = async () => { const handleOk = async () => {

View File

@@ -10,22 +10,18 @@
> >
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical"> <a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="20"> <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-col :span="12">
<a-form-item field="name" label="服务名称"> <a-form-item field="name" label="服务名称">
<a-input v-model="formData.name" placeholder="请输入服务名称" /> <a-input v-model="formData.name" placeholder="请输入服务名称" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<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>
<a-form-item field="target_url" label="目标监控URL">
<a-input v-model="formData.target_url" placeholder="请输入目标监控URL" />
</a-form-item>
<a-row :gutter="20"> <a-row :gutter="20">
<a-col :span="12"> <a-col :span="12">
<a-form-item field="method" label="HTTP方法"> <a-form-item field="method" label="HTTP方法">

View File

@@ -1,154 +1,244 @@
<template> <template>
<div <div :class="['custom-node', `status-${data.status || 'normal'}`, { 'node-selected': selected }]" :style="nodeStyle">
:class="['custom-node', { 'node-selected': selected }]" <Handle type="target" :position="Position.Top" class="handle handle-top" />
:style="nodeStyle"
>
<!-- 连接点 - 目标 -->
<Handle
type="target"
:position="Position.Top"
class="handle handle-top"
/>
<!-- 节点内容 --> <div class="node-content">
<a-space direction="vertical" align="center" :size="4"> <div class="icon-wrapper" :style="iconWrapperStyle">
<a-avatar :size="48" :style="avatarStyle"> <component :is="iconComponent" :size="32" :stroke-width="1.5" />
<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> </div>
<a-tag v-if="data.alerts && data.alerts > 0" color="danger" size="small"> <div class="node-info">
{{ data.alerts }}个告警 <div class="node-label" :title="data.label">{{ data.label }}</div>
</a-tag> <div v-if="data.ip" class="node-ip" :title="data.ip">{{ data.ip }}</div>
</a-space> </div>
<!-- 连接点 - --> <div v-if="data.alerts && data.alerts > 0" class="alert-badge">
<Handle <span class="alert-count">{{ data.alerts }}</span>
type="source" </div>
:position="Position.Bottom" </div>
class="handle handle-bottom"
/> <Handle type="source" :position="Position.Bottom" class="handle handle-bottom" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue'
import { Handle, Position, type NodeProps } from '@vue-flow/core'; import { Handle, Position, type NodeProps } from '@vue-flow/core'
import { DEVICE_TYPE_CONFIG } from '../config'; import { DEVICE_TYPE_CONFIG } from '../config'
import type { DeviceType, DeviceStatus, NodeData } from '../types'; import type { NodeData } from '../types'
import { import { IconServer } from '@tabler/icons-vue'
IconDesktop,
IconCloud,
IconStorage,
IconMore,
IconSafe,
IconFile,
} from '@arco-design/web-vue/es/icon';
// 使用Vue Flow的NodeProps类型 const props = defineProps<NodeProps<NodeData>>()
const props = defineProps<NodeProps<NodeData>>();
// 从props中解构数据 const data = computed(() => props.data)
const data = computed(() => props.data); const selected = computed(() => props.selected)
const selected = computed(() => props.selected);
const iconMap: Record<string, any> = { const config = computed(() => DEVICE_TYPE_CONFIG[data.value.type] || DEVICE_TYPE_CONFIG.server)
server: IconDesktop, const iconComponent = computed(() => config.value.icon)
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 statusColors: Record<string, { border: string; bg: string; glow: string }> = {
const iconComponent = computed(() => iconMap[data.value.type] || IconDesktop); 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> = { const statusConfig = computed(() => statusColors[data.value.status || 'normal'])
normal: '#52C41A',
warning: '#FAAD14',
error: '#F53F3F',
};
const borderColor = computed(() => statusColors[data.value.status || 'normal']);
const nodeStyle = computed(() => ({ const nodeStyle = computed(() => ({
'--border-color': selected.value ? '#165DFF' : borderColor.value, '--border-color': selected.value ? '#3B82F6' : statusConfig.value.border,
'--bg-color': '#fff', '--bg-color': statusConfig.value.bg,
'--shadow': selected.value ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 6px rgba(0, 0, 0, 0.1)', '--node-color': config.value.color,
})); '--glow-color': selected.value ? 'rgba(59, 130, 246, 0.3)' : statusConfig.value.glow,
}))
const avatarStyle = computed(() => ({ const iconWrapperStyle = computed(() => ({
backgroundColor: `${config.value.color}1A`, background: `linear-gradient(135deg, ${config.value.color}15 0%, ${config.value.color}25 100%)`,
color: config.value.color, color: config.value.color,
})); boxShadow: `0 2px 8px ${config.value.color}30`,
}))
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.custom-node { .custom-node {
background: var(--bg-color); background: var(--bg-color);
border: 2px solid var(--border-color); border: 2px solid var(--border-color);
border-radius: 8px; border-radius: 12px;
padding: 12px; padding: 0;
min-width: 140px; min-width: 160px;
box-shadow: var(--shadow); box-shadow:
transition: all 0.2s ease; 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; 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 { &: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 { &.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 { &.status-warning {
text-align: center; 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 { .node-label {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
max-width: 120px; color: var(--color-text-1);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
line-height: 1.4;
} }
.node-ip { .node-ip {
font-size: 12px; font-size: 12px;
color: #86909c; color: var(--color-text-3);
max-width: 120px; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .handle {
background: #165DFF; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
width: 10px; width: 12px;
height: 10px; height: 12px;
border: 2px solid #fff; border: 2px solid #fff;
border-radius: 50%; 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 { &.handle-top {
top: -5px; top: -6px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
&:hover {
transform: translateX(-50%) scale(1.3);
}
} }
&.handle-bottom { &.handle-bottom {
bottom: -5px; bottom: -6px;
left: 50%; left: 50%;
transform: translateX(-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> </style>

View File

@@ -2,10 +2,7 @@
<a-dropdown :popup-visible="visible" @update:popup-visible="handleClose"> <a-dropdown :popup-visible="visible" @update:popup-visible="handleClose">
<slot></slot> <slot></slot>
<template #content> <template #content>
<a-doption <a-doption :class="{ 'selected-option': selectedType === 'default' }" @click="handleSelect('default')">
:class="{ 'selected-option': selectedType === 'default' }"
@click="handleSelect('default')"
>
<template #icon> <template #icon>
<icon-minus /> <icon-minus />
</template> </template>
@@ -14,10 +11,7 @@
<div class="option-subtitle">贝塞尔曲线,平滑自然</div> <div class="option-subtitle">贝塞尔曲线,平滑自然</div>
</div> </div>
</a-doption> </a-doption>
<a-doption <a-doption :class="{ 'selected-option': selectedType === 'straight' }" @click="handleSelect('straight')">
:class="{ 'selected-option': selectedType === 'straight' }"
@click="handleSelect('straight')"
>
<template #icon> <template #icon>
<icon-arrow-right /> <icon-arrow-right />
</template> </template>
@@ -26,10 +20,7 @@
<div class="option-subtitle">直接连接,简洁明了</div> <div class="option-subtitle">直接连接,简洁明了</div>
</div> </div>
</a-doption> </a-doption>
<a-doption <a-doption :class="{ 'selected-option': selectedType === 'step' }" @click="handleSelect('step')">
:class="{ 'selected-option': selectedType === 'step' }"
@click="handleSelect('step')"
>
<template #icon> <template #icon>
<icon-minus /> <icon-minus />
</template> </template>
@@ -38,24 +29,9 @@
<div class="option-subtitle">直角转折,类似线路图</div> <div class="option-subtitle">直角转折,类似线路图</div>
</div> </div>
</a-doption> </a-doption>
<a-doption <a-doption :class="{ 'selected-option': selectedType === 'simplebezier' }" @click="handleSelect('simplebezier')">
:class="{ 'selected-option': selectedType === 'smoothstep' }"
@click="handleSelect('smoothstep')"
>
<template #icon> <template #icon>
<icon-arrow-up /> <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>
<a-doption
:class="{ 'selected-option': selectedType === 'simplebezier' }"
@click="handleSelect('simplebezier')"
>
<template #icon>
<icon-minus style="transform: scaleY(0.8);" />
</template> </template>
<div class="option-content"> <div class="option-content">
<div class="option-title">简单贝塞尔曲线</div> <div class="option-title">简单贝塞尔曲线</div>
@@ -67,30 +43,30 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { IconMinus, IconArrowRight, IconArrowUp } from '@arco-design/web-vue/es/icon'; import { IconMinus, IconArrowRight } from '@arco-design/web-vue/es/icon'
import type { EdgeType } from '../types'; import type { EdgeType } from '../types'
interface Props { interface Props {
visible: boolean; visible: boolean
selectedType: EdgeType; selectedType: EdgeType
} }
interface Emits { interface Emits {
(e: 'update:visible', value: boolean): void; (e: 'update:visible', value: boolean): void
(e: 'selectType', type: EdgeType): void; (e: 'selectType', type: EdgeType): void
} }
defineProps<Props>(); defineProps<Props>()
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>()
const handleSelect = (type: EdgeType) => { const handleSelect = (type: EdgeType) => {
emit('selectType', type); emit('selectType', type)
emit('update:visible', false); emit('update:visible', false)
}; }
const handleClose = (value: boolean) => { const handleClose = (value: boolean) => {
emit('update:visible', value); emit('update:visible', value)
}; }
</script> </script>
<style scoped lang="less"> <style scoped lang="less">

View File

@@ -2,7 +2,10 @@
<div class="group-panel-wrapper"> <div class="group-panel-wrapper">
<div class="group-panel"> <div class="group-panel">
<div class="panel-header"> <div class="panel-header">
<div class="header-title">拓扑</div> <div class="header-title">
<IconGitBranch :size="20" :stroke-width="1.5" />
<span>拓扑视图</span>
</div>
</div> </div>
<!-- 自动拓扑分组选择 --> <!-- 自动拓扑分组选择 -->
@@ -25,9 +28,12 @@
@click="handleTopologySelect(topology)" @click="handleTopologySelect(topology)"
> >
<div class="topology-icon"> <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 class="topology-name">{{ topology.name }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -38,11 +44,11 @@
<a-menu :selected-keys="selectedGroup === null ? ['all'] : []"> <a-menu :selected-keys="selectedGroup === null ? ['all'] : []">
<a-menu-item key="all" @click="handleSelectAll"> <a-menu-item key="all" @click="handleSelectAll">
<template #icon> <template #icon>
<icon-share-alt /> <IconLayout2 :size="18" :stroke-width="1.5" />
</template> </template>
<template #default>全部</template> <template #default>全部节点</template>
<template #extra> <template #extra>
<span class="menu-subtitle">显示所有拓扑图</span> <span class="menu-count">{{ nodes.length }}</span>
</template> </template>
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>
@@ -68,36 +74,35 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed } from 'vue'
import { Node } from '@vue-flow/core'; import { Node } from '@vue-flow/core'
import { DEVICE_TYPE_CONFIG } from '../config'; import type { TopoGroup, NodeData } from '../types'
import type { TopoGroup, NodeData } from '../types'; import { IconGitBranch, IconLayout2 } from '@tabler/icons-vue'
import { IconShareAlt, IconApps } from '@arco-design/web-vue/es/icon'; import GroupTreeItem from './GroupTreeItem.vue'
import GroupTreeItem from './GroupTreeItem.vue';
interface Props { interface Props {
groups: TopoGroup[]; groups: TopoGroup[]
selectedGroup: string | null; selectedGroup: string | null
expandedGroups: Set<string>; expandedGroups: Set<string>
nodes: Node[]; nodes: Node[]
isAutoTopo?: boolean; isAutoTopo?: boolean
} }
interface Emits { interface Emits {
(e: 'selectGroup', groupId: string | null): void; (e: 'selectGroup', groupId: string | null): void
(e: 'toggleGroup', groupId: string): void; (e: 'toggleGroup', groupId: string): void
(e: 'groupChange', groupId: number | null): void; (e: 'groupChange', groupId: number | null): void
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
isAutoTopo: false, isAutoTopo: false,
}); })
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>()
const topologyGroups = ref<any[]>([]); const topologyGroups = ref<any[]>([])
const selectedGroupId = ref<number | null>(null); const selectedGroupId = ref<number | null>(null)
const topologyList = ref<any[]>([]); const topologyList = ref<any[]>([])
const selectedTopologyId = ref<number | null>(null); const selectedTopologyId = ref<number | null>(null)
const treeData = computed(() => { const treeData = computed(() => {
const convertToTree = (groups: any[]): any[] => { const convertToTree = (groups: any[]): any[] => {
@@ -106,51 +111,42 @@ const treeData = computed(() => {
title: group.name, title: group.name,
disabled: group.children && group.children.length > 0, disabled: group.children && group.children.length > 0,
children: group.children && group.children.length > 0 ? convertToTree(group.children) : undefined, children: group.children && group.children.length > 0 ? convertToTree(group.children) : undefined,
})); }))
}; }
return convertToTree(topologyGroups.value); return convertToTree(topologyGroups.value)
}); })
const handleSelectAll = () => { const handleSelectAll = () => {
emit('selectGroup', null); emit('selectGroup', null)
}; }
const handleSelectGroup = (groupId: string) => { const handleSelectGroup = (groupId: string) => {
emit('selectGroup', groupId); emit('selectGroup', groupId)
}; }
const handleToggleGroup = (groupId: string) => { const handleToggleGroup = (groupId: string) => {
emit('toggleGroup', groupId); emit('toggleGroup', groupId)
}; }
const handleGroupChange = async (value: number | null) => { const handleGroupChange = async (value: number | null) => {
selectedGroupId.value = value; selectedGroupId.value = value
topologyList.value = []; topologyList.value = []
selectedTopologyId.value = null; selectedTopologyId.value = null
emit('groupChange', null); emit('groupChange', null)
if (value) { if (value) {
try { try {
// 这里应该调用API获取拓扑列表 // 这里应该调用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) { } catch (error) {
console.error('获取拓扑列表失败:', error); console.error('获取拓扑列表失败:', error)
} }
} }
}; }
const handleTopologySelect = (topology: any) => { const handleTopologySelect = (topology: any) => {
selectedTopologyId.value = topology.id; selectedTopologyId.value = topology.id
emit('groupChange', topology.id); emit('groupChange', topology.id)
}; }
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -172,9 +168,12 @@ const handleTopologySelect = (topology: any) => {
.panel-header { .panel-header {
padding: 16px; padding: 16px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border-2);
.header-title { .header-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: var(--color-text-1); color: var(--color-text-1);
@@ -188,52 +187,68 @@ const handleTopologySelect = (topology: any) => {
margin-top: 16px; margin-top: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 8px;
} }
.topology-item { .topology-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 14px; padding: 12px 14px;
border-radius: 8px; border-radius: 8px;
border: 1.5px solid var(--color-border-2); border: 1px solid var(--color-border-2);
background: var(--color-bg-2); background: var(--color-bg-2);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
&:hover { &:hover {
border-color: var(--color-primary-light-3); border-color: rgb(var(--primary-6));
background: var(--color-fill-2); background: var(--color-fill-2);
transform: translateY(-1px); transform: translateX(2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
} }
&.active { &.active {
border-color: rgb(var(--primary-6)); border-color: rgb(var(--primary-6));
background: var(--color-primary-light-1); background: var(--color-primary-light-1);
.topology-icon {
background: rgb(var(--primary-6));
color: #fff;
}
} }
.topology-icon { .topology-icon {
width: 32px; width: 36px;
height: 32px; height: 36px;
border-radius: 6px; border-radius: 8px;
background: var(--color-primary-light-3); background: var(--color-fill-3);
color: rgb(var(--primary-6)); color: var(--color-text-2);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
transition: all 0.2s;
}
.topology-info {
flex: 1;
min-width: 0;
} }
.topology-name { .topology-name {
flex: 1;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--color-text-1); color: var(--color-text-1);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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; padding: 8px 0;
} }
.menu-subtitle { .menu-count {
font-size: 12px; font-size: 12px;
color: var(--color-text-3); color: var(--color-text-3);
background: var(--color-fill-2);
padding: 2px 8px;
border-radius: 4px;
margin-left: auto; margin-left: auto;
} }
.panel-footer {
padding: 16px;
border-top: 1px solid var(--color-border);
}
</style> </style>

View File

@@ -1,124 +1,124 @@
<template> <template>
<div class="group-tree-item"> <div class="group-tree-item">
<div <div
:class="['group-item-content', { selected: isSelected }]" :class="['group-item-content', { selected: isSelected, 'has-children': hasChildren }]"
:style="{ paddingLeft: `${24 + level * 24}px` }" :style="{ paddingLeft: `${16 + level * 24}px` }"
@click="handleSelect" @click="handleSelect"
> >
<!-- 展开/折叠按钮 --> <!-- 展开/折叠按钮 -->
<div class="toggle-icon" @click.stop="handleToggle"> <div v-if="hasChildren" class="toggle-icon" @click.stop="handleToggle">
<icon-down v-if="hasChildren && isExpanded" /> <IconChevronDown v-if="isExpanded" :size="16" :stroke-width="2" />
<icon-right v-else-if="hasChildren" /> <IconChevronRight v-else :size="16" :stroke-width="2" />
<div v-else class="placeholder" />
</div> </div>
<div v-else class="toggle-placeholder" />
<!-- 设备图标 --> <!-- 设备图标 -->
<div class="device-icon" :style="{ background: iconBg, color: iconColor }"> <div class="device-icon" :style="iconStyle">
<component :is="iconComponent" :size="16" /> <component :is="iconComponent" :size="18" :stroke-width="1.5" />
</div> </div>
<!-- 设备信息 --> <!-- 设备信息 -->
<div class="device-info"> <div class="device-info">
<div class="device-name"> <div class="device-name">
{{ nodeData.label }} {{ nodeData?.label || group.name }}
<a-tag v-if="nodeData.status === 'error'" color="red" size="small">异常</a-tag> <span v-if="nodeData?.status === 'error'" class="status-dot status-error" />
<a-tag v-else-if="nodeData.status === 'warning'" color="orange" size="small">告警</a-tag> <span v-else-if="nodeData?.status === 'warning'" class="status-dot status-warning" />
</div> </div>
<div class="device-details"> <div class="device-meta">
<span v-if="nodeData.ip" class="ip-address">{{ nodeData.ip }}</span> <span v-if="nodeData?.ip" class="ip-address">{{ nodeData.ip }}</span>
<a-tag v-if="nodeData.alerts && nodeData.alerts > 0" color="red" size="small"> <span v-if="nodeData?.alerts && nodeData?.alerts > 0" class="alert-indicator">{{ nodeData.alerts }} 告警</span>
{{ nodeData.alerts }}个告警
</a-tag>
</div> </div>
</div> </div>
<!-- 子节点数量 -->
<div v-if="hasChildren" class="children-count">
{{ group.children?.length || 0 }}
</div>
</div> </div>
<!-- 子分组 --> <!-- 子分组 -->
<a-collapse :model-value="expandedGroups" :default-expanded-key="undefined" :bordered="false"> <div v-if="hasChildren && isExpanded" class="children-container">
<template v-if="hasChildren && isExpanded"> <group-tree-item
<group-tree-item v-for="child in group.children"
v-for="child in group.children" :key="child.id"
:key="child.id" :group="child"
:group="child" :level="level + 1"
:level="level + 1" :selected-group="selectedGroup"
:selected-group="selectedGroup" :expanded-groups="expandedGroups"
:expanded-groups="expandedGroups" :nodes="nodes"
:nodes="nodes" @select="$emit('select', $event)"
@select="$emit('select', $event)" @toggle="$emit('toggle', $event)"
@toggle="$emit('toggle', $event)" />
/> </div>
</template>
</a-collapse>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue'
import { Node } from '@vue-flow/core'; import { Node } from '@vue-flow/core'
import { IconDown, IconRight } from '@arco-design/web-vue/es/icon'; import { DEVICE_TYPE_CONFIG } from '../config'
import { DEVICE_TYPE_CONFIG } from '../config'; import type { TopoGroup, NodeData, DeviceType } from '../types'
import type { TopoGroup, NodeData, DeviceType } from '../types'; import { IconChevronDown, IconChevronRight, IconServer } from '@tabler/icons-vue'
interface Props { interface Props {
group: TopoGroup; group: TopoGroup
level: number; level: number
selectedGroup: string | null; selectedGroup: string | null
expandedGroups: Set<string>; expandedGroups: Set<string>
nodes: Node[]; nodes: Node[]
} }
interface Emits { interface Emits {
(e: 'select', groupId: string): void; (e: 'select', groupId: string): void
(e: 'toggle', groupId: string): void; (e: 'toggle', groupId: string): void
} }
const props = defineProps<Props>(); const props = defineProps<Props>()
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>()
const nodeData = computed(() => { const nodeData = computed(() => {
const node = props.nodes.find((n) => n.id === props.group.nodeId); const node = props.nodes.find((n) => n.id === props.group.nodeId)
return (node?.data as NodeData) || null; return (node?.data as NodeData) || null
}); })
const hasChildren = computed(() => { const hasChildren = computed(() => {
return props.group.children && props.group.children.length > 0; return props.group.children && props.group.children.length > 0
}); })
const isExpanded = computed(() => { const isExpanded = computed(() => {
return props.expandedGroups.has(props.group.id); return props.expandedGroups.has(props.group.id)
}); })
const isSelected = computed(() => { const isSelected = computed(() => {
return props.selectedGroup === props.group.id; return props.selectedGroup === props.group.id
}); })
const deviceConfig = computed(() => { const deviceConfig = computed(() => {
if (!nodeData.value) return null; if (!nodeData.value) return null
return DEVICE_TYPE_CONFIG[nodeData.value.type as DeviceType]; return DEVICE_TYPE_CONFIG[nodeData.value.type as DeviceType]
}); })
const iconComponent = computed(() => { const iconComponent = computed(() => {
return deviceConfig.value?.icon; return deviceConfig.value?.icon || IconServer
}); })
const iconBg = computed(() => { const iconStyle = computed(() => {
const color = deviceConfig.value?.color || '#888'; const color = deviceConfig.value?.color || '#6B7280'
return `${color}15`; return {
}); background: `linear-gradient(135deg, ${color}15 0%, ${color}20 100%)`,
color: color,
const iconColor = computed(() => { }
return deviceConfig.value?.color || '#888'; })
});
const handleSelect = () => { const handleSelect = () => {
emit('select', props.group.id); emit('select', props.group.id)
}; }
const handleToggle = () => { const handleToggle = () => {
if (hasChildren.value) { if (hasChildren.value) {
emit('toggle', props.group.id); emit('toggle', props.group.id)
} }
}; }
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -126,71 +126,138 @@ const handleToggle = () => {
.group-item-content { .group-item-content {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 8px; padding: 10px 12px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s ease;
border-radius: 4px; border-radius: 6px;
margin: 4px 8px; margin: 2px 8px;
position: relative;
&:hover { &:hover {
background-color: var(--color-fill-2); background: var(--color-fill-2);
} }
&.selected { &.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 { .toggle-icon {
width: 24px; width: 20px;
height: 24px; height: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer; cursor: pointer;
color: var(--color-text-3);
border-radius: 4px;
transition: all 0.2s;
.placeholder { &:hover {
width: 24px; background: var(--color-fill-3);
height: 24px; color: var(--color-text-1);
} }
} }
.toggle-placeholder {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.device-icon { .device-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 6px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 12px; margin-right: 12px;
flex-shrink: 0; flex-shrink: 0;
transition: all 0.2s;
} }
.device-info { .device-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
} }
.device-name { .device-name {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 500;
color: var(--color-text-1); 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; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.device-details { .ip-address {
display: flex; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--color-text-3); 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> </style>

View File

@@ -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 }> = { export const DEVICE_TYPE_CONFIG: Record<DeviceType, { icon: any; label: string; color: string }> = {
server: { icon: 'icon-server', label: '服务器', color: '#2196F3' }, server: { icon: IconServer, label: '服务器', color: '#3B82F6' },
router: { icon: 'icon-router', label: '路由器', color: '#FF9800' }, router: { icon: IconRouter, label: '路由器', color: '#F59E0B' },
switch: { icon: 'icon-desktop', label: '交换机', color: '#4CAF50' }, switch: { icon: IconDeviceDesktop, label: '交换机', color: '#10B981' },
desktop: { icon: 'icon-desktop', label: '终端', color: '#9C27B0' }, desktop: { icon: IconDeviceDesktop, label: '终端', color: '#8B5CF6' },
cloud: { icon: 'icon-cloud', label: '云端节点', color: '#00BCD4' }, cloud: { icon: IconCloud, label: '云端节点', color: '#06B6D4' },
text: { icon: 'icon-text', label: '文本标注', color: '#757575' }, text: { icon: IconNotes, label: '文本标注', color: '#6B7280' },
region: { icon: 'icon-rectangle', label: '区域', color: '#FF5722' }, 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

View File

@@ -1,29 +1,30 @@
import { computed, ComputedRef } from 'vue'; import { computed, ComputedRef, unref, Ref } from 'vue'
import { Edge } from '@vue-flow/core'; import { Edge } from '@vue-flow/core'
type EdgeType = 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier'; type EdgeType = 'default' | 'straight' | 'step' | 'simplebezier'
/** /**
* 边样式计算Hook * 边样式计算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(() => { const styledEdges = computed(() => {
return edges.map((edge) => { const edgesValue = unref(edges)
const isVirtual = edge.data?.type === 'virtual'; const edgeTypeValue = unref(edgeType)
const hasLabel = edge.data?.label && edge.data.label.trim() !== '';
return edgesValue.map((edge) => {
const isVirtual = edge.data?.type === 'virtual'
const hasLabel = edge.data?.label && edge.data.label.trim() !== ''
return { return {
...edge, ...edge,
type: edgeType, // 使用全局设置的边类型 type: edgeTypeValue,
label: hasLabel ? edge.data?.label : undefined, label: hasLabel ? edge.data?.label : undefined,
animated: true, // 流动效果 animated: true,
style: { style: {
stroke: isVirtual stroke: isVirtual ? '#F57C00' : '#1976D2',
? '#F57C00' // 虚拟链路使用橙色
: '#1976D2', // 物理链路使用蓝色
strokeWidth: 2, strokeWidth: 2,
strokeDasharray: isVirtual ? '5,5' : undefined, // 虚拟链路使用虚线 strokeDasharray: isVirtual ? '5,5' : undefined,
}, },
labelStyle: hasLabel labelStyle: hasLabel
? { ? {
@@ -38,9 +39,9 @@ export function useEdgeStyles(edges: Edge[], edgeType: EdgeType): ComputedRef<Ed
fillOpacity: 0.9, fillOpacity: 0.9,
} }
: undefined, : undefined,
}; }
}); })
}); })
return styledEdges; return styledEdges
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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 { export interface NodeData {
label: string; // 节点标签/名称 label: string // 节点标签/名称
type: DeviceType; // 节点类型 type: DeviceType // 节点类型
ip?: string; // 节点IP地址 ip?: string // 节点IP地址
status?: DeviceStatus; // 节点状态 status?: DeviceStatus // 节点状态
alerts?: number; // 告警数量 alerts?: number // 告警数量
traffic?: string; // 流量信息(如"100Mbps" traffic?: string // 流量信息(如"100Mbps"
description?: string; // 节点描述 description?: string // 节点描述
// 节点层级关系 // 节点层级关系
parentId?: string | null; // 父节点ID,null表示根节点 parentId?: string | null // 父节点ID,null表示根节点
level?: number; // 层级0为一级节点 level?: number // 层级0为一级节点
position?: { x: number; y: number }; // 节点位置坐标 position?: { x: number; y: number } // 节点位置坐标
/** 与 dc-control 拓扑节点 ref 一致(资产导入等) */ /** 与 dc-control 拓扑节点 ref 一致(资产导入等) */
ref_type?: string; ref_type?: string
ref_id?: number; ref_id?: number
sub_topology_id?: number | null; sub_topology_id?: number | null
} }
// 拓扑分组类型(从节点自动生成) // 拓扑分组类型(从节点自动生成)
export interface TopoGroup { export interface TopoGroup {
id: string; // 对应节点ID id: string // 对应节点ID
name: string; // 对应节点名称 name: string // 对应节点名称
nodeId: string; // 关联的节点ID nodeId: string // 关联的节点ID
children?: TopoGroup[]; // 子分组(对应子节点) children?: TopoGroup[] // 子分组(对应子节点)
parentId?: string; // 父分组ID parentId?: string // 父分组ID
level: number; // 层级 level: number // 层级
} }
// 链路数据接口 // 链路数据接口
export interface LinkData { export interface LinkData {
type?: 'physical' | 'virtual'; type?: 'physical' | 'virtual'
bandwidth?: string; bandwidth?: string
traffic?: 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