fix
This commit is contained in:
91
src/api/ops/discovery.ts
Normal file
91
src/api/ops/discovery.ts
Normal 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
58
src/api/ops/ipScan.ts
Normal 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}`)
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
// ==================== 链路管理接口 ====================
|
||||
|
||||
/** 获取链路列表 */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
387
src/views/ops/pages/netarch/ip-scan-tasks/index.vue
Normal file
387
src/views/ops/pages/netarch/ip-scan-tasks/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 拓扑分组类型(从节点自动生成)
|
||||
|
||||
Reference in New Issue
Block a user