fix: 重构虚拟机内网外网参数设置选择网络
Build and Deploy Vue3 / build (push) Successful in 1m28s
Build and Deploy Vue3 / deploy (push) Successful in 1m1s

This commit is contained in:
2026-03-26 16:36:25 +08:00
parent 40a5e486a6
commit 1a4587f893
13 changed files with 1028 additions and 135 deletions
+14
View File
@@ -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) => { export const deleteNetwork = (params) => {
return http2.delete('/api/v1/admin/server/host_service/point/network/delete', { 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 }) 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' }
})
}
/** /**
* ================================ * ================================
* 主控服务接口 - 安全组管理 * 主控服务接口 - 安全组管理
+30 -5
View File
@@ -5,10 +5,20 @@
<el-input v-model="keyword" placeholder="搜索网络" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch"> <el-input v-model="keyword" placeholder="搜索网络" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
</el-input> </el-input>
<el-select v-model="typeFilter" placeholder="网络类型" clearable style="width: 130px" @change="handleSearch"> <el-select v-if="!filterType" v-model="typeFilter" placeholder="网络类型" clearable style="width: 130px" @change="handleSearch">
<el-option label="网桥(Bridge)" value="bridge" /> <el-option label="网桥(Bridge)" value="bridge" />
<el-option label="内网(NAT)" value="nat" /> <el-option label="内网(NAT)" value="nat" />
</el-select> </el-select>
<el-tag v-else type="success" size="small">{{ filterType === 'bridge' ? '网桥' : filterType === 'nat' ? '内网' : filterType }}</el-tag>
<el-select v-if="!filterUsed" v-model="usedFilter" placeholder="占用状态" clearable style="width: 130px" @change="handleSearch">
<el-option label="未占用" value="false" />
<el-option label="已占用" value="true" />
</el-select>
<el-tag v-else :type="filterUsed === 'false' ? 'success' : 'info'" size="small">{{ filterUsed === 'false' ? '仅未占用' : '仅已占用' }}</el-tag>
<el-select v-model="ipVersionFilter" placeholder="IP版本" clearable style="width: 110px" @change="handleSearch">
<el-option label="IPv4" value="ipv4" />
<el-option label="IPv6" value="ipv6" />
</el-select>
<el-button :icon="Refresh" @click="loadList" circle /> <el-button :icon="Refresh" @click="loadList" circle />
</div> </div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" <el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
@@ -26,6 +36,11 @@
<el-table-column prop="gateway" label="网关" width="130" /> <el-table-column prop="gateway" label="网关" width="130" />
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip /> <el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
<el-table-column prop="bridge_name" label="网桥名称" width="100" /> <el-table-column prop="bridge_name" label="网桥名称" width="100" />
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.used ? 'danger' : 'success'" size="small">{{ row.used ? '已占用' : '空闲' }}</el-tag>
</template>
</el-table-column>
</el-table> </el-table>
<div class="pagination-wrapper" v-if="total > 0"> <div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" <el-pagination v-model:current-page="page" v-model:page-size="pageSize"
@@ -54,7 +69,9 @@ import { getNetworkList } from '@/api/admin/kvmService'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 }, serviceId: { type: Number, default: 0 },
hostId: { type: Number, default: 0 } hostId: { type: Number, default: 0 },
filterType: { type: String, default: '' },
filterUsed: { type: String, default: '' }
}) })
const emit = defineEmits(['update:modelValue', 'confirm', 'create']) const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
@@ -67,6 +84,8 @@ const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const keyword = ref('') const keyword = ref('')
const typeFilter = ref('') const typeFilter = ref('')
const usedFilter = ref('')
const ipVersionFilter = ref('')
const selectedItem = ref(null) const selectedItem = ref(null)
const type = ref('bridge') const type = ref('bridge')
@@ -75,7 +94,9 @@ watch(() => props.modelValue, (val) => {
if (val) { if (val) {
page.value = 1 page.value = 1
keyword.value = '' keyword.value = ''
typeFilter.value = '' typeFilter.value = props.filterType || ''
usedFilter.value = props.filterUsed || ''
ipVersionFilter.value = ''
selectedItem.value = null selectedItem.value = null
loadList() loadList()
} }
@@ -88,9 +109,13 @@ const loadList = async () => {
if (!props.serviceId || !props.hostId) return if (!props.serviceId || !props.hostId) return
loading.value = true loading.value = true
try { 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 (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) const res = await getNetworkList(params)
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data const inner = res.data.data
+2 -2
View File
@@ -9,7 +9,7 @@ const isDevelopment = import.meta.env.MODE === 'development'
// API 基础地址 // API 基础地址
// 开发环境使用 vite 代理 (baseUrl 为空),生产环境使用实际地址 // 开发环境使用 vite 代理 (baseUrl 为空),生产环境使用实际地址
const API_BASE_MAP = { 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', production: import.meta.env.VITE_API_BASE_URL || 'https://cloudapi.007yjs.com',
staging: import.meta.env.VITE_API_BASE_URL || 'https://apiservertest.s1f.ren' 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 export const baseUrl = API_BASE_MAP[currentEnv] || API_BASE_MAP.development
// ACS 服务基础地址 // ACS 服务基础地址
export const acsBaseUrl = isDevelopment ? '' : baseUrl export const acsBaseUrl = baseUrl
// 网站标题 // 网站标题
export const siteTitle = '007UI管理系统' export const siteTitle = '007UI管理系统'
+303 -4
View File
@@ -201,7 +201,136 @@
<el-tab-pane label="备份管理" name="backup"> <el-tab-pane label="备份管理" name="backup">
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" /> <BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="组网管理" name="networking">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">用户组网列表</h3>
<div style="display: flex; gap: 8px; align-items: center">
<el-input v-model="nwFilterUserId" placeholder="按用户ID筛选" style="width: 140px" size="small" clearable @clear="loadNetworkingList" @keyup.enter="loadNetworkingList" />
<el-input v-model="nwKeyword" placeholder="关键词搜索" style="width: 160px" size="small" clearable @clear="loadNetworkingList" @keyup.enter="loadNetworkingList" />
<el-button size="small" :icon="Search" @click="loadNetworkingList">搜索</el-button>
<el-button size="small" type="primary" @click="handleNwCreate">创建组网</el-button>
<el-button size="small" :icon="Refresh" @click="loadNetworkingList" :loading="nwLoading">刷新</el-button>
</div>
</div>
<el-table :data="nwList" v-loading="nwLoading" stripe size="small">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="user_id" label="用户ID" width="80" />
<el-table-column prop="host_id" label="宿主机ID" width="90" />
<el-table-column label="网桥" width="120">
<template #default="{ row }">{{ row.bridge_name || '-' }}</template>
</el-table-column>
<el-table-column label="网关" min-width="140">
<template #default="{ row }"><span class="mono-text">{{ row.gateway || '-' }}</span></template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleNwDetail(row)">详情</el-button>
<el-button link type="success" size="small" @click="handleNwAssign(row)">分配IP</el-button>
<el-button link type="danger" size="small" @click="handleNwDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!nwList.length && !nwLoading" description="暂无组网" :image-size="60" />
<div class="pagination-wrapper" v-if="nwTotal > 0">
<el-pagination v-model:current-page="nwPage" v-model:page-size="nwPageSize"
:page-sizes="[10, 20, 50]" :total="nwTotal" layout="total, sizes, prev, pager, next" small
@size-change="s => { nwPageSize = s; nwPage = 1; loadNetworkingList() }"
@current-change="p => { nwPage = p; loadNetworkingList() }" />
</div>
</div>
</el-tab-pane>
</el-tabs> </el-tabs>
<!-- 组网详情弹窗 -->
<el-dialog v-model="nwDetailVisible" title="组网详情" width="800px" destroy-on-close>
<el-descriptions :column="2" border v-if="nwDetailData" style="margin-bottom: 16px">
<el-descriptions-item label="ID">{{ nwDetailData.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ nwDetailData.name }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ nwDetailData.user_id }}</el-descriptions-item>
<el-descriptions-item label="宿主机ID">{{ nwDetailData.host_id }}</el-descriptions-item>
<el-descriptions-item label="网桥">{{ nwDetailData.bridge_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ nwDetailData.gateway || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(nwDetailData.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(nwDetailData.updated_at) }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin: 0 0 12px">组网下的网络 (已分配)</h4>
<el-table :data="nwDetailNetworks" size="small" stripe>
<el-table-column label="网络ID" width="70">
<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 size="small">{{ row.network?.type || '-' }}</el-tag></template>
</el-table-column>
<el-table-column label="虚拟机ID" width="90">
<template #default="{ row }">{{ row.network?.vm_id || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleNwRemoveNet(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!nwDetailNetworks.length" description="暂无分配的网络" :image-size="50" />
<template #footer><el-button @click="nwDetailVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 创建组网弹窗 -->
<el-dialog v-model="nwCreateVisible" title="创建组网" width="480px" destroy-on-close>
<el-form ref="nwCreateFormRef" :model="nwCreateForm" :rules="nwCreateRules" label-width="100px">
<el-form-item label="用户" prop="user_id">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwUserSelector = true">选择</el-button>
<el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button>
</div>
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="nwCreateForm.bridge_name" placeholder="可选" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="nwCreateForm.gateway" placeholder="可选 10.0.0.1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="nwCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button>
</template>
</el-dialog>
<!-- 分配IP弹窗 -->
<el-dialog v-model="nwAssignVisible" title="为虚拟机分配组网IP" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item>
<el-form-item label="虚拟机" required>
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwVmSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="指定IP">
<el-input v-model="nwAssignIp" placeholder="留空自动分配" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="nwAssignVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button>
</template>
</el-dialog>
<UserListSelector v-model="showNwUserSelector" @confirm="handleNwUserSelected" />
<VmSelectorPopup v-model="showNwVmSelector" :service-id="serviceId" :host-id="hostId" @confirm="handleNwVmSelected" />
</div> </div>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
@@ -267,9 +396,11 @@
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { import {
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost,
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
assignUserNetworking, removeUserNetworkingNetwork
} from '@/api/admin/kvmService' } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue' 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 SnapshotManage from '@/views/virtualization/SnapshotManage.vue'
import BackupManage from '@/views/virtualization/BackupManage.vue' import BackupManage from '@/views/virtualization/BackupManage.vue'
import { useTagsViewStore } from '@/store/tagsViewStore' import { useTagsViewStore } from '@/store/tagsViewStore'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
const route = useRoute() const route = useRoute()
@@ -291,7 +424,7 @@ const serviceName = computed(() => route.query.service_name || '')
const hostId = computed(() => parseInt(route.query.id) || 0) const hostId = computed(() => parseInt(route.query.id) || 0)
const activeTab = ref('info') 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 imageManageRef = ref(null)
const networkManageRef = 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 } const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef }
watch(activeTab, (tab) => { watch(activeTab, (tab) => {
if (!['info', 'monitor'].includes(tab)) { if (!['info', 'monitor', 'networking'].includes(tab)) {
if (!hostTabLoaded[tab]) { if (!hostTabLoaded[tab]) {
hostTabLoaded[tab] = true hostTabLoaded[tab] = true
} else { } else {
@@ -311,6 +444,7 @@ watch(activeTab, (tab) => {
} }
if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() } if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() }
else stopPolling() else stopPolling()
if (tab === 'networking') loadNetworkingList()
}) })
const loading = ref(false) 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 } }) 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 let loadedHostId = null
const initPage = () => { const initPage = () => {
@@ -694,4 +992,5 @@ onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
.unit-input-row { display: flex; gap: 6px; width: 100%; } .unit-input-row { display: flex; gap: 6px; width: 100%; }
.wide-number { flex: 1; min-width: 140px; } .wide-number { flex: 1; min-width: 140px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
</style> </style>
+8 -8
View File
@@ -119,8 +119,8 @@
<el-form-item label="SSH 密码"> <el-form-item label="SSH 密码">
<el-input v-model="formData.password" placeholder="SSH 密码(可选)" show-password /> <el-input v-model="formData.password" placeholder="SSH 密码(可选)" show-password />
</el-form-item> </el-form-item>
<el-form-item label="私钥路径"> <el-form-item label="SSH 私钥">
<el-input v-model="formData.private_key_path" placeholder="SSH 私钥文件路径(可选)" /> <el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
</el-form-item> </el-form-item>
<el-divider content-position="left">资源限制</el-divider> <el-divider content-position="left">资源限制</el-divider>
<el-form-item label="最大CPU(核)"> <el-form-item label="最大CPU(核)">
@@ -206,7 +206,7 @@
<el-input v-if="currentDetail.password" :model-value="currentDetail.password" readonly show-password style="max-width: 200px" /> <el-input v-if="currentDetail.password" :model-value="currentDetail.password" readonly show-password style="max-width: 200px" />
<span v-else class="text-muted">未设置</span> <span v-else class="text-muted">未设置</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="私钥路径">{{ currentDetail.private_key_path || '-' }}</el-descriptions-item> <el-descriptions-item label="SSH 私钥">{{ currentDetail.private_key ? '已配置' : '未设置' }}</el-descriptions-item>
<el-descriptions-item label="最大CPU">{{ currentDetail.max_cpu ? currentDetail.max_cpu + ' 核' : '-' }}</el-descriptions-item> <el-descriptions-item label="最大CPU">{{ currentDetail.max_cpu ? currentDetail.max_cpu + ' 核' : '-' }}</el-descriptions-item>
<el-descriptions-item label="最大内存">{{ formatMemKB(currentDetail.max_memory) }}</el-descriptions-item> <el-descriptions-item label="最大内存">{{ formatMemKB(currentDetail.max_memory) }}</el-descriptions-item>
<el-descriptions-item label="最大磁盘">{{ formatDiskGB(currentDetail.max_disk) }}</el-descriptions-item> <el-descriptions-item label="最大磁盘">{{ formatDiskGB(currentDetail.max_disk) }}</el-descriptions-item>
@@ -324,7 +324,7 @@ const metricsData = ref(null)
const formData = reactive({ const formData = reactive({
id: undefined, name: '', base_url: '', ip: '', token: '', 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, max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
_groupName: '' _groupName: ''
@@ -408,7 +408,7 @@ const getGroupName = (gid) => {
const resetForm = () => { const resetForm = () => {
Object.assign(formData, { Object.assign(formData, {
id: undefined, name: '', base_url: '', ip: '', token: '', 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, max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
_groupName: '' _groupName: ''
@@ -470,7 +470,7 @@ const handleEdit = (row) => {
// 先用列表数据回填,密码需从详情取 // 先用列表数据回填,密码需从详情取
Object.assign(formData, { Object.assign(formData, {
id: row.id, name: row.name, base_url: row.base_url, ip: row.ip, token: row.token || '', 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, 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, rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
host_group_id: row.host_group_id || 0, description: row.description || '', 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 const detail = body.data.host ?? body.data.data ?? body.data
if (detail.password) formData.password = detail.password if (detail.password) formData.password = detail.password
if (detail.token) formData.token = detail.token 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(() => {}) }).catch(() => {})
dialogVisible.value = true dialogVisible.value = true
@@ -500,7 +500,7 @@ const handleSubmit = () => {
// 可选参数为空时不提交 // 可选参数为空时不提交
if (!payload.token) delete payload.token if (!payload.token) delete payload.token
if (!payload.password) delete payload.password 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.description) delete payload.description
if (!payload.host_group_id) delete payload.host_group_id if (!payload.host_group_id) delete payload.host_group_id
let res let res
+10 -5
View File
@@ -65,7 +65,7 @@
</el-table-column> </el-table-column>
<el-table-column label="状态" width="80"> <el-table-column label="状态" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row._isHost" :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '启用' : '禁用' }}</el-tag> <el-tag v-if="row._isHost" :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '在线' : '离线' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="280" fixed="right"> <el-table-column label="操作" width="280" fixed="right">
@@ -190,6 +190,9 @@
<el-form-item label="SSH 密码"> <el-form-item label="SSH 密码">
<el-input v-model="hostForm.password" placeholder="可选" show-password /> <el-input v-model="hostForm.password" placeholder="可选" show-password />
</el-form-item> </el-form-item>
<el-form-item label="SSH 私钥">
<el-input v-model="hostForm.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
</el-form-item>
<el-divider content-position="left">资源限制</el-divider> <el-divider content-position="left">资源限制</el-divider>
<el-form-item label="最大CPU(核)"> <el-form-item label="最大CPU(核)">
<el-input-number v-model="hostForm.max_cpu" :min="0" controls-position="right" style="width: 240px" /> <el-input-number v-model="hostForm.max_cpu" :min="0" controls-position="right" style="width: 240px" />
@@ -525,7 +528,7 @@ const hostDialogType = ref('add')
const hostFormRef = ref(null) const hostFormRef = ref(null)
const hostForm = reactive({ const hostForm = reactive({
id: undefined, name: '', base_url: '', ip: '', token: '', 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, max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: ''
}) })
@@ -537,13 +540,13 @@ const hostFormRules = {
const handleAddHost = () => { const handleAddHost = () => {
hostDialogType.value = 'add' 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 hostDialogVisible.value = true
} }
const handleAddHostToGroup = (group) => { const handleAddHostToGroup = (group) => {
hostDialogType.value = 'add' 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 hostDialogVisible.value = true
} }
@@ -551,7 +554,7 @@ const handleEditHost = (row) => {
hostDialogType.value = 'edit' hostDialogType.value = 'edit'
Object.assign(hostForm, { Object.assign(hostForm, {
id: row.id, name: row.name, base_url: row.base_url || '', ip: row.ip || '', token: row.token || '', 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, 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, rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
host_group_id: row.host_group_id || 0, description: row.description || '' 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 const d = res.data.data.host ?? res.data.data.data ?? res.data.data
if (d.password) hostForm.password = d.password if (d.password) hostForm.password = d.password
if (d.token) hostForm.token = d.token if (d.token) hostForm.token = d.token
if (d.private_key) hostForm.private_key = d.private_key
} }
}).catch(() => {}) }).catch(() => {})
hostDialogVisible.value = true hostDialogVisible.value = true
@@ -575,6 +579,7 @@ const submitHostForm = () => {
delete payload.id delete payload.id
if (!payload.token) delete payload.token if (!payload.token) delete payload.token
if (!payload.password) delete payload.password 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.host_group_id) delete payload.host_group_id
if (!payload.description) delete payload.description if (!payload.description) delete payload.description
let res let res
+52 -4
View File
@@ -45,9 +45,17 @@
<div class="profile-stats"> <div class="profile-stats">
<div class="stat-item"> <div class="stat-item">
<div class="stat-label">认证Token</div> <div class="stat-label">认证Token</div>
<div class="stat-value"> <div class="stat-value" v-if="serviceInfo.Token || serviceInfo.token">
<el-tag v-if="serviceInfo.Token || serviceInfo.token" type="success" size="small">已设置</el-tag> <code class="token-text">{{ tokenVisible ? (serviceInfo.Token || serviceInfo.token) : '••••••••••••' }}</code>
<el-tag v-else type="info" size="small">未设置</el-tag> <el-button link size="small" @click="tokenVisible = !tokenVisible" style="margin-left: 4px">
<el-icon><View v-if="!tokenVisible" /><Hide v-else /></el-icon>
</el-button>
<el-button link size="small" @click="copyToken" style="margin-left: 2px">
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
<div class="stat-value" v-else>
<el-tag type="info" size="small">未设置</el-tag>
</div> </div>
</div> </div>
<div class="stat-item"> <div class="stat-item">
@@ -134,7 +142,7 @@
import { ref, reactive, computed, provide, onMounted, onActivated, watch } from 'vue' import { ref, reactive, computed, provide, onMounted, onActivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { import {
getKvmServiceDetail, updateKvmService, deleteKvmService getKvmServiceDetail, updateKvmService, deleteKvmService
} from '@/api/admin/kvmService' } from '@/api/admin/kvmService'
@@ -241,6 +249,29 @@ const goBack = () => {
router.push('/virtualization/kvm-service') 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 editDialogVisible = ref(false)
const formRef = ref(null) const formRef = ref(null)
@@ -482,6 +513,23 @@ onMounted(() => {
color: #303133; 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 { .note-value {
font-weight: 400; font-weight: 400;
font-size: 13px; font-size: 13px;
+101 -3
View File
@@ -10,11 +10,13 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建网络</el-button> <el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建网络</el-button>
<el-button type="success" @click="handleBatchAdd">批量创建</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button> <el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div> </div>
</div> </div>
<div class="embedded-toolbar" v-if="embedded"> <div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建网络</el-button> <el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建网络</el-button>
<el-button type="success" @click="handleBatchAdd">批量创建</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button> <el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div> </div>
@@ -27,7 +29,10 @@
<el-option label="网桥(Bridge)" value="bridge" /> <el-option label="网桥(Bridge)" value="bridge" />
<el-option label="内网(NAT)" value="nat" /> <el-option label="内网(NAT)" value="nat" />
</el-select> </el-select>
<el-select v-model="filterIpVersion" placeholder="IP版本" clearable style="width: 120px" @change="handleSearch">
<el-option label="IPv4" value="ipv4" />
<el-option label="IPv6" value="ipv6" />
</el-select>
</div> </div>
<!-- 网络列表 --> <!-- 网络列表 -->
@@ -133,6 +138,46 @@
</el-descriptions> </el-descriptions>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template> <template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog> </el-dialog>
<!-- 批量创建弹窗 -->
<el-dialog v-model="batchDialogVisible" title="批量创建网络" width="560px" destroy-on-close>
<el-alert type="info" :closable="false" style="margin-bottom: 16px">通过指定 IP 范围(start_ip ~ end_ip)批量创建网络条目</el-alert>
<el-form ref="batchFormRef" :model="batchForm" :rules="batchFormRules" label-width="120px">
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="batchForm.host_id" placeholder="选择宿主机" filterable 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="起始IP" prop="start_ip">
<el-input v-model="batchForm.start_ip" placeholder=" 192.168.1.10" />
</el-form-item>
<el-form-item label="结束IP" prop="end_ip">
<el-input v-model="batchForm.end_ip" placeholder=" 192.168.1.50" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="batchForm.gateway" placeholder="可选 192.168.1.1" />
</el-form-item>
<el-form-item label="子网掩码">
<el-input v-model="batchForm.mask" placeholder="可选 24" />
</el-form-item>
<el-form-item label="DNS">
<el-input v-model="batchForm.nameservers" placeholder="可选 114.114.114.114,8.8.8.8" />
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="batchForm.bridge_name" placeholder="可选" />
</el-form-item>
<el-form-item label="网络类型">
<el-select v-model="batchForm.type" style="width: 100%">
<el-option label="网桥(Bridge)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleBatchSubmit">确定创建</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -141,7 +186,7 @@ import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue' 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' import { extractApiError } from '@/utils/kvmErrorUtil'
const route = useRoute() const route = useRoute()
@@ -160,6 +205,7 @@ const networkList = ref([])
const total = ref(0) const total = ref(0)
const keyword = ref('') const keyword = ref('')
const filterType = ref('') const filterType = ref('')
const filterIpVersion = ref('')
const hostIdInput = ref(0) const hostIdInput = ref(0)
const hostOptions = ref([]) const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 }) 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 } 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 (keyword.value) params.key = keyword.value
if (filterType.value) params.type = filterType.value if (filterType.value) params.type = filterType.value
if (filterIpVersion.value) params.ip_version = filterIpVersion.value
const res = await getNetworkList(params) const res = await getNetworkList(params)
const body = res?.data const body = res?.data
if (body?.code === 200 && body?.data) { if (body?.code === 200 && body?.data) {
@@ -269,7 +316,7 @@ const handleSubmit = () => {
if (dialogType.value === 'add') { if (dialogType.value === 'add') {
res = await createNetwork(fd) res = await createNetwork(fd)
} else { } else {
fd.append('network_id', formData.id) fd.append('id', formData.id)
res = await updateNetwork(fd) res = await updateNetwork(fd)
} }
if (res?.data?.code === 200) { if (res?.data?.code === 200) {
@@ -309,6 +356,57 @@ const handleDelete = (row) => {
}).catch(() => {}) }).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') } const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(async () => { onMounted(async () => {
@@ -73,6 +73,7 @@
<el-table-column prop="port_range" label="端口范围" min-width="120" /> <el-table-column prop="port_range" label="端口范围" min-width="120" />
<el-table-column prop="ip_range" label="IP 范围" min-width="140" /> <el-table-column prop="ip_range" label="IP 范围" min-width="140" />
<el-table-column prop="priority" label="优先级" width="80" /> <el-table-column prop="priority" label="优先级" width="80" />
<el-table-column prop="description" label="备注" width="80" />
<el-table-column label="操作" width="130"> <el-table-column label="操作" width="130">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEditRule(row)">编辑</el-button> <el-button link type="primary" size="small" @click="handleEditRule(row)">编辑</el-button>
@@ -162,6 +163,7 @@
<el-form-item label="端口范围"><el-input v-model="ruleForm.port_range" placeholder="如 80 或 80-90" /></el-form-item> <el-form-item label="端口范围"><el-input v-model="ruleForm.port_range" placeholder="如 80 或 80-90" /></el-form-item>
<el-form-item label="IP 范围"><el-input v-model="ruleForm.ip_range" placeholder="如 0.0.0.0/0" /></el-form-item> <el-form-item label="IP 范围"><el-input v-model="ruleForm.ip_range" placeholder="如 0.0.0.0/0" /></el-form-item>
<el-form-item label="优先级"><el-input-number v-model="ruleForm.priority" :min="0" :max="9999" style="width: 100%" /></el-form-item> <el-form-item label="优先级"><el-input-number v-model="ruleForm.priority" :min="0" :max="9999" style="width: 100%" /></el-form-item>
<el-form-item label="备注"><el-input v-model="ruleForm.description" placeholder="请输入备注" /></el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="ruleDialogVisible = false">取消</el-button> <el-button @click="ruleDialogVisible = false">取消</el-button>
@@ -193,7 +195,7 @@ const tagsViewStore = useTagsViewStore()
const serviceId = computed(() => parseInt(route.query.service_id) || 0) const serviceId = computed(() => parseInt(route.query.service_id) || 0)
const serviceName = computed(() => route.query.service_name || '') const serviceName = computed(() => route.query.service_name || '')
const sgId = computed(() => parseInt(route.query.id) || 0) const sgId = computed(() => parseInt(route.query.sg_id) || 0)
const loading = ref(false) const loading = ref(false)
const actionLoading = ref(false) const actionLoading = ref(false)
@@ -370,12 +372,12 @@ const handleSetShared = (shared) => {
const handleAddRule = () => { const handleAddRule = () => {
ruleDialogType.value = 'add' ruleDialogType.value = 'add'
Object.assign(ruleForm, { id: undefined, group_id: sgId.value, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 }) Object.assign(ruleForm, { id: undefined, group_id: sgId.value, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0, description: '' })
ruleDialogVisible.value = true ruleDialogVisible.value = true
} }
const handleEditRule = (rule) => { const handleEditRule = (rule) => {
ruleDialogType.value = 'edit' ruleDialogType.value = 'edit'
Object.assign(ruleForm, { id: rule.id, group_id: sgId.value, port_group_id: sgId.value, protocol: rule.protocol || 'tcp', action: rule.action || 'allow', port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0 }) Object.assign(ruleForm, { id: rule.id, group_id: sgId.value, port_group_id: sgId.value, protocol: rule.protocol || 'tcp', action: rule.action || 'allow', port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0, description: rule.description || '' })
ruleDialogVisible.value = true ruleDialogVisible.value = true
} }
const submitRule = () => { const submitRule = () => {
@@ -433,7 +433,7 @@ const handleApply = (row) => {
} }
const handleGoDetail = (row) => { const handleGoDetail = (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 } })
} }
const handleDelete = (row) => { const handleDelete = (row) => {
+469 -80
View File
@@ -33,6 +33,7 @@
<el-dropdown-item divided command="rebuild">重装虚拟机</el-dropdown-item> <el-dropdown-item divided command="rebuild">重装虚拟机</el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item> <el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item> <el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
<el-dropdown-item divided command="migrateVm">迁移虚拟机</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@@ -129,7 +130,7 @@
<div class="config-row"> <div class="config-row">
<div class="config-cell"> <div class="config-cell">
<span class="config-label">流量上限(GB)</span> <span class="config-label">流量上限(GB)</span>
<span class="config-value">{{ detail.traffic_max != null ? (detail.traffic_max / 1024 / 1024).toFixed(2) : '-' }}</span> <span class="config-value">{{ detail.traffic_max != null ? (detail.traffic_max / 1024).toFixed(2) : '-' }}</span>
</div> </div>
<div class="config-cell"> <div class="config-cell">
<span class="config-label">快照配额</span> <span class="config-label">快照配额</span>
@@ -399,6 +400,63 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="组网管理" name="userNetworking">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">用户组网</h3>
<div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="handleVnCreate"><el-icon><Plus /></el-icon>创建组网</el-button>
<el-button size="small" :icon="Refresh" @click="loadVmNetworkingList" :loading="vnLoading">刷新</el-button>
</div>
</div>
<el-table :data="vnList" v-loading="vnLoading" stripe size="small">
<el-table-column label="组网" min-width="130">
<template #default="{ row }">{{ row.networking?.name || '-' }} <span style="color:#909399">(ID: {{ row.networking?.id }})</span></template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.network" type="success" size="small">已加入</el-tag>
<el-tag v-else type="info" size="small">未加入</el-tag>
</template>
</el-table-column>
<el-table-column label="网桥" width="130">
<template #default="{ row }"><span class="mono-text">{{ row.network?.bridge_name || row.networking?.bridge_name || '-' }}</span></template>
</el-table-column>
<el-table-column label="IP地址" min-width="150">
<template #default="{ row }">
<span v-if="row.network" class="mono-text">{{ row.network.address || '-' }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="网关" width="140">
<template #default="{ row }">
<span v-if="row.network" class="mono-text">{{ row.network.gateway || '-' }}</span>
<span v-else class="mono-text" style="color:#c0c4cc">{{ row.networking?.gateway || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="MAC地址" min-width="160">
<template #default="{ row }">
<span v-if="row.network" class="mono-text">{{ row.network.mac_address || '-' }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag v-if="row.network" :type="row.network.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.network.type || '-' }}</el-tag>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.network" link type="primary" size="small" @click="handleJoinNetworking(row)">加入</el-button>
<el-button v-else link type="danger" size="small" @click="handleLeaveNetworking(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!vnList.length && !vnLoading" description="没有匹配的可用组网" :image-size="60" />
</div>
</el-tab-pane>
<el-tab-pane label="监控" name="monitor"> <el-tab-pane label="监控" name="monitor">
<div class="section-block"> <div class="section-block">
<div class="section-header"> <div class="section-header">
@@ -532,19 +590,20 @@
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width: 200px" /> <el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width: 200px" />
</el-form-item> </el-form-item>
<el-form-item label="网络"> <el-form-item label="网络">
<div style="width: 100%"> <div style="display: flex; align-items: center; gap: 8px; width: 100%">
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px" v-if="editSelectedNetworks.length"> <el-tag v-if="editSelectedNetworks.length" closable @close="removeEditNetwork(editSelectedNetworks[0].id)">
<el-tag v-for="n in editSelectedNetworks" :key="n.id" closable size="small" @close="removeEditNetwork(n.id)"> {{ editSelectedNetworks[0].name }} (ID:{{ editSelectedNetworks[0].id }})
{{ n.name }} (ID:{{ n.id }}) </el-tag>
</el-tag> <el-button size="small" @click="showEditNetworkSelector = true">{{ editSelectedNetworks.length ? '更换网络' : '选择网络' }}</el-button>
</div>
<el-button size="small" @click="showEditNetworkSelector = true">选择网络</el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="内网组网"> <el-form-item label="内网">
<el-select v-model="editForm.internet_network_id" placeholder="选择内网组网可选" clearable filterable style="width: 100%"> <div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-option v-for="n in networkingOptions" :key="n.id" :label="`${n.name} (ID: ${n.id})`" :value="n.id" /> <el-tag v-if="editSelectedInternalNetworks.length" closable @close="removeEditInternalNetwork(editSelectedInternalNetworks[0].id)">
</el-select> {{ editSelectedInternalNetworks[0].name }} (ID:{{ editSelectedInternalNetworks[0].id }})
</el-tag>
<el-button size="small" @click="showEditInternalNetworkSelector = true">{{ editSelectedInternalNetworks.length ? '更换内网' : '选择内网' }}</el-button>
</div>
</el-form-item> </el-form-item>
<el-form-item label="安全组"> <el-form-item label="安全组">
<el-select v-model="editForm.port_group_id" placeholder="选择安全组可选" filterable clearable style="width: 100%"> <el-select v-model="editForm.port_group_id" placeholder="选择安全组可选" filterable clearable style="width: 100%">
@@ -557,8 +616,10 @@
<el-button type="primary" :loading="actionLoading" @click="submitEditVm">确定</el-button> <el-button type="primary" :loading="actionLoading" @click="submitEditVm">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 编辑用网络选择器 --> <!-- 编辑用网络选择器(外网 bridge -->
<NetworkSelectorPopup v-model="showEditNetworkSelector" :service-id="serviceId" :host-id="vmHostId" @confirm="handleEditNetworkConfirm" /> <NetworkSelectorPopup v-model="showEditNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleEditNetworkConfirm" @create="() => handleNetCreate('edit')" />
<!-- 编辑用内网选择器(内网 nat -->
<NetworkSelectorPopup v-model="showEditInternalNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleEditInternalNetworkConfirm" @create="() => handleNetCreate('editInternal')" />
<!-- 重构虚拟机弹窗 --> <!-- 重构虚拟机弹窗 -->
<el-dialog v-model="refactorDialogVisible" title="重构虚拟机" width="650px" destroy-on-close> <el-dialog v-model="refactorDialogVisible" title="重构虚拟机" width="650px" destroy-on-close>
@@ -627,19 +688,20 @@
<el-input v-model="refactorForm.vnc_password" placeholder="不填随机" show-password /> <el-input v-model="refactorForm.vnc_password" placeholder="不填随机" show-password />
</el-form-item> </el-form-item>
<el-form-item label="网络"> <el-form-item label="网络">
<div style="width: 100%"> <div style="display: flex; align-items: center; gap: 8px; width: 100%">
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px" v-if="refactorSelectedNetworks.length"> <el-tag v-if="refactorSelectedNetworks.length" closable @close="removeRefactorNetwork(refactorSelectedNetworks[0].id)">
<el-tag v-for="n in refactorSelectedNetworks" :key="n.id" closable size="small" @close="removeRefactorNetwork(n.id)"> {{ refactorSelectedNetworks[0].name }} (ID:{{ refactorSelectedNetworks[0].id }})
{{ n.name }} (ID:{{ n.id }}) </el-tag>
</el-tag> <el-button size="small" @click="showRefactorNetworkSelector = true">{{ refactorSelectedNetworks.length ? '更换网络' : '选择网络' }}</el-button>
</div>
<el-button size="small" @click="showRefactorNetworkSelector = true">选择网络</el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="内网组网"> <el-form-item label="内网">
<el-select v-model="refactorForm.internet_network_id" placeholder="选择内网组网可选" clearable filterable style="width: 100%"> <div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-option v-for="n in networkingOptions" :key="n.id" :label="`${n.name} (ID: ${n.id})`" :value="n.id" /> <el-tag v-if="refactorSelectedInternalNetworks.length" closable @close="removeRefactorInternalNetwork(refactorSelectedInternalNetworks[0].id)">
</el-select> {{ refactorSelectedInternalNetworks[0].name }} (ID:{{ refactorSelectedInternalNetworks[0].id }})
</el-tag>
<el-button size="small" @click="showRefactorInternalNetworkSelector = true">{{ refactorSelectedInternalNetworks.length ? '更换内网' : '选择内网' }}</el-button>
</div>
</el-form-item> </el-form-item>
<el-form-item label="安全组"> <el-form-item label="安全组">
<el-select v-model="refactorForm.port_group_id" placeholder="选择安全组可选" filterable clearable style="width: 100%"> <el-select v-model="refactorForm.port_group_id" placeholder="选择安全组可选" filterable clearable style="width: 100%">
@@ -653,8 +715,10 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 重构用网络选择器 --> <!-- 重构用网络选择器(外网 bridge -->
<NetworkSelectorPopup v-model="showRefactorNetworkSelector" :service-id="serviceId" :host-id="vmHostId" @confirm="handleRefactorNetworkConfirm" /> <NetworkSelectorPopup v-model="showRefactorNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleRefactorNetworkConfirm" @create="() => handleNetCreate('refactor')" />
<!-- 重构用内网选择器(内网 nat -->
<NetworkSelectorPopup v-model="showRefactorInternalNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleRefactorInternalNetworkConfirm" @create="() => handleNetCreate('refactorInternal')" />
<!-- VNC 连接弹窗 --> <!-- VNC 连接弹窗 -->
<el-dialog v-model="vncDialogVisible" title="获取 VNC 连接" width="560px" destroy-on-close> <el-dialog v-model="vncDialogVisible" title="获取 VNC 连接" width="560px" destroy-on-close>
@@ -690,7 +754,7 @@
<el-form-item label="上行带宽(Mbps)"> <el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" /> <el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="每月最大流量(KB)"> <el-form-item label="每月最大流量(MB)">
<el-input-number v-model="trafficForm.traffic_max" :min="0" controls-position="right" style="width: 100%" /> <el-input-number v-model="trafficForm.traffic_max" :min="0" controls-position="right" style="width: 100%" />
</el-form-item> </el-form-item>
</el-form> </el-form>
@@ -700,8 +764,50 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 迁移虚拟机弹窗 -->
<el-dialog v-model="migrateDialogVisible" title="迁移虚拟机更换宿主机" width="560px" destroy-on-close>
<el-alert type="warning" :closable="false" style="margin-bottom: 16px">将虚拟机迁移到其他宿主机或宿主机组,请谨慎操作!</el-alert>
<el-form label-width="130px" v-loading="migrateOptionsLoading">
<el-form-item label="当前宿主机">
<el-tag>{{ detail?.host_name || detail?.host_id || '-' }}</el-tag>
</el-form-item>
<el-form-item label="迁移目标方式">
<el-radio-group v-model="migrateMode">
<el-radio value="host">指定宿主机</el-radio>
<el-radio value="group">指定宿主机组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="目标宿主机" v-if="migrateMode === 'host'">
<el-select v-model="migrateForm.target_host_id" placeholder="选择目标宿主机" filterable style="width: 100%">
<el-option v-for="h in migrateHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" :disabled="h.id === detail?.host_id" />
</el-select>
</el-form-item>
<el-form-item label="目标宿主机组" v-if="migrateMode === 'group'">
<el-select v-model="migrateForm.target_host_group_id" placeholder="选择目标宿主机组" filterable style="width: 100%">
<el-option v-for="g in migrateGroupOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="新IPv4数量">
<el-input-number v-model="migrateForm.ipv4_num" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="新IPv6数量">
<el-input-number v-model="migrateForm.ipv6_num" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="migrateDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="actionLoading" @click="submitMigrateVm">确定迁移</el-button>
</template>
</el-dialog>
<!-- 绑定网络选择器 --> <!-- 绑定网络选择器 -->
<NetworkSelectorPopup v-model="showNetBindSelector" :service-id="serviceId" :host-id="vmHostId" @confirm="handleNetBindConfirm" @create="handleNetCreate" /> <NetworkSelectorPopup v-model="showNetBindSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleNetBindConfirm" @create="() => handleNetCreate('bind')" />
<!-- 创建/编辑网络弹窗 --> <!-- 创建/编辑网络弹窗 -->
<el-dialog v-model="netDialogVisible" :title="netDialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close> <el-dialog v-model="netDialogVisible" :title="netDialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close>
@@ -723,7 +829,7 @@
<el-form-item label="逻辑端口名"><el-input v-model="netForm.ls_name" placeholder="不填使用默认" /></el-form-item> <el-form-item label="逻辑端口名"><el-input v-model="netForm.ls_name" placeholder="不填使用默认" /></el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="netDialogVisible = false">取消</el-button> <el-button @click="handleNetDialogCancel">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitNetForm">确定</el-button> <el-button type="primary" :loading="actionLoading" @click="submitNetForm">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -968,6 +1074,47 @@
<el-button type="primary" :loading="sgSubmitLoading" @click="submitSgRule">确定</el-button> <el-button type="primary" :loading="sgSubmitLoading" @click="submitSgRule">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 加入组网弹窗 -->
<el-dialog v-model="vnJoinVisible" title="加入组网" width="480px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="组网">{{ vnJoinTarget?.name || '-' }} (ID: {{ vnJoinTarget?.id }})</el-form-item>
<el-form-item label="网桥"><span class="mono-text">{{ vnJoinTarget?.bridge_name || '-' }}</span></el-form-item>
<el-form-item label="网关"><span class="mono-text">{{ vnJoinTarget?.gateway || '-' }}</span></el-form-item>
<el-form-item label="指定IP">
<el-input v-model="vnJoinIp" placeholder="留空自动分配" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="vnJoinVisible = false">取消</el-button>
<el-button type="primary" :loading="vnSubmitLoading" @click="submitJoinNetworking">加入</el-button>
</template>
</el-dialog>
<!-- 创建组网弹窗 -->
<el-dialog v-model="vnCreateVisible" title="创建组网" width="500px" destroy-on-close>
<el-form ref="vnCreateFormRef" :model="vnCreateForm" :rules="vnCreateRules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="vnCreateForm.name" placeholder="组网名称" />
</el-form-item>
<el-form-item label="网桥名称" prop="bridge_name">
<el-input v-model="vnCreateForm.bridge_name" placeholder="网桥名称" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="vnCreateForm.gateway" placeholder="可选 10.0.0.1/24" />
</el-form-item>
<el-form-item label="宿主机">
<span style="color: #606266">{{ vnCreateHostName || '加载中...' }} <span style="color: #909399">(ID: {{ vmHostId }})</span></span>
</el-form-item>
<el-form-item label="用户">
<span style="color: #606266">{{ vnCreateUserName || '加载中...' }} <span style="color: #909399">(ID: {{ detail?.user_id || '-' }})</span></span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="vnCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="vnSubmitLoading" @click="submitVnCreate">创建</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
@@ -981,7 +1128,7 @@ import {
startVm, stopVm, rebootVm, suspendVm, resumeVm, startVm, stopVm, rebootVm, suspendVm, resumeVm,
rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm, rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm,
getVmVnc, getVncNodeList, getVmVnc, getVncNodeList,
getSecurityGroupList, getUserNetworkingList, getSecurityGroupList, getUserNetworkingList, createUserNetworking, assignUserNetworking, removeUserNetworkingNetwork,
getRemoteHostList, getSecurityGroupDetail, createSecurityGroup, getRemoteHostList, getSecurityGroupDetail, createSecurityGroup,
syncSecurityGroup, deleteSecurityGroup, syncSecurityGroup, deleteSecurityGroup,
enableSecurityGroupWhitelist, disableSecurityGroupWhitelist, enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
@@ -990,8 +1137,10 @@ import {
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume, createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume,
getVmList, bindSecurityGroup, unbindSecurityGroup, getVmList, bindSecurityGroup, unbindSecurityGroup,
getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, getSnapshotCount, setSnapshotLimit, 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' } from '@/api/admin/kvmService'
import { getUserInfo } from '@/api/admin/user'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue' import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
@@ -1007,7 +1156,7 @@ const tagsViewStore = useTagsViewStore()
const serviceId = computed(() => parseInt(route.query.service_id) || 0) const serviceId = computed(() => parseInt(route.query.service_id) || 0)
const serviceName = computed(() => route.query.service_name || '') 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 loading = ref(false)
const actionLoading = ref(false) const actionLoading = ref(false)
@@ -1074,7 +1223,8 @@ const handleMoreCommand = (cmd) => {
if (powerActions.includes(cmd)) { handlePower(cmd); return } if (powerActions.includes(cmd)) { handlePower(cmd); return }
const actionMap = { const actionMap = {
editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic, 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]() if (actionMap[cmd]) actionMap[cmd]()
} }
@@ -1337,30 +1487,24 @@ const editFormRef = ref(null)
const editForm = reactive({ const editForm = reactive({
rx_bandwidth: 0, tx_bandwidth: 0, rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '', ssh_port: 22, root_password: '', ssh_port: 22,
internet_network_id: 0, port_group_id: 0 port_group_id: ''
}) })
const editSelectedNetworks = ref([]) const editSelectedNetworks = ref([])
const showEditNetworkSelector = ref(false) const showEditNetworkSelector = ref(false)
const editSelectedInternalNetworks = ref([])
const showEditInternalNetworkSelector = ref(false)
const handleEditNetworkConfirm = (network) => { const handleEditNetworkConfirm = (network) => {
if (!editSelectedNetworks.value.find(n => n.id === network.id)) { editSelectedNetworks.value = [{ id: network.id, name: network.name }]
editSelectedNetworks.value.push({ id: network.id, name: network.name })
}
} }
const removeEditNetwork = (id) => { const removeEditNetwork = (id) => {
editSelectedNetworks.value = editSelectedNetworks.value.filter(n => n.id !== id) editSelectedNetworks.value = editSelectedNetworks.value.filter(n => n.id !== id)
if (editForm.internet_network_id === id) editForm.internet_network_id = 0
} }
const handleEditInternalNetworkConfirm = (network) => {
const networkingOptions = ref([]) editSelectedInternalNetworks.value = [{ id: network.id, name: network.name }]
const loadNetworkingOptions = async () => { }
try { const removeEditInternalNetwork = (id) => {
const res = await getUserNetworkingList({ service_id: serviceId.value, page: 1, count: 10 }) editSelectedInternalNetworks.value = editSelectedInternalNetworks.value.filter(n => n.id !== id)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
networkingOptions.value = Array.isArray(inner) ? inner : (inner.data || [])
}
} catch { /* */ }
} }
const handleEditVm = async () => { const handleEditVm = async () => {
@@ -1371,16 +1515,17 @@ const handleEditVm = async () => {
tx_bandwidth: d.tx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
root_password: '', root_password: '',
ssh_port: d.ssh_port || 22, ssh_port: d.ssh_port || 22,
internet_network_id: '', port_group_id: vmPortGroup.value?.id || ''
port_group_id: vmPortGroup.value?.id || 0
}) })
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 editDialogVisible.value = true
dialogOptionsLoading.value = true dialogOptionsLoading.value = true
try { try {
await Promise.all([ await Promise.all([
!sgOptions.value.length ? loadSgOptions() : Promise.resolve(), !sgOptions.value.length ? loadSgOptions() : Promise.resolve()
loadNetworkingOptions()
]) ])
} finally { dialogOptionsLoading.value = false } } finally { dialogOptionsLoading.value = false }
} }
@@ -1396,7 +1541,7 @@ const submitEditVm = async () => {
if (editForm.root_password) fd.append('root_password', editForm.root_password) if (editForm.root_password) fd.append('root_password', editForm.root_password)
fd.append('ssh_port', editForm.ssh_port) fd.append('ssh_port', editForm.ssh_port)
editSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id)) 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) if (editForm.port_group_id) fd.append('port_group_id', editForm.port_group_id)
const res = await updateVm(fd) const res = await updateVm(fd)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() } 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, memory: 0, vcpu: 0, rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '', uuid: '', mate_data_id: '', physical_name: '', config_path: '', root_password: '', uuid: '', mate_data_id: '', physical_name: '', config_path: '',
ssh_port: 0, vnc_port: 0, vnc_password: '', ssh_port: 0, vnc_port: 0, vnc_password: '',
internet_network_id: 0, port_group_id: 0 port_group_id: ''
}) })
const refactorMemUnit = ref(1048576) const refactorMemUnit = ref(1048576)
const refactorMemDisplay = ref(0) const refactorMemDisplay = ref(0)
const refactorSelectedNetworks = ref([]) const refactorSelectedNetworks = ref([])
const showRefactorNetworkSelector = ref(false) const showRefactorNetworkSelector = ref(false)
const refactorSelectedInternalNetworks = ref([])
const showRefactorInternalNetworkSelector = ref(false)
const onRefactorMemUnitChange = () => { const onRefactorMemUnitChange = () => {
refactorMemDisplay.value = refactorForm.memory ? Math.round(refactorForm.memory / refactorMemUnit.value * 100) / 100 : 0 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) }) watch(refactorMemDisplay, (v) => { refactorForm.memory = Math.round(v * refactorMemUnit.value) })
const handleRefactorNetworkConfirm = (network) => { const handleRefactorNetworkConfirm = (network) => {
if (!refactorSelectedNetworks.value.find(n => n.id === network.id)) { refactorSelectedNetworks.value = [{ id: network.id, name: network.name }]
refactorSelectedNetworks.value.push({ id: network.id, name: network.name })
}
} }
const removeRefactorNetwork = (id) => { const removeRefactorNetwork = (id) => {
refactorSelectedNetworks.value = refactorSelectedNetworks.value.filter(n => n.id !== 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 () => { const handleRefactorVm = async () => {
@@ -1443,10 +1593,12 @@ const handleRefactorVm = async () => {
uuid: d.uuid || '', mate_data_id: d.mate_data_id || '', uuid: d.uuid || '', mate_data_id: d.mate_data_id || '',
physical_name: d.physical_name || '', config_path: d.config_path || '', physical_name: d.physical_name || '', config_path: d.config_path || '',
ssh_port: d.ssh_port || 0, vnc_port: 0, vnc_password: '', ssh_port: d.ssh_port || 0, vnc_port: 0, vnc_password: '',
internet_network_id: 0, port_group_id: vmPortGroup.value?.id || ''
port_group_id: vmPortGroup.value?.id || 0
}) })
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 const mem = d.memory || 0
if (mem >= 1048576 && mem % 1048576 === 0) { refactorMemUnit.value = 1048576; refactorMemDisplay.value = mem / 1048576 } 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 } else if (mem >= 1024 && mem % 1024 === 0) { refactorMemUnit.value = 1024; refactorMemDisplay.value = mem / 1024 }
@@ -1455,8 +1607,7 @@ const handleRefactorVm = async () => {
dialogOptionsLoading.value = true dialogOptionsLoading.value = true
try { try {
await Promise.all([ await Promise.all([
!sgOptions.value.length ? loadSgOptions() : Promise.resolve(), !sgOptions.value.length ? loadSgOptions() : Promise.resolve()
loadNetworkingOptions()
]) ])
} finally { dialogOptionsLoading.value = false } } 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_port) fd.append('vnc_port', refactorForm.vnc_port)
if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password) if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password)
refactorSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id)) 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) if (refactorForm.port_group_id) fd.append('port_group_id', refactorForm.port_group_id)
const res = await refactorVm(fd) const res = await refactorVm(fd)
if (res?.data?.code === 200) { ElMessage.success('重构成功'); refactorDialogVisible.value = false; loadDetail() } 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 } } 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 连接 ---- // ---- VNC 连接 ----
const vncDialogVisible = ref(false) const vncDialogVisible = ref(false)
const vncNodeId = ref(null) const vncNodeId = ref(null)
@@ -1560,7 +1758,7 @@ const sgOptions = ref([])
const loadSgOptions = async () => { const loadSgOptions = async () => {
try { 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) { if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data const inner = res.data.data
sgOptions.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : []) 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 netDialogVisible = ref(false)
const netDialogType = ref('add') const netDialogType = ref('add')
const netDialogSource = ref('')
const netFormRef = ref(null) const netFormRef = ref(null)
const netDetailVisible = ref(false) const netDetailVisible = ref(false)
const netDetailData = ref(null) const netDetailData = ref(null)
@@ -1615,11 +1814,23 @@ const netFormRules = {
type: [{ required: true, message: '请选择类型', trigger: 'change' }] type: [{ required: true, message: '请选择类型', trigger: 'change' }]
} }
const handleNetCreate = () => { const handleNetCreate = (source = '') => {
netDialogSource.value = source
netDialogType.value = 'add' 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 }) 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 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) => { const handleNetEdit = (row) => {
netDialogType.value = 'edit' 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 }) 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) if (netForm.ls_name) fd.append('ls_name', netForm.ls_name)
let res let res
if (netDialogType.value === 'add') { res = await createNetwork(fd) } 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() } if (res?.data?.code === 200) { ElMessage.success(netDialogType.value === 'add' ? '创建成功' : '修改成功'); netDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '操作失败')) else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
@@ -1867,7 +2078,14 @@ const submitSgCreate = () => {
if (!valid) return if (!valid) return
sgSubmitLoading.value = true sgSubmitLoading.value = true
try { 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) { if (res?.data?.code === 200) {
ElMessage.success('创建成功') ElMessage.success('创建成功')
sgCreateDialogVisible.value = false sgCreateDialogVisible.value = false
@@ -1894,7 +2112,8 @@ const submitSgSync = async () => {
if (!sgSyncHostId.value) { ElMessage.warning('请选择宿主机'); return } if (!sgSyncHostId.value) { ElMessage.warning('请选择宿主机'); return }
sgSubmitLoading.value = true sgSubmitLoading.value = true
try { 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) { if (res?.data?.code === 200) {
ElMessage.success('同步成功') ElMessage.success('同步成功')
sgSyncDialogVisible.value = false sgSyncDialogVisible.value = false
@@ -1909,7 +2128,8 @@ const handleSgApply = (row) => {
confirmButtonText: '确定应用', cancelButtonText: '取消', type: 'info' confirmButtonText: '确定应用', cancelButtonText: '取消', type: 'info'
}).then(async () => { }).then(async () => {
try { 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('应用成功') if (res?.data?.code === 200) ElMessage.success('应用成功')
else ElMessage.error(extractApiError(res?.data, '应用失败')) else ElMessage.error(extractApiError(res?.data, '应用失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '应用失败')) } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '应用失败')) }
@@ -1918,7 +2138,7 @@ const handleSgApply = (row) => {
// 编辑(跳转安全组详情页) // 编辑(跳转安全组详情页)
const handleSgGoDetail = (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 () => { }).then(async () => {
try { try {
const api = row.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist 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() } if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadDetail() }
else ElMessage.error(extractApiError(res?.data, `${action}失败`)) else ElMessage.error(extractApiError(res?.data, `${action}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) }
@@ -1965,7 +2186,8 @@ const submitSgVmBind = async () => {
sgSubmitLoading.value = true sgSubmitLoading.value = true
try { try {
const api = sgVmBindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup 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) { if (res?.data?.code === 200) {
ElMessage.success(sgVmBindType.value === 'bind' ? '绑定成功' : '解绑成功') ElMessage.success(sgVmBindType.value === 'bind' ? '绑定成功' : '解绑成功')
sgVmBindDialogVisible.value = false sgVmBindDialogVisible.value = false
@@ -1994,20 +2216,20 @@ const handleSgViewDetail = async (row) => {
const sgRuleDialogVisible = ref(false) const sgRuleDialogVisible = ref(false)
const sgRuleDialogType = ref('add') const sgRuleDialogType = ref('add')
const sgRuleFormRef = ref(null) 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 = { const sgRuleRules = {
protocol: [{ required: true, message: '请选择协议', trigger: 'change' }], protocol: [{ required: true, message: '请选择协议', trigger: 'change' }],
action: [{ required: true, message: '请选择动作', trigger: 'change' }] action: [{ required: true, message: '请选择动作', trigger: 'change' }]
} }
const handleSgAddRule = () => { const handleSgAddRule = () => {
sgRuleDialogType.value = 'add' 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 sgRuleDialogVisible.value = true
} }
const handleSgEditRule = (rule) => { const handleSgEditRule = (rule) => {
sgRuleDialogType.value = 'edit' sgRuleDialogType.value = 'edit'
Object.assign(sgRuleForm, { 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', protocol: rule.protocol || 'tcp', action: rule.action || 'allow',
port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0 port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0
}) })
@@ -2018,9 +2240,19 @@ const submitSgRule = () => {
if (!valid) return if (!valid) return
sgSubmitLoading.value = true sgSubmitLoading.value = true
try { 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' const res = sgRuleDialogType.value === 'add'
? await createSecurityGroupRule({ service_id: serviceId.value, ...sgRuleForm }) ? await createSecurityGroupRule(fd)
: await updateSecurityGroupRule({ service_id: serviceId.value, ...sgRuleForm }) : await updateSecurityGroupRule(fd)
if (res?.data?.code === 200) { if (res?.data?.code === 200) {
ElMessage.success(sgRuleDialogType.value === 'add' ? '规则创建成功' : '规则修改成功') ElMessage.success(sgRuleDialogType.value === 'add' ? '规则创建成功' : '规则修改成功')
sgRuleDialogVisible.value = false sgRuleDialogVisible.value = false
@@ -2297,6 +2529,162 @@ const goBack = () => {
router.back() 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 let loadedVmId = null
const initPage = () => { const initPage = () => {
if (!vmId.value || loadedVmId === vmId.value) return if (!vmId.value || loadedVmId === vmId.value) return
@@ -2316,6 +2704,7 @@ watch(activeTab, (tab) => {
else stopPolling() else stopPolling()
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() } if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
if (tab === 'backup') { loadBackups(); loadBackupQuota() } if (tab === 'backup') { loadBackups(); loadBackupQuota() }
if (tab === 'userNetworking') loadVmNetworkingList()
}) })
onActivated(() => { onActivated(() => {
isPageActive = true isPageActive = true
+20 -8
View File
@@ -174,9 +174,18 @@
<el-radio value="ids">选择网络IP</el-radio> <el-radio value="ids">选择网络IP</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="IP数量" v-if="ipMode === 'num'"> <el-row :gutter="16" v-if="ipMode === 'num'">
<el-input-number v-model="createForm.ip_num" :min="1" controls-position="right" style="width: 100%" /> <el-col :span="12">
</el-form-item> <el-form-item label="IPv4数量">
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="IPv6数量">
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'"> <el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
<el-select v-model="createForm.network_ids" multiple filterable placeholder="选择可用网络IP" style="width: 100%"> <el-select v-model="createForm.network_ids" multiple filterable placeholder="选择可用网络IP" style="width: 100%">
<el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''}`" :value="n.id" /> <el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''}`" :value="n.id" />
@@ -447,7 +456,7 @@ const vmMetricsData = ref(null)
const createForm = reactive({ const createForm = reactive({
name: '', host_id: null, image_id: 0, vcpu: 0, memory: 0, name: '', host_id: null, image_id: 0, vcpu: 0, memory: 0,
system_size: 0, rx_bandwidth: 0, tx_bandwidth: 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: '' _imageName: '', _groupName: '', _userName: ''
}) })
@@ -513,6 +522,7 @@ const loadList = async () => {
loading.value = true loading.value = true
try { try {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size } 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 (keyword.value) params.key = keyword.value
if (filterStatus.value) params.status = filterStatus.value if (filterStatus.value) params.status = filterStatus.value
const res = await getVmList(params) const res = await getVmList(params)
@@ -531,7 +541,7 @@ const handleAdd = () => {
Object.assign(createForm, { Object.assign(createForm, {
name: '', host_id: injectedHostId?.value || null, image_id: 0, name: '', host_id: injectedHostId?.value || null, image_id: 0,
vcpu: 0, memory: 0, system_size: 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: '' _imageName: '', _groupName: '', _userName: ''
}) })
memoryUnit.value = 'GB' memoryUnit.value = 'GB'
@@ -549,7 +559,7 @@ const submitCreate = () => {
if (hostMode.value === 'host' && !createForm.host_id) { ElMessage.warning('请选择宿主机'); return } if (hostMode.value === 'host' && !createForm.host_id) { ElMessage.warning('请选择宿主机'); return }
if (hostMode.value === 'group' && !createForm.host_group_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 === '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) => { createFormRef.value?.validate(async (valid) => {
if (!valid) return if (!valid) return
@@ -567,8 +577,10 @@ const submitCreate = () => {
if (createForm.tx_bandwidth) fd.append('tx_bandwidth', createForm.tx_bandwidth) if (createForm.tx_bandwidth) fd.append('tx_bandwidth', createForm.tx_bandwidth)
if (hostMode.value === 'host') fd.append('host_id', createForm.host_id) if (hostMode.value === 'host') fd.append('host_id', createForm.host_id)
else fd.append('host_group_id', createForm.host_group_id) else fd.append('host_group_id', createForm.host_group_id)
if (ipMode.value === 'num') fd.append('ip_num', createForm.ip_num) if (ipMode.value === 'num') {
else createForm.network_ids.forEach(id => fd.append('network_ids', id)) 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) const res = await createVm(fd)
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() } if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败')) else ElMessage.error(extractApiError(res?.data, '创建失败'))
+13 -12
View File
@@ -35,18 +35,19 @@ export default defineConfig(({ mode }) => {
host: '0.0.0.0', host: '0.0.0.0',
port: 5176, port: 5176,
strictPort: false, strictPort: false,
proxy: { // proxy 已关闭,前端直接请求后端地址(在 src/config/env.js 中配置)
'/api': { // proxy: {
target: proxyTarget, // '/api': {
changeOrigin: true, // target: proxyTarget,
secure: false // changeOrigin: true,
}, // secure: false
'/acs': { // },
target: proxyTarget, // '/acs': {
changeOrigin: true, // target: proxyTarget,
secure: false // changeOrigin: true,
} // secure: false
} // }
// }
}, },
build: { build: {
rollupOptions: { rollupOptions: {