diff --git a/src/api/admin/kvmService.js b/src/api/admin/kvmService.js index 4705a5e..c9eda77 100644 --- a/src/api/admin/kvmService.js +++ b/src/api/admin/kvmService.js @@ -260,6 +260,13 @@ export const updateNetwork = (data) => { }) } +/** 批量创建网络 */ +export const batchCreateNetwork = (data) => { + return http2.post('/api/v1/admin/server/host_service/point/network/batch_create', data, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + /** 删除网络 */ export const deleteNetwork = (params) => { return http2.delete('/api/v1/admin/server/host_service/point/network/delete', { params }) @@ -436,6 +443,13 @@ export const deleteVm = (params) => { return http2.delete('/api/v1/admin/server/host_service/point/vm/delete', { params }) } +/** 迁移虚拟机(更换宿主机) */ +export const migrateVm = (data) => { + return http2.post('/api/v1/admin/service/host_service/point/vm/migrate', data, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + /** * ================================ * 主控服务接口 - 安全组管理 diff --git a/src/components/admin/NetworkSelectorPopup.vue b/src/components/admin/NetworkSelectorPopup.vue index d8e0c5b..faa5e4f 100644 --- a/src/components/admin/NetworkSelectorPopup.vue +++ b/src/components/admin/NetworkSelectorPopup.vue @@ -5,10 +5,20 @@ - + + 仅{{ filterType === 'bridge' ? '网桥' : filterType === 'nat' ? '内网' : filterType }} + + + + + {{ filterUsed === 'false' ? '仅未占用' : '仅已占用' }} + + + + + + +
props.modelValue, (val) => { if (val) { page.value = 1 keyword.value = '' - typeFilter.value = '' + typeFilter.value = props.filterType || '' + usedFilter.value = props.filterUsed || '' + ipVersionFilter.value = '' selectedItem.value = null loadList() } @@ -88,9 +109,13 @@ const loadList = async () => { if (!props.serviceId || !props.hostId) return loading.value = true try { - const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value,type: type.value } + const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value } + const effectiveType = props.filterType || typeFilter.value || type.value + if (effectiveType) params.type = effectiveType if (keyword.value) params.keyword = keyword.value - if (typeFilter.value) params.type = typeFilter.value + const effectiveUsed = props.filterUsed || usedFilter.value + if (effectiveUsed) params.used = effectiveUsed + if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value const res = await getNetworkList(params) if (res?.data?.code === 200 && res?.data?.data) { const inner = res.data.data diff --git a/src/config/env.js b/src/config/env.js index b91a842..be87778 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -9,7 +9,7 @@ const isDevelopment = import.meta.env.MODE === 'development' // API 基础地址 // 开发环境使用 vite 代理 (baseUrl 为空),生产环境使用实际地址 const API_BASE_MAP = { - development: '', // 开发环境通过 vite proxy 代理 + development: import.meta.env.VITE_API_BASE_URL || 'https://apiservertest.s1f.ren', // 直接请求后端,不走 vite proxy production: import.meta.env.VITE_API_BASE_URL || 'https://cloudapi.007yjs.com', staging: import.meta.env.VITE_API_BASE_URL || 'https://apiservertest.s1f.ren' } @@ -19,7 +19,7 @@ const currentEnv = import.meta.env.VITE_APP_ENV || import.meta.env.MODE || 'deve export const baseUrl = API_BASE_MAP[currentEnv] || API_BASE_MAP.development // ACS 服务基础地址 -export const acsBaseUrl = isDevelopment ? '' : baseUrl +export const acsBaseUrl = baseUrl // 网站标题 export const siteTitle = '007UI管理系统' diff --git a/src/views/virtualization/HostDetail.vue b/src/views/virtualization/HostDetail.vue index 4d2016d..aa04c6e 100644 --- a/src/views/virtualization/HostDetail.vue +++ b/src/views/virtualization/HostDetail.vue @@ -201,7 +201,136 @@ + + +
+
+

用户组网列表

+
+ + + 搜索 + 创建组网 + 刷新 +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + {{ nwDetailData.id }} + {{ nwDetailData.name }} + {{ nwDetailData.user_id }} + {{ nwDetailData.host_id }} + {{ nwDetailData.bridge_name || '-' }} + {{ nwDetailData.gateway || '-' }} + {{ formatTimestamp(nwDetailData.created_at) }} + {{ formatTimestamp(nwDetailData.updated_at) }} + +

组网下的网络 (已分配)

+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + 选择 + 清除 +
+
+ + + + + + +
+ +
+ + + + + {{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }}) + +
+ + 选择 +
+
+ + + +
+ +
+ + +
@@ -267,9 +396,11 @@ import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Box, Connection } from '@element-plus/icons-vue' +import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Box, Connection, Search, Plus } from '@element-plus/icons-vue' import { - getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost + getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost, + getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking, + assignUserNetworking, removeUserNetworkingNetwork } from '@/api/admin/kvmService' import { extractApiError } from '@/utils/kvmErrorUtil' import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue' @@ -280,6 +411,8 @@ import VmManage from '@/views/virtualization/VmManage.vue' import SnapshotManage from '@/views/virtualization/SnapshotManage.vue' import BackupManage from '@/views/virtualization/BackupManage.vue' import { useTagsViewStore } from '@/store/tagsViewStore' +import UserListSelector from '@/components/admin/UserListSelector.vue' +import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue' import * as echarts from 'echarts' const route = useRoute() @@ -291,7 +424,7 @@ const serviceName = computed(() => route.query.service_name || '') const hostId = computed(() => parseInt(route.query.id) || 0) const activeTab = ref('info') -const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false }) +const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, networking: false }) const imageManageRef = ref(null) const networkManageRef = ref(null) @@ -302,7 +435,7 @@ const backupManageRef = ref(null) const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef } watch(activeTab, (tab) => { - if (!['info', 'monitor'].includes(tab)) { + if (!['info', 'monitor', 'networking'].includes(tab)) { if (!hostTabLoaded[tab]) { hostTabLoaded[tab] = true } else { @@ -311,6 +444,7 @@ watch(activeTab, (tab) => { } if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() } else stopPolling() + if (tab === 'networking') loadNetworkingList() }) const loading = ref(false) @@ -607,6 +741,170 @@ const goBack = () => { router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } }) } +// ---- 组网管理 ---- +const nwLoading = ref(false) +const nwSubmitLoading = ref(false) +const nwList = ref([]) +const nwTotal = ref(0) +const nwPage = ref(1) +const nwPageSize = ref(10) +const nwFilterUserId = ref('') +const nwKeyword = ref('') + +const nwDetailVisible = ref(false) +const nwDetailData = ref(null) +const nwDetailNetworks = ref([]) +const nwDetailLoading = ref(false) + +const nwCreateVisible = ref(false) +const nwCreateFormRef = ref(null) +const nwCreateForm = reactive({ user_id: 0, bridge_name: '', gateway: '' }) +const nwCreateUserName = ref('') +const showNwUserSelector = ref(false) +const nwCreateRules = { + user_id: [{ required: true, message: '请选择用户', trigger: 'change', type: 'number', min: 1 }] +} + +const nwAssignVisible = ref(false) +const nwAssignTarget = ref(null) +const nwAssignVmId = ref(0) +const nwAssignVmName = ref('') +const nwAssignIp = ref('') +const showNwVmSelector = ref(false) + +const loadNetworkingList = async () => { + nwLoading.value = true + try { + const params = { + service_id: serviceId.value, + page: nwPage.value, + count: nwPageSize.value, + host_id: hostId.value + } + if (nwFilterUserId.value) params.user_id = parseInt(nwFilterUserId.value) + if (nwKeyword.value) params.keyword = nwKeyword.value + const res = await getUserNetworkingList(params) + if (res?.data?.code === 200 && res?.data?.data) { + const inner = res.data.data + nwList.value = Array.isArray(inner) ? inner : (inner.data || []) + nwTotal.value = inner.meta?.count ?? inner.total ?? nwList.value.length + } + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取组网列表失败')) } + finally { nwLoading.value = false } +} + +const handleNwDetail = async (row) => { + nwDetailVisible.value = true + nwDetailData.value = row + nwDetailNetworks.value = [] + nwDetailLoading.value = true + try { + const res = await getUserNetworkingDetail({ service_id: serviceId.value, networking_id: row.id }) + if (res?.data?.code === 200 && res?.data?.data) { + const inner = res.data.data + nwDetailData.value = inner.data ?? inner + nwDetailNetworks.value = inner.networks || [] + } + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取详情失败')) } + finally { nwDetailLoading.value = false } +} + +const handleNwCreate = () => { + Object.assign(nwCreateForm, { user_id: 0, bridge_name: '', gateway: '' }) + nwCreateUserName.value = '' + nwCreateVisible.value = true +} + +const handleNwUserSelected = (user) => { + nwCreateForm.user_id = user.user_id || user.id + nwCreateUserName.value = user.user_name || user.name || '' +} + +const submitNwCreate = async () => { + if (!nwCreateForm.user_id) { ElMessage.warning('请选择用户'); return } + nwSubmitLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('host_id', hostId.value) + fd.append('user_id', nwCreateForm.user_id) + if (nwCreateForm.bridge_name) fd.append('bridge_name', nwCreateForm.bridge_name) + if (nwCreateForm.gateway) fd.append('gateway', nwCreateForm.gateway) + const res = await createUserNetworking(fd) + if (res?.data?.code === 200) { + ElMessage.success('创建成功') + nwCreateVisible.value = false + loadNetworkingList() + } else ElMessage.error(extractApiError(res?.data, '创建失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } + finally { nwSubmitLoading.value = false } +} + +const handleNwDelete = (row) => { + ElMessageBox.confirm(`确定删除组网「${row.name || row.id}」?该操作不可撤销。`, '删除确认', { type: 'warning' }) + .then(async () => { + try { + const res = await deleteUserNetworking({ service_id: serviceId.value, networking_id: row.id }) + if (res?.data?.code === 200) { ElMessage.success('已删除'); loadNetworkingList() } + else ElMessage.error(extractApiError(res?.data, '删除失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) } + }).catch(() => {}) +} + +const handleNwAssign = (row) => { + nwAssignTarget.value = row + nwAssignVmId.value = 0 + nwAssignVmName.value = '' + nwAssignIp.value = '' + nwAssignVisible.value = true +} + +const handleNwVmSelected = (vm) => { + nwAssignVmId.value = vm.id + nwAssignVmName.value = vm.name || '' +} + +const submitNwAssign = async () => { + if (!nwAssignVmId.value || !nwAssignTarget.value) { ElMessage.warning('请选择虚拟机'); return } + nwSubmitLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('networking_id', nwAssignTarget.value.id) + fd.append('vm_id', nwAssignVmId.value) + if (nwAssignIp.value.trim()) fd.append('ip', nwAssignIp.value.trim()) + const res = await assignUserNetworking(fd) + if (res?.data?.code === 200) { + ElMessage.success('分配成功') + nwAssignVisible.value = false + if (nwDetailVisible.value && nwDetailData.value?.id === nwAssignTarget.value.id) { + handleNwDetail(nwAssignTarget.value) + } + } else ElMessage.error(extractApiError(res?.data, '分配失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '分配失败')) } + finally { nwSubmitLoading.value = false } +} + +const handleNwRemoveNet = (netItem) => { + const net = netItem.network || netItem + if (!net.id || !nwDetailData.value?.id) { ElMessage.warning('缺少网络信息'); return } + ElMessageBox.confirm(`确定移除网络 (ID: ${net.id}, VM: ${net.vm_id || '-'}) ?`, '移除确认', { type: 'warning' }) + .then(async () => { + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('networking_id', nwDetailData.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('已移除') + handleNwDetail(nwDetailData.value) + } else ElMessage.error(extractApiError(res?.data, '移除失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '移除失败')) } + }).catch(() => {}) +} + let loadedHostId = null const initPage = () => { @@ -694,4 +992,5 @@ onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() }) .unit-input-row { display: flex; gap: 6px; width: 100%; } .wide-number { flex: 1; min-width: 140px; } +.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; } diff --git a/src/views/virtualization/HostManage.vue b/src/views/virtualization/HostManage.vue index 5a87e21..d9c8691 100644 --- a/src/views/virtualization/HostManage.vue +++ b/src/views/virtualization/HostManage.vue @@ -119,8 +119,8 @@ - - + + 资源限制 @@ -206,7 +206,7 @@ 未设置 - {{ currentDetail.private_key_path || '-' }} + {{ currentDetail.private_key ? '已配置' : '未设置' }} {{ currentDetail.max_cpu ? currentDetail.max_cpu + ' 核' : '-' }} {{ formatMemKB(currentDetail.max_memory) }} {{ formatDiskGB(currentDetail.max_disk) }} @@ -324,7 +324,7 @@ const metricsData = ref(null) const formData = reactive({ id: undefined, name: '', base_url: '', ip: '', token: '', - port: 22, user: '', password: '', private_key_path: '', + port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', _groupName: '' @@ -408,7 +408,7 @@ const getGroupName = (gid) => { const resetForm = () => { Object.assign(formData, { id: undefined, name: '', base_url: '', ip: '', token: '', - port: 22, user: '', password: '', private_key_path: '', + port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', _groupName: '' @@ -470,7 +470,7 @@ const handleEdit = (row) => { // 先用列表数据回填,密码需从详情取 Object.assign(formData, { id: row.id, name: row.name, base_url: row.base_url, ip: row.ip, token: row.token || '', - port: row.port || 22, user: row.user || '', password: row.password || '', private_key_path: row.private_key_path || '', + port: row.port || 22, user: row.user || '', password: row.password || '', private_key: row.private_key || '', max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0, rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0, host_group_id: row.host_group_id || 0, description: row.description || '', @@ -483,7 +483,7 @@ const handleEdit = (row) => { const detail = body.data.host ?? body.data.data ?? body.data if (detail.password) formData.password = detail.password if (detail.token) formData.token = detail.token - if (detail.private_key_path) formData.private_key_path = detail.private_key_path + if (detail.private_key) formData.private_key = detail.private_key } }).catch(() => {}) dialogVisible.value = true @@ -500,7 +500,7 @@ const handleSubmit = () => { // 可选参数为空时不提交 if (!payload.token) delete payload.token if (!payload.password) delete payload.password - if (!payload.private_key_path) delete payload.private_key_path + if (!payload.private_key) delete payload.private_key if (!payload.description) delete payload.description if (!payload.host_group_id) delete payload.host_group_id let res diff --git a/src/views/virtualization/HostTreeManage.vue b/src/views/virtualization/HostTreeManage.vue index b50b580..08b1a6f 100644 --- a/src/views/virtualization/HostTreeManage.vue +++ b/src/views/virtualization/HostTreeManage.vue @@ -65,7 +65,7 @@ @@ -190,6 +190,9 @@ + + + 资源限制 @@ -525,7 +528,7 @@ const hostDialogType = ref('add') const hostFormRef = ref(null) const hostForm = reactive({ id: undefined, name: '', base_url: '', ip: '', token: '', - port: 22, user: '', password: '', + port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' }) @@ -537,13 +540,13 @@ const hostFormRules = { const handleAddHost = () => { hostDialogType.value = 'add' - Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' }) + Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' }) hostDialogVisible.value = true } const handleAddHostToGroup = (group) => { hostDialogType.value = 'add' - Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '' }) + Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '' }) hostDialogVisible.value = true } @@ -551,7 +554,7 @@ const handleEditHost = (row) => { hostDialogType.value = 'edit' Object.assign(hostForm, { id: row.id, name: row.name, base_url: row.base_url || '', ip: row.ip || '', token: row.token || '', - port: row.port || 22, user: row.user || '', password: row.password || '', + port: row.port || 22, user: row.user || '', password: row.password || '', private_key: row.private_key || '', max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0, rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0, host_group_id: row.host_group_id || 0, description: row.description || '' @@ -561,6 +564,7 @@ const handleEditHost = (row) => { const d = res.data.data.host ?? res.data.data.data ?? res.data.data if (d.password) hostForm.password = d.password if (d.token) hostForm.token = d.token + if (d.private_key) hostForm.private_key = d.private_key } }).catch(() => {}) hostDialogVisible.value = true @@ -575,6 +579,7 @@ const submitHostForm = () => { delete payload.id if (!payload.token) delete payload.token if (!payload.password) delete payload.password + if (!payload.private_key) delete payload.private_key if (!payload.host_group_id) delete payload.host_group_id if (!payload.description) delete payload.description let res diff --git a/src/views/virtualization/KvmServiceDetail.vue b/src/views/virtualization/KvmServiceDetail.vue index a7a504a..5cfdfd4 100644 --- a/src/views/virtualization/KvmServiceDetail.vue +++ b/src/views/virtualization/KvmServiceDetail.vue @@ -45,9 +45,17 @@
认证Token
-
- 已设置 - 未设置 +
+ {{ tokenVisible ? (serviceInfo.Token || serviceInfo.token) : '••••••••••••' }} + + + + + + +
+
+ 未设置
@@ -134,7 +142,7 @@ import { ref, reactive, computed, provide, onMounted, onActivated, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { ArrowLeft, Refresh, Edit, Delete, Monitor } from '@element-plus/icons-vue' +import { ArrowLeft, Refresh, Edit, Delete, Monitor, View, Hide, CopyDocument } from '@element-plus/icons-vue' import { getKvmServiceDetail, updateKvmService, deleteKvmService } from '@/api/admin/kvmService' @@ -241,6 +249,29 @@ const goBack = () => { router.push('/virtualization/kvm-service') } +// Token 显示/复制 +const tokenVisible = ref(false) +const copyToken = () => { + const token = serviceInfo.value.Token || serviceInfo.value.token + if (!token) return + const textarea = document.createElement('textarea') + textarea.value = token + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + try { + document.execCommand('copy') + ElMessage.success('Token 已复制到剪贴板') + } catch { + ElMessage.error('复制失败') + } finally { + document.body.removeChild(textarea) + } +} + + + // 编辑服务 const editDialogVisible = ref(false) const formRef = ref(null) @@ -482,6 +513,23 @@ onMounted(() => { color: #303133; } +.token-text { + font-family: monospace; + font-size: 12px; + color: #606266; + background: #f5f7fa; + padding: 2px 8px; + border-radius: 4px; + user-select: all; + word-break: break-all; + max-width: 180px; + display: inline-block; + vertical-align: middle; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .note-value { font-weight: 400; font-size: 13px; diff --git a/src/views/virtualization/NetworkManage.vue b/src/views/virtualization/NetworkManage.vue index 570867d..bb15a14 100644 --- a/src/views/virtualization/NetworkManage.vue +++ b/src/views/virtualization/NetworkManage.vue @@ -10,11 +10,13 @@
创建网络 + 批量创建 刷新
创建网络 + 批量创建 刷新
@@ -27,7 +29,10 @@ - + + + +
@@ -133,6 +138,46 @@ + + + + 通过指定 IP 范围(start_ip ~ end_ip)批量创建网络条目 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -141,7 +186,7 @@ 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, getNetworkList, getNetworkDetail, createNetwork, updateNetwork, deleteNetwork } from '@/api/admin/kvmService' +import { getRemoteHostList, getNetworkList, getNetworkDetail, createNetwork, batchCreateNetwork, updateNetwork, deleteNetwork } from '@/api/admin/kvmService' import { extractApiError } from '@/utils/kvmErrorUtil' const route = useRoute() @@ -160,6 +205,7 @@ const networkList = ref([]) const total = ref(0) const keyword = ref('') const filterType = ref('') +const filterIpVersion = ref('') const hostIdInput = ref(0) const hostOptions = ref([]) const queryParams = reactive({ page: 1, page_size: 10 }) @@ -213,6 +259,7 @@ const loadList = async () => { const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, page_size: queryParams.page_size } if (keyword.value) params.key = keyword.value if (filterType.value) params.type = filterType.value + if (filterIpVersion.value) params.ip_version = filterIpVersion.value const res = await getNetworkList(params) const body = res?.data if (body?.code === 200 && body?.data) { @@ -269,7 +316,7 @@ const handleSubmit = () => { if (dialogType.value === 'add') { res = await createNetwork(fd) } else { - fd.append('network_id', formData.id) + fd.append('id', formData.id) res = await updateNetwork(fd) } if (res?.data?.code === 200) { @@ -309,6 +356,57 @@ const handleDelete = (row) => { }).catch(() => {}) } +// ---- 批量创建网络 ---- +const batchDialogVisible = ref(false) +const batchFormRef = ref(null) +const batchForm = reactive({ + host_id: 0, start_ip: '', end_ip: '', gateway: '', mask: '', + nameservers: '', bridge_name: '', type: 'bridge' +}) +const batchFormRules = { + host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }], + start_ip: [{ required: true, message: '请输入起始IP', trigger: 'blur' }], + end_ip: [{ required: true, message: '请输入结束IP', trigger: 'blur' }] +} + +const handleBatchAdd = () => { + Object.assign(batchForm, { + host_id: hostIdInput.value || hostId.value || 0, + start_ip: '', end_ip: '', gateway: '', mask: '', + nameservers: '', bridge_name: '', type: 'bridge' + }) + batchDialogVisible.value = true +} + +const handleBatchSubmit = () => { + batchFormRef.value?.validate(async (valid) => { + if (!valid) return + submitLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('host_id', batchForm.host_id) + fd.append('start_ip', batchForm.start_ip) + fd.append('end_ip', batchForm.end_ip) + if (batchForm.gateway) fd.append('gateway', batchForm.gateway) + if (batchForm.mask) fd.append('mask', batchForm.mask) + if (batchForm.nameservers) fd.append('nameservers', batchForm.nameservers) + if (batchForm.bridge_name) fd.append('bridge_name', batchForm.bridge_name) + if (batchForm.type) fd.append('type', batchForm.type) + const res = await batchCreateNetwork(fd) + if (res?.data?.code === 200) { + ElMessage.success('批量创建成功') + batchDialogVisible.value = false + loadList() + } else { + ElMessage.error(extractApiError(res?.data, '批量创建失败')) + } + } catch (e) { + ElMessage.error(extractApiError(e?.response?.data, '批量创建失败')) + } finally { submitLoading.value = false } + }) +} + const goBack = () => { router.push('/virtualization/kvm-service') } onMounted(async () => { diff --git a/src/views/virtualization/SecurityGroupDetail.vue b/src/views/virtualization/SecurityGroupDetail.vue index 0eefc04..27e5a5f 100644 --- a/src/views/virtualization/SecurityGroupDetail.vue +++ b/src/views/virtualization/SecurityGroupDetail.vue @@ -73,6 +73,7 @@ + - - + + + + @@ -627,19 +688,20 @@
-
-
- - {{ n.name }} (ID:{{ n.id }}) - -
- 选择网络 +
+ + {{ refactorSelectedNetworks[0].name }} (ID:{{ refactorSelectedNetworks[0].id }}) + + {{ refactorSelectedNetworks.length ? '更换网络' : '选择网络' }}
- - - - + +
+ + {{ refactorSelectedInternalNetworks[0].name }} (ID:{{ refactorSelectedInternalNetworks[0].id }}) + + {{ refactorSelectedInternalNetworks.length ? '更换内网' : '选择内网' }} +
@@ -653,8 +715,10 @@ - - + + + + @@ -690,7 +754,7 @@ - + @@ -700,8 +764,50 @@ + + + 将虚拟机迁移到其他宿主机或宿主机组,请谨慎操作! + + + {{ detail?.host_name || detail?.host_id || '-' }} + + + + 指定宿主机 + 指定宿主机组 + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -723,7 +829,7 @@ @@ -968,6 +1074,47 @@ 确定 + + + + + {{ vnJoinTarget?.name || '-' }} (ID: {{ vnJoinTarget?.id }}) + {{ vnJoinTarget?.bridge_name || '-' }} + {{ vnJoinTarget?.gateway || '-' }} + + + + + + + + + + + + + + + + + + + + + {{ vnCreateHostName || '加载中...' }} (ID: {{ vmHostId }}) + + + {{ vnCreateUserName || '加载中...' }} (ID: {{ detail?.user_id || '-' }}) + + + +
@@ -981,7 +1128,7 @@ import { startVm, stopVm, rebootVm, suspendVm, resumeVm, rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm, getVmVnc, getVncNodeList, - getSecurityGroupList, getUserNetworkingList, + getSecurityGroupList, getUserNetworkingList, createUserNetworking, assignUserNetworking, removeUserNetworkingNetwork, getRemoteHostList, getSecurityGroupDetail, createSecurityGroup, syncSecurityGroup, deleteSecurityGroup, enableSecurityGroupWhitelist, disableSecurityGroupWhitelist, @@ -990,8 +1137,10 @@ import { createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume, getVmList, bindSecurityGroup, unbindSecurityGroup, getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, getSnapshotCount, setSnapshotLimit, - getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress, getBackupCount, setBackupLimit + getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress, getBackupCount, setBackupLimit, + migrateVm, getRemoteHostGroupList, getRemoteHostDetail } from '@/api/admin/kvmService' +import { getUserInfo } from '@/api/admin/user' import { extractApiError } from '@/utils/kvmErrorUtil' import * as echarts from 'echarts' import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue' @@ -1007,7 +1156,7 @@ const tagsViewStore = useTagsViewStore() const serviceId = computed(() => parseInt(route.query.service_id) || 0) const serviceName = computed(() => route.query.service_name || '') -const vmId = computed(() => parseInt(route.query.vm_id) || parseInt(route.query.id) || 0) +const vmId = computed(() => parseInt(route.query.vm_id) || 0) const loading = ref(false) const actionLoading = ref(false) @@ -1074,7 +1223,8 @@ const handleMoreCommand = (cmd) => { if (powerActions.includes(cmd)) { handlePower(cmd); return } const actionMap = { editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic, - rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue + rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue, + migrateVm: handleMigrateVm } if (actionMap[cmd]) actionMap[cmd]() } @@ -1337,30 +1487,24 @@ const editFormRef = ref(null) const editForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, root_password: '', ssh_port: 22, - internet_network_id: 0, port_group_id: 0 + port_group_id: '' }) const editSelectedNetworks = ref([]) const showEditNetworkSelector = ref(false) +const editSelectedInternalNetworks = ref([]) +const showEditInternalNetworkSelector = ref(false) const handleEditNetworkConfirm = (network) => { - if (!editSelectedNetworks.value.find(n => n.id === network.id)) { - editSelectedNetworks.value.push({ id: network.id, name: network.name }) - } + editSelectedNetworks.value = [{ id: network.id, name: network.name }] } const removeEditNetwork = (id) => { editSelectedNetworks.value = editSelectedNetworks.value.filter(n => n.id !== id) - if (editForm.internet_network_id === id) editForm.internet_network_id = 0 } - -const networkingOptions = ref([]) -const loadNetworkingOptions = async () => { - try { - const res = await getUserNetworkingList({ service_id: serviceId.value, page: 1, count: 10 }) - if (res?.data?.code === 200 && res?.data?.data) { - const inner = res.data.data - networkingOptions.value = Array.isArray(inner) ? inner : (inner.data || []) - } - } catch { /* */ } +const handleEditInternalNetworkConfirm = (network) => { + editSelectedInternalNetworks.value = [{ id: network.id, name: network.name }] +} +const removeEditInternalNetwork = (id) => { + editSelectedInternalNetworks.value = editSelectedInternalNetworks.value.filter(n => n.id !== id) } const handleEditVm = async () => { @@ -1371,16 +1515,17 @@ const handleEditVm = async () => { tx_bandwidth: d.tx_bandwidth || 0, root_password: '', ssh_port: d.ssh_port || 22, - internet_network_id: '', - port_group_id: vmPortGroup.value?.id || 0 + port_group_id: vmPortGroup.value?.id || '' }) - editSelectedNetworks.value = vmNetworks.value.map(n => ({ id: n.id, name: n.name })) + const bridgeNets = vmNetworks.value.filter(n => n.type === 'bridge') + const natNets = vmNetworks.value.filter(n => n.type === 'nat') + editSelectedNetworks.value = bridgeNets.length ? [{ id: bridgeNets[0].id, name: bridgeNets[0].name }] : [] + editSelectedInternalNetworks.value = natNets.length ? [{ id: natNets[0].id, name: natNets[0].name }] : [] editDialogVisible.value = true dialogOptionsLoading.value = true try { await Promise.all([ - !sgOptions.value.length ? loadSgOptions() : Promise.resolve(), - loadNetworkingOptions() + !sgOptions.value.length ? loadSgOptions() : Promise.resolve() ]) } finally { dialogOptionsLoading.value = false } } @@ -1396,7 +1541,7 @@ const submitEditVm = async () => { if (editForm.root_password) fd.append('root_password', editForm.root_password) fd.append('ssh_port', editForm.ssh_port) editSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id)) - if (editForm.internet_network_id) fd.append('internet_network_id', editForm.internet_network_id) + editSelectedInternalNetworks.value.forEach(n => fd.append('internet_network_id', n.id)) if (editForm.port_group_id) fd.append('port_group_id', editForm.port_group_id) const res = await updateVm(fd) if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() } @@ -1411,12 +1556,14 @@ const refactorForm = reactive({ memory: 0, vcpu: 0, rx_bandwidth: 0, tx_bandwidth: 0, root_password: '', uuid: '', mate_data_id: '', physical_name: '', config_path: '', ssh_port: 0, vnc_port: 0, vnc_password: '', - internet_network_id: 0, port_group_id: 0 + port_group_id: '' }) const refactorMemUnit = ref(1048576) const refactorMemDisplay = ref(0) const refactorSelectedNetworks = ref([]) const showRefactorNetworkSelector = ref(false) +const refactorSelectedInternalNetworks = ref([]) +const showRefactorInternalNetworkSelector = ref(false) const onRefactorMemUnitChange = () => { refactorMemDisplay.value = refactorForm.memory ? Math.round(refactorForm.memory / refactorMemUnit.value * 100) / 100 : 0 @@ -1424,13 +1571,16 @@ const onRefactorMemUnitChange = () => { watch(refactorMemDisplay, (v) => { refactorForm.memory = Math.round(v * refactorMemUnit.value) }) const handleRefactorNetworkConfirm = (network) => { - if (!refactorSelectedNetworks.value.find(n => n.id === network.id)) { - refactorSelectedNetworks.value.push({ id: network.id, name: network.name }) - } + refactorSelectedNetworks.value = [{ id: network.id, name: network.name }] } const removeRefactorNetwork = (id) => { refactorSelectedNetworks.value = refactorSelectedNetworks.value.filter(n => n.id !== id) - if (refactorForm.internet_network_id === id) refactorForm.internet_network_id = 0 +} +const handleRefactorInternalNetworkConfirm = (network) => { + refactorSelectedInternalNetworks.value = [{ id: network.id, name: network.name }] +} +const removeRefactorInternalNetwork = (id) => { + refactorSelectedInternalNetworks.value = refactorSelectedInternalNetworks.value.filter(n => n.id !== id) } const handleRefactorVm = async () => { @@ -1443,10 +1593,12 @@ const handleRefactorVm = async () => { uuid: d.uuid || '', mate_data_id: d.mate_data_id || '', physical_name: d.physical_name || '', config_path: d.config_path || '', ssh_port: d.ssh_port || 0, vnc_port: 0, vnc_password: '', - internet_network_id: 0, - port_group_id: vmPortGroup.value?.id || 0 + port_group_id: vmPortGroup.value?.id || '' }) - refactorSelectedNetworks.value = vmNetworks.value.map(n => ({ id: n.id, name: n.name })) + const bridgeNets = vmNetworks.value.filter(n => n.type === 'bridge') + const natNets = vmNetworks.value.filter(n => n.type === 'nat') + refactorSelectedNetworks.value = bridgeNets.length ? [{ id: bridgeNets[0].id, name: bridgeNets[0].name }] : [] + refactorSelectedInternalNetworks.value = natNets.length ? [{ id: natNets[0].id, name: natNets[0].name }] : [] const mem = d.memory || 0 if (mem >= 1048576 && mem % 1048576 === 0) { refactorMemUnit.value = 1048576; refactorMemDisplay.value = mem / 1048576 } else if (mem >= 1024 && mem % 1024 === 0) { refactorMemUnit.value = 1024; refactorMemDisplay.value = mem / 1024 } @@ -1455,8 +1607,7 @@ const handleRefactorVm = async () => { dialogOptionsLoading.value = true try { await Promise.all([ - !sgOptions.value.length ? loadSgOptions() : Promise.resolve(), - loadNetworkingOptions() + !sgOptions.value.length ? loadSgOptions() : Promise.resolve() ]) } finally { dialogOptionsLoading.value = false } } @@ -1480,7 +1631,7 @@ const submitRefactorVm = async () => { if (refactorForm.vnc_port) fd.append('vnc_port', refactorForm.vnc_port) if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password) refactorSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id)) - if (refactorForm.internet_network_id) fd.append('internet_network_id', refactorForm.internet_network_id) + refactorSelectedInternalNetworks.value.forEach(n => fd.append('internet_network_id', n.id)) if (refactorForm.port_group_id) fd.append('port_group_id', refactorForm.port_group_id) const res = await refactorVm(fd) if (res?.data?.code === 200) { ElMessage.success('重构成功'); refactorDialogVisible.value = false; loadDetail() } @@ -1517,6 +1668,53 @@ const submitUpdateTraffic = async () => { } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false } } +// ---- 迁移虚拟机 ---- +const migrateDialogVisible = ref(false) +const migrateOptionsLoading = ref(false) +const migrateMode = ref('host') +const migrateHostOptions = ref([]) +const migrateGroupOptions = ref([]) +const migrateForm = reactive({ target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 }) + +const handleMigrateVm = async () => { + Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 }) + migrateMode.value = 'host' + migrateDialogVisible.value = true + migrateOptionsLoading.value = true + try { + const [hostRes, groupRes] = await Promise.all([ + getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 }), + getRemoteHostGroupList({ service_id: serviceId.value, page: 1, page_size: 10 }) + ]) + if (hostRes?.data?.code === 200 && hostRes?.data?.data) { + const inner = hostRes.data.data + migrateHostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.data || []) + } + if (groupRes?.data?.code === 200 && groupRes?.data?.data) { + const inner = groupRes.data.data + migrateGroupOptions.value = Array.isArray(inner) ? inner : (inner.host_groups || inner.data || []) + } + } catch { /* */ } finally { migrateOptionsLoading.value = false } +} + +const submitMigrateVm = async () => { + if (migrateMode.value === 'host' && !migrateForm.target_host_id) { ElMessage.warning('请选择目标宿主机'); return } + if (migrateMode.value === 'group' && !migrateForm.target_host_group_id) { ElMessage.warning('请选择目标宿主机组'); return } + actionLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('vm_id', vmId.value) + if (migrateMode.value === 'host') fd.append('target_host_id', migrateForm.target_host_id) + else fd.append('target_host_group_id', migrateForm.target_host_group_id) + if (migrateForm.ipv4_num) fd.append('ipv4_num', migrateForm.ipv4_num) + if (migrateForm.ipv6_num) fd.append('ipv6_num', migrateForm.ipv6_num) + const res = await migrateVm(fd) + if (res?.data?.code === 200) { ElMessage.success('迁移成功'); migrateDialogVisible.value = false; loadDetail() } + else ElMessage.error(extractApiError(res?.data, '迁移失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false } +} + // ---- VNC 连接 ---- const vncDialogVisible = ref(false) const vncNodeId = ref(null) @@ -1560,7 +1758,7 @@ const sgOptions = ref([]) const loadSgOptions = async () => { try { - const res = await getSecurityGroupList({ service_id: serviceId.value, page: 1, page_size: 200 }) + const res = await getSecurityGroupList({ service_id: serviceId.value, page: 1, page_size: 10 }) if (res?.data?.code === 200 && res?.data?.data) { const inner = res.data.data sgOptions.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : []) @@ -1604,6 +1802,7 @@ const handleNetBindConfirm = async (selectedNetwork) => { // ---- 网络操作(创建/编辑/删除/详情) ---- const netDialogVisible = ref(false) const netDialogType = ref('add') +const netDialogSource = ref('') const netFormRef = ref(null) const netDetailVisible = ref(false) const netDetailData = ref(null) @@ -1615,11 +1814,23 @@ const netFormRules = { type: [{ required: true, message: '请选择类型', trigger: 'change' }] } -const handleNetCreate = () => { +const handleNetCreate = (source = '') => { + netDialogSource.value = source netDialogType.value = 'add' Object.assign(netForm, { id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: vmHostId.value }) netDialogVisible.value = true } +const handleNetDialogCancel = () => { + netDialogVisible.value = false + const src = netDialogSource.value + netDialogSource.value = '' + if (src === 'edit') showEditNetworkSelector.value = true + else if (src === 'editInternal') showEditInternalNetworkSelector.value = true + else if (src === 'refactor') showRefactorNetworkSelector.value = true + else if (src === 'refactorInternal') showRefactorInternalNetworkSelector.value = true + else if (src === 'bind') showNetBindSelector.value = true +} + const handleNetEdit = (row) => { netDialogType.value = 'edit' Object.assign(netForm, { id: row.id, name: row.name || '', address: row.address || '', gateway: row.gateway || '', nameservers: row.nameservers || '', type: row.type || 'bridge', mac_address: row.mac_address || '', bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '', ls_name: row.ls_name || '', host_id: row.host_id || vmHostId.value }) @@ -1644,7 +1855,7 @@ const submitNetForm = () => { if (netForm.ls_name) fd.append('ls_name', netForm.ls_name) let res if (netDialogType.value === 'add') { res = await createNetwork(fd) } - else { fd.append('network_id', netForm.id); res = await updateNetwork(fd) } + else { fd.append('id', netForm.id); res = await updateNetwork(fd) } if (res?.data?.code === 200) { ElMessage.success(netDialogType.value === 'add' ? '创建成功' : '修改成功'); netDialogVisible.value = false; loadDetail() } else ElMessage.error(extractApiError(res?.data, '操作失败')) } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false } @@ -1867,7 +2078,14 @@ const submitSgCreate = () => { if (!valid) return sgSubmitLoading.value = true try { - const res = await createSecurityGroup({ service_id: serviceId.value, ...sgCreateForm }) + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('name', sgCreateForm.name) + fd.append('host_id', sgCreateForm.host_id) + fd.append('direction', sgCreateForm.direction) + if (sgCreateForm.lock) fd.append('lock', true) + if (sgCreateForm.drop_all) fd.append('drop_all', true) + const res = await createSecurityGroup(fd) if (res?.data?.code === 200) { ElMessage.success('创建成功') sgCreateDialogVisible.value = false @@ -1894,7 +2112,8 @@ const submitSgSync = async () => { if (!sgSyncHostId.value) { ElMessage.warning('请选择宿主机'); return } sgSubmitLoading.value = true try { - const res = await syncSecurityGroup({ service_id: serviceId.value, id: sgSyncTarget.value.id, host_id: sgSyncHostId.value }) + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sgSyncTarget.value.id); fd.append('host_id', sgSyncHostId.value) + const res = await syncSecurityGroup(fd) if (res?.data?.code === 200) { ElMessage.success('同步成功') sgSyncDialogVisible.value = false @@ -1909,7 +2128,8 @@ const handleSgApply = (row) => { confirmButtonText: '确定应用', cancelButtonText: '取消', type: 'info' }).then(async () => { try { - const res = await applySecurityGroup({ service_id: serviceId.value, id: row.id }) + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', row.id) + const res = await applySecurityGroup(fd) if (res?.data?.code === 200) ElMessage.success('应用成功') else ElMessage.error(extractApiError(res?.data, '应用失败')) } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '应用失败')) } @@ -1918,7 +2138,7 @@ const handleSgApply = (row) => { // 编辑(跳转安全组详情页) const handleSgGoDetail = (row) => { - router.push({ path: '/virtualization/security-group-detail', query: { service_id: serviceId.value, service_name: serviceName.value, id: row.id } }) + router.push({ path: '/virtualization/security-group-detail', query: { service_id: serviceId.value, service_name: serviceName.value, sg_id: row.id } }) } // 白名单切换 @@ -1929,7 +2149,8 @@ const handleSgToggleWhitelist = (row) => { }).then(async () => { try { const api = row.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist - const res = await api({ service_id: serviceId.value, id: row.id }) + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', row.id) + const res = await api(fd) if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadDetail() } else ElMessage.error(extractApiError(res?.data, `${action}失败`)) } catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) } @@ -1965,7 +2186,8 @@ const submitSgVmBind = async () => { sgSubmitLoading.value = true try { const api = sgVmBindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup - const res = await api({ service_id: serviceId.value, id: sgVmBindTarget.value.id, vm_id: vmId.value }) + const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sgVmBindTarget.value.id); fd.append('vm_id', vmId.value) + const res = await api(fd) if (res?.data?.code === 200) { ElMessage.success(sgVmBindType.value === 'bind' ? '绑定成功' : '解绑成功') sgVmBindDialogVisible.value = false @@ -1994,20 +2216,20 @@ const handleSgViewDetail = async (row) => { const sgRuleDialogVisible = ref(false) const sgRuleDialogType = ref('add') const sgRuleFormRef = ref(null) -const sgRuleForm = reactive({ id: undefined, group_id: 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 }) +const sgRuleForm = reactive({ id: undefined, group_id: 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: '' }) const sgRuleRules = { protocol: [{ required: true, message: '请选择协议', trigger: 'change' }], action: [{ required: true, message: '请选择动作', trigger: 'change' }] } const handleSgAddRule = () => { sgRuleDialogType.value = 'add' - Object.assign(sgRuleForm, { id: undefined, group_id: sgCurrentDetail.value?.id || 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 }) + Object.assign(sgRuleForm, { id: undefined, group_id: sgCurrentDetail.value?.id || 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: '' }) sgRuleDialogVisible.value = true } const handleSgEditRule = (rule) => { sgRuleDialogType.value = 'edit' Object.assign(sgRuleForm, { - id: rule.id, group_id: sgCurrentDetail.value?.id || 0, port_group_id: sgCurrentDetail.value?.id || 0, + id: rule.id, group_id: sgCurrentDetail.value?.id || 0, port_group_id: sgCurrentDetail.value?.id || '', protocol: rule.protocol || 'tcp', action: rule.action || 'allow', port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0 }) @@ -2018,9 +2240,19 @@ const submitSgRule = () => { if (!valid) return sgSubmitLoading.value = true try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('group_id', sgRuleForm.group_id) + if (sgRuleForm.id) fd.append('id', sgRuleForm.id) + if (sgRuleForm.port_group_id) fd.append('port_group_id', sgRuleForm.port_group_id) + fd.append('protocol', sgRuleForm.protocol) + fd.append('action', sgRuleForm.action) + if (sgRuleForm.port_range) fd.append('port_range', sgRuleForm.port_range) + if (sgRuleForm.ip_range) fd.append('ip_range', sgRuleForm.ip_range) + fd.append('priority', sgRuleForm.priority || 0) const res = sgRuleDialogType.value === 'add' - ? await createSecurityGroupRule({ service_id: serviceId.value, ...sgRuleForm }) - : await updateSecurityGroupRule({ service_id: serviceId.value, ...sgRuleForm }) + ? await createSecurityGroupRule(fd) + : await updateSecurityGroupRule(fd) if (res?.data?.code === 200) { ElMessage.success(sgRuleDialogType.value === 'add' ? '规则创建成功' : '规则修改成功') sgRuleDialogVisible.value = false @@ -2297,6 +2529,162 @@ const goBack = () => { router.back() } +// ---- 组网管理 ---- +const vnLoading = ref(false) +const vnSubmitLoading = ref(false) +const vnList = ref([]) +const vnJoinVisible = ref(false) +const vnJoinTarget = ref(null) +const vnJoinIp = ref('') + +const vmBridgeNameMap = computed(() => { + const map = {} + for (const n of vmNetworks.value) { + if (n.bridge_name) map[n.bridge_name] = n + } + return map +}) + +const loadVmNetworkingList = async () => { + if (!detail.value) return + vnLoading.value = true + vnList.value = [] + try { + const userId = detail.value.user_id + const hostId = vmHostId.value + if (!userId || !hostId) { vnLoading.value = false; return } + const res = await getUserNetworkingList({ + service_id: serviceId.value, page: 1, count: 10, + host_id: hostId, user_id: userId + }) + if (res?.data?.code === 200 && res?.data?.data) { + const inner = res.data.data + const networkings = Array.isArray(inner) ? inner : (inner.data || []) + const results = [] + for (const nw of networkings) { + const matchedVmNet = nw.bridge_name ? vmBridgeNameMap.value[nw.bridge_name] : null + results.push({ networking: nw, network: matchedVmNet || null }) + } + vnList.value = results + } + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取组网信息失败')) } + finally { vnLoading.value = false } +} + +// 创建组网 +const vnCreateVisible = ref(false) +const vnCreateFormRef = ref(null) +const vnCreateForm = reactive({ name: '', bridge_name: '', gateway: '' }) +const vnCreateHostName = ref('') +const vnCreateUserName = ref('') +const vnCreateRules = { + name: [{ required: true, message: '请输入名称', trigger: 'blur' }], + bridge_name: [{ required: true, message: '请输入网桥名称', trigger: 'blur' }] +} + +const handleVnCreate = async () => { + Object.assign(vnCreateForm, { name: '', bridge_name: '', gateway: '' }) + vnCreateHostName.value = '' + vnCreateUserName.value = '' + vnCreateVisible.value = true + const userId = detail.value?.user_id + const hostId = vmHostId.value + const requests = [] + if (userId) { + requests.push( + getUserInfo({ user_id: userId }).then(res => { + if (res?.data?.code === 200 && res?.data?.data) { + vnCreateUserName.value = res.data.data.UserName || '' + } + }).catch(() => {}) + ) + } + if (hostId) { + requests.push( + getRemoteHostDetail({ service_id: serviceId.value, id: hostId }).then(res => { + if (res?.data?.code === 200 && res?.data?.data) { + const h = res.data.data.host ?? res.data.data + vnCreateHostName.value = h.name || '' + } + }).catch(() => {}) + ) + } + await Promise.all(requests) +} + +const submitVnCreate = () => { + vnCreateFormRef.value?.validate(async (valid) => { + if (!valid) return + vnSubmitLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('name', vnCreateForm.name) + fd.append('bridge_name', vnCreateForm.bridge_name) + fd.append('host_id', vmHostId.value) + fd.append('user_id', detail.value?.user_id || 0) + if (vnCreateForm.gateway) fd.append('gateway', vnCreateForm.gateway) + const res = await createUserNetworking(fd) + if (res?.data?.code === 200) { + ElMessage.success('创建组网成功') + vnCreateVisible.value = false + loadVmNetworkingList() + } else ElMessage.error(extractApiError(res?.data, '创建组网失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建组网失败')) } + finally { vnSubmitLoading.value = false } + }) +} + +const handleJoinNetworking = (row) => { + vnJoinTarget.value = row.networking + vnJoinIp.value = '' + vnJoinVisible.value = true +} + +const submitJoinNetworking = async () => { + if (!vnJoinTarget.value) return + vnSubmitLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('networking_id', vnJoinTarget.value.id) + fd.append('vm_id', vmId.value) + if (vnJoinIp.value.trim()) fd.append('ip', vnJoinIp.value.trim()) + const res = await assignUserNetworking(fd) + if (res?.data?.code === 200) { + ElMessage.success('加入组网成功') + vnJoinVisible.value = false + loadVmNetworkingList() + loadDetail() + } else ElMessage.error(extractApiError(res?.data, '加入组网失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加入组网失败')) } + finally { vnSubmitLoading.value = false } +} + +const handleLeaveNetworking = (row) => { + const netId = row.network?.id + const networkingId = row.networking?.id + if (!netId || !networkingId) { ElMessage.warning('缺少网络信息'); return } + ElMessageBox.confirm( + `确定要将该虚拟机从组网「${row.networking?.name || networkingId}」中移除?`, + '移除确认', { type: 'warning' } + ).then(async () => { + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('networking_id', networkingId) + fd.append('network_id', netId) + fd.append('vm_id', vmId.value) + const res = await removeUserNetworkingNetwork(fd) + if (res?.data?.code === 200) { + ElMessage.success('已移除') + loadVmNetworkingList() + loadDetail() + } else ElMessage.error(extractApiError(res?.data, '移除失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '移除失败')) } + }).catch(() => {}) +} + let loadedVmId = null const initPage = () => { if (!vmId.value || loadedVmId === vmId.value) return @@ -2316,6 +2704,7 @@ watch(activeTab, (tab) => { else stopPolling() if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() } if (tab === 'backup') { loadBackups(); loadBackupQuota() } + if (tab === 'userNetworking') loadVmNetworkingList() }) onActivated(() => { isPageActive = true diff --git a/src/views/virtualization/VmManage.vue b/src/views/virtualization/VmManage.vue index 79c6327..fc7fc23 100644 --- a/src/views/virtualization/VmManage.vue +++ b/src/views/virtualization/VmManage.vue @@ -174,9 +174,18 @@ 选择网络IP
- - - + + + + + + + + + + + + @@ -447,7 +456,7 @@ const vmMetricsData = ref(null) const createForm = reactive({ name: '', host_id: null, image_id: 0, vcpu: 0, memory: 0, system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, - host_group_id: null, user_id: 0, ip_num: 0, network_ids: [], + host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [], _imageName: '', _groupName: '', _userName: '' }) @@ -513,6 +522,7 @@ const loadList = async () => { loading.value = true try { const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size } + if (hostId.value) params.host_id = hostId.value if (keyword.value) params.key = keyword.value if (filterStatus.value) params.status = filterStatus.value const res = await getVmList(params) @@ -531,7 +541,7 @@ const handleAdd = () => { Object.assign(createForm, { name: '', host_id: injectedHostId?.value || null, image_id: 0, vcpu: 0, memory: 0, system_size: 0, - rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: null, user_id: 0, ip_num: 0, network_ids: [], + rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [], _imageName: '', _groupName: '', _userName: '' }) memoryUnit.value = 'GB' @@ -549,7 +559,7 @@ const submitCreate = () => { if (hostMode.value === 'host' && !createForm.host_id) { ElMessage.warning('请选择宿主机'); return } if (hostMode.value === 'group' && !createForm.host_group_id) { ElMessage.warning('请选择宿主机组'); return } if (ipMode.value === 'ids' && !createForm.network_ids.length) { ElMessage.warning('请选择网络IP'); return } - if (ipMode.value === 'num' && !createForm.ip_num) { ElMessage.warning('请输入IP数量'); return } + if (ipMode.value === 'num' && !createForm.ipv4_num && !createForm.ipv6_num) { ElMessage.warning('请输入IPv4或IPv6数量'); return } createFormRef.value?.validate(async (valid) => { if (!valid) return @@ -567,8 +577,10 @@ const submitCreate = () => { if (createForm.tx_bandwidth) fd.append('tx_bandwidth', createForm.tx_bandwidth) if (hostMode.value === 'host') fd.append('host_id', createForm.host_id) else fd.append('host_group_id', createForm.host_group_id) - if (ipMode.value === 'num') fd.append('ip_num', createForm.ip_num) - else createForm.network_ids.forEach(id => fd.append('network_ids', id)) + if (ipMode.value === 'num') { + if (createForm.ipv4_num) fd.append('ipv4_num', createForm.ipv4_num) + if (createForm.ipv6_num) fd.append('ipv6_num', createForm.ipv6_num) + } else createForm.network_ids.forEach(id => fd.append('network_ids', id)) const res = await createVm(fd) if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() } else ElMessage.error(extractApiError(res?.data, '创建失败')) diff --git a/vite.config.js b/vite.config.js index 170dfad..609e72b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -35,18 +35,19 @@ export default defineConfig(({ mode }) => { host: '0.0.0.0', port: 5176, strictPort: false, - proxy: { - '/api': { - target: proxyTarget, - changeOrigin: true, - secure: false - }, - '/acs': { - target: proxyTarget, - changeOrigin: true, - secure: false - } - } + // proxy 已关闭,前端直接请求后端地址(在 src/config/env.js 中配置) + // proxy: { + // '/api': { + // target: proxyTarget, + // changeOrigin: true, + // secure: false + // }, + // '/acs': { + // target: proxyTarget, + // changeOrigin: true, + // secure: false + // } + // } }, build: { rollupOptions: {