This commit is contained in:
zxr
2026-04-09 13:52:10 +08:00
parent 2ceb18cbee
commit 86bf1dd9b3
11 changed files with 1128 additions and 224 deletions

91
src/api/ops/discovery.ts Normal file
View File

@@ -0,0 +1,91 @@
import { request } from '@/api/request'
/** 与后端 discovery/summary 对齐 */
export interface DiscoverySummary {
device_total_current: number
online_current: number
offline_current: number
other_status_current: number
primary_scan_ids: number[]
last_run?: DiscoveryScanRun | null
}
export interface DiscoveryScanRun {
id: number
scan_id: number
run_type: string
status: string
started_at: string
finished_at?: string | null
device_total: number
online_count: number
offline_count: number
new_count: number
error_message?: string
trigger_by: string
}
export interface DiscoveryDeviceRow {
id: number
ip_address: string
scan_id?: number
run_id?: number
status: string
mac_address?: string
hostname?: string
device_type?: string
manufacturer?: string
discovery_methods?: string
last_seen_time?: string
last_scan_time?: string
first_seen_time?: string
}
export interface DiscoveryDevicesResponse {
scope: string
scan_id?: number
primary_scan_ids?: number[]
total: number
page: number
page_size: number
data: DiscoveryDeviceRow[]
}
export interface DiscoveryScanRunsResponse {
total: number
page: number
page_size: number
data: DiscoveryScanRun[]
}
export interface DiscoveryConfig {
id: number
primary_scan_ids: number[]
primary_scan_ids_raw?: string
}
export const fetchDiscoverySummary = () =>
request.get<{ code: number; details?: DiscoverySummary }>('/DC-Control/v1/discovery/summary')
export const fetchDiscoveryDevices = (params?: {
scope?: 'current' | 'raw'
scan_id?: number
page?: number
size?: number
status?: string
keyword?: string
}) =>
request.get<{ code: number; details?: DiscoveryDevicesResponse }>('/DC-Control/v1/discovery/devices', {
params,
})
export const fetchDiscoveryScanRuns = (params?: { scan_id?: number; page?: number; size?: number }) =>
request.get<{ code: number; details?: DiscoveryScanRunsResponse }>('/DC-Control/v1/discovery/scan-runs', {
params,
})
export const fetchDiscoveryConfig = () =>
request.get<{ code: number; details?: DiscoveryConfig }>('/DC-Control/v1/discovery/config')
export const updateDiscoveryConfig = (data: { primary_scan_ids: number[] }) =>
request.put<{ code: number; details?: DiscoveryConfig }>('/DC-Control/v1/discovery/config', data)

58
src/api/ops/ipScan.ts Normal file
View File

@@ -0,0 +1,58 @@
import { request } from '@/api/request'
/** IP 扫描任务(与 dc-control ControlIpScan 对齐) */
export interface IpScanTask {
id: number
name: string
type: string
description?: string
target_range: string
port_range?: string
config?: string
timeout?: number
concurrency?: number
cron_expr?: string
status?: string
server_id: number
last_scan_time?: string
next_scan_time?: string
last_scan_status?: string
total_count?: number
online_count?: number
scan_count?: number
enable?: boolean
created_at?: string
updated_at?: string
}
export interface IpScanListResponse {
total: number
page: number
page_size: number
data: IpScanTask[]
}
/** 扫描任务分页列表 */
export const fetchIpScanList = (params?: { page?: number; size?: number; keyword?: string }) =>
request.get<{ code: number; details?: IpScanListResponse; message?: string }>('/DC-Control/v1/ipscans', {
params,
})
export const fetchIpScanDetail = (id: number) =>
request.get<{ code: number; details?: IpScanTask; message?: string }>(`/DC-Control/v1/ipscans/${id}`)
/** 触发一次扫描(会创建 scan_run 并调用 Agent */
export const triggerIpScan = (id: number) =>
request.post<{ code: number; message?: string }>(`/DC-Control/v1/ipscans/${id}/trigger`)
/** 创建扫描任务 */
export const createIpScan = (data: Partial<IpScanTask>) =>
request.post<{ code: number; details?: IpScanTask; message?: string }>('/DC-Control/v1/ipscans', data)
/** 更新扫描任务 */
export const updateIpScan = (id: number, data: Partial<IpScanTask>) =>
request.put<{ code: number; message?: string }>(`/DC-Control/v1/ipscans/${id}`, data)
/** 删除扫描任务 */
export const deleteIpScan = (id: number) =>
request.delete<{ code: number; message?: string }>(`/DC-Control/v1/ipscans/${id}`)

View File

@@ -75,6 +75,10 @@ export interface TopologyNode {
parentId?: string | null
level?: number
position?: { x: number; y: number }
/** 设计 v2.0:绑定资产 / 子拓扑 */
ref_type?: string
ref_id?: number
sub_topology_id?: number | null
created_at?: string
updated_at?: string
}
@@ -155,6 +159,13 @@ export const deleteNode = (topologyId: number, nodeId: string) =>
export const updateNodesPositions = (topologyId: number, positions: Array<{ id: string; position: { x: number; y: number } }>) =>
request.put(`/DC-Control/v1/topologies/${topologyId}/nodes/positions`, { positions })
/** 按资产 ID 批量导入节点(后端生成 node_id=asset-{id} 与 ref_type=asset */
export const batchImportAssetNodes = (topologyId: number, asset_ids: number[]) =>
request.post<{ code: number; details?: { message: string; imported: number } }>(
`/DC-Control/v1/topologies/${topologyId}/nodes/batch-import`,
{ asset_ids },
)
// ==================== 链路管理接口 ====================
/** 获取链路列表 */

View File

@@ -409,10 +409,26 @@ export const localMenuFlatItems: MenuItem[] = [
app_id: 2,
parent_id: 35,
menu_path: '/netarch/auto-topo',
component: 'ops/pages/netarch/auto-topology',
type: 1,
sort_key: 22,
created_at: '2026-01-05T22:31:45.684645+08:00',
},
{
id: 12040,
identity: '019c8000-0001-7000-8000-000000000040',
title: 'IP 扫描任务',
title_en: 'IP Scan Tasks',
code: 'ops:网络架构管理:ip扫描任务',
description: '创建与管理 IP 发现扫描任务',
app_id: 2,
parent_id: 35,
menu_path: '/netarch/ip-scan-tasks',
component: 'ops/pages/netarch/ip-scan-tasks',
type: 1,
sort_key: 23,
created_at: '2026-04-09T10:00:00+08:00',
},
{
id: 36,
identity: '019b591d-0231-7667-a9fc-cfeb05da5aab',
@@ -424,10 +440,27 @@ export const localMenuFlatItems: MenuItem[] = [
parent_id: 35,
menu_path: '/netarch/topo-group',
menu_icon: 'appstore',
component: 'ops/pages/netarch/topo-group',
type: 1,
sort_key: 23,
sort_key: 24,
created_at: '2025-12-26T13:23:51.985419+08:00',
},
{
id: 12041,
identity: '019c8000-0001-7000-8000-000000000041',
title: '拓扑画布',
title_en: 'Topology Canvas',
code: 'ops:网络架构管理:拓扑画布',
description: '编辑网络拓扑(可带 ?id= 打开指定拓扑)',
app_id: 2,
parent_id: 35,
menu_path: '/netarch/topo',
menu_icon: 'appstore',
component: 'ops/pages/netarch/topo',
type: 1,
sort_key: 25,
created_at: '2026-04-09T10:00:00+08:00',
},
{
id: 37,
identity: '019b591d-0240-7d6d-90b8-a0a6303665dc',
@@ -439,8 +472,9 @@ export const localMenuFlatItems: MenuItem[] = [
parent_id: 35,
menu_path: '/netarch/traffic',
menu_icon: 'appstore',
component: 'ops/pages/netarch/traffic',
type: 1,
sort_key: 24,
sort_key: 26,
created_at: '2025-12-26T13:23:52.000879+08:00',
},
{
@@ -454,8 +488,9 @@ export const localMenuFlatItems: MenuItem[] = [
parent_id: 35,
menu_path: '/netarch/ip',
menu_icon: 'appstore',
component: 'ops/pages/netarch/ip',
type: 1,
sort_key: 25,
sort_key: 27,
created_at: '2025-12-26T13:23:52.012353+08:00',
},
{
@@ -724,6 +759,7 @@ export const localMenuFlatItems: MenuItem[] = [
parent_id: 54,
menu_path: '/assets/classify',
menu_icon: 'appstore',
component: 'ops/pages/assets/classify',
type: 1,
sort_key: 41,
created_at: '2025-12-26T13:23:52.299831+08:00',
@@ -739,6 +775,7 @@ export const localMenuFlatItems: MenuItem[] = [
parent_id: 54,
menu_path: '/assets/device',
menu_icon: 'appstore',
component: 'ops/pages/assets/device/list',
type: 1,
sort_key: 42,
created_at: '2025-12-26T13:23:52.315149+08:00',
@@ -754,6 +791,7 @@ export const localMenuFlatItems: MenuItem[] = [
parent_id: 54,
menu_path: '/assets/supplier',
menu_icon: 'appstore',
component: 'ops/pages/assets/supplier/list',
type: 1,
sort_key: 43,
created_at: '2025-12-26T13:23:52.283421+08:00',

View File

@@ -440,11 +440,28 @@ export const localMenuItems: MenuItem[] = [
app_id: 2,
parent_id: 35,
menu_path: '/netarch/auto-topo',
component: 'ops/pages/netarch/auto-topology',
type: 1,
sort_key: 6,
created_at: '2026-01-05T22:31:45.684645+08:00',
children: [],
},
{
id: 12040,
identity: '019c8000-0001-7000-8000-000000000040',
title: 'IP 扫描任务',
title_en: 'IP Scan Tasks',
code: 'ops:网络架构管理:ip扫描任务',
description: '创建与管理 IP 发现扫描任务',
app_id: 2,
parent_id: 35,
menu_path: '/netarch/ip-scan-tasks',
component: 'ops/pages/netarch/ip-scan-tasks',
type: 1,
sort_key: 7,
created_at: '2026-04-09T10:00:00+08:00',
children: [],
},
{
id: 36,
identity: '019b591d-0231-7667-a9fc-cfeb05da5aab',
@@ -456,11 +473,29 @@ export const localMenuItems: MenuItem[] = [
parent_id: 35,
menu_path: '/netarch/topo-group',
menu_icon: 'appstore',
component: 'ops/pages/netarch/topo-group',
type: 1,
sort_key: 6,
sort_key: 8,
created_at: '2025-12-26T13:23:51.985419+08:00',
children: [],
},
{
id: 12041,
identity: '019c8000-0001-7000-8000-000000000041',
title: '拓扑画布',
title_en: 'Topology Canvas',
code: 'ops:网络架构管理:拓扑画布',
description: '编辑网络拓扑(可通过「拓扑管理」进入或带 ?id= 打开指定拓扑)',
app_id: 2,
parent_id: 35,
menu_path: '/netarch/topo',
menu_icon: 'appstore',
component: 'ops/pages/netarch/topo',
type: 1,
sort_key: 9,
created_at: '2026-04-09T10:00:00+08:00',
children: [],
},
{
id: 37,
identity: '019b591d-0240-7d6d-90b8-a0a6303665dc',
@@ -472,8 +507,9 @@ export const localMenuItems: MenuItem[] = [
parent_id: 35,
menu_path: '/netarch/traffic',
menu_icon: 'appstore',
component: 'ops/pages/netarch/traffic',
type: 1,
sort_key: 6,
sort_key: 10,
created_at: '2025-12-26T13:23:52.000879+08:00',
children: [],
},
@@ -488,8 +524,9 @@ export const localMenuItems: MenuItem[] = [
parent_id: 35,
menu_path: '/netarch/ip',
menu_icon: 'appstore',
component: 'ops/pages/netarch/ip',
type: 1,
sort_key: 6,
sort_key: 11,
created_at: '2025-12-26T13:23:52.012353+08:00',
children: [],
},
@@ -780,6 +817,7 @@ export const localMenuItems: MenuItem[] = [
parent_id: 54,
menu_path: '/assets/classify',
menu_icon: 'appstore',
component: 'ops/pages/assets/classify',
type: 1,
sort_key: 10,
created_at: '2025-12-26T13:23:52.299831+08:00',
@@ -796,6 +834,7 @@ export const localMenuItems: MenuItem[] = [
parent_id: 54,
menu_path: '/assets/device',
menu_icon: 'appstore',
component: 'ops/pages/assets/device/list',
type: 1,
sort_key: 10,
created_at: '2025-12-26T13:23:52.315149+08:00',
@@ -812,6 +851,7 @@ export const localMenuItems: MenuItem[] = [
parent_id: 54,
menu_path: '/assets/supplier',
menu_icon: 'appstore',
component: 'ops/pages/assets/supplier/list',
type: 1,
sort_key: 10,
created_at: '2025-12-26T13:23:52.283421+08:00',

View File

@@ -1,36 +1,80 @@
<template>
<div class="container">
<!-- 页面标题 -->
<div class="page-header">
<div class="page-title">
<h2>自动感知拓扑图</h2>
<p class="page-subtitle">自动发现网络设备构建实时网络拓扑</p>
<p class="page-subtitle">自动发现网络设备数据来自 DC-Control /discovery /ipscans</p>
</div>
<div class="page-actions">
<a-button type="primary" :loading="isScanning" @click="handleScan">
<a-select
v-model="selectedScanId"
:style="{ width: '220px', marginRight: '8px' }"
placeholder="选择扫描任务"
:loading="scansLoading"
allow-clear
@change="onScanChange"
>
<a-option v-for="t in scanTasks" :key="t.id" :value="t.id">{{ t.name }} (#{{ t.id }})</a-option>
</a-select>
<a-button type="primary" :loading="isScanning" :disabled="!selectedScanId" @click="handleScan">
<template #icon>
<icon-refresh v-if="isScanning" class="animate-spin" />
<icon-play-circle v-else />
</template>
{{ isScanning ? '扫描中...' : '启动扫描' }}
</a-button>
<a-button :loading="pageLoading" @click="reloadAll">
<template #icon><icon-refresh /></template>
刷新数据
</a-button>
<a-button @click="goIpScanTasks">IP 扫描任务</a-button>
<a-button @click="goAssetDevices">设备资产</a-button>
</div>
</div>
<!-- 统计卡片 -->
<a-card :bordered="false" class="mb-6" title="自动感知 · 主扫描范围">
<p class="config-hint">
仅将所选扫描任务纳入当前视图统计与下方设备列表对应 DC-Control PUT /discovery/config
</p>
<div class="primary-scan-row">
<a-select
v-model="primaryScanIds"
multiple
allow-search
allow-clear
placeholder="选择主扫描任务(可多选)"
:loading="scansLoading"
:max-tag-count="4"
class="primary-scan-select"
>
<a-option v-for="t in scanTasks" :key="t.id" :value="t.id">{{ t.name }} (#{{ t.id }})</a-option>
</a-select>
<a-button type="primary" :loading="savingDiscovery" @click="savePrimaryScanConfig">保存配置</a-button>
</div>
</a-card>
<a-row :gutter="16" class="stats-row">
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-primary">
<icon-apps />
</div>
<div class="stats-icon stats-icon-primary"><icon-apps /></div>
<div class="stats-info">
<div class="stats-title">发现设备</div>
<div class="stats-value">156</div>
<div class="stats-desc text-success">
<icon-arrow-rise />
较上次 +2
<div class="stats-title">发现设备当前视图</div>
<div class="stats-value">{{ summary?.device_total_current ?? '—' }}</div>
<div class="stats-desc text-muted"> IP 取最新一条</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-success"><icon-wifi /></div>
<div class="stats-info">
<div class="stats-title">在线设备</div>
<div class="stats-value">{{ summary?.online_current ?? '—' }}</div>
<div class="stats-desc">
离线 {{ summary?.offline_current ?? '—' }} / 其它 {{ summary?.other_status_current ?? '—' }}
</div>
</div>
</div>
@@ -39,13 +83,11 @@
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-success">
<icon-wifi />
</div>
<div class="stats-icon stats-icon-warning"><icon-exclamation-circle /></div>
<div class="stats-info">
<div class="stats-title">在线设备</div>
<div class="stats-value">148</div>
<div class="stats-desc">在线率 94.8%</div>
<div class="stats-title">上次运行新增</div>
<div class="stats-value">{{ summary?.last_run?.new_count ?? '—' }}</div>
<div class="stats-desc text-muted">来自最近一次 scan_run</div>
</div>
</div>
</a-card>
@@ -53,170 +95,132 @@
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-warning">
<icon-exclamation-circle />
</div>
<div class="stats-info">
<div class="stats-title">新增设备</div>
<div class="stats-value">2</div>
<div class="stats-desc">待确认</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false">
<div class="stats-content">
<div class="stats-icon stats-icon-default">
<icon-clock-circle />
</div>
<div class="stats-icon stats-icon-default"><icon-clock-circle /></div>
<div class="stats-info">
<div class="stats-title">上次扫描</div>
<div class="stats-value">14:30</div>
<div class="stats-desc">12分钟前</div>
<div class="stats-value">{{ lastScanTimeShort }}</div>
<div class="stats-desc text-muted">{{ lastScanTimeRel }}</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 扫描进度和配置 -->
<a-row :gutter="16" class="mt-6 scan-row">
<a-col :xs="24" :lg="16" class="scan-col">
<a-card :bordered="false" class="scan-card">
<template #title>
<div class="card-title">
<span>扫描进度</span>
<a-tag v-if="isScanning" color="orange" bordered>扫描</a-tag>
<span>扫描进度 / 最近一次运行</span>
<a-tag v-if="runInProgress" color="orange" bordered>进行</a-tag>
<a-tag v-else-if="summary?.last_run?.status === 'success'" color="green" bordered>已完成</a-tag>
<a-tag v-else-if="summary?.last_run?.status === 'failed'" color="red" bordered>失败</a-tag>
</div>
</template>
<div v-if="isScanning" class="scanning-content">
<div v-if="runInProgress" class="scanning-content">
<div class="flex items-center gap-4 mb-4">
<icon-refresh class="h-5 w-5 animate-spin text-primary" />
<div class="flex-1">
<p class="text-sm font-medium">正在扫描网段 10.0.0.0/16</p>
<p class="text-xs text-muted">已发现 89 个设备</p>
<p class="text-sm font-medium">扫描任务执行中</p>
<p class="text-xs text-muted">任务 #{{ selectedScanId }}请等待 Agent 回调写入结果</p>
</div>
</div>
<a-progress :percent="45" :stroke-width="8" />
<a-row :gutter="16" class="mt-4 text-center">
<a-col :span="6">
<div class="text-lg font-semibold">45%</div>
<div class="text-xs text-muted">扫描进度</div>
</a-col>
<a-col :span="6">
<div class="text-lg font-semibold">89</div>
<div class="text-xs text-muted">已发现</div>
</a-col>
<a-col :span="6">
<div class="text-lg font-semibold">2</div>
<div class="text-xs text-muted">新设备</div>
</a-col>
<a-col :span="6">
<div class="text-lg font-semibold">5:23</div>
<div class="text-xs text-muted">预计剩余</div>
</a-col>
</a-row>
</div>
<div v-else class="scan-complete-content">
<div v-else-if="summary?.last_run" class="scan-complete-content">
<div class="flex items-center gap-4 mb-4">
<icon-check-circle class="h-5 w-5 text-success" />
<div class="flex-1">
<p class="text-sm font-medium">上次全网扫描已完成</p>
<p class="text-xs text-muted">2024-01-15 14:30:00 - 耗时 12分钟</p>
<p class="text-sm font-medium">最近运行{{ summary.last_run.status }}</p>
<p class="text-xs text-muted">
{{ formatRange(summary.last_run.started_at, summary.last_run.finished_at) }}
</p>
</div>
</div>
<a-row :gutter="16" class="stats-grid">
<a-col :span="6" class="text-center">
<div class="text-lg font-semibold">156</div>
<div class="text-xs text-muted">设备</div>
<div class="text-lg font-semibold">{{ summary.last_run.device_total }}</div>
<div class="text-xs text-muted">设备</div>
</a-col>
<a-col :span="6" class="text-center">
<div class="text-lg font-semibold text-success">148</div>
<div class="text-lg font-semibold text-success">{{ summary.last_run.online_count }}</div>
<div class="text-xs text-muted">在线</div>
</a-col>
<a-col :span="6" class="text-center">
<div class="text-lg font-semibold text-muted">6</div>
<div class="text-lg font-semibold text-muted">{{ summary.last_run.offline_count }}</div>
<div class="text-xs text-muted">离线</div>
</a-col>
<a-col :span="6" class="text-center">
<div class="text-lg font-semibold text-warning">2</div>
<div class="text-xs text-muted">发现</div>
<div class="text-lg font-semibold text-warning">{{ summary.last_run.new_count }}</div>
<div class="text-xs text-muted"></div>
</a-col>
</a-row>
</div>
<div v-else class="scan-complete-content">
<p class="text-sm text-muted">暂无扫描运行记录请选择任务并点击启动扫描</p>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="8" class="scan-col">
<a-card :bordered="false" title="扫描配置" class="scan-card">
<div class="config-section">
<a-card :bordered="false" title="扫描配置(当前任务)" class="scan-card">
<div v-if="activeTask" class="config-section">
<div class="config-item">
<p class="config-label">扫描网段</p>
<div class="config-value-list">
<a-tag color="arcoblue" bordered>10.0.0.0/16</a-tag>
<a-tag color="arcoblue" bordered>192.168.0.0/16</a-tag>
<a-tag color="arcoblue" bordered>{{ activeTask.target_range || '—' }}</a-tag>
</div>
</div>
<div class="config-item">
<p class="config-label">发现协议</p>
<p class="config-label">任务类型 / 状态</p>
<div class="config-tags">
<a-tag color="arcoblue" bordered>SNMP</a-tag>
<a-tag color="arcoblue" bordered>ICMP</a-tag>
<a-tag color="arcoblue" bordered>ARP</a-tag>
<a-tag color="arcoblue" bordered>CDP</a-tag>
<a-tag color="arcoblue" bordered>LLDP</a-tag>
<a-tag bordered>{{ activeTask.type }}</a-tag>
<a-tag :color="activeTask.last_scan_status === 'running' ? 'orange' : 'arcoblue'" bordered>
{{ activeTask.last_scan_status || '—' }}
</a-tag>
</div>
</div>
<div class="config-item">
<p class="config-label">自动扫描</p>
<div class="flex items-center justify-between">
<span class="text-sm"> 6 小时</span>
<a-switch :model-value="true" type="round" />
</div>
<p class="config-label">Cron定期</p>
<span class="text-sm">{{ activeTask.cron_expr || '未配置' }}</span>
</div>
</div>
<div v-else class="text-sm text-muted">请选择扫描任务</div>
</a-card>
</a-col>
</a-row>
<!-- 扫描历史 -->
<a-card class="mt-6" :bordered="false" title="扫描历史">
<a-table
:data="scanHistory"
:data="scanHistoryRows"
:columns="historyColumns"
:pagination="false"
row-key="time"
:pagination="historyPagination"
row-key="id"
@page-change="onHistoryPageChange"
>
<template #started="{ record }">
{{ formatTime(record.started_at) }}
</template>
<template #newDevices="{ record }">
<span v-if="record.newDevices > 0" class="text-warning">+{{ record.newDevices }}</span>
<span v-if="record.new_count > 0" class="text-warning">+{{ record.new_count }}</span>
<span v-else class="text-muted">0</span>
</template>
<template #status="{ record }">
<a-tag :color="record.status === 'success' ? 'green' : 'orange'" bordered>
{{ record.status === 'success' ? '完成' : '部分完成' }}
<a-tag :color="record.status === 'success' ? 'green' : record.status === 'running' ? 'orange' : 'red'" bordered>
{{ record.status }}
</a-tag>
</template>
<template #duration="{ record }">{{ formatDuration(record.started_at, record.finished_at) }}</template>
</a-table>
</a-card>
<!-- 发现的设备 -->
<a-card class="mt-6" :bordered="false">
<template #title>
<div class="table-header">
<span>发现的设备</span>
<span>发现的设备当前视图</span>
<a-space>
<a-select v-model="deviceTypeFilter" placeholder="全部类型" style="width: 120px">
<a-option value="">全部类型</a-option>
<a-option value="switch">交换机</a-option>
<a-option value="router">路由器</a-option>
<a-option value="server">服务器</a-option>
<a-option value="unknown">未知设备</a-option>
</a-select>
<a-select v-model="deviceStatusFilter" placeholder="全部状态" style="width: 120px">
<a-option value="">全部状态</a-option>
<a-select v-model="deviceStatusFilter" placeholder="状态" style="width: 120px" allow-clear @change="reloadDevices">
<a-option value="online">在线</a-option>
<a-option value="offline">离线</a-option>
<a-option value="new">新发现</a-option>
<a-option value="unknown">未知</a-option>
</a-select>
</a-space>
</div>
@@ -224,144 +228,314 @@
<a-table
:data="discoveredDevices"
:columns="deviceColumns"
row-key="ip"
:pagination="devicePagination"
row-key="id"
:loading="devicesLoading"
@page-change="onDevicePageChange"
>
<template #status="{ record }">
<a-tag v-if="record.status === 'online'" color="green" bordered>在线</a-tag>
<a-tag v-else-if="record.status === 'offline'" color="red" bordered>离线</a-tag>
<a-tag v-else color="orange" bordered>新发现</a-tag>
<a-tag v-else color="orange" bordered>{{ record.status || '未知' }}</a-tag>
</template>
<template #method="{ record }">{{ formatMethods(record.discovery_methods) }}</template>
<template #lastSeen="{ record }">{{ formatTime(record.last_seen_time) }}</template>
</a-table>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
import {
IconRefresh,
IconCheckCircle,
IconSettings,
IconDesktop,
IconWifi,
IconExclamationCircle,
IconPlayCircle,
IconSafe,
IconApps,
IconClockCircle,
IconArrowRise,
} from '@arco-design/web-vue/es/icon'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import {
fetchDiscoverySummary,
fetchDiscoveryDevices,
fetchDiscoveryScanRuns,
updateDiscoveryConfig,
type DiscoverySummary,
type DiscoveryDeviceRow,
type DiscoveryScanRun,
} from '@/api/ops/discovery'
import { fetchIpScanList, fetchIpScanDetail, triggerIpScan, type IpScanTask } from '@/api/ops/ipScan'
// 扫描状态
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const router = useRouter()
const pageLoading = ref(false)
const savingDiscovery = ref(false)
const primaryScanIds = ref<number[]>([])
const scansLoading = ref(false)
const devicesLoading = ref(false)
const isScanning = ref(false)
// 设备筛选
const deviceTypeFilter = ref('')
const scanTasks = ref<IpScanTask[]>([])
const selectedScanId = ref<number | undefined>(undefined)
const activeTask = ref<IpScanTask | null>(null)
const summary = ref<DiscoverySummary | null>(null)
const scanHistoryRows = ref<DiscoveryScanRun[]>([])
const discoveredDevices = ref<DiscoveryDeviceRow[]>([])
const deviceStatusFilter = ref('')
// 启动扫描
const handleScan = () => {
isScanning.value = true
setTimeout(() => {
isScanning.value = false
const historyPagination = ref({ current: 1, pageSize: 10, total: 0 })
const devicePagination = ref({ current: 1, pageSize: 20, total: 0 })
let pollTimer: ReturnType<typeof setInterval> | null = null
const runInProgress = computed(() => {
if (summary.value?.last_run?.status === 'running') return true
if (activeTask.value?.last_scan_status === 'running') return true
return false
})
const lastScanTimeShort = computed(() => {
const t = summary.value?.last_run?.finished_at || summary.value?.last_run?.started_at
return t ? dayjs(t).format('HH:mm') : '—'
})
const lastScanTimeRel = computed(() => {
const t = summary.value?.last_run?.finished_at || summary.value?.last_run?.started_at
return t ? dayjs(t).fromNow() : ''
})
/** 统一解包 bsm-sdk 风格响应 */
function unwrap<T>(res: any): T | undefined {
if (!res || res.code !== 0) return undefined
return (res.details ?? res.data) as T
}
const loadScans = async () => {
scansLoading.value = true
try {
const res = await fetchIpScanList({ page: 1, size: 200 })
const page = unwrap<{ total: number; data: IpScanTask[] }>(res)
scanTasks.value = page?.data || []
if (!selectedScanId.value && scanTasks.value.length) {
selectedScanId.value = scanTasks.value[0].id
await loadTaskDetail()
}
} finally {
scansLoading.value = false
}
}
const loadTaskDetail = async () => {
if (!selectedScanId.value) {
activeTask.value = null
return
}
const res = await fetchIpScanDetail(selectedScanId.value)
const t = unwrap<IpScanTask>(res)
activeTask.value = t || null
}
const syncPrimaryScanFromSummary = () => {
const ids = summary.value?.primary_scan_ids
primaryScanIds.value = Array.isArray(ids) ? [...ids] : []
}
const loadSummary = async () => {
const res = await fetchDiscoverySummary()
summary.value = unwrap<DiscoverySummary>(res) || null
syncPrimaryScanFromSummary()
}
const savePrimaryScanConfig = async () => {
savingDiscovery.value = true
try {
const res: any = await updateDiscoveryConfig({ primary_scan_ids: [...primaryScanIds.value] })
if (res?.code === 0) {
Message.success('主扫描配置已保存')
await Promise.all([loadSummary(), loadDevices()])
} else {
Message.error(res?.message || '保存失败')
}
} catch (e) {
console.error(e)
Message.error('保存请求失败')
} finally {
savingDiscovery.value = false
}
}
const goIpScanTasks = () => {
router.push({ path: '/netarch/ip-scan-tasks' })
}
const goAssetDevices = () => {
router.push({ path: '/assets/device' })
}
const loadHistory = async () => {
const res = await fetchDiscoveryScanRuns({
scan_id: selectedScanId.value,
page: historyPagination.value.current,
size: historyPagination.value.pageSize,
})
const d = unwrap<{ total: number; data: DiscoveryScanRun[] }>(res)
scanHistoryRows.value = d?.data || []
historyPagination.value.total = d?.total || 0
}
const loadDevices = async () => {
devicesLoading.value = true
try {
const res = await fetchDiscoveryDevices({
scope: 'current',
page: devicePagination.value.current,
size: devicePagination.value.pageSize,
status: deviceStatusFilter.value || undefined,
})
const d = unwrap<{ total: number; data: DiscoveryDeviceRow[] }>(res)
discoveredDevices.value = d?.data || []
devicePagination.value.total = d?.total || 0
} finally {
devicesLoading.value = false
}
}
const reloadAll = async () => {
pageLoading.value = true
try {
await Promise.all([loadSummary(), loadTaskDetail(), loadHistory(), loadDevices()])
} finally {
pageLoading.value = false
}
}
const reloadDevices = () => {
devicePagination.value.current = 1
loadDevices()
}
const onHistoryPageChange = (current: number) => {
historyPagination.value.current = current
loadHistory()
}
const onDevicePageChange = (current: number) => {
devicePagination.value.current = current
loadDevices()
}
const onScanChange = () => {
historyPagination.value.current = 1
loadTaskDetail()
loadHistory()
}
const stopPoll = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
const startPoll = () => {
stopPoll()
pollTimer = setInterval(async () => {
await loadSummary()
await loadTaskDetail()
if (!runInProgress.value) {
isScanning.value = false
stopPoll()
await Promise.all([loadHistory(), loadDevices()])
}
}, 3000)
}
// 发现的设备数据
const discoveredDevices = ref([
{
ip: '10.0.0.1',
mac: '00:1A:2B:3C:4D:01',
hostname: 'Core-SW-01',
vendor: 'Cisco Systems',
type: '交换机',
status: 'online',
lastSeen: '刚刚',
method: 'SNMP',
},
{
ip: '10.0.0.2',
mac: '00:1A:2B:3C:4D:02',
hostname: 'Core-SW-02',
vendor: 'Cisco Systems',
type: '交换机',
status: 'online',
lastSeen: '刚刚',
method: 'SNMP',
},
{
ip: '10.0.1.1',
mac: '00:1A:2B:3C:4D:10',
hostname: 'Router-01',
vendor: 'Cisco Systems',
type: '路由器',
status: 'online',
lastSeen: '1分钟前',
method: 'ICMP+ARP',
},
{
ip: '10.0.2.50',
mac: '00:50:56:AB:CD:01',
hostname: 'ESXi-01',
vendor: 'VMware, Inc.',
type: '服务器',
status: 'online',
lastSeen: '刚刚',
method: 'SNMP',
},
{
ip: '10.0.3.100',
mac: '00:00:5E:00:01:01',
hostname: 'Unknown',
vendor: 'Dell Technologies',
type: '未知',
status: 'new',
lastSeen: '5分钟前',
method: 'ARP',
},
{
ip: '10.0.2.200',
mac: '00:1A:2B:3C:4D:FF',
hostname: 'Printer-01',
vendor: 'HP Inc.',
type: '打印机',
status: 'offline',
lastSeen: '2小时前',
method: 'ICMP',
},
])
const handleScan = async () => {
if (!selectedScanId.value) {
Message.warning('请先选择扫描任务')
return
}
try {
const res = await triggerIpScan(selectedScanId.value)
if (res?.code === 0) {
Message.success('已触发扫描')
isScanning.value = true
await loadSummary()
await loadTaskDetail()
startPoll()
} else {
Message.error((res as any)?.message || '触发失败')
}
} catch (e) {
console.error(e)
Message.error('触发请求失败')
}
}
// 设备表格列
const deviceColumns: TableColumnData[] = [
{ title: 'IP地址', dataIndex: 'ip', width: 120 },
{ title: 'MAC地址', dataIndex: 'mac', width: 150 },
{ title: '主机名', dataIndex: 'hostname', width: 120 },
{ title: '厂商', dataIndex: 'vendor', width: 140 },
{ title: '设备类型', dataIndex: 'type', width: 100 },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
{ title: '最后发现', dataIndex: 'lastSeen', width: 100 },
{ title: '发现方式', dataIndex: 'method', width: 100 },
]
const formatRange = (a?: string, b?: string) => {
if (!a) return ''
const sa = dayjs(a).format('YYYY-MM-DD HH:mm:ss')
const sb = b ? dayjs(b).format('YYYY-MM-DD HH:mm:ss') : '进行中'
return `${sa}${sb}`
}
// 扫描历史数据
const scanHistory = ref([
{ time: '2024-01-15 14:30:00', type: '全网扫描', duration: '12分钟', devices: 156, newDevices: 2, status: 'success' },
{ time: '2024-01-15 12:00:00', type: '增量扫描', duration: '3分钟', devices: 154, newDevices: 0, status: 'success' },
{ time: '2024-01-15 08:00:00', type: '全网扫描', duration: '15分钟', devices: 154, newDevices: 1, status: 'success' },
{ time: '2024-01-14 20:00:00', type: '增量扫描', duration: '2分钟', devices: 153, newDevices: 0, status: 'success' },
{ time: '2024-01-14 14:30:00', type: '全网扫描', duration: '18分钟', devices: 153, newDevices: 3, status: 'warning' },
])
const formatDuration = (a?: string, b?: string) => {
if (!a || !b) return '—'
const sec = dayjs(b).diff(dayjs(a), 'second')
if (sec < 60) return `${sec}`
return `${Math.floor(sec / 60)}`
}
const formatTime = (t?: string) => (t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '—')
const formatMethods = (raw?: string) => {
if (!raw) return '—'
try {
const j = JSON.parse(raw)
if (Array.isArray(j)) return j.join(', ')
return raw
} catch {
return raw
}
}
// 扫描历史表格列
const historyColumns: TableColumnData[] = [
{ title: '时间', dataIndex: 'time', width: 160 },
{ title: '类型', dataIndex: 'type', width: 100 },
{ title: '耗时', dataIndex: 'duration', width: 80 },
{ title: '设备数', dataIndex: 'devices', width: 80, align: 'center' },
{ title: '新增', dataIndex: 'newDevices', slotName: 'newDevices', width: 80, align: 'center' },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
{ title: '开始时间', slotName: 'started', width: 180 },
{ title: '类型', dataIndex: 'run_type', width: 100 },
{ title: '耗时', slotName: 'duration', width: 100 },
{ title: '设备数', dataIndex: 'device_total', width: 90, align: 'center' },
{ title: '新增', slotName: 'newDevices', width: 80, align: 'center' },
{ title: '状态', slotName: 'status', width: 100, align: 'center' },
]
const deviceColumns: TableColumnData[] = [
{ title: 'IP', dataIndex: 'ip_address', width: 130 },
{ title: 'MAC', dataIndex: 'mac_address', width: 150 },
{ title: '主机名', dataIndex: 'hostname', width: 140 },
{ title: '厂商', dataIndex: 'manufacturer', width: 140 },
{ title: '类型', dataIndex: 'device_type', width: 100 },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
{ title: '最后发现', slotName: 'lastSeen', width: 160 },
{ title: '发现方式', slotName: 'method', width: 120 },
]
onMounted(async () => {
await loadScans()
await reloadAll()
})
onUnmounted(() => {
stopPoll()
})
</script>
<script lang="ts">
@@ -399,13 +573,12 @@ export default {
.page-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
}
.mr-2 {
margin-right: 8px;
}
.stats-row {
margin-bottom: 0;
}
@@ -474,6 +647,10 @@ export default {
&.text-success {
color: #00b42a;
}
&.text-muted {
color: var(--color-text-3);
}
}
}
@@ -481,6 +658,29 @@ export default {
margin-top: 24px;
}
.mb-6 {
margin-bottom: 24px;
}
.config-hint {
margin: 0 0 12px;
font-size: 13px;
color: var(--color-text-3);
}
.primary-scan-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.primary-scan-select {
min-width: 320px;
flex: 1;
max-width: 560px;
}
.scan-row {
display: flex;
align-items: stretch;
@@ -587,10 +787,6 @@ export default {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-4 {
gap: 16px;
}

View File

@@ -0,0 +1,387 @@
<template>
<div class="container">
<div class="page-header">
<div class="page-title">
<h2>IP 扫描任务</h2>
<p class="page-subtitle">创建与维护发现任务供自动感知页触发扫描数据来自 DC-Control /ipscans</p>
</div>
<a-space>
<a-button @click="goAutoTopology">自动感知</a-button>
<a-button type="primary" @click="openCreate">
<template #icon><icon-plus /></template>
新建任务
</a-button>
</a-space>
</div>
<a-card :bordered="false">
<a-table
row-key="id"
:columns="columns"
:data="tableData"
:loading="loading"
:pagination="pagination"
@page-change="onPageChange"
>
<template #target="{ record }">
<span class="mono">{{ record.target_range || '—' }}</span>
</template>
<template #server="{ record }">
{{ serverLabel(record.server_id) }}
</template>
<template #status="{ record }">
<a-tag bordered>{{ record.status || '—' }}</a-tag>
</template>
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="openEdit(record)">编辑</a-button>
<a-button type="text" size="small" @click="handleTrigger(record)">触发扫描</a-button>
<a-popconfirm content="确定删除该任务?" @ok="handleDelete(record)">
<a-button type="text" size="small" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
<a-modal
v-model:visible="modalVisible"
:title="editingId ? '编辑扫描任务' : '新建扫描任务'"
width="640px"
unmount-on-close
:ok-loading="submitting"
@ok="submitForm"
@cancel="closeModal"
>
<a-form :model="form" layout="vertical">
<a-form-item label="任务名称" required>
<a-input v-model="form.name" placeholder="唯一名称" allow-clear />
</a-form-item>
<a-form-item label="扫描类型" required>
<a-select v-model="form.type" placeholder="类型">
<a-option value="ping">ping</a-option>
<a-option value="port">port</a-option>
<a-option value="full">full</a-option>
</a-select>
</a-form-item>
<a-form-item label="目标范围" required>
<a-textarea
v-model="form.target_range"
placeholder="CIDR、IP 段等,按后端约定填写"
:auto-size="{ minRows: 2, maxRows: 6 }"
/>
</a-form-item>
<a-form-item label="端口范围">
<a-input v-model="form.port_range" placeholder="如 80,443 或 8080-8090可选" allow-clear />
</a-form-item>
<a-form-item label="执行 Agent服务器" required>
<a-select
v-model="form.server_id"
placeholder="选择已注册服务器"
allow-search
:loading="serversLoading"
>
<a-option v-for="s in servers" :key="s.id" :value="s.id">
{{ s.name }} {{ s.ip_address }} (#{{ s.id }})
</a-option>
</a-select>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="超时(秒)">
<a-input-number v-model="form.timeout" :min="1" :max="300" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="并发数">
<a-input-number v-model="form.concurrency" :min="1" :max="5000" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="Cron可选">
<a-input v-model="form.cron_expr" placeholder="定期扫描 Cron 表达式" allow-clear />
</a-form-item>
<a-form-item label="描述">
<a-input v-model="form.description" allow-clear />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import {
fetchIpScanList,
createIpScan,
updateIpScan,
deleteIpScan,
triggerIpScan,
type IpScanTask,
} from '@/api/ops/ipScan'
import { fetchServerList, type ServerItem } from '@/api/ops/server'
const router = useRouter()
const loading = ref(false)
const serversLoading = ref(false)
const submitting = ref(false)
const tableData = ref<IpScanTask[]>([])
const servers = ref<ServerItem[]>([])
const pagination = ref({
current: 1,
pageSize: 20,
total: 0,
})
const modalVisible = ref(false)
const editingId = ref<number | null>(null)
const defaultForm = (): Partial<IpScanTask> => ({
name: '',
type: 'ping',
description: '',
target_range: '',
port_range: '',
timeout: 5,
concurrency: 100,
cron_expr: '',
server_id: undefined,
status: 'stopped',
})
const form = reactive<Partial<IpScanTask>>(defaultForm())
function unwrapList(res: any): { total: number; data: IpScanTask[] } | undefined {
if (!res || res.code !== 0) return undefined
const d = res.details ?? res.data
if (!d) return undefined
return { total: d.total ?? 0, data: d.data ?? [] }
}
const loadServers = async () => {
serversLoading.value = true
try {
const res: any = await fetchServerList({ page: 1, size: 500 })
if (res.code === 0) {
const d = res.details || {}
servers.value = d.data || []
}
} finally {
serversLoading.value = false
}
}
const loadTable = async () => {
loading.value = true
try {
const res: any = await fetchIpScanList({
page: pagination.value.current,
size: pagination.value.pageSize,
})
const page = unwrapList(res)
tableData.value = page?.data || []
pagination.value.total = page?.total || 0
if (res.code !== 0) {
Message.error(res.message || '加载失败')
}
} finally {
loading.value = false
}
}
const serverLabel = (id?: number) => {
if (!id) return '—'
const s = servers.value.find((x) => x.id === id)
return s ? `${s.name} (#${id})` : `#${id}`
}
const onPageChange = (current: number) => {
pagination.value.current = current
loadTable()
}
const resetForm = () => {
Object.assign(form, defaultForm())
}
const openCreate = () => {
editingId.value = null
resetForm()
modalVisible.value = true
}
const openEdit = (row: IpScanTask) => {
editingId.value = row.id
Object.assign(form, {
name: row.name,
type: row.type,
description: row.description || '',
target_range: row.target_range,
port_range: row.port_range || '',
timeout: row.timeout ?? 5,
concurrency: row.concurrency ?? 100,
cron_expr: row.cron_expr || '',
server_id: row.server_id,
status: row.status || 'stopped',
})
modalVisible.value = true
}
const closeModal = () => {
modalVisible.value = false
}
const submitForm = async () => {
if (!form.name?.trim()) {
Message.warning('请填写任务名称')
return
}
if (!form.type) {
Message.warning('请选择扫描类型')
return
}
if (!form.target_range?.trim()) {
Message.warning('请填写目标范围')
return
}
if (!form.server_id) {
Message.warning('请选择执行 Agent')
return
}
submitting.value = true
try {
const body: Partial<IpScanTask> = {
name: form.name.trim(),
type: form.type,
description: form.description,
target_range: form.target_range.trim(),
port_range: form.port_range,
timeout: form.timeout,
concurrency: form.concurrency,
cron_expr: form.cron_expr,
server_id: form.server_id,
enable: true,
}
if (!editingId.value) {
body.status = 'stopped'
const res: any = await createIpScan(body)
if (res?.code === 0) {
Message.success('已创建')
modalVisible.value = false
await loadTable()
} else {
Message.error(res?.message || '创建失败')
}
} else {
body.status = form.status
const res: any = await updateIpScan(editingId.value, body)
if (res?.code === 0) {
Message.success('已保存')
modalVisible.value = false
await loadTable()
} else {
Message.error(res?.message || '保存失败')
}
}
} catch (e) {
console.error(e)
Message.error('请求失败')
} finally {
submitting.value = false
}
}
const handleDelete = async (row: IpScanTask) => {
try {
const res: any = await deleteIpScan(row.id)
if (res?.code === 0) {
Message.success('已删除')
await loadTable()
} else {
Message.error(res?.message || '删除失败')
}
} catch (e) {
console.error(e)
Message.error('删除请求失败')
}
}
const handleTrigger = async (row: IpScanTask) => {
try {
const res: any = await triggerIpScan(row.id)
if (res?.code === 0) {
Message.success('已触发扫描')
} else {
Message.error(res?.message || '触发失败')
}
} catch (e) {
console.error(e)
Message.error('触发失败')
}
}
const goAutoTopology = () => {
router.push({ path: '/netarch/auto-topo' })
}
const columns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 72 },
{ title: '名称', dataIndex: 'name', ellipsis: true, tooltip: true },
{ title: '类型', dataIndex: 'type', width: 88 },
{ title: '目标范围', slotName: 'target', minWidth: 200 },
{ title: 'Agent', slotName: 'server', width: 180, ellipsis: true, tooltip: true },
{ title: '状态', slotName: 'status', width: 100 },
{ title: '操作', slotName: 'actions', width: 220, fixed: 'right' },
]
onMounted(async () => {
await loadServers()
await loadTable()
})
</script>
<script lang="ts">
export default {
name: 'IpScanTasks',
}
</script>
<style scoped lang="less">
.container {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
.page-title {
h2 {
margin: 0;
font-size: 20px;
font-weight: 500;
color: var(--color-text-1);
}
.page-subtitle {
margin: 8px 0 0;
font-size: 14px;
color: var(--color-text-3);
}
}
}
.mono {
font-family: monospace;
font-size: 13px;
}
</style>

View File

@@ -28,6 +28,15 @@
<div class="value">{{ nodeData.ip || '未配置' }}</div>
</div>
</a-col>
<a-col v-if="nodeData.ref_type || nodeData.ref_id" :span="12">
<div class="info-item">
<div class="label">资产绑定</div>
<div class="value">
{{ nodeData.ref_type || '—' }}
<template v-if="nodeData.ref_id"> #{{ nodeData.ref_id }}</template>
</div>
</div>
</a-col>
<a-col :span="12">
<div class="info-item">
<div class="label">设备状态</div>

View File

@@ -36,6 +36,13 @@
</a-dropdown>
</a-button-group>
<a-tooltip v-if="props.onBatchImportAssets" content="按资产 ID 批量导入(绑定 ref_type=asset">
<a-button type="outline" size="small" @click="props.onBatchImportAssets">
<icon-import :size="18" />
<span class="btn-text">资产导入</span>
</a-button>
</a-tooltip>
<!-- 布局 -->
<a-button-group type="outline" size="small">
<a-dropdown trigger="click" @select="props.onLayout">
@@ -101,6 +108,7 @@ import {
IconRefresh,
IconDownload,
IconRotateLeft,
IconImport,
} from '@arco-design/web-vue/es/icon';
interface Props {
@@ -113,10 +121,13 @@ interface Props {
onRefresh: () => void;
onExport: () => void;
onReset?: () => void;
/** 打开「批量导入资产」对话框(无拓扑 ID 时不传) */
onBatchImportAssets?: () => void;
}
const props = withDefaults(defineProps<Props>(), {
onReset: undefined,
onBatchImportAssets: undefined,
});
</script>

View File

@@ -27,6 +27,7 @@
@refresh="refreshTopology"
@export="exportTopology"
@reset="resetTopology"
:on-batch-import-assets="currentTopologyId ? openBatchImportAssets : undefined"
/>
<!-- Vue Flow 画布 -->
@@ -103,6 +104,22 @@
node-name="链路"
@confirm="handleDeleteEdgeConfirm"
/>
<a-modal
v-model:visible="batchImportAssetsOpen"
title="批量导入资产节点"
@ok="handleBatchImportAssetsConfirm"
@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>
</p>
<a-textarea
v-model="batchImportAssetIdsText"
placeholder="请输入资产 ID逗号或换行分隔例如1,2,3"
:auto-size="{ minRows: 4, maxRows: 8 }"
/>
</a-modal>
</div>
</template>
@@ -179,6 +196,45 @@ const edgeActionDialogOpen = ref(false);
const edgeEditDialogOpen = ref(false);
const deleteEdgeDialogOpen = ref(false);
const batchImportAssetsOpen = ref(false);
const batchImportAssetIdsText = ref('');
const openBatchImportAssets = () => {
batchImportAssetIdsText.value = '';
batchImportAssetsOpen.value = true;
};
const handleBatchImportAssetsConfirm = async () => {
const id = currentTopologyId.value;
if (!id) {
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)))];
if (assetIds.length === 0) {
Message.warning('请输入至少一个有效的资产 ID');
return;
}
try {
const res: any = await TopoAPI.batchImportAssetNodes(id, assetIds);
if (res?.code === 0) {
Message.success(`已导入 ${res.details?.imported ?? assetIds.length} 个节点请求已提交`);
batchImportAssetsOpen.value = false;
await refreshTopology();
} else {
Message.error(res?.message || '导入失败');
}
} catch (e) {
console.error(e);
Message.error('导入请求失败');
}
};
// 布局钩子
const { applyLayout } = useTopoLayout();
@@ -269,6 +325,9 @@ const loadData = async () => {
parentId: node.parentId,
level: node.level ?? 0,
position: node.position,
ref_type: node.ref_type,
ref_id: node.ref_id,
sub_topology_id: node.sub_topology_id,
},
}));
}

View File

@@ -17,6 +17,10 @@ export interface NodeData {
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;
}
// 拓扑分组类型(从节点自动生成)