Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/UserNetworkingManage.vue
T
lin b3ed406f84
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m9s
fix: 提交修改
2026-04-15 16:02:36 +08:00

437 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="networking-manage-container">
<div class="page-header" v-if="!embedded">
<div class="header-left">
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
<div class="header-info">
<h3>用户组网管理</h3>
<span class="sub-info" v-if="serviceName">主控服务{{ serviceName }}</span>
</div>
</div>
<div class="header-right">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建组网</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建组网</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<div class="filter-bar">
<el-input v-model="keyword" placeholder="关键词搜索" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="filterHostId" placeholder="按宿主机筛选" clearable filterable style="width: 200px" @change="handleSearch">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name || ''} (${h.ip || h.id})`" :value="h.id" />
</el-select>
<el-input v-model="filterUserId" placeholder="按用户ID筛选" clearable style="width: 160px" @keyup.enter="handleSearch" @clear="handleSearch" />
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="user_id" label="用户ID" width="90" />
<el-table-column label="宿主机" width="130">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column>
<el-table-column prop="bridge_name" label="网桥" width="120" show-overflow-tooltip />
<el-table-column prop="gateway" label="网关" width="140" show-overflow-tooltip />
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleViewDetail(row)">详情</el-button>
<el-button link type="success" size="small" @click="handleAssign(row)">分配IP</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
<!-- 创建组网弹窗 -->
<el-dialog v-model="createDialogVisible" title="创建用户组网" width="520px" destroy-on-close>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="createForm.name" placeholder="组网名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
<el-form-item label="用户" prop="user_id">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-input :model-value="createForm.user_id ? `${createUserName} (ID: ${createForm.user_id})` : ''" readonly placeholder="请选择用户" style="flex: 1" />
<el-button type="primary" @click="showCreateUserSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable clearable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name || ''} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="createForm.bridge_name" placeholder=" br0可选" />
</el-form-item>
<el-form-item label="网关地址">
<el-input v-model="createForm.gateway" placeholder=" 10.0.0.1可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">确定</el-button>
</template>
</el-dialog>
<!-- 分配组网 IP 弹窗 -->
<el-dialog v-model="assignDialogVisible" title="为虚拟机分配组网 IP" width="480px" destroy-on-close>
<el-form ref="assignFormRef" :model="assignForm" :rules="assignRules" label-width="100px">
<el-form-item label="组网">
<el-input :model-value="assignTarget?.name || '-'" disabled />
</el-form-item>
<el-form-item label="虚拟机" prop="vm_id">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-input :model-value="assignForm.vm_id ? `${assignVmName || ''} (ID: ${assignForm.vm_id})` : ''" readonly placeholder="请选择虚拟机" style="flex: 1" />
<el-button type="primary" @click="showAssignVmSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="指定 IP">
<el-input v-model="assignForm.ip" placeholder="留空自动分配" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitAssign">分配</el-button>
</template>
</el-dialog>
<!-- 虚拟机选择器 -->
<VmSelectorPopup v-model="showAssignVmSelector" :service-id="serviceId" :current-id="assignForm.vm_id" @confirm="handleAssignVmSelected" />
<!-- 用户选择器(创建组网用) -->
<UserListSelector v-model="showCreateUserSelector" :current-user-id="createForm.user_id" @confirm="handleCreateUserSelected" />
<!-- 组网详情弹窗 -->
<el-dialog v-model="detailVisible" title="组网详情" width="800px" destroy-on-close>
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading" style="margin-bottom: 20px">
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="描述">{{ currentDetail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentDetail.user_id }}</el-descriptions-item>
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
<el-descriptions-item label="网桥">{{ currentDetail.bridge_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ currentDetail.gateway || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(currentDetail.updated_at) }}</el-descriptions-item>
</el-descriptions>
<div class="networks-section" v-if="detailNetworks.length">
<div class="networks-header">
<h4>组网下的网络 ({{ detailNetworks.length }})</h4>
</div>
<el-table :data="detailNetworks" stripe size="small">
<el-table-column label="网络ID" width="80">
<template #default="{ row }">{{ row.network?.id || '-' }}</template>
</el-table-column>
<el-table-column label="IP 地址" min-width="150">
<template #default="{ row }">
<span class="mono-text">{{ row.network?.address || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="MAC 地址" min-width="160">
<template #default="{ row }">
<span class="mono-text">{{ row.network?.mac_address || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="networkTypeTagType(row.network?.type)" size="small">{{ networkTypeLabel(row.network?.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定虚拟机" width="110">
<template #default="{ row }">
<span v-if="row.network?.vm_id">ID: {{ row.network.vm_id }}</span>
<el-tag v-else type="info" size="small">未绑定</el-tag>
</template>
</el-table-column>
<el-table-column label="网桥" width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.network?.bridge_name || '-' }}</template>
</el-table-column>
<el-table-column label="目标设备" width="90" show-overflow-tooltip>
<template #default="{ row }">{{ row.network?.target_device || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleRemoveNetwork(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else-if="!detailLoading" description="该组网下暂无网络" :image-size="60" />
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue'
import {
getRemoteHostList,
getUserNetworkingList, getUserNetworkingDetail,
createUserNetworking, assignUserNetworking,
deleteUserNetworking, removeUserNetworkingNetwork
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
const submitLoading = ref(false)
const detailLoading = ref(false)
const list = ref([])
const total = ref(0)
const keyword = ref('')
const filterHostId = ref(null)
const filterUserId = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, count: 10 })
const formatTime = (t) => {
if (!t) return '-'
if (t.seconds) return dayjs.unix(t.seconds).format('YYYY-MM-DD HH:mm:ss')
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
}
const NETWORK_TYPE_MAP = {
nat: { label: 'NAT', type: '' },
bridge: { label: '桥接', type: 'success' },
direct: { label: '直连', type: 'warning' }
}
const networkTypeLabel = (t) => NETWORK_TYPE_MAP[t]?.label || t || '-'
const networkTypeTagType = (t) => NETWORK_TYPE_MAP[t]?.type ?? 'info'
const getHostLabel = (hid) => {
const h = hostOptions.value.find(x => x.id === hid)
return h ? `${h.name || ''} (${h.ip || h.id})` : (hid || '-')
}
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
}
} catch (e) { console.error('加载宿主机列表失败:', e) }
}
const loadList = async () => {
loading.value = true
try {
const params = {
service_id: serviceId.value,
page: queryParams.page,
count: queryParams.count
}
if (keyword.value) params.keyword = keyword.value
if (filterHostId.value) params.host_id = filterHostId.value
if (filterUserId.value) params.user_id = parseInt(filterUserId.value) || undefined
const res = await getUserNetworkingList(params)
if (res?.data?.code === 200) {
const d = res.data.data
if (d) {
list.value = d.data || d.list || (Array.isArray(d) ? d : [])
total.value = d.meta?.count ?? d.total ?? list.value.length
} else {
list.value = []
total.value = 0
}
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const handleSearch = () => { queryParams.page = 1; loadList() }
// ---- 创建组网 ----
const createDialogVisible = ref(false)
const createFormRef = ref(null)
const createForm = reactive({ name: '', description: '', user_id: null, host_id: null, bridge_name: '', gateway: '' })
const createUserName = ref('')
const showCreateUserSelector = ref(false)
const createRules = {
name: [{ required: true, message: '请输入组网名称', trigger: 'blur' }],
user_id: [{ required: true, message: '请选择用户', trigger: 'change' }],
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
}
const handleAdd = () => {
Object.assign(createForm, { name: '', description: '', user_id: null, host_id: null, bridge_name: '', gateway: '' })
createUserName.value = ''
createDialogVisible.value = true
}
const handleCreateUserSelected = (user) => {
createForm.user_id = user.user_id || user.id
createUserName.value = user.user_name || user.name || ''
createFormRef.value?.validateField('user_id')
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('name', createForm.name)
if (createForm.description) fd.append('description', createForm.description)
fd.append('user_id', createForm.user_id)
fd.append('host_id', createForm.host_id)
if (createForm.bridge_name) fd.append('bridge_name', createForm.bridge_name)
if (createForm.gateway) fd.append('gateway', createForm.gateway)
const res = await createUserNetworking(fd)
if (res?.data?.code === 200) {
ElMessage.success('组网创建成功')
createDialogVisible.value = false
loadList()
} else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { submitLoading.value = false }
})
}
// ---- 分配组网 IP ----
const assignDialogVisible = ref(false)
const assignFormRef = ref(null)
const assignTarget = ref(null)
const assignVmName = ref('')
const showAssignVmSelector = ref(false)
const assignForm = reactive({ vm_id: null, ip: '' })
const assignRules = {
vm_id: [{ required: true, message: '请选择虚拟机', trigger: 'change' }]
}
const handleAssign = (row) => {
assignTarget.value = row
Object.assign(assignForm, { vm_id: null, ip: '' })
assignVmName.value = ''
assignDialogVisible.value = true
}
const handleAssignVmSelected = (vm) => {
assignForm.vm_id = vm.id
assignVmName.value = vm.name || ''
}
const submitAssign = () => {
assignFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('networking_id', assignTarget.value.id)
fd.append('vm_id', assignForm.vm_id)
if (assignForm.ip) fd.append('ip', assignForm.ip)
const res = await assignUserNetworking(fd)
if (res?.data?.code === 200) {
ElMessage.success('IP 分配成功')
assignDialogVisible.value = false
loadList()
} else ElMessage.error(extractApiError(res?.data, '分配失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '分配失败')) } finally { submitLoading.value = false }
})
}
// ---- 组网详情 ----
const detailVisible = ref(false)
const currentDetail = ref(null)
const detailNetworks = ref([])
const handleViewDetail = async (row) => {
currentDetail.value = row
detailNetworks.value = []
detailVisible.value = true
detailLoading.value = true
try {
const res = await getUserNetworkingDetail({ service_id: serviceId.value, networking_id: row.id })
if (res?.data?.code === 200) {
const d = res.data.data
currentDetail.value = d?.data || d || row
detailNetworks.value = d?.networks || []
}
} catch { ElMessage.warning('获取详情失败') } finally { detailLoading.value = false }
}
// ---- 删除组网 ----
const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除组网「${row.name || row.id}」吗?此操作不可恢复。`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteUserNetworking({ service_id: serviceId.value, networking_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
// ---- 移除组网下的网络 ----
const handleRemoveNetwork = (netItem) => {
const net = netItem.network || {}
const addrLabel = net.address ? ` (${net.address})` : ''
const vmLabel = net.vm_id ? `,关联虚拟机 ID: ${net.vm_id}` : ''
ElMessageBox.confirm(`确定要从组网中移除网络 #${net.id || '?'}${addrLabel}${vmLabel} 吗?`, '移除确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('networking_id', currentDetail.value.id)
fd.append('network_id', net.id)
fd.append('vm_id', net.vm_id || 0)
const res = await removeUserNetworkingNetwork(fd)
if (res?.data?.code === 200) {
ElMessage.success('移除成功')
handleViewDetail(currentDetail.value)
} else ElMessage.error(extractApiError(res?.data, '移除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '移除失败')) }
}).catch(() => {})
}
const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(async () => {
if (serviceId.value) {
await loadHostOptions()
loadList()
}
})
</script>
<style scoped>
.networking-manage-container { padding: 20px; }
.networks-section { margin-top: 8px; }
.networks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.networks-header h4 { margin: 0; font-size: 15px; color: #303133; }
</style>