This commit is contained in:
2026-04-09 00:02:42 +08:00
parent ac759da246
commit 2ceb18cbee
14 changed files with 3222 additions and 673 deletions

440
src/api/ops/ipam.ts Normal file
View File

@@ -0,0 +1,440 @@
import { request } from '@/api/request'
/** IP地址运行状态 */
export type IPStatus = 'online' | 'offline' | 'unknown'
/** IP地址分配状态 */
export type AllocationStatus = 'allocated' | 'unallocated' | 'reserved'
/** IP地址使用状态 */
export type UsageStatus = 'used' | 'unused' | 'used_within_30d' | 'used_before_30d'
/** IP来源类型 */
export type SourceType = 'scan' | 'dhcp' | 'manual'
/** 概览统计 */
export interface IPAMOverview {
total_ip: number
allocated: number
unallocated: number
reserved: number
used: number
unused: number
used_within_30d: number
used_before_30d: number
conflict_total: number
conflict_unresolved: number
subnet_usage_top10?: SubnetUsageItem[]
}
/** 子网使用率项 */
export interface SubnetUsageItem {
subnet_id: number
cidr: string
name: string
total: number
used: number
usage_percent: number
}
/** IP地址项 */
export interface IPAddressItem {
id: number
ip_address: string
status: IPStatus
allocation_status: AllocationStatus
usage_status: UsageStatus
subnet_id: number
subnet_name?: string
subnet_cidr?: string
hostname: string
mac_address: string
last_used_at: string
source_type: SourceType
owner_type: string
owner_id: number
remark: string
tags: string
created_at: string
updated_at: string
}
/** IP地址列表参数 */
export interface IPAddressListParams {
page?: number
size?: number
scan_id?: number
subnet_id?: number
status?: IPStatus
allocation_status?: AllocationStatus
usage_status?: UsageStatus
keyword?: string
}
/** IP地址列表响应 */
export interface IPAddressListResponse {
total: number
page: number
page_size: number
data: IPAddressItem[]
}
/** IP地址表单数据 */
export interface IPAddressFormData {
ip_address?: string
status?: IPStatus
allocation_status?: AllocationStatus
subnet_id?: number
hostname?: string
mac_address?: string
source_type?: SourceType
owner_type?: string
owner_id?: number
remark?: string
tags?: string
}
/** IP分组项 */
export interface IPGroupItem {
id: number
name: string
parent_id: number
description: string
is_default: boolean
created_at: string
updated_at: string
children?: IPGroupItem[]
}
/** IP分组列表参数 */
export interface IPGroupListParams {
parent_id?: number
keyword?: string
}
/** IP分组列表响应 */
export interface IPGroupListResponse {
data: IPGroupItem[]
}
/** IP分组表单数据 */
export interface IPGroupFormData {
name?: string
parent_id?: number
description?: string
}
/** IP子网项 */
export interface IPSubnetItem {
id: number
cidr: string
name: string
group_id: number
group_name?: string
gateway: string
vlan: string
total: number
used: number
available: number
reserved_ranges_json: string
description: string
created_at: string
updated_at: string
}
/** IP子网列表参数 */
export interface IPSubnetListParams {
page?: number
size?: number
group_id?: number
keyword?: string
}
/** IP子网列表响应 */
export interface IPSubnetListResponse {
total: number
page: number
page_size: number
data: IPSubnetItem[]
}
/** IP子网表单数据 */
export interface IPSubnetFormData {
cidr?: string
name?: string
group_id?: number
gateway?: string
vlan?: string
reserved_ranges_json?: string
description?: string
}
/** DHCP租约项 */
export interface DHCPLeaseItem {
id: number
ip_address: string
mac_address: string
hostname: string
subnet_id: number
subnet_name?: string
lease_start: string
lease_end: string
dhcp_server: string
status: string
created_at: string
updated_at: string
}
/** DHCP租约列表参数 */
export interface DHCPLeaseListParams {
page?: number
size?: number
subnet_id?: number
keyword?: string
}
/** DHCP租约列表响应 */
export interface DHCPLeaseListResponse {
total: number
page: number
page_size: number
data: DHCPLeaseItem[]
}
/** DHCP租约表单数据 */
export interface DHCPLeaseFormData {
ip_address?: string
mac_address?: string
hostname?: string
subnet_id?: number
lease_start?: string
lease_end?: string
dhcp_server?: string
status?: string
}
/** IP冲突项 */
export interface IPConflictItem {
id: number
ip_address: string
mac_address_1: string
mac_address_2: string
hostname_1: string
hostname_2: string
subnet_id: number
subnet_name?: string
detected_at: string
resolved_at: string
status: string
evidence_json: string
created_at: string
updated_at: string
}
/** IP冲突列表参数 */
export interface IPConflictListParams {
page?: number
size?: number
subnet_id?: number
status?: string
keyword?: string
}
/** IP冲突列表响应 */
export interface IPConflictListResponse {
total: number
page: number
page_size: number
data: IPConflictItem[]
}
/** IP冲突表单数据 */
export interface IPConflictFormData {
ip_address?: string
mac_address_1?: string
mac_address_2?: string
hostname_1?: string
hostname_2?: string
subnet_id?: number
evidence_json?: string
status?: string
}
/** IP变更项 */
export interface IPChangeItem {
id: number
ip_address: string
change_type: string
before_json: string
after_json: string
subnet_id: number
subnet_name?: string
changed_by: string
changed_at: string
remark: string
created_at: string
}
/** IP变更列表参数 */
export interface IPChangeListParams {
page?: number
size?: number
subnet_id?: number
change_type?: string
keyword?: string
}
/** IP变更列表响应 */
export interface IPChangeListResponse {
total: number
page: number
page_size: number
data: IPChangeItem[]
}
/** IP变更表单数据 */
export interface IPChangeFormData {
ip_address?: string
change_type?: string
before_json?: string
after_json?: string
subnet_id?: number
changed_by?: string
remark?: string
}
/** IP异常项 */
export interface IPAnomalyItem {
id: number
ip_address: string
anomaly_type: string
subnet_id: number
subnet_name?: string
detail_json: string
detected_at: string
status: string
remark: string
created_at: string
updated_at: string
}
/** IP异常列表参数 */
export interface IPAnomalyListParams {
page?: number
size?: number
subnet_id?: number
anomaly_type?: string
status?: string
keyword?: string
}
/** IP异常列表响应 */
export interface IPAnomalyListResponse {
total: number
page: number
page_size: number
data: IPAnomalyItem[]
}
/** IP异常表单数据 */
export interface IPAnomalyFormData {
ip_address?: string
anomaly_type?: string
subnet_id?: number
detail_json?: string
status?: string
remark?: string
}
/** ========== 概览 API ========== */
/** 获取IPAM概览统计 */
export const fetchIPAMOverview = () => request.get<IPAMOverview>('/DC-Control/v1/ipam/overview')
/** ========== IP地址 API ========== */
/** 获取IP地址列表 */
export const fetchIPAddressList = (params?: IPAddressListParams) =>
request.get<IPAddressListResponse>('/DC-Control/v1/ipaddresses', { params })
/** 获取IP地址详情 */
export const fetchIPAddressDetail = (id: number) => request.get<IPAddressItem>(`/DC-Control/v1/ipaddresses/${id}`)
/** 创建IP地址 */
export const createIPAddress = (data: IPAddressFormData) => request.post<IPAddressItem>('/DC-Control/v1/ipaddresses', data)
/** 更新IP地址 */
export const updateIPAddress = (id: number, data: Partial<IPAddressFormData>) =>
request.put<{ message: string }>(`/DC-Control/v1/ipaddresses/${id}`, data)
/** 删除IP地址 */
export const deleteIPAddress = (id: number) => request.delete<{ message: string }>(`/DC-Control/v1/ipaddresses/${id}`)
/** ========== IP分组 API ========== */
/** 获取IP分组列表树形 */
export const fetchIPGroupList = (params?: IPGroupListParams) => request.get<IPGroupListResponse>('/DC-Control/v1/ip-groups', { params })
/** 创建IP分组 */
export const createIPGroup = (data: IPGroupFormData) => request.post<IPGroupItem>('/DC-Control/v1/ip-groups', data)
/** 更新IP分组 */
export const updateIPGroup = (id: number, data: Partial<IPGroupFormData>) =>
request.put<{ message: string }>(`/DC-Control/v1/ip-groups/${id}`, data)
/** 删除IP分组 */
export const deleteIPGroup = (id: number) => request.delete<{ message: string }>(`/DC-Control/v1/ip-groups/${id}`)
/** ========== IP子网 API ========== */
/** 获取IP子网列表 */
export const fetchIPSubnetList = (params?: IPSubnetListParams) => request.get<IPSubnetListResponse>('/DC-Control/v1/ip-subnets', { params })
/** 获取IP子网详情 */
export const fetchIPSubnetDetail = (id: number) => request.get<IPSubnetItem>(`/DC-Control/v1/ip-subnets/${id}`)
/** 创建IP子网 */
export const createIPSubnet = (data: IPSubnetFormData) => request.post<IPSubnetItem>('/DC-Control/v1/ip-subnets', data)
/** 更新IP子网 */
export const updateIPSubnet = (id: number, data: Partial<IPSubnetFormData>) =>
request.put<{ message: string }>(`/DC-Control/v1/ip-subnets/${id}`, data)
/** 删除IP子网 */
export const deleteIPSubnet = (id: number) => request.delete<{ message: string }>(`/DC-Control/v1/ip-subnets/${id}`)
/** ========== DHCP租约 API ========== */
/** 获取DHCP租约列表 */
export const fetchDHCPLeaseList = (params?: DHCPLeaseListParams) =>
request.get<DHCPLeaseListResponse>('/DC-Control/v1/ipam/dhcp-leases', { params })
/** 创建DHCP租约 */
export const createDHCPLease = (data: DHCPLeaseFormData) => request.post<DHCPLeaseItem>('/DC-Control/v1/ipam/dhcp-leases', data)
/** ========== IP冲突 API ========== */
/** 获取IP冲突列表 */
export const fetchIPConflictList = (params?: IPConflictListParams) =>
request.get<IPConflictListResponse>('/DC-Control/v1/ipam/conflicts', { params })
/** 创建IP冲突记录 */
export const createIPConflict = (data: IPConflictFormData) => request.post<IPConflictItem>('/DC-Control/v1/ipam/conflicts', data)
/** ========== IP变更 API ========== */
/** 获取IP变更列表 */
export const fetchIPChangeList = (params?: IPChangeListParams) =>
request.get<IPChangeListResponse>('/DC-Control/v1/ipam/changes', { params })
/** 创建IP变更记录 */
export const createIPChange = (data: IPChangeFormData) => request.post<IPChangeItem>('/DC-Control/v1/ipam/changes', data)
/** ========== IP异常 API ========== */
/** 获取IP异常列表 */
export const fetchIPAnomalyList = (params?: IPAnomalyListParams) =>
request.get<IPAnomalyListResponse>('/DC-Control/v1/ipam/anomalies', { params })
/** 创建IP异常记录 */
export const createIPAnomaly = (data: IPAnomalyFormData) => request.post<IPAnomalyItem>('/DC-Control/v1/ipam/anomalies', data)

View File

@@ -0,0 +1,282 @@
<template>
<div class="anomalies-tab">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="IP异常记录"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #anomaly_type="{ record }">
<a-tag :color="getAnomalyTypeColor(record.anomaly_type)" bordered>
{{ getAnomalyTypeText(record.anomaly_type) }}
</a-tag>
</template>
<template #status="{ record }">
<a-tag :color="record.status === 'resolved' ? 'green' : 'orange'" bordered>
{{ record.status === 'resolved' ? '已处理' : '待处理' }}
</a-tag>
</template>
<template #detected_at="{ record }">
{{ formatDateTime(record.detected_at) }}
</template>
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
</template>
</search-table>
<a-modal v-model:visible="showDetailDialog" title="异常详情" :width="700" :footer="false">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="IP地址">{{ currentDetail?.ip_address }}</a-descriptions-item>
<a-descriptions-item label="所属子网">{{ currentDetail?.subnet_name }}</a-descriptions-item>
<a-descriptions-item label="异常类型">
<a-tag :color="getAnomalyTypeColor(currentDetail?.anomaly_type)" bordered>
{{ getAnomalyTypeText(currentDetail?.anomaly_type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="currentDetail?.status === 'resolved' ? 'green' : 'orange'" bordered>
{{ currentDetail?.status === 'resolved' ? '已处理' : '待处理' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="发现时间">{{ formatDateTime(currentDetail?.detected_at) }}</a-descriptions-item>
<a-descriptions-item label="备注">{{ currentDetail?.remark || '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="currentDetail?.detail_json" class="detail-section">
<div class="detail-title">详细信息</div>
<pre class="detail-content">{{ formatJson(currentDetail?.detail_json) }}</pre>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import SearchTable from '@/components/search-table/index.vue'
import {
fetchIPAnomalyList,
fetchIPSubnetList,
type IPAnomalyItem,
type IPAnomalyListParams,
type IPSubnetItem,
} from '@/api/ops/ipam'
import type { FormItem } from '@/components/search-form/types'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
const loading = ref(false)
const tableData = ref<IPAnomalyItem[]>([])
const subnets = ref<IPSubnetItem[]>([])
const showDetailDialog = ref(false)
const currentDetail = ref<IPAnomalyItem | null>(null)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formModel = reactive({
keyword: '',
subnet_id: '',
anomaly_type: '',
status: '',
})
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址',
span: 6,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
span: 6,
},
{
field: 'anomaly_type',
label: '异常类型',
type: 'select',
placeholder: '全部类型',
options: [
{ label: 'IP扫描异常', value: 'scan_anomaly' },
{ label: 'MAC地址变更', value: 'mac_change' },
{ label: '未知设备', value: 'unknown_device' },
{ label: 'IP地址泄漏', value: 'ip_leak' },
],
span: 6,
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '待处理', value: 'pending' },
{ label: '已处理', value: 'resolved' },
],
span: 6,
},
])
const columns: TableColumnData[] = [
{ title: 'IP地址', dataIndex: 'ip_address', width: 150 },
{ title: '异常类型', dataIndex: 'anomaly_type', slotName: 'anomaly_type', width: 120, align: 'center' },
{ title: '所属子网', dataIndex: 'subnet_name', width: 150 },
{ title: '发现时间', dataIndex: 'detected_at', slotName: 'detected_at', width: 180 },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
{ title: '备注', dataIndex: 'remark', width: 200 },
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 80, fixed: 'right' },
]
const loadSubnets = async () => {
try {
const response = await fetchIPSubnetList({ size: 1000 })
if (response && response.code === 0) {
subnets.value = response.details?.data || response.data || []
}
} catch (error) {
console.error('Failed to load subnets:', error)
}
}
const loadData = async () => {
loading.value = true
try {
const params: IPAnomalyListParams = {
page: pagination.current,
size: pagination.pageSize,
...formModel,
}
Object.keys(params).forEach((key) => {
if (!params[key as keyof IPAnomalyListParams]) {
delete params[key as keyof IPAnomalyListParams]
}
})
const response = await fetchIPAnomalyList(params)
if (response && response.code === 0) {
tableData.value = response.details?.data || response.data || []
pagination.total = response.details?.total || response.total || 0
}
} catch (error) {
console.error('Failed to load anomalies:', error)
} finally {
loading.value = false
}
}
const handleFormModelUpdate = (model: typeof formModel) => {
Object.assign(formModel, model)
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const handleReset = () => {
Object.assign(formModel, {
keyword: '',
subnet_id: '',
anomaly_type: '',
status: '',
})
pagination.current = 1
loadData()
}
const handlePageChange = (page: number) => {
pagination.current = page
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleDetail = (record: IPAnomalyItem) => {
currentDetail.value = record
showDetailDialog.value = true
}
const getAnomalyTypeColor = (type?: string) => {
const colorMap: Record<string, string> = {
scan_anomaly: 'red',
mac_change: 'orange',
unknown_device: 'purple',
ip_leak: 'arcoblue',
}
return colorMap[type || ''] || 'gray'
}
const getAnomalyTypeText = (type?: string) => {
const textMap: Record<string, string> = {
scan_anomaly: 'IP扫描异常',
mac_change: 'MAC地址变更',
unknown_device: '未知设备',
ip_leak: 'IP地址泄漏',
}
return textMap[type || ''] || type || '-'
}
const formatDateTime = (datetime?: string) => {
if (!datetime) return '-'
return new Date(datetime).toLocaleString('zh-CN')
}
const formatJson = (jsonStr?: string) => {
if (!jsonStr) return '{}'
try {
return JSON.stringify(JSON.parse(jsonStr), null, 2)
} catch {
return jsonStr
}
}
onMounted(() => {
loadSubnets()
loadData()
})
</script>
<style scoped lang="less">
.detail-section {
margin-top: 16px;
}
.detail-title {
font-weight: 500;
margin-bottom: 8px;
color: var(--color-text-1);
}
.detail-content {
background: var(--color-fill-1);
padding: 12px;
border-radius: 4px;
font-size: 12px;
max-height: 300px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<div class="changes-tab">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="IP变更记录"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #change_type="{ record }">
<a-tag :color="getChangeTypeColor(record.change_type)" bordered>
{{ getChangeTypeText(record.change_type) }}
</a-tag>
</template>
<template #changed_at="{ record }">
{{ formatDateTime(record.changed_at) }}
</template>
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
</template>
</search-table>
<a-modal v-model:visible="showDetailDialog" title="变更详情" :width="800" :footer="false">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="IP地址">{{ currentDetail?.ip_address }}</a-descriptions-item>
<a-descriptions-item label="所属子网">{{ currentDetail?.subnet_name }}</a-descriptions-item>
<a-descriptions-item label="变更类型">
<a-tag :color="getChangeTypeColor(currentDetail?.change_type)" bordered>
{{ getChangeTypeText(currentDetail?.change_type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="变更人">{{ currentDetail?.changed_by || '-' }}</a-descriptions-item>
<a-descriptions-item label="变更时间" :span="2">{{ formatDateTime(currentDetail?.changed_at) }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">{{ currentDetail?.remark || '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="currentDetail?.before_json || currentDetail?.after_json" class="diff-section">
<a-row :gutter="16">
<a-col :span="12">
<div class="diff-title">变更前</div>
<pre class="diff-content before">{{ formatJson(currentDetail?.before_json) }}</pre>
</a-col>
<a-col :span="12">
<div class="diff-title">变更后</div>
<pre class="diff-content after">{{ formatJson(currentDetail?.after_json) }}</pre>
</a-col>
</a-row>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import SearchTable from '@/components/search-table/index.vue'
import {
fetchIPChangeList,
fetchIPSubnetList,
type IPChangeItem,
type IPChangeListParams,
type IPSubnetItem,
} from '@/api/ops/ipam'
import type { FormItem } from '@/components/search-form/types'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
const loading = ref(false)
const tableData = ref<IPChangeItem[]>([])
const subnets = ref<IPSubnetItem[]>([])
const showDetailDialog = ref(false)
const currentDetail = ref<IPChangeItem | null>(null)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formModel = reactive({
keyword: '',
subnet_id: '',
change_type: '',
})
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址',
span: 8,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
span: 8,
},
{
field: 'change_type',
label: '变更类型',
type: 'select',
placeholder: '全部类型',
options: [
{ label: '分配', value: 'allocate' },
{ label: '释放', value: 'release' },
{ label: '修改', value: 'modify' },
{ label: '状态变更', value: 'status_change' },
],
span: 8,
},
])
const columns: TableColumnData[] = [
{ title: 'IP地址', dataIndex: 'ip_address', width: 150 },
{ title: '变更类型', dataIndex: 'change_type', slotName: 'change_type', width: 120, align: 'center' },
{ title: '所属子网', dataIndex: 'subnet_name', width: 150 },
{ title: '变更人', dataIndex: 'changed_by', width: 120 },
{ title: '变更时间', dataIndex: 'changed_at', slotName: 'changed_at', width: 180 },
{ title: '备注', dataIndex: 'remark', width: 200 },
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 80, fixed: 'right' },
]
const loadSubnets = async () => {
try {
const response = await fetchIPSubnetList({ size: 1000 })
if (response && response.code === 0) {
subnets.value = response.details?.data || response.data || []
}
} catch (error) {
console.error('Failed to load subnets:', error)
}
}
const loadData = async () => {
loading.value = true
try {
const params: IPChangeListParams = {
page: pagination.current,
size: pagination.pageSize,
...formModel,
}
Object.keys(params).forEach((key) => {
if (!params[key as keyof IPChangeListParams]) {
delete params[key as keyof IPChangeListParams]
}
})
const response = await fetchIPChangeList(params)
if (response && response.code === 0) {
tableData.value = response.details?.data || response.data || []
pagination.total = response.details?.total || response.total || 0
}
} catch (error) {
console.error('Failed to load changes:', error)
} finally {
loading.value = false
}
}
const handleFormModelUpdate = (model: typeof formModel) => {
Object.assign(formModel, model)
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const handleReset = () => {
Object.assign(formModel, {
keyword: '',
subnet_id: '',
change_type: '',
})
pagination.current = 1
loadData()
}
const handlePageChange = (page: number) => {
pagination.current = page
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleDetail = (record: IPChangeItem) => {
currentDetail.value = record
showDetailDialog.value = true
}
const getChangeTypeColor = (type?: string) => {
const colorMap: Record<string, string> = {
allocate: 'green',
release: 'orange',
modify: 'blue',
status_change: 'purple',
}
return colorMap[type || ''] || 'gray'
}
const getChangeTypeText = (type?: string) => {
const textMap: Record<string, string> = {
allocate: '分配',
release: '释放',
modify: '修改',
status_change: '状态变更',
}
return textMap[type || ''] || type || '-'
}
const formatDateTime = (datetime?: string) => {
if (!datetime) return '-'
return new Date(datetime).toLocaleString('zh-CN')
}
const formatJson = (jsonStr?: string) => {
if (!jsonStr) return '{}'
try {
return JSON.stringify(JSON.parse(jsonStr), null, 2)
} catch {
return jsonStr
}
}
onMounted(() => {
loadSubnets()
loadData()
})
</script>
<style scoped lang="less">
.diff-section {
margin-top: 16px;
}
.diff-title {
font-weight: 500;
margin-bottom: 8px;
color: var(--color-text-1);
}
.diff-content {
background: var(--color-fill-1);
padding: 12px;
border-radius: 4px;
font-size: 12px;
max-height: 300px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
&.before {
border-left: 3px solid #f53f3f;
}
&.after {
border-left: 3px solid #00b42a;
}
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<div class="conflicts-tab">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="IP冲突记录"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #status="{ record }">
<a-tag :color="record.status === 'resolved' ? 'green' : 'red'" bordered>
{{ record.status === 'resolved' ? '已解决' : '未解决' }}
</a-tag>
</template>
<template #detected_at="{ record }">
{{ formatDateTime(record.detected_at) }}
</template>
<template #resolved_at="{ record }">
{{ record.resolved_at ? formatDateTime(record.resolved_at) : '-' }}
</template>
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
</template>
</search-table>
<a-modal v-model:visible="showDetailDialog" title="冲突详情" :width="700" :footer="false">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="IP地址">{{ currentDetail?.ip_address }}</a-descriptions-item>
<a-descriptions-item label="所属子网">{{ currentDetail?.subnet_name }}</a-descriptions-item>
<a-descriptions-item label="MAC地址1">{{ currentDetail?.mac_address_1 }}</a-descriptions-item>
<a-descriptions-item label="主机名1">{{ currentDetail?.hostname_1 }}</a-descriptions-item>
<a-descriptions-item label="MAC地址2">{{ currentDetail?.mac_address_2 }}</a-descriptions-item>
<a-descriptions-item label="主机名2">{{ currentDetail?.hostname_2 }}</a-descriptions-item>
<a-descriptions-item label="发现时间">{{ formatDateTime(currentDetail?.detected_at) }}</a-descriptions-item>
<a-descriptions-item label="解决时间">{{ currentDetail?.resolved_at ? formatDateTime(currentDetail?.resolved_at) : '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="currentDetail?.status === 'resolved' ? 'green' : 'red'" bordered>
{{ currentDetail?.status === 'resolved' ? '已解决' : '未解决' }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
<div v-if="currentDetail?.evidence_json" class="evidence-section">
<div class="evidence-title">证据信息</div>
<pre class="evidence-content">{{ formatJson(currentDetail?.evidence_json) }}</pre>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import SearchTable from '@/components/search-table/index.vue'
import {
fetchIPConflictList,
fetchIPSubnetList,
type IPConflictItem,
type IPConflictListParams,
type IPSubnetItem,
} from '@/api/ops/ipam'
import type { FormItem } from '@/components/search-form/types'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
const loading = ref(false)
const tableData = ref<IPConflictItem[]>([])
const subnets = ref<IPSubnetItem[]>([])
const showDetailDialog = ref(false)
const currentDetail = ref<IPConflictItem | null>(null)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formModel = reactive({
keyword: '',
subnet_id: '',
status: '',
})
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址',
span: 8,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
span: 8,
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '未解决', value: 'unresolved' },
{ label: '已解决', value: 'resolved' },
],
span: 8,
},
])
const columns: TableColumnData[] = [
{ title: 'IP地址', dataIndex: 'ip_address', width: 150 },
{ title: 'MAC地址1', dataIndex: 'mac_address_1', width: 180 },
{ title: '主机名1', dataIndex: 'hostname_1', width: 150 },
{ title: 'MAC地址2', dataIndex: 'mac_address_2', width: 180 },
{ title: '主机名2', dataIndex: 'hostname_2', width: 150 },
{ title: '所属子网', dataIndex: 'subnet_name', width: 150 },
{ title: '发现时间', dataIndex: 'detected_at', slotName: 'detected_at', width: 180 },
{ title: '解决时间', dataIndex: 'resolved_at', slotName: 'resolved_at', width: 180 },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 80, fixed: 'right' },
]
const loadSubnets = async () => {
try {
const response = await fetchIPSubnetList({ size: 1000 })
if (response && response.code === 0) {
subnets.value = response.details?.data || response.data || []
}
} catch (error) {
console.error('Failed to load subnets:', error)
}
}
const loadData = async () => {
loading.value = true
try {
const params: IPConflictListParams = {
page: pagination.current,
size: pagination.pageSize,
...formModel,
}
Object.keys(params).forEach((key) => {
if (!params[key as keyof IPConflictListParams]) {
delete params[key as keyof IPConflictListParams]
}
})
const response = await fetchIPConflictList(params)
if (response && response.code === 0) {
tableData.value = response.details?.data || response.data || []
pagination.total = response.details?.total || response.total || 0
}
} catch (error) {
console.error('Failed to load conflicts:', error)
} finally {
loading.value = false
}
}
const handleFormModelUpdate = (model: typeof formModel) => {
Object.assign(formModel, model)
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const handleReset = () => {
Object.assign(formModel, {
keyword: '',
subnet_id: '',
status: '',
})
pagination.current = 1
loadData()
}
const handlePageChange = (page: number) => {
pagination.current = page
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleDetail = (record: IPConflictItem) => {
currentDetail.value = record
showDetailDialog.value = true
}
const formatDateTime = (datetime?: string) => {
if (!datetime) return '-'
return new Date(datetime).toLocaleString('zh-CN')
}
const formatJson = (jsonStr?: string) => {
if (!jsonStr) return '{}'
try {
return JSON.stringify(JSON.parse(jsonStr), null, 2)
} catch {
return jsonStr
}
}
onMounted(() => {
loadSubnets()
loadData()
})
</script>
<style scoped lang="less">
.evidence-section {
margin-top: 16px;
}
.evidence-title {
font-weight: 500;
margin-bottom: 8px;
color: var(--color-text-1);
}
.evidence-content {
background: var(--color-fill-1);
padding: 12px;
border-radius: 4px;
font-size: 12px;
max-height: 300px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<div class="dhcp-leases-tab">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="DHCP租约"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #status="{ record }">
<a-tag :color="record.status === 'active' ? 'green' : 'gray'" bordered>
{{ record.status === 'active' ? '活跃' : '已过期' }}
</a-tag>
</template>
<template #lease_start="{ record }">
{{ formatDateTime(record.lease_start) }}
</template>
<template #lease_end="{ record }">
{{ formatDateTime(record.lease_end) }}
</template>
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
</template>
</search-table>
<a-modal v-model:visible="showDetailDialog" title="租约详情" :width="600" :footer="false">
<a-descriptions :data="detailData" :column="2" bordered />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import SearchTable from '@/components/search-table/index.vue'
import { fetchDHCPLeaseList, fetchIPSubnetList, type DHCPLeaseItem, type DHCPLeaseListParams, type IPSubnetItem } from '@/api/ops/ipam'
import type { FormItem } from '@/components/search-form/types'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
const loading = ref(false)
const tableData = ref<DHCPLeaseItem[]>([])
const subnets = ref<IPSubnetItem[]>([])
const showDetailDialog = ref(false)
const currentDetail = ref<DHCPLeaseItem | null>(null)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formModel = reactive({
keyword: '',
subnet_id: '',
})
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址、MAC地址',
span: 8,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
span: 8,
},
])
const columns: TableColumnData[] = [
{ title: 'IP地址', dataIndex: 'ip_address', width: 150 },
{ title: 'MAC地址', dataIndex: 'mac_address', width: 180 },
{ title: '主机名', dataIndex: 'hostname', width: 200 },
{ title: '所属子网', dataIndex: 'subnet_name', width: 150 },
{ title: 'DHCP服务器', dataIndex: 'dhcp_server', width: 150 },
{ title: '租约开始', dataIndex: 'lease_start', slotName: 'lease_start', width: 180 },
{ title: '租约结束', dataIndex: 'lease_end', slotName: 'lease_end', width: 180 },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
{ title: '操作', dataIndex: 'actions', slotName: 'actions', width: 100, fixed: 'right' },
]
const detailData = computed(() => {
if (!currentDetail.value) return []
return [
{ label: 'IP地址', value: currentDetail.value.ip_address },
{ label: 'MAC地址', value: currentDetail.value.mac_address },
{ label: '主机名', value: currentDetail.value.hostname },
{ label: '所属子网', value: currentDetail.value.subnet_name },
{ label: 'DHCP服务器', value: currentDetail.value.dhcp_server },
{ label: '租约开始', value: formatDateTime(currentDetail.value.lease_start) },
{ label: '租约结束', value: formatDateTime(currentDetail.value.lease_end) },
{ label: '状态', value: currentDetail.value.status === 'active' ? '活跃' : '已过期' },
]
})
const loadSubnets = async () => {
try {
const response = await fetchIPSubnetList({ size: 1000 })
if (response && response.code === 0) {
subnets.value = response.details?.data || response.data || []
}
} catch (error) {
console.error('Failed to load subnets:', error)
}
}
const loadData = async () => {
loading.value = true
try {
const params: DHCPLeaseListParams = {
page: pagination.current,
size: pagination.pageSize,
...formModel,
}
Object.keys(params).forEach((key) => {
if (!params[key as keyof DHCPLeaseListParams]) {
delete params[key as keyof DHCPLeaseListParams]
}
})
const response = await fetchDHCPLeaseList(params)
if (response && response.code === 0) {
tableData.value = response.details?.data || response.data || []
pagination.total = response.details?.total || response.total || 0
}
} catch (error) {
console.error('Failed to load DHCP leases:', error)
} finally {
loading.value = false
}
}
const handleFormModelUpdate = (model: typeof formModel) => {
Object.assign(formModel, model)
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const handleReset = () => {
Object.assign(formModel, {
keyword: '',
subnet_id: '',
})
pagination.current = 1
loadData()
}
const handlePageChange = (page: number) => {
pagination.current = page
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleDetail = (record: DHCPLeaseItem) => {
currentDetail.value = record
showDetailDialog.value = true
}
const formatDateTime = (datetime: string) => {
if (!datetime) return '-'
return new Date(datetime).toLocaleString('zh-CN')
}
onMounted(() => {
loadSubnets()
loadData()
})
</script>

View File

@@ -0,0 +1,343 @@
<template>
<div class="ip-list-tab">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="IP地址列表"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon>
<icon-plus />
</template>
新增IP
</a-button>
</template>
<template #allocation_status="{ record }">
<a-tag :color="getAllocationStatusColor(record.allocation_status)" bordered>
{{ getAllocationStatusText(record.allocation_status) }}
</a-tag>
</template>
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)" bordered>
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template #usage_status="{ record }">
<a-tag :color="getUsageStatusColor(record.usage_status)" bordered>
{{ getUsageStatusText(record.usage_status) }}
</a-tag>
</template>
<template #source_type="{ record }">
<a-tag :color="getSourceTypeColor(record.source_type)" bordered>
{{ getSourceTypeText(record.source_type) }}
</a-tag>
</template>
<template #last_used_at="{ record }">
{{ formatDateTime(record.last_used_at) }}
</template>
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm content="确定删除该IP地址吗" @ok="handleDelete(record)">
<a-button type="text" size="small" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</search-table>
<IPFormDialog v-model="showFormDialog" :edit-data="currentEditData" :subnets="subnets" @success="handleFormSuccess" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import SearchTable from '@/components/search-table/index.vue'
import IPFormDialog from './IPFormDialog.vue'
import { ipAllocationColumns } from '../config/columns'
import {
fetchIPAddressList,
deleteIPAddress,
fetchIPSubnetList,
type IPAddressItem,
type IPAddressListParams,
type IPSubnetItem,
type AllocationStatus,
type IPStatus,
type UsageStatus,
type SourceType,
} from '@/api/ops/ipam'
import type { FormItem } from '@/components/search-form/types'
const loading = ref(false)
const tableData = ref<IPAddressItem[]>([])
const subnets = ref<IPSubnetItem[]>([])
const showFormDialog = ref(false)
const currentEditData = ref<IPAddressItem | null>(null)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formModel = reactive({
keyword: '',
subnet_id: '',
allocation_status: '',
status: '',
usage_status: '',
})
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址、主机名',
span: 6,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: subnets.value.map((s) => ({ label: `${s.name} (${s.cidr})`, value: s.id })),
span: 6,
},
{
field: 'allocation_status',
label: '分配状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '已分配', value: 'allocated' },
{ label: '未分配', value: 'unallocated' },
{ label: '保留', value: 'reserved' },
],
span: 6,
},
{
field: 'status',
label: '运行状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '未知', value: 'unknown' },
],
span: 6,
},
{
field: 'usage_status',
label: '使用状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '已使用', value: 'used' },
{ label: '未使用', value: 'unused' },
{ label: '30天内使用', value: 'used_within_30d' },
{ label: '30天前使用', value: 'used_before_30d' },
],
span: 6,
},
])
const columns = ipAllocationColumns
const loadSubnets = async () => {
try {
const response = await fetchIPSubnetList({ size: 1000 })
if (response && response.code === 0) {
subnets.value = response.details?.data || response.data || []
}
} catch (error) {
console.error('Failed to load subnets:', error)
}
}
const loadData = async () => {
loading.value = true
try {
const params: IPAddressListParams = {
page: pagination.current,
size: pagination.pageSize,
...formModel,
}
Object.keys(params).forEach((key) => {
if (!params[key as keyof IPAddressListParams]) {
delete params[key as keyof IPAddressListParams]
}
})
const response = await fetchIPAddressList(params)
if (response && response.code === 0) {
tableData.value = response.details?.data || response.data || []
pagination.total = response.details?.total || response.total || 0
}
} catch (error) {
console.error('Failed to load IP addresses:', error)
} finally {
loading.value = false
}
}
const handleFormModelUpdate = (model: typeof formModel) => {
Object.assign(formModel, model)
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const handleReset = () => {
Object.assign(formModel, {
keyword: '',
subnet_id: '',
allocation_status: '',
status: '',
usage_status: '',
})
pagination.current = 1
loadData()
}
const handlePageChange = (page: number) => {
pagination.current = page
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleAdd = () => {
currentEditData.value = null
showFormDialog.value = true
}
const handleEdit = (record: IPAddressItem) => {
currentEditData.value = record
showFormDialog.value = true
}
const handleDelete = async (record: IPAddressItem) => {
try {
await deleteIPAddress(record.id)
Message.success('删除成功')
loadData()
} catch (error) {
Message.error('删除失败')
}
}
const handleFormSuccess = () => {
loadData()
}
const getAllocationStatusColor = (status: AllocationStatus) => {
const colorMap: Record<AllocationStatus, string> = {
allocated: 'blue',
unallocated: 'gray',
reserved: 'orange',
}
return colorMap[status] || 'gray'
}
const getAllocationStatusText = (status: AllocationStatus) => {
const textMap: Record<AllocationStatus, string> = {
allocated: '已分配',
unallocated: '未分配',
reserved: '保留',
}
return textMap[status] || status
}
const getStatusColor = (status: IPStatus) => {
const colorMap: Record<IPStatus, string> = {
online: 'green',
offline: 'red',
unknown: 'gray',
}
return colorMap[status] || 'gray'
}
const getStatusText = (status: IPStatus) => {
const textMap: Record<IPStatus, string> = {
online: '在线',
offline: '离线',
unknown: '未知',
}
return textMap[status] || status
}
const getUsageStatusColor = (status: UsageStatus) => {
const colorMap: Record<UsageStatus, string> = {
used: 'green',
unused: 'gray',
used_within_30d: 'blue',
used_before_30d: 'orange',
}
return colorMap[status] || 'gray'
}
const getUsageStatusText = (status: UsageStatus) => {
const textMap: Record<UsageStatus, string> = {
used: '已使用',
unused: '未使用',
used_within_30d: '30天内使用',
used_before_30d: '30天前使用',
}
return textMap[status] || status
}
const getSourceTypeColor = (type: SourceType) => {
const colorMap: Record<SourceType, string> = {
scan: 'cyan',
dhcp: 'purple',
manual: 'arcoblue',
}
return colorMap[type] || 'gray'
}
const getSourceTypeText = (type: SourceType) => {
const textMap: Record<SourceType, string> = {
scan: '扫描',
dhcp: 'DHCP',
manual: '手工',
}
return textMap[type] || type
}
const formatDateTime = (datetime: string) => {
if (!datetime) return '-'
return new Date(datetime).toLocaleString('zh-CN')
}
onMounted(() => {
loadSubnets()
loadData()
})
</script>

View File

@@ -0,0 +1,176 @@
<template>
<a-modal
v-model:visible="visible"
:title="isEdit ? '编辑IP地址' : '新增IP地址'"
:width="600"
:mask-closable="false"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="ip_address" label="IP地址">
<a-input v-model="formData.ip_address" placeholder="例如: 10.0.1.10" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="subnet_id" label="所属子网">
<a-select v-model="formData.subnet_id" placeholder="请选择子网" allow-clear>
<a-option v-for="subnet in subnets" :key="subnet.id" :value="subnet.id">{{ subnet.name }} ({{ subnet.cidr }})</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="hostname" label="主机名">
<a-input v-model="formData.hostname" placeholder="请输入主机名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="mac_address" label="MAC地址">
<a-input v-model="formData.mac_address" placeholder="例如: 00:50:56:AB:01:10" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item field="allocation_status" label="分配状态">
<a-select v-model="formData.allocation_status" placeholder="请选择">
<a-option value="allocated">已分配</a-option>
<a-option value="unallocated">未分配</a-option>
<a-option value="reserved">保留</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="source_type" label="来源类型">
<a-select v-model="formData.source_type" placeholder="请选择">
<a-option value="manual">手工</a-option>
<a-option value="scan">扫描</a-option>
<a-option value="dhcp">DHCP</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="status" label="运行状态">
<a-select v-model="formData.status" placeholder="请选择">
<a-option value="online">在线</a-option>
<a-option value="offline">离线</a-option>
<a-option value="unknown">未知</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="tags" label="标签">
<a-input v-model="formData.tags" placeholder="多个标签用逗号分隔" />
</a-form-item>
<a-form-item field="remark" label="备注">
<a-textarea v-model="formData.remark" placeholder="请输入备注" :max-length="200" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue'
import type { FormInstance } from '@arco-design/web-vue/es/form'
import { Message } from '@arco-design/web-vue'
import { type IPAddressItem, type IPAddressFormData, type IPSubnetItem, createIPAddress, updateIPAddress } from '@/api/ops/ipam'
const props = defineProps<{
modelValue: boolean
editData?: IPAddressItem | null
subnets?: IPSubnetItem[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}>()
const visible = ref(props.modelValue)
const formRef = ref<FormInstance>()
const isEdit = ref(false)
const formData = reactive<IPAddressFormData>({
ip_address: '',
subnet_id: undefined,
hostname: '',
mac_address: '',
allocation_status: 'allocated',
source_type: 'manual',
status: 'unknown',
tags: '',
remark: '',
})
const rules = {
ip_address: [{ required: true, message: '请输入IP地址' }],
subnet_id: [{ required: true, message: '请选择所属子网' }],
}
watch(
() => props.modelValue,
(val) => {
visible.value = val
if (val) {
if (props.editData) {
isEdit.value = true
Object.assign(formData, {
ip_address: props.editData.ip_address,
subnet_id: props.editData.subnet_id,
hostname: props.editData.hostname,
mac_address: props.editData.mac_address,
allocation_status: props.editData.allocation_status,
source_type: props.editData.source_type,
status: props.editData.status,
tags: props.editData.tags,
remark: props.editData.remark,
})
} else {
isEdit.value = false
Object.assign(formData, {
ip_address: '',
subnet_id: undefined,
hostname: '',
mac_address: '',
allocation_status: 'allocated',
source_type: 'manual',
status: 'unknown',
tags: '',
remark: '',
})
}
}
}
)
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleOk = async () => {
try {
const valid = await formRef.value?.validate()
if (valid) return
if (isEdit.value && props.editData) {
await updateIPAddress(props.editData.id, formData)
Message.success('更新成功')
} else {
await createIPAddress(formData)
Message.success('创建成功')
}
emit('success')
visible.value = false
} catch (error) {
Message.error('操作失败')
}
}
const handleCancel = () => {
visible.value = false
}
</script>

View File

@@ -0,0 +1,332 @@
<template>
<div class="overview-tab">
<a-row :gutter="16" class="stats-row">
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false" :loading="loading">
<div class="stats-content">
<div class="stats-icon stats-icon-primary">
<icon-apps />
</div>
<div class="stats-info">
<div class="stats-title">总IP数</div>
<div class="stats-value">{{ formatNumber(overview?.total_ip || 0) }}</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" :loading="loading">
<div class="stats-content">
<div class="stats-icon stats-icon-cyan">
<icon-storage />
</div>
<div class="stats-info">
<div class="stats-title">已分配</div>
<div class="stats-value">{{ formatNumber(overview?.allocated || 0) }}</div>
<div class="stats-desc">使用率 {{ getPercent(overview?.allocated, overview?.total_ip) }}%</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false" :loading="loading">
<div class="stats-content">
<div class="stats-icon stats-icon-blue">
<icon-check-circle />
</div>
<div class="stats-info">
<div class="stats-title">已使用</div>
<div class="stats-value">{{ formatNumber(overview?.used || 0) }}</div>
<div class="stats-desc">30天内使用 {{ overview?.used_within_30d || 0 }}</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stats-card" :bordered="false" :loading="loading">
<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">{{ overview?.conflict_unresolved || 0 }}</div>
<div class="stats-desc">未解决冲突</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="16" class="mt-6">
<a-col :span="24">
<a-card :bordered="false" :loading="loading">
<template #title>
<div class="card-title-row">
<span class="card-title">子网使用率 TOP10</span>
</div>
</template>
<div v-if="overview?.subnet_usage_top10?.length" class="chart-container">
<div v-for="subnet in overview.subnet_usage_top10" :key="subnet.subnet_id" class="subnet-usage-item">
<div class="subnet-info">
<span class="subnet-name">{{ subnet.name }}</span>
<span class="subnet-cidr">{{ subnet.cidr }}</span>
</div>
<div class="subnet-usage-bar">
<a-progress :percent="subnet.usage_percent" :stroke-width="12" :color="getUsageColor(subnet.usage_percent)" />
</div>
<div class="subnet-usage-text">{{ subnet.used }} / {{ subnet.total }} ({{ subnet.usage_percent }}%)</div>
</div>
</div>
<a-empty v-else description="暂无数据" />
</a-card>
</a-col>
</a-row>
<a-row :gutter="16" class="mt-6">
<a-col :xs="24" :lg="12">
<a-card :bordered="false" :loading="loading">
<template #title>
<span class="card-title">分配状态统计</span>
</template>
<div class="stat-grid">
<div class="stat-item">
<span class="stat-label">已分配</span>
<span class="stat-value">{{ formatNumber(overview?.allocated || 0) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">未分配</span>
<span class="stat-value">{{ formatNumber(overview?.unallocated || 0) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">保留</span>
<span class="stat-value">{{ formatNumber(overview?.reserved || 0) }}</span>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" :loading="loading">
<template #title>
<span class="card-title">使用状态统计</span>
</template>
<div class="stat-grid">
<div class="stat-item">
<span class="stat-label">已使用</span>
<span class="stat-value">{{ formatNumber(overview?.used || 0) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">未使用</span>
<span class="stat-value">{{ formatNumber(overview?.unused || 0) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">30天内使用</span>
<span class="stat-value">{{ formatNumber(overview?.used_within_30d || 0) }}</span>
</div>
<div class="stat-item">
<span class="stat-label">30天前使用</span>
<span class="stat-value">{{ formatNumber(overview?.used_before_30d || 0) }}</span>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { IconApps, IconStorage, IconCheckCircle, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
import { fetchIPAMOverview, type IPAMOverview } from '@/api/ops/ipam'
const loading = ref(false)
const overview = ref<IPAMOverview | null>(null)
const loadData = async () => {
loading.value = true
try {
const response = await fetchIPAMOverview()
if (response && response.code === 0) {
overview.value = response.details || response.data || response
}
} catch (error) {
console.error('Failed to load overview:', error)
} finally {
loading.value = false
}
}
const formatNumber = (num: number) => {
return num.toLocaleString()
}
const getPercent = (used?: number, total?: number) => {
if (!used || !total) return 0
return Math.round((used / total) * 100)
}
const getUsageColor = (percent: number) => {
if (percent >= 80) return '#F53F3F'
if (percent >= 70) return '#FF7D00'
return '#165DFF'
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="less">
.stats-row {
margin-bottom: 0;
}
.stats-card {
.stats-content {
display: flex;
align-items: center;
gap: 16px;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
&-primary {
background: rgba(22, 93, 255, 0.1);
color: #165dff;
}
&-cyan {
background: rgba(20, 201, 201, 0.1);
color: #14c9c9;
}
&-blue {
background: rgba(22, 93, 255, 0.1);
color: #165dff;
}
&-warning {
background: rgba(255, 125, 0, 0.1);
color: #ff7d00;
}
}
.stats-info {
flex: 1;
}
.stats-title {
font-size: 14px;
color: var(--color-text-3);
margin-bottom: 4px;
}
.stats-value {
font-size: 24px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.2;
}
.stats-desc {
font-size: 12px;
margin-top: 4px;
color: var(--color-text-3);
}
}
.mt-6 {
margin-top: 24px;
}
.card-title-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.card-title {
font-weight: 500;
color: var(--color-text-1);
}
.chart-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.subnet-usage-item {
display: grid;
grid-template-columns: 200px 1fr 120px;
gap: 16px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--color-border);
&:last-child {
border-bottom: none;
}
}
.subnet-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.subnet-name {
font-weight: 500;
color: var(--color-text-1);
}
.subnet-cidr {
font-size: 12px;
color: var(--color-text-3);
}
.subnet-usage-bar {
flex: 1;
}
.subnet-usage-text {
text-align: right;
font-size: 12px;
color: var(--color-text-2);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: var(--color-fill-1);
border-radius: 8px;
.stat-label {
font-size: 14px;
color: var(--color-text-3);
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: var(--color-text-1);
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<a-modal
v-model:visible="visible"
:title="isEdit ? '编辑子网' : '新增子网'"
:width="600"
:mask-closable="false"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="cidr" label="子网CIDR">
<a-input v-model="formData.cidr" placeholder="例如: 10.0.1.0/24" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="名称">
<a-input v-model="formData.name" placeholder="例如: 服务器网段" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="gateway" label="网关">
<a-input v-model="formData.gateway" placeholder="例如: 10.0.1.1" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="vlan" label="VLAN">
<a-input v-model="formData.vlan" placeholder="例如: VLAN 10" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="group_id" label="所属分组">
<a-select v-model="formData.group_id" placeholder="请选择分组" allow-clear>
<a-option v-for="group in groups" :key="group.id" :value="group.id">
{{ group.name }}
</a-option>
</a-select>
</a-form-item>
<a-form-item field="reserved_ranges_json" label="保留地址范围">
<a-input v-model="formData.reserved_ranges_json" placeholder='例如: [{"start":"10.0.1.1","end":"10.0.1.10"}]' />
</a-form-item>
<a-form-item field="description" label="描述">
<a-textarea v-model="formData.description" placeholder="请输入描述" :max-length="200" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue'
import type { FormInstance } from '@arco-design/web-vue/es/form'
import { Message } from '@arco-design/web-vue'
import { type IPSubnetItem, type IPSubnetFormData, type IPGroupItem, createIPSubnet, updateIPSubnet } from '@/api/ops/ipam'
const props = defineProps<{
modelValue: boolean
editData?: IPSubnetItem | null
groups?: IPGroupItem[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}>()
const visible = ref(props.modelValue)
const formRef = ref<FormInstance>()
const isEdit = ref(false)
const formData = reactive<IPSubnetFormData>({
cidr: '',
name: '',
gateway: '',
vlan: '',
group_id: undefined,
reserved_ranges_json: '',
description: '',
})
const rules = {
cidr: [{ required: true, message: '请输入子网CIDR' }],
name: [{ required: true, message: '请输入名称' }],
gateway: [{ required: true, message: '请输入网关' }],
}
watch(
() => props.modelValue,
(val) => {
visible.value = val
if (val) {
if (props.editData) {
isEdit.value = true
Object.assign(formData, {
cidr: props.editData.cidr,
name: props.editData.name,
gateway: props.editData.gateway,
vlan: props.editData.vlan,
group_id: props.editData.group_id,
reserved_ranges_json: props.editData.reserved_ranges_json,
description: props.editData.description,
})
} else {
isEdit.value = false
Object.assign(formData, {
cidr: '',
name: '',
gateway: '',
vlan: '',
group_id: undefined,
reserved_ranges_json: '',
description: '',
})
}
}
}
)
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleOk = async () => {
try {
const valid = await formRef.value?.validate()
if (valid) return
if (isEdit.value && props.editData) {
await updateIPSubnet(props.editData.id, formData)
Message.success('更新成功')
} else {
await createIPSubnet(formData)
Message.success('创建成功')
}
emit('success')
visible.value = false
} catch (error) {
Message.error('操作失败')
}
}
const handleCancel = () => {
visible.value = false
}
</script>

View File

@@ -0,0 +1,233 @@
<template>
<div class="subnet-list-tab">
<search-table
:form-model="formModel"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
title="子网管理"
search-button-text="查询"
reset-button-text="重置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="handleRefresh"
>
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon>
<icon-plus />
</template>
新增子网
</a-button>
</template>
<template #usage="{ record }">
<a-progress
:percent="Math.round((record.used / record.total) * 100)"
:stroke-width="8"
:show-text="false"
:color="getUsageColor((record.used / record.total) * 100)"
/>
<div class="usage-text">{{ record.used }} / {{ record.total }} ({{ Math.round((record.used / record.total) * 100) }}%)</div>
</template>
<template #status="{ record }">
<a-tag :color="getUsageStatusColor(record.used, record.total)" bordered>
{{ getUsageStatusText(record.used, record.total) }}
</a-tag>
</template>
<template #actions="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm content="确定删除该子网吗?" @ok="handleDelete(record)">
<a-button type="text" size="small" status="danger">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</search-table>
<SubnetFormDialog v-model="showFormDialog" :edit-data="currentEditData" :groups="groups" @success="handleFormSuccess" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import SearchTable from '@/components/search-table/index.vue'
import SubnetFormDialog from './SubnetFormDialog.vue'
import { subnetColumns } from '../config/columns'
import {
fetchIPSubnetList,
deleteIPSubnet,
fetchIPGroupList,
type IPSubnetItem,
type IPSubnetListParams,
type IPGroupItem,
} from '@/api/ops/ipam'
import type { FormItem } from '@/components/search-form/types'
const loading = ref(false)
const tableData = ref<IPSubnetItem[]>([])
const groups = ref<IPGroupItem[]>([])
const showFormDialog = ref(false)
const currentEditData = ref<IPSubnetItem | null>(null)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
const formModel = reactive({
keyword: '',
group_id: '',
})
const formItems = computed<FormItem[]>(() => [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入子网CIDR、名称',
span: 8,
},
{
field: 'group_id',
label: '分组',
type: 'select',
placeholder: '全部分组',
options: groups.value.map((g) => ({ label: g.name, value: g.id })),
span: 8,
},
])
const columns = subnetColumns
const loadGroups = async () => {
try {
const response = await fetchIPGroupList()
if (response && response.code === 0) {
groups.value = response.details?.data || response.data || []
}
} catch (error) {
console.error('Failed to load groups:', error)
}
}
const loadData = async () => {
loading.value = true
try {
const params: IPSubnetListParams = {
page: pagination.current,
size: pagination.pageSize,
...formModel,
}
Object.keys(params).forEach((key) => {
if (!params[key as keyof IPSubnetListParams]) {
delete params[key as keyof IPSubnetListParams]
}
})
const response = await fetchIPSubnetList(params)
if (response && response.code === 0) {
tableData.value = response.details?.data || response.data || []
pagination.total = response.details?.total || response.total || 0
}
} catch (error) {
console.error('Failed to load subnets:', error)
} finally {
loading.value = false
}
}
const handleFormModelUpdate = (model: typeof formModel) => {
Object.assign(formModel, model)
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const handleReset = () => {
Object.assign(formModel, {
keyword: '',
group_id: '',
})
pagination.current = 1
loadData()
}
const handlePageChange = (page: number) => {
pagination.current = page
loadData()
}
const handleRefresh = () => {
loadData()
}
const handleAdd = () => {
currentEditData.value = null
showFormDialog.value = true
}
const handleEdit = (record: IPSubnetItem) => {
currentEditData.value = record
showFormDialog.value = true
}
const handleDelete = async (record: IPSubnetItem) => {
try {
await deleteIPSubnet(record.id)
Message.success('删除成功')
loadData()
} catch (error) {
Message.error('删除失败')
}
}
const handleFormSuccess = () => {
loadData()
}
const getUsageColor = (percent: number) => {
if (percent >= 80) return '#F53F3F'
if (percent >= 70) return '#FF7D00'
return '#165DFF'
}
const getUsageStatusColor = (used: number, total: number) => {
const percent = (used / total) * 100
if (percent >= 80) return 'red'
if (percent >= 70) return 'orange'
return 'green'
}
const getUsageStatusText = (used: number, total: number) => {
const percent = (used / total) * 100
if (percent >= 80) return '严重'
if (percent >= 70) return '警告'
return '正常'
}
onMounted(() => {
loadGroups()
loadData()
})
</script>
<style scoped lang="less">
.usage-text {
font-size: 12px;
color: var(--color-text-3);
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,325 @@
export const subnetColumns = [
{
dataIndex: 'cidr',
title: '子网',
width: 140,
},
{
dataIndex: 'name',
title: '名称',
width: 120,
},
{
dataIndex: 'vlan',
title: 'VLAN',
width: 100,
},
{
dataIndex: 'gateway',
title: '网关',
width: 120,
},
{
dataIndex: 'total',
title: '总数',
width: 80,
align: 'center' as const,
},
{
dataIndex: 'used',
title: '已用',
width: 80,
align: 'center' as const,
},
{
dataIndex: 'available',
title: '可用',
width: 80,
align: 'center' as const,
},
{
dataIndex: 'usage',
title: '使用率',
width: 160,
slotName: 'usage',
},
{
dataIndex: 'status',
title: '状态',
width: 100,
align: 'center' as const,
slotName: 'status',
},
{
dataIndex: 'actions',
title: '操作',
width: 120,
fixed: 'right' as const,
slotName: 'actions',
},
]
export const ipAllocationColumns = [
{
dataIndex: 'ip_address',
title: 'IP地址',
width: 150,
},
{
dataIndex: 'hostname',
title: '主机名',
width: 200,
},
{
dataIndex: 'mac_address',
title: 'MAC地址',
width: 180,
},
{
dataIndex: 'allocation_status',
title: '分配状态',
width: 100,
align: 'center' as const,
slotName: 'allocation_status',
},
{
dataIndex: 'status',
title: '运行状态',
width: 100,
align: 'center' as const,
slotName: 'status',
},
{
dataIndex: 'usage_status',
title: '使用状态',
width: 120,
align: 'center' as const,
slotName: 'usage_status',
},
{
dataIndex: 'source_type',
title: '来源',
width: 80,
align: 'center' as const,
slotName: 'source_type',
},
{
dataIndex: 'last_used_at',
title: '最后使用',
width: 180,
slotName: 'last_used_at',
},
{
dataIndex: 'remark',
title: '备注',
width: 150,
},
{
dataIndex: 'actions',
title: '操作',
width: 100,
fixed: 'right' as const,
slotName: 'actions',
},
]
export const dhcpLeaseColumns = [
{
dataIndex: 'ip_address',
title: 'IP地址',
width: 150,
},
{
dataIndex: 'mac_address',
title: 'MAC地址',
width: 180,
},
{
dataIndex: 'hostname',
title: '主机名',
width: 200,
},
{
dataIndex: 'subnet_name',
title: '所属子网',
width: 150,
},
{
dataIndex: 'dhcp_server',
title: 'DHCP服务器',
width: 150,
},
{
dataIndex: 'lease_start',
title: '租约开始',
width: 180,
slotName: 'lease_start',
},
{
dataIndex: 'lease_end',
title: '租约结束',
width: 180,
slotName: 'lease_end',
},
{
dataIndex: 'status',
title: '状态',
width: 100,
align: 'center' as const,
slotName: 'status',
},
{
dataIndex: 'actions',
title: '操作',
width: 80,
fixed: 'right' as const,
slotName: 'actions',
},
]
export const conflictColumns = [
{
dataIndex: 'ip_address',
title: 'IP地址',
width: 150,
},
{
dataIndex: 'mac_address_1',
title: 'MAC地址1',
width: 180,
},
{
dataIndex: 'hostname_1',
title: '主机名1',
width: 150,
},
{
dataIndex: 'mac_address_2',
title: 'MAC地址2',
width: 180,
},
{
dataIndex: 'hostname_2',
title: '主机名2',
width: 150,
},
{
dataIndex: 'subnet_name',
title: '所属子网',
width: 150,
},
{
dataIndex: 'detected_at',
title: '发现时间',
width: 180,
slotName: 'detected_at',
},
{
dataIndex: 'resolved_at',
title: '解决时间',
width: 180,
slotName: 'resolved_at',
},
{
dataIndex: 'status',
title: '状态',
width: 100,
align: 'center' as const,
slotName: 'status',
},
{
dataIndex: 'actions',
title: '操作',
width: 80,
fixed: 'right' as const,
slotName: 'actions',
},
]
export const changeColumns = [
{
dataIndex: 'ip_address',
title: 'IP地址',
width: 150,
},
{
dataIndex: 'change_type',
title: '变更类型',
width: 120,
align: 'center' as const,
slotName: 'change_type',
},
{
dataIndex: 'subnet_name',
title: '所属子网',
width: 150,
},
{
dataIndex: 'changed_by',
title: '变更人',
width: 120,
},
{
dataIndex: 'changed_at',
title: '变更时间',
width: 180,
slotName: 'changed_at',
},
{
dataIndex: 'remark',
title: '备注',
width: 200,
},
{
dataIndex: 'actions',
title: '操作',
width: 80,
fixed: 'right' as const,
slotName: 'actions',
},
]
export const anomalyColumns = [
{
dataIndex: 'ip_address',
title: 'IP地址',
width: 150,
},
{
dataIndex: 'anomaly_type',
title: '异常类型',
width: 120,
align: 'center' as const,
slotName: 'anomaly_type',
},
{
dataIndex: 'subnet_name',
title: '所属子网',
width: 150,
},
{
dataIndex: 'detected_at',
title: '发现时间',
width: 180,
slotName: 'detected_at',
},
{
dataIndex: 'status',
title: '状态',
width: 100,
align: 'center' as const,
slotName: 'status',
},
{
dataIndex: 'remark',
title: '备注',
width: 200,
},
{
dataIndex: 'actions',
title: '操作',
width: 80,
fixed: 'right' as const,
slotName: 'actions',
},
]

View File

@@ -0,0 +1,194 @@
import type { FormItem } from '@/components/search-form/types'
export const subnetSearchForm: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入子网CIDR、名称',
span: 6,
},
{
field: 'group_id',
label: '分组',
type: 'select',
placeholder: '全部分组',
options: [],
span: 6,
},
]
export const ipSearchForm: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址、主机名',
span: 6,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: [],
span: 6,
},
{
field: 'allocation_status',
label: '分配状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '已分配', value: 'allocated' },
{ label: '未分配', value: 'unallocated' },
{ label: '保留', value: 'reserved' },
],
span: 6,
},
{
field: 'status',
label: '运行状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '未知', value: 'unknown' },
],
span: 6,
},
{
field: 'usage_status',
label: '使用状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '已使用', value: 'used' },
{ label: '未使用', value: 'unused' },
{ label: '30天内使用', value: 'used_within_30d' },
{ label: '30天前使用', value: 'used_before_30d' },
],
span: 6,
},
]
export const dhcpLeaseSearchForm: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址、MAC地址',
span: 8,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: [],
span: 8,
},
]
export const conflictSearchForm: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址',
span: 8,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: [],
span: 8,
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '未解决', value: 'unresolved' },
{ label: '已解决', value: 'resolved' },
],
span: 8,
},
]
export const changeSearchForm: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址',
span: 8,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: [],
span: 8,
},
{
field: 'change_type',
label: '变更类型',
type: 'select',
placeholder: '全部类型',
options: [
{ label: '分配', value: 'allocate' },
{ label: '释放', value: 'release' },
{ label: '修改', value: 'modify' },
{ label: '状态变更', value: 'status_change' },
],
span: 8,
},
]
export const anomalySearchForm: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入IP地址',
span: 6,
},
{
field: 'subnet_id',
label: '子网',
type: 'select',
placeholder: '全部子网',
options: [],
span: 6,
},
{
field: 'anomaly_type',
label: '异常类型',
type: 'select',
placeholder: '全部类型',
options: [
{ label: 'IP扫描异常', value: 'scan_anomaly' },
{ label: 'MAC地址变更', value: 'mac_change' },
{ label: '未知设备', value: 'unknown_device' },
{ label: 'IP地址泄漏', value: 'ip_leak' },
],
span: 6,
},
{
field: 'status',
label: '状态',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '待处理', value: 'pending' },
{ label: '已处理', value: 'resolved' },
],
span: 6,
},
]

View File

@@ -1,420 +1,42 @@
<template> <template>
<div class="container"> <div class="container">
<!-- 页面标题 --> <a-tabs v-model:active-key="activeTab" type="card-gutter">
<!-- <div class="page-header"> <a-tab-pane key="overview" title="概览">
<div class="page-title"> <OverviewTab />
<h2>IP地址管理</h2> </a-tab-pane>
<p class="page-subtitle">管理IP地址分配监控地址池使用情况</p> <a-tab-pane key="ip-list" title="IP地址列表">
</div> <IPAddressListTab />
<div class="page-actions"> </a-tab-pane>
<a-button type="secondary" class="mr-2"> <a-tab-pane key="subnets" title="子网管理">
<template #icon><icon-download /></template> <SubnetListTab />
导出 </a-tab-pane>
</a-button> <a-tab-pane key="dhcp-leases" title="DHCP租约">
<a-button type="primary"> <DHCPLeasesTab />
<template #icon><icon-plus /></template> </a-tab-pane>
添加子网 <a-tab-pane key="conflicts" title="冲突记录">
</a-button> <ConflictsTab />
</div> </a-tab-pane>
</div> --> <a-tab-pane key="changes" title="变更记录">
<ChangesTab />
<!-- 统计卡片 --> </a-tab-pane>
<a-row :gutter="16" class="stats-row"> <a-tab-pane key="anomalies" title="异常记录">
<a-col :xs="24" :sm="12" :lg="6"> <AnomaliesTab />
<a-card class="stats-card" :bordered="false"> </a-tab-pane>
<div class="stats-content"> </a-tabs>
<div class="stats-icon stats-icon-primary">
<icon-wifi />
</div>
<div class="stats-info">
<div class="stats-title">IP地址池</div>
<div class="stats-value">6</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-cyan">
<icon-apps />
</div>
<div class="stats-info">
<div class="stats-title">总IP数</div>
<div class="stats-value">1,524</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-blue">
<icon-storage />
</div>
<div class="stats-info">
<div class="stats-title">已分配</div>
<div class="stats-value">630</div>
<div class="stats-desc">使用率 41.3%</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-exclamation-circle />
</div>
<div class="stats-info">
<div class="stats-title">冲突告警</div>
<div class="stats-value">0</div>
<div class="stats-desc text-success">无IP冲突</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 地址池概览和快速查询 -->
<a-row :gutter="16" class="mt-6 overview-row">
<a-col :xs="24" :lg="16" class="overview-col">
<a-card :bordered="false" class="overview-card">
<template #title>
<div class="card-title-row">
<span class="card-title">地址池概览</span>
<span class="card-subtitle">各子网使用情况可视化</span>
</div>
</template>
<div class="subnet-list">
<div
v-for="subnet in ipSubnets"
:key="subnet.subnet"
:class="['subnet-item', { active: selectedSubnet === subnet.subnet }]"
@click="selectedSubnet = selectedSubnet === subnet.subnet ? '' : subnet.subnet"
>
<div class="subnet-header">
<div>
<p class="subnet-name">{{ subnet.name }}</p>
<p class="subnet-info">{{ subnet.subnet }} · {{ subnet.vlan }}</p>
</div>
<div class="subnet-usage">
<p class="usage-value">{{ subnet.used }} / {{ subnet.total }}</p>
<p class="usage-label">已用 / 总数</p>
</div>
</div>
<div class="subnet-progress">
<a-progress
:percent="Math.round((subnet.used / subnet.total) * 100)"
:stroke-width="8"
:show-text="false"
:color="getUsageColor((subnet.used / subnet.total) * 100)"
/>
</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :lg="8" class="overview-col">
<a-card :bordered="false" class="overview-card">
<template #title>
<div class="card-title-row">
<span class="card-title">快速查询</span>
<span class="card-subtitle">搜索IP地址或主机名</span>
</div>
</template>
<div class="search-section">
<a-input v-model="searchQuery" placeholder="输入IP或主机名..." allow-clear>
<template #prefix><icon-search /></template>
</a-input>
</div>
<a-divider />
<div class="stats-section">
<h4 class="section-title">分配类型统计</h4>
<div class="stat-item">
<div class="stat-label">
<span class="color-dot color-dot-blue"></span>
<span>静态分配</span>
</div>
<span class="stat-value">245 (39%)</span>
</div>
<div class="stat-item">
<div class="stat-label">
<span class="color-dot color-dot-cyan"></span>
<span>DHCP分配</span>
</div>
<span class="stat-value">385 (61%)</span>
</div>
</div>
<a-divider />
<div class="stats-section">
<h4 class="section-title">DHCP服务器</h4>
<div class="stat-item">
<span class="stat-label">主DHCP</span>
<a-tag color="green" bordered>在线</a-tag>
</div>
<div class="stat-item">
<span class="stat-label">备DHCP</span>
<a-tag color="green" bordered>在线</a-tag>
</div>
<div class="stat-item">
<span class="stat-label">租约数</span>
<span class="stat-value">385</span>
</div>
<div class="stat-item">
<span class="stat-label">平均租期</span>
<span class="stat-value">8小时</span>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 子网列表 -->
<a-card class="mt-6" :bordered="false">
<template #title>
<div class="table-header">
<span>子网列表</span>
<a-select v-model="subnetFilter" placeholder="全部子网" style="width: 140px">
<a-option value="">全部子网</a-option>
<a-option value="server">服务器网段</a-option>
<a-option value="office">办公网段</a-option>
<a-option value="guest">访客网段</a-option>
</a-select>
</div>
</template>
<a-table
:data="ipSubnets"
:columns="subnetColumns"
:pagination="false"
row-key="subnet"
>
<template #usage="{ record }">
<a-progress
:percent="Math.round((record.used / record.total) * 100)"
:stroke-width="8"
:show-text="false"
:color="getUsageColor((record.used / record.total) * 100)"
/>
</template>
<template #status="{ record }">
<a-tag
v-if="record.usageStatus === 'warning'"
color="orange"
bordered
>
使用率高
</a-tag>
<a-tag v-else color="green" bordered>正常</a-tag>
</template>
</a-table>
</a-card>
<!-- IP分配记录 -->
<a-card class="mt-6" :bordered="false">
<template #title>
<div class="table-header">
<span>IP分配记录</span>
<a-space>
<a-select v-model="typeFilter" placeholder="全部类型" style="width: 120px">
<a-option value="">全部类型</a-option>
<a-option value="static">静态</a-option>
<a-option value="dhcp">DHCP</a-option>
</a-select>
<a-select v-model="statusFilter" placeholder="全部状态" style="width: 120px">
<a-option value="">全部状态</a-option>
<a-option value="online">在线</a-option>
<a-option value="offline">离线</a-option>
</a-select>
</a-space>
</div>
</template>
<a-table
:data="ipAllocations"
:columns="allocationColumns"
:pagination="false"
row-key="ip"
>
<template #status="{ record }">
<a-tag :color="record.status === 'online' ? 'green' : 'red'" bordered>
{{ record.status === 'online' ? '在线' : '离线' }}
</a-tag>
</template>
</a-table>
</a-card>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue' import { ref } from 'vue'
import { import OverviewTab from './components/OverviewTab.vue'
IconWifi, import IPAddressListTab from './components/IPAddressListTab.vue'
IconApps, import SubnetListTab from './components/SubnetListTab.vue'
IconStorage, import DHCPLeasesTab from './components/DHCPLeasesTab.vue'
IconExclamationCircle, import ConflictsTab from './components/ConflictsTab.vue'
IconDownload, import ChangesTab from './components/ChangesTab.vue'
IconPlus, import AnomaliesTab from './components/AnomaliesTab.vue'
IconSearch,
} from '@arco-design/web-vue/es/icon'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
// 搜索和筛选 const activeTab = ref('overview')
const searchQuery = ref('')
const selectedSubnet = ref('')
const subnetFilter = ref('')
const typeFilter = ref('')
const statusFilter = ref('')
// 获取使用率颜色
const getUsageColor = (percent: number) => {
if (percent >= 70) return '#FF7D00'
return '#165DFF'
}
// 子网数据
const ipSubnets = ref([
{
subnet: '10.0.0.0/24',
name: '核心网络',
vlan: 'VLAN 1',
gateway: '10.0.0.1',
total: 254,
used: 45,
available: 209,
usageStatus: 'success',
},
{
subnet: '10.0.1.0/24',
name: '服务器网段',
vlan: 'VLAN 10',
gateway: '10.0.1.1',
total: 254,
used: 186,
available: 68,
usageStatus: 'warning',
},
{
subnet: '10.0.2.0/24',
name: '虚拟化网段',
vlan: 'VLAN 20',
gateway: '10.0.2.1',
total: 254,
used: 124,
available: 130,
usageStatus: 'success',
},
{
subnet: '10.0.3.0/24',
name: '存储网段',
vlan: 'VLAN 30',
gateway: '10.0.3.1',
total: 254,
used: 32,
available: 222,
usageStatus: 'success',
},
{
subnet: '192.168.1.0/24',
name: '办公网络',
vlan: 'VLAN 100',
gateway: '192.168.1.1',
total: 254,
used: 198,
available: 56,
usageStatus: 'warning',
},
{
subnet: '192.168.2.0/24',
name: '访客网络',
vlan: 'VLAN 200',
gateway: '192.168.2.1',
total: 254,
used: 45,
available: 209,
usageStatus: 'success',
},
])
// IP分配数据
const ipAllocations = ref([
{
ip: '10.0.1.10',
hostname: 'Web-Server-01',
mac: '00:50:56:AB:01:10',
type: '静态',
status: 'online',
lastSeen: '在线',
},
{
ip: '10.0.1.11',
hostname: 'Web-Server-02',
mac: '00:50:56:AB:01:11',
type: '静态',
status: 'online',
lastSeen: '在线',
},
{
ip: '10.0.1.50',
hostname: 'DB-Master',
mac: '00:50:56:AB:01:50',
type: '静态',
status: 'online',
lastSeen: '在线',
},
{
ip: '10.0.1.51',
hostname: 'DB-Slave',
mac: '00:50:56:AB:01:51',
type: '静态',
status: 'online',
lastSeen: '在线',
},
{
ip: '192.168.1.100',
hostname: 'PC-Zhang',
mac: '00:1A:2B:3C:4D:10',
type: 'DHCP',
status: 'online',
lastSeen: '在线',
},
{
ip: '192.168.1.101',
hostname: 'PC-Li',
mac: '00:1A:2B:3C:4D:11',
type: 'DHCP',
status: 'offline',
lastSeen: '2小时前',
},
])
// 子网表格列
const subnetColumns: TableColumnData[] = [
{ title: '子网', dataIndex: 'subnet', width: 140 },
{ title: '名称', dataIndex: 'name', width: 120 },
{ title: 'VLAN', dataIndex: 'vlan', width: 100 },
{ title: '网关', dataIndex: 'gateway', width: 120 },
{ title: '总数', dataIndex: 'total', width: 80, align: 'center' },
{ title: '已用', dataIndex: 'used', width: 80, align: 'center' },
{ title: '可用', dataIndex: 'available', width: 80, align: 'center' },
{ title: '使用率', dataIndex: 'usage', slotName: 'usage', width: 160 },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
]
// IP分配表格列
const allocationColumns: TableColumnData[] = [
{ title: 'IP地址', dataIndex: 'ip', width: 150 },
{ title: '主机名', dataIndex: 'hostname', width: 200 },
{ title: 'MAC地址', dataIndex: 'mac', width: 180 },
{ title: '分配类型', dataIndex: 'type', width: 100, align: 'center' },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
{ title: '最后在线', dataIndex: 'lastSeen', width: 120 },
]
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -428,268 +50,9 @@ export default {
padding: 20px; padding: 20px;
} }
.page-header { :deep(.arco-tabs-card-gutter) {
display: flex; .arco-tabs-content {
justify-content: space-between; padding: 16px 0;
align-items: flex-start;
margin-bottom: 24px;
.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);
}
}
.page-actions {
display: flex;
gap: 8px;
}
}
.mr-2 {
margin-right: 8px;
}
.stats-row {
margin-bottom: 0;
}
.stats-card {
.stats-content {
display: flex;
align-items: center;
gap: 16px;
}
.stats-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
&-primary {
background: rgba(22, 93, 255, 0.1);
color: #165dff;
}
&-cyan {
background: rgba(20, 201, 201, 0.1);
color: #14c9c9;
}
&-blue {
background: rgba(22, 93, 255, 0.1);
color: #165dff;
}
&-success {
background: rgba(0, 180, 42, 0.1);
color: #00b42a;
}
}
.stats-info {
flex: 1;
}
.stats-title {
font-size: 14px;
color: var(--color-text-3);
margin-bottom: 4px;
}
.stats-value {
font-size: 24px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.2;
}
.stats-desc {
font-size: 12px;
margin-top: 4px;
color: var(--color-text-3);
&.text-success {
color: #00b42a;
}
}
}
.mt-6 {
margin-top: 24px;
}
.overview-row {
display: flex;
align-items: stretch;
}
.overview-col {
display: flex;
:deep(.arco-card) {
flex: 1;
display: flex;
flex-direction: column;
.arco-card-body {
flex: 1;
}
}
}
.overview-card {
height: 100%;
.card-title-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.card-title {
font-weight: 500;
color: var(--color-text-1);
}
.card-subtitle {
font-size: 12px;
font-weight: 400;
color: var(--color-text-3);
}
}
.subnet-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.subnet-item {
padding: 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: rgba(22, 93, 255, 0.5);
}
&.active {
border-color: #165dff;
background: rgba(22, 93, 255, 0.05);
}
}
.subnet-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.subnet-name {
font-weight: 500;
color: var(--color-text-1);
margin: 0;
}
.subnet-info {
font-size: 12px;
color: var(--color-text-3);
margin: 4px 0 0;
}
.subnet-usage {
text-align: right;
.usage-value {
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
margin: 0;
}
.usage-label {
font-size: 12px;
color: var(--color-text-3);
margin: 4px 0 0;
}
}
.subnet-progress {
margin-top: 12px;
}
.search-section {
margin-top: 8px;
}
.stats-section {
.section-title {
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
margin: 0 0 16px;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.stat-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text-3);
}
.stat-value {
font-size: 14px;
font-weight: 500;
color: var(--color-text-1);
}
}
.color-dot {
width: 12px;
height: 12px;
border-radius: 2px;
&-blue {
background: #165dff;
}
&-cyan {
background: #14c9c9;
}
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
} }
</style> </style>

File diff suppressed because one or more lines are too long