feat: 对接主控服务接口
This commit is contained in:
+425
-1
@@ -126,7 +126,7 @@ export const deleteRemoteHostGroup = (params) => {
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 远程宿主机管理
|
||||
* 主控服务接口 - 宿主机管理
|
||||
* ================================
|
||||
*/
|
||||
|
||||
@@ -139,3 +139,427 @@ export const getRemoteHostList = (params) => {
|
||||
export const getRemoteHostDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/host/detail', { params })
|
||||
}
|
||||
|
||||
/** 获取宿主机指标数据 */
|
||||
export const getRemoteHostMetrics = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/host/metrics', { params })
|
||||
}
|
||||
|
||||
/** 新增宿主机 */
|
||||
export const addRemoteHost = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/host/add', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改宿主机 */
|
||||
export const updateRemoteHost = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/host/update', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除宿主机 */
|
||||
export const deleteRemoteHost = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/host/delete', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 镜像管理
|
||||
* ================================
|
||||
*/
|
||||
|
||||
/** 获取镜像列表 */
|
||||
export const getImageList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/image/list', { params })
|
||||
}
|
||||
|
||||
/** 获取镜像详情 */
|
||||
export const getImageDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/image/detail', { params })
|
||||
}
|
||||
|
||||
/** 获取镜像在指定宿主机上的状态 */
|
||||
export const getImageHostStatus = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/image/host_status', { params })
|
||||
}
|
||||
|
||||
/** 创建镜像 */
|
||||
export const createImage = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/image/create', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改镜像 */
|
||||
export const updateImage = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/image/update', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除镜像 */
|
||||
export const deleteImage = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/image/delete', { params })
|
||||
}
|
||||
|
||||
/** 重新下载镜像 */
|
||||
export const reloadImage = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/image/reload', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 向宿主机同步镜像 */
|
||||
export const syncImageToHost = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/image/sync', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 指定宿主机重新下载指定镜像 */
|
||||
export const reloadImageOnHost = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/image/reload_host', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 网络管理
|
||||
* ================================
|
||||
*/
|
||||
|
||||
/** 获取网络列表 */
|
||||
export const getNetworkList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/network/list', { params })
|
||||
}
|
||||
|
||||
/** 获取网络详情 */
|
||||
export const getNetworkDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/network/detail', { params })
|
||||
}
|
||||
|
||||
/** 创建网络 */
|
||||
export const createNetwork = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/network/create', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改网络 */
|
||||
export const updateNetwork = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/network/update', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除网络 */
|
||||
export const deleteNetwork = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/network/delete', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 数据卷管理
|
||||
* ================================
|
||||
*/
|
||||
|
||||
/** 获取数据卷列表 */
|
||||
export const getVolumeList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/volume/list', { params })
|
||||
}
|
||||
|
||||
/** 获取数据卷详情 */
|
||||
export const getVolumeDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/volume/detail', { params })
|
||||
}
|
||||
|
||||
/** 创建数据卷 */
|
||||
export const createVolume = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/volume/create', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 调整数据卷大小 */
|
||||
export const resizeVolume = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/volume/resize', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 挂载卷到虚拟机 */
|
||||
export const mountVolume = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/volume/mount', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 卸载卷 */
|
||||
export const unmountVolume = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/volume/unmount', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 迁移卷 */
|
||||
export const transferVolume = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/volume/transfer', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除卷 */
|
||||
export const deleteVolume = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/volume/delete', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 虚拟机管理
|
||||
* ================================
|
||||
*/
|
||||
|
||||
/** 获取虚拟机列表 */
|
||||
export const getVmList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vm/list', { params })
|
||||
}
|
||||
|
||||
/** 获取虚拟机详情 */
|
||||
export const getVmDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vm/detail', { params })
|
||||
}
|
||||
|
||||
/** 获取虚拟机状态 */
|
||||
export const getVmStatus = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vm/status', { params })
|
||||
}
|
||||
|
||||
/** 获取虚拟机指标数据 */
|
||||
export const getVmMetrics = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vm/metrics', { params })
|
||||
}
|
||||
|
||||
/** 创建虚拟机 */
|
||||
export const createVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/create', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改虚拟机 */
|
||||
export const updateVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/update', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 重建虚拟机 */
|
||||
export const rebuildVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/rebuild', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 重构虚拟机 */
|
||||
export const refactorVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/refactor', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改虚拟机带宽 */
|
||||
export const updateVmTraffic = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/update_traffic', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 启动虚拟机 */
|
||||
export const startVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/start', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 停止虚拟机 */
|
||||
export const stopVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/stop', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 重启虚拟机 */
|
||||
export const rebootVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/reboot', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 暂停虚拟机 */
|
||||
export const suspendVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/suspend', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 恢复虚拟机 */
|
||||
export const resumeVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/resume', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 虚拟机进入救援系统 */
|
||||
export const rescueVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/rescue', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 虚拟机退出救援系统 */
|
||||
export const exitRescueVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/exit_rescue', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除虚拟机 */
|
||||
export const deleteVm = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/delete', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 安全组管理
|
||||
* ================================
|
||||
*/
|
||||
|
||||
/** 获取安全组列表 */
|
||||
export const getSecurityGroupList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/post_group/list', { params })
|
||||
}
|
||||
|
||||
/** 获取安全组详情 */
|
||||
export const getSecurityGroupDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/post_group/detail', { params })
|
||||
}
|
||||
|
||||
/** 创建安全组 */
|
||||
export const createSecurityGroup = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/create', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 同步安全组 */
|
||||
export const syncSecurityGroup = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/sync', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 绑定安全组到虚拟机 */
|
||||
export const bindSecurityGroup = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/bind', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 解绑安全组 */
|
||||
export const unbindSecurityGroup = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/unbind', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除安全组 */
|
||||
export const deleteSecurityGroup = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/post_group/delete', { params })
|
||||
}
|
||||
|
||||
/** 开启安全组白名单 */
|
||||
export const enableSecurityGroupWhitelist = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/enable_whitelist', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 关闭安全组白名单 */
|
||||
export const disableSecurityGroupWhitelist = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/disable_whitelist', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 新增安全组规则 */
|
||||
export const createSecurityGroupRule = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/create_rule', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改安全组规则 */
|
||||
export const updateSecurityGroupRule = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/update_rule', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除安全组规则 */
|
||||
export const deleteSecurityGroupRule = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/post_group/delete_rule', { params })
|
||||
}
|
||||
|
||||
/** 应用安全组 */
|
||||
export const applySecurityGroup = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/post_group/apply', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - VNC 节点管理
|
||||
* ================================
|
||||
*/
|
||||
|
||||
/** 获取 VNC 节点列表 */
|
||||
export const getVncNodeList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vnc/list', { params })
|
||||
}
|
||||
|
||||
/** 获取虚拟机 VNC 连接信息 */
|
||||
export const getVmVnc = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vnc/vm_vnc', { params })
|
||||
}
|
||||
|
||||
/** 新增 VNC 节点 */
|
||||
export const addVncNode = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vnc/add', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 测试 VNC 节点连接 */
|
||||
export const testVncNode = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vnc/test', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 修改 VNC 节点 */
|
||||
export const updateVncNode = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vnc/update', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除 VNC 节点 */
|
||||
export const deleteVncNode = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/vnc/delete', { params })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择宿主机组" width="600px" append-to-body @close="handleClose">
|
||||
<div class="selector-container">
|
||||
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.note || row.Note || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { getHostGroupList } from '@/api/admin/kvmService'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
serviceId: { type: Number, default: 0 },
|
||||
currentId: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const selectedItem = ref(null)
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) loadList()
|
||||
})
|
||||
watch(visible, (val) => emit('update:modelValue', val))
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getHostGroupList({ service_id: props.serviceId })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
|
||||
list.value = items.map(i => ({
|
||||
id: i.Id ?? i.id,
|
||||
name: i.Name ?? i.name,
|
||||
note: i.Note ?? i.note
|
||||
}))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
|
||||
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||
const handleConfirm = () => {
|
||||
if (selectedItem.value) {
|
||||
emit('confirm', selectedItem.value)
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
const handleClose = () => { selectedItem.value = null }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selector-container { min-height: 200px; }
|
||||
:deep(.current-row) { background-color: #ecf5ff !important; }
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择镜像" width="700px" append-to-body @close="handleClose">
|
||||
<div class="selector-container">
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索镜像名称" clearable style="width: 200px" @keyup.enter="loadList" @clear="loadList">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 120px" @change="loadList">
|
||||
<el-option label="Linux" value="linux" />
|
||||
<el-option label="Windows" value="windows" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="系统" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ row.os_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="{ ready: 'success', error: 'danger', downloading: 'warning' }[row.status] || 'info'" size="small">
|
||||
{{ { ready: '就绪', error: '错误', downloading: '下载中', pending: '等待中' }[row.status] || row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { getImageList } from '@/api/admin/kvmService'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
serviceId: { type: Number, default: 0 },
|
||||
currentId: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const selectedItem = ref(null)
|
||||
const keyword = ref('')
|
||||
const filterOsType = ref('')
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) loadList()
|
||||
})
|
||||
watch(visible, (val) => emit('update:modelValue', val))
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: props.serviceId, page: 1, count: 100 }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterOsType.value) params.os_type = filterOsType.value
|
||||
const res = await getImageList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
list.value = inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
|
||||
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||
const handleConfirm = () => {
|
||||
if (selectedItem.value) {
|
||||
emit('confirm', selectedItem.value)
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
const handleClose = () => { selectedItem.value = null }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selector-container { min-height: 200px; }
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
:deep(.current-row) { background-color: #ecf5ff !important; }
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择虚拟机" width="700px" append-to-body @close="handleClose">
|
||||
<div class="selector-container">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="hostIdFilter" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="loadList">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="配置" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.vcpu }}核 / {{ formatMem(row.memory) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { getRemoteHostList, getVmList } from '@/api/admin/kvmService'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
serviceId: { type: Number, default: 0 },
|
||||
hostId: { type: Number, default: 0 },
|
||||
currentId: { type: Number, default: 0 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const selectedItem = ref(null)
|
||||
const hostIdFilter = ref(0)
|
||||
const hostOptions = ref([])
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) { loadHostOptions(); if (props.hostId) { hostIdFilter.value = props.hostId; loadList() } }
|
||||
})
|
||||
watch(visible, (val) => emit('update:modelValue', val))
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: props.serviceId, page: 1, page_size: 100 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
if (!hostIdFilter.value && hostOptions.value.length) hostIdFilter.value = hostOptions.value[0].id
|
||||
if (hostIdFilter.value) loadList()
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
if (!hostIdFilter.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVmList({ service_id: props.serviceId, host_id: hostIdFilter.value, page: 1, count: 100 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
list.value = inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const formatMem = (kb) => {
|
||||
if (!kb) return '-'
|
||||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
||||
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
|
||||
return kb + ' KB'
|
||||
}
|
||||
|
||||
const statusType = (s) => ({ running: 'success', ready: 'success', stopped: 'danger', error: 'danger', paused: 'warning' }[s] || 'info')
|
||||
const statusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停' }[s] || s || '-')
|
||||
|
||||
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
|
||||
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||
const handleConfirm = () => {
|
||||
if (selectedItem.value) {
|
||||
emit('confirm', selectedItem.value)
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
const handleClose = () => { selectedItem.value = null }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selector-container { min-height: 200px; }
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
:deep(.current-row) { background-color: #ecf5ff !important; }
|
||||
</style>
|
||||
@@ -407,12 +407,133 @@ const routes = [
|
||||
title: '主控服务管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'kvm-service-detail',
|
||||
name: 'KvmServiceDetail',
|
||||
component: () => import('../views/virtualization/KvmServiceDetail.vue'),
|
||||
meta: {
|
||||
title: '主控服务详情',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'host-group-mapping',
|
||||
name: 'HostGroupMapping',
|
||||
component: () => import('../views/virtualization/HostGroupMapping.vue'),
|
||||
meta: {
|
||||
title: '宿主机组映射管理',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'host-manage',
|
||||
name: 'HostManage',
|
||||
component: () => import('../views/virtualization/HostManage.vue'),
|
||||
meta: {
|
||||
title: '宿主机管理',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'image-manage',
|
||||
name: 'ImageManage',
|
||||
component: () => import('../views/virtualization/ImageManage.vue'),
|
||||
meta: {
|
||||
title: '镜像管理',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'network-manage',
|
||||
name: 'NetworkManage',
|
||||
component: () => import('../views/virtualization/NetworkManage.vue'),
|
||||
meta: {
|
||||
title: '网络管理',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'volume-manage',
|
||||
name: 'VolumeManage',
|
||||
component: () => import('../views/virtualization/VolumeManage.vue'),
|
||||
meta: {
|
||||
title: '数据卷管理',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'vm-manage',
|
||||
name: 'VmManage',
|
||||
component: () => import('../views/virtualization/VmManage.vue'),
|
||||
meta: {
|
||||
title: '虚拟机管理',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'security-group',
|
||||
name: 'SecurityGroupManage',
|
||||
component: () => import('../views/virtualization/SecurityGroupManage.vue'),
|
||||
meta: {
|
||||
title: '安全组管理',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'vnc-node',
|
||||
name: 'VncNodeManage',
|
||||
component: () => import('../views/virtualization/VncNodeManage.vue'),
|
||||
meta: {
|
||||
title: 'VNC节点管理',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'host-detail',
|
||||
name: 'VirtHostDetail',
|
||||
component: () => import('../views/virtualization/HostDetail.vue'),
|
||||
meta: {
|
||||
title: '宿主机详情',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'image-detail',
|
||||
name: 'VirtImageDetail',
|
||||
component: () => import('../views/virtualization/ImageDetail.vue'),
|
||||
meta: {
|
||||
title: '镜像详情',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'vm-detail',
|
||||
name: 'VirtVmDetail',
|
||||
component: () => import('../views/virtualization/VmDetail.vue'),
|
||||
meta: {
|
||||
title: '虚拟机详情',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'security-group-detail',
|
||||
name: 'VirtSecurityGroupDetail',
|
||||
component: () => import('../views/virtualization/SecurityGroupDetail.vue'),
|
||||
meta: {
|
||||
title: '安全组详情',
|
||||
hidden: true,
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="host-detail-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回宿主机列表</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">宿主机详情</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" plain :icon="Edit" @click="handleEdit">编辑</el-button>
|
||||
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<!-- 基本信息卡片 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP">{{ detail.ip || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务地址">{{ detail.base_url || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH 端口">{{ detail.port || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH 用户">{{ detail.user || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认证Token" :span="2">
|
||||
<el-input v-if="detail.token" :model-value="detail.token" readonly show-password style="max-width: 300px" />
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH 密码">
|
||||
<el-input v-if="detail.password" :model-value="detail.password" readonly show-password style="max-width: 200px" />
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="私钥路径">{{ detail.private_key_path || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机组">{{ detail.host_group_id ? `#${detail.host_group_id}` : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="detail.is_active ? 'success' : 'danger'" size="small">{{ detail.is_active ? '启用' : '禁用' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="介绍" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 资源限制 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">资源限制</span></template>
|
||||
<div class="resource-cards">
|
||||
<div class="res-item"><div class="res-label">最大CPU</div><div class="res-value">{{ detail.max_cpu ? detail.max_cpu + ' 核' : '-' }}</div></div>
|
||||
<div class="res-item"><div class="res-label">最大内存</div><div class="res-value">{{ formatMemMB(detail.max_memory) }}</div></div>
|
||||
<div class="res-item"><div class="res-label">最大磁盘</div><div class="res-value">{{ formatDiskGB(detail.max_disk) }}</div></div>
|
||||
<div class="res-item"><div class="res-label">下行带宽</div><div class="res-value">{{ detail.rx_bandwidth || 0 }} Mbps</div></div>
|
||||
<div class="res-item"><div class="res-label">上行带宽</div><div class="res-value">{{ detail.tx_bandwidth || 0 }} Mbps</div></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 实时指标 -->
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-title">实时指标</span>
|
||||
<el-button size="small" :icon="Refresh" @click="loadMetrics" :loading="metricsLoading">刷新指标</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="metricsLoading">
|
||||
<template v-if="metricsData">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12" v-if="metricsData.cpu">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="使用率">{{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.memory">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.memory.free) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ metricsData.memory.percent ?? '-' }}%</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12" v-if="metricsData.disk">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template>
|
||||
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item">
|
||||
<div class="disk-path">{{ path }}</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(info.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(info.used) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(info.free) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ info.percent ?? '-' }}%</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.network || metricsData.internet_speed">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<template v-if="metricsData.network">
|
||||
<el-descriptions-item label="接收">{{ formatBytesRaw(metricsData.network.rx_bytes) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发送">{{ formatBytesRaw(metricsData.network.tx_bytes) }}</el-descriptions-item>
|
||||
</template>
|
||||
<template v-if="metricsData.internet_speed">
|
||||
<el-descriptions-item label="实时接收">{{ formatBytesRaw(metricsData.internet_speed.rx_bytes) }}/s</el-descriptions-item>
|
||||
<el-descriptions-item label="实时发送">{{ formatBytesRaw(metricsData.internet_speed.tx_bytes) }}/s</el-descriptions-item>
|
||||
</template>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-empty v-else description="暂无指标数据,点击刷新加载" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="640px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
|
||||
<el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item>
|
||||
<el-form-item label="认证Token"><el-input v-model="formData.token" show-password /></el-form-item>
|
||||
<el-divider content-position="left">SSH 配置</el-divider>
|
||||
<el-form-item label="SSH 端口"><el-input-number v-model="formData.port" :min="0" :max="65535" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="SSH 用户名"><el-input v-model="formData.user" /></el-form-item>
|
||||
<el-form-item label="SSH 密码"><el-input v-model="formData.password" show-password /></el-form-item>
|
||||
<el-form-item label="私钥路径"><el-input v-model="formData.private_key_path" /></el-form-item>
|
||||
<el-divider content-position="left">资源限制</el-divider>
|
||||
<el-form-item label="最大CPU(核)"><el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="最大内存(MB)"><el-input-number v-model="formData.max_memory" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="最大磁盘(GB)"><el-input-number v-model="formData.max_disk" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="下行带宽(Mbps)"><el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="上行带宽(Mbps)"><el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="宿主机组">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showGroupSelector = true">选择</el-button>
|
||||
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<HostGroupSelectorPopup v-model="showGroupSelector" :service-id="serviceId" :current-id="formData.host_group_id" @confirm="g => formData.host_group_id = g.id" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Box, Connection } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost
|
||||
} from '@/api/admin/kvmService'
|
||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => route.query.service_name || '')
|
||||
const hostId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const metricsData = ref(null)
|
||||
const editDialogVisible = ref(false)
|
||||
const showGroupSelector = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key_path: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: ''
|
||||
})
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
base_url: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
|
||||
ip: [{ required: true, message: '请输入IP', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
||||
return '-'
|
||||
}
|
||||
const formatMemMB = (v) => { if (!v) return '-'; return v >= 1024 ? (v / 1024).toFixed(1) + ' GB' : v + ' MB' }
|
||||
const formatDiskGB = (v) => { if (!v) return '-'; return v >= 1024 ? (v / 1024).toFixed(1) + ' TB' : v + ' GB' }
|
||||
const formatBytesRaw = (val) => {
|
||||
if (!val && val !== 0) return '-'; val = Number(val)
|
||||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||||
return val + ' B'
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!serviceId.value || !hostId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRemoteHostDetail({ service_id: serviceId.value, id: hostId.value })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
detail.value = body.data.host ?? body.data.data ?? body.data
|
||||
} else { ElMessage.error(body?.message || '加载失败') }
|
||||
} catch (e) { ElMessage.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const loadMetrics = async () => {
|
||||
metricsLoading.value = true
|
||||
metricsData.value = null
|
||||
try {
|
||||
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) { metricsData.value = body.data.data ?? body.data }
|
||||
else ElMessage.warning('暂无指标数据')
|
||||
} catch { ElMessage.error('获取指标失败') } finally { metricsLoading.value = false }
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!detail.value) return
|
||||
const d = detail.value
|
||||
Object.assign(formData, {
|
||||
name: d.name || '', base_url: d.base_url || '', ip: d.ip || '', token: d.token || '',
|
||||
port: d.port || 22, user: d.user || '', password: d.password || '', private_key_path: d.private_key_path || '',
|
||||
max_cpu: d.max_cpu || 0, max_memory: d.max_memory || 0, max_disk: d.max_disk || 0,
|
||||
rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
|
||||
host_group_id: d.host_group_id || 0, description: d.description || ''
|
||||
})
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...formData, service_id: serviceId.value, id: hostId.value }
|
||||
if (!payload.token) delete payload.token
|
||||
if (!payload.password) delete payload.password
|
||||
if (!payload.private_key_path) delete payload.private_key_path
|
||||
if (!payload.description) delete payload.description
|
||||
if (!payload.host_group_id) delete payload.host_group_id
|
||||
const res = await updateRemoteHost(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '修改失败')
|
||||
} catch (e) { ElMessage.error('修改失败') } finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
ElMessageBox.confirm(`确定要删除宿主机「${detail.value?.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteRemoteHost({ service_id: serviceId.value, id: hostId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
tagsViewStore.delVisitedView(route)
|
||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||
}
|
||||
|
||||
onMounted(() => { loadDetail(); loadMetrics() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.host-detail-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.back-btn { font-size: 14px; color: #606266; }
|
||||
.back-btn:hover { color: #409eff; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.main-content { padding: 20px; }
|
||||
.info-card { margin-bottom: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
|
||||
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
.resource-cards { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||
.res-item { text-align: center; min-width: 120px; padding: 12px 16px; background: #f5f7fa; border-radius: 8px; }
|
||||
.res-label { font-size: 12px; color: #909399; margin-bottom: 4px; }
|
||||
.res-value { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.metrics-card { margin-bottom: 0; }
|
||||
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||||
.disk-item { margin-bottom: 8px; }
|
||||
.disk-path { font-weight: 500; color: #409eff; font-size: 13px; margin-bottom: 4px; font-family: 'Consolas', monospace; }
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="host-group-mapping-container">
|
||||
<!-- 顶部信息 -->
|
||||
<div class="page-header">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
@@ -18,6 +18,10 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleSync" :loading="syncLoading"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
|
||||
<el-button @click="loadHostGroups"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 布局:左侧本地主机组列表 / 右侧详情&操作 -->
|
||||
<div class="content-layout">
|
||||
@@ -204,7 +208,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, RefreshRight, Search, ArrowLeft } from '@element-plus/icons-vue'
|
||||
@@ -223,9 +227,11 @@ import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => route.query.service_name || '')
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const syncLoading = ref(false)
|
||||
@@ -604,6 +610,13 @@ onMounted(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.embedded-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 布局 */
|
||||
.content-layout {
|
||||
display: grid;
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
<template>
|
||||
<div class="host-manage-container">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
<h3>宿主机管理</h3>
|
||||
<span class="sub-info" v-if="serviceName">所属主控服务:{{ serviceName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增宿主机
|
||||
</el-button>
|
||||
<el-button @click="loadList">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新增宿主机</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索宿主机名称/IP" clearable style="width: 240px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filterGroupId" placeholder="筛选宿主机组" clearable style="width: 180px" @change="handleSearch">
|
||||
<el-option v-for="g in hostGroupOptions" :key="g.id" :label="g.name || g.Name" :value="g.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 宿主机列表 -->
|
||||
<el-table :data="hostList" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="IP / 服务地址" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="host-addr">{{ row.ip || '-' }}</div>
|
||||
<div class="host-url" v-if="row.base_url">{{ row.base_url }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="SSH" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.port">:{{ row.port }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="资源限制" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="resource-info">
|
||||
<el-tag size="small" type="info" v-if="row.max_cpu">CPU: {{ row.max_cpu }}核</el-tag>
|
||||
<el-tag size="small" type="info" v-if="row.max_memory">内存: {{ formatMemMB(row.max_memory) }}</el-tag>
|
||||
<el-tag size="small" type="info" v-if="row.max_disk">磁盘: {{ formatDiskGB(row.max_disk) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="带宽" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.rx_bandwidth || row.tx_bandwidth">
|
||||
↓{{ row.rx_bandwidth || 0 }} Mbps / ↑{{ row.tx_bandwidth || 0 }} Mbps
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="宿主机组" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.host_group_id">{{ getGroupName(row.host_group_id) }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '启用' : '禁用' }}</el-tag>
|
||||
</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="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="total > queryParams.page_size">
|
||||
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="handleSizeChange" @current-change="handlePageChange" />
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="640px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="宿主机名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址" prop="base_url">
|
||||
<el-input v-model="formData.base_url" placeholder="宿主机服务 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip">
|
||||
<el-input v-model="formData.ip" placeholder="宿主机 IP" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证Token">
|
||||
<el-input v-model="formData.token" placeholder="宿主机服务 Token(可选)" show-password />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">SSH 配置</el-divider>
|
||||
<el-form-item label="SSH 端口">
|
||||
<el-input-number v-model="formData.port" :min="0" :max="65535" placeholder="22" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 用户名">
|
||||
<el-input v-model="formData.user" placeholder="默认 tunneluser" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 密码">
|
||||
<el-input v-model="formData.password" placeholder="SSH 密码(可选)" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="私钥路径">
|
||||
<el-input v-model="formData.private_key_path" placeholder="SSH 私钥文件路径(可选)" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">资源限制</el-divider>
|
||||
<el-form-item label="最大CPU(核)">
|
||||
<el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大内存(MB)">
|
||||
<el-input-number v-model="formData.max_memory" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大磁盘(GB)">
|
||||
<el-input-number v-model="formData.max_disk" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下行带宽(Mbps)">
|
||||
<el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="上行带宽(Mbps)">
|
||||
<el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="宿主机组">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}${formData._groupName ? ' - ' + formData._groupName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0; formData._groupName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="宿主机介绍(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 宿主机组选择器 -->
|
||||
<HostGroupSelectorPopup
|
||||
v-model="showHostGroupSelector"
|
||||
:service-id="serviceId"
|
||||
:current-id="formData.host_group_id"
|
||||
@confirm="handleHostGroupSelected"
|
||||
/>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="宿主机详情" width="680px" destroy-on-close>
|
||||
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP">{{ currentDetail.ip || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务地址">{{ currentDetail.base_url || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH 端口">{{ currentDetail.port || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH 用户">{{ currentDetail.user || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机组">{{ getGroupName(currentDetail.host_group_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="currentDetail.is_active ? 'success' : 'danger'" size="small">{{ currentDetail.is_active ? '启用' : '禁用' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="认证Token" :span="2">
|
||||
<el-input v-if="currentDetail.token" :model-value="currentDetail.token" readonly show-password style="max-width: 300px" />
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH 密码">
|
||||
<el-input v-if="currentDetail.password" :model-value="currentDetail.password" readonly show-password style="max-width: 200px" />
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="私钥路径">{{ currentDetail.private_key_path || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最大CPU">{{ currentDetail.max_cpu ? currentDetail.max_cpu + ' 核' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最大内存">{{ formatMemMB(currentDetail.max_memory) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最大磁盘">{{ formatDiskGB(currentDetail.max_disk) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="带宽">↓{{ currentDetail.rx_bandwidth || 0 }} Mbps / ↑{{ currentDetail.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 指标弹窗 -->
|
||||
<el-dialog v-model="metricsVisible" title="宿主机指标" width="700px" destroy-on-close>
|
||||
<div v-loading="metricsLoading">
|
||||
<template v-if="metricsData">
|
||||
<!-- CPU -->
|
||||
<el-card shadow="never" class="metrics-card" v-if="metricsData.cpu">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="使用率">{{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<!-- 内存 -->
|
||||
<el-card shadow="never" class="metrics-card" v-if="metricsData.memory">
|
||||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.memory.free) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ metricsData.memory.percent ?? '-' }}%</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<!-- 磁盘 -->
|
||||
<el-card shadow="never" class="metrics-card" v-if="metricsData.disk">
|
||||
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template>
|
||||
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item">
|
||||
<div class="disk-path">{{ path }}</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(info.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(info.used) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(info.free) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ info.percent ?? '-' }}%</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-card>
|
||||
<!-- 网络 -->
|
||||
<el-card shadow="never" class="metrics-card" v-if="metricsData.network || metricsData.internet_speed">
|
||||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<template v-if="metricsData.network">
|
||||
<el-descriptions-item label="接收">{{ formatBytesRaw(metricsData.network.rx_bytes) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发送">{{ formatBytesRaw(metricsData.network.tx_bytes) }}</el-descriptions-item>
|
||||
</template>
|
||||
<template v-if="metricsData.internet_speed">
|
||||
<el-descriptions-item label="实时接收速率">{{ formatBytesRaw(metricsData.internet_speed.rx_bytes) }}/s</el-descriptions-item>
|
||||
<el-descriptions-item label="实时发送速率">{{ formatBytesRaw(metricsData.internet_speed.tx_bytes) }}/s</el-descriptions-item>
|
||||
</template>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
<el-empty v-else description="暂无指标数据" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="metricsVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, Monitor, Coin, Box, Connection } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getRemoteHostDetail, getRemoteHostMetrics,
|
||||
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
||||
getHostGroupList
|
||||
} from '@/api/admin/kvmService'
|
||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const hostList = ref([])
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const filterGroupId = ref('')
|
||||
const hostGroupOptions = ref([])
|
||||
const showHostGroupSelector = ref(false)
|
||||
|
||||
const queryParams = reactive({ page: 1, page_size: 10, keyword: '', host_group_id: '' })
|
||||
|
||||
// 弹窗
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const formRef = ref(null)
|
||||
const detailVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
const metricsVisible = ref(false)
|
||||
const metricsData = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
id: undefined, name: '', base_url: '', ip: '', token: '',
|
||||
port: 22, user: '', password: '', private_key_path: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
|
||||
_groupName: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
base_url: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
|
||||
ip: [{ required: true, message: '请输入 IP 地址', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
/** 格式化内存(MB) */
|
||||
const formatMemMB = (val) => {
|
||||
if (!val) return '-'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' GB'
|
||||
return val + ' MB'
|
||||
}
|
||||
|
||||
/** 格式化磁盘(GB) */
|
||||
const formatDiskGB = (val) => {
|
||||
if (!val) return '-'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' TB'
|
||||
return val + ' GB'
|
||||
}
|
||||
|
||||
/** 格式化原始字节 */
|
||||
const formatBytesRaw = (val) => {
|
||||
if (!val && val !== 0) return '-'
|
||||
val = Number(val)
|
||||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||||
return val + ' B'
|
||||
}
|
||||
|
||||
/** 格式化后端 {seconds, nanos} 时间戳 */
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) {
|
||||
const d = new Date(Number(ts.seconds) * 1000)
|
||||
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
if (typeof ts === 'string' || typeof ts === 'number') {
|
||||
const d = new Date(ts)
|
||||
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
const getGroupName = (gid) => {
|
||||
if (!gid) return '-'
|
||||
const g = hostGroupOptions.value.find(x => x.id === gid)
|
||||
return g ? `${g.name} (#${gid})` : `#${gid}`
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: undefined, name: '', base_url: '', ip: '', token: '',
|
||||
port: 22, user: '', password: '', private_key_path: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
|
||||
_groupName: ''
|
||||
})
|
||||
}
|
||||
|
||||
const handleHostGroupSelected = (group) => {
|
||||
formData.host_group_id = group.id
|
||||
formData._groupName = group.name || ''
|
||||
}
|
||||
|
||||
// 加载宿主机组选项
|
||||
const loadHostGroupOptions = async () => {
|
||||
try {
|
||||
const res = await getHostGroupList({ service_id: serviceId.value })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const items = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||||
hostGroupOptions.value = items.map(i => ({ id: i.Id ?? i.id, name: i.Name ?? i.name }))
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 加载列表
|
||||
const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterGroupId.value) params.host_group_id = filterGroupId.value
|
||||
const res = await getRemoteHostList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
const items = Array.isArray(inner) ? inner : (inner.hosts || inner.data || inner.list || [])
|
||||
hostList.value = items
|
||||
total.value = inner.total ?? inner.all_count ?? items.length
|
||||
} else {
|
||||
hostList.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取宿主机列表失败:', e)
|
||||
ElMessage.error('获取宿主机列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
const handleSizeChange = (s) => { queryParams.page_size = s; queryParams.page = 1; loadList() }
|
||||
const handlePageChange = (p) => { queryParams.page = p; loadList() }
|
||||
|
||||
const handleAdd = () => { dialogType.value = 'add'; resetForm(); dialogVisible.value = true }
|
||||
|
||||
const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
// 先用列表数据回填,密码需从详情取
|
||||
Object.assign(formData, {
|
||||
id: row.id, name: row.name, base_url: row.base_url, ip: row.ip, token: row.token || '',
|
||||
port: row.port || 22, user: row.user || '', password: row.password || '', private_key_path: row.private_key_path || '',
|
||||
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
|
||||
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
|
||||
host_group_id: row.host_group_id || 0, description: row.description || '',
|
||||
_groupName: getGroupName(row.host_group_id)
|
||||
})
|
||||
// 异步获取详情以补全password等字段
|
||||
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const detail = body.data.host ?? body.data.data ?? body.data
|
||||
if (detail.password) formData.password = detail.password
|
||||
if (detail.token) formData.token = detail.token
|
||||
if (detail.private_key_path) formData.private_key_path = detail.private_key_path
|
||||
}
|
||||
}).catch(() => {})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...formData, service_id: serviceId.value }
|
||||
delete payload.id
|
||||
delete payload._groupName
|
||||
// 可选参数为空时不提交
|
||||
if (!payload.token) delete payload.token
|
||||
if (!payload.password) delete payload.password
|
||||
if (!payload.private_key_path) delete payload.private_key_path
|
||||
if (!payload.description) delete payload.description
|
||||
if (!payload.host_group_id) delete payload.host_group_id
|
||||
let res
|
||||
if (dialogType.value === 'add') {
|
||||
res = await addRemoteHost(payload)
|
||||
} else {
|
||||
payload.id = formData.id
|
||||
res = await updateRemoteHost(payload)
|
||||
}
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
|
||||
dialogVisible.value = false
|
||||
loadList()
|
||||
} else {
|
||||
ElMessage.error(body?.message || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row) => {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentDetail.value = null
|
||||
try {
|
||||
const res = await getRemoteHostDetail({ service_id: serviceId.value, id: row.id })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
// API返回 data.host 嵌套
|
||||
currentDetail.value = body.data.host ?? body.data.data ?? body.data
|
||||
} else {
|
||||
currentDetail.value = row
|
||||
}
|
||||
} catch (e) {
|
||||
currentDetail.value = row
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMetrics = async (row) => {
|
||||
metricsVisible.value = true
|
||||
metricsLoading.value = true
|
||||
metricsData.value = null
|
||||
try {
|
||||
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: row.id })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
metricsData.value = body.data.data ?? body.data
|
||||
} else {
|
||||
ElMessage.warning('暂无指标数据')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取指标失败')
|
||||
} finally {
|
||||
metricsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoDetail = (row) => {
|
||||
router.push({ path: '/virtualization/host-detail', query: { service_id: serviceId.value, service_name: serviceName.value, id: row.id } })
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除宿主机「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteRemoteHost({ service_id: serviceId.value, id: row.id })
|
||||
const body = res?.data
|
||||
if (body?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(body?.message || '删除失败')
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败: ' + (e?.response?.data?.message || e.message))
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(() => {
|
||||
if (serviceId.value) { loadList(); loadHostGroupOptions() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.host-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.host-addr { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
|
||||
.host-url { font-size: 12px; color: #909399; margin-top: 2px; }
|
||||
.resource-info { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.bind-selector-row { display: flex; align-items: center; width: 100%; }
|
||||
.metrics-card { margin-bottom: 12px; }
|
||||
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||||
.disk-item { margin-bottom: 8px; }
|
||||
.disk-path { font-weight: 500; color: #409eff; font-size: 13px; margin-bottom: 4px; font-family: 'Consolas', monospace; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="image-detail-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回镜像列表</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">镜像详情</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" plain :icon="Edit" @click="handleEdit">编辑</el-button>
|
||||
<el-button type="success" plain @click="handleSyncToHost">同步到宿主机</el-button>
|
||||
<el-button type="warning" plain @click="handleReloadOnHost">重下载</el-button>
|
||||
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<!-- 基本信息 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="系统类型">
|
||||
<el-tag :type="detail.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ detail.os_type || '-' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="镜像类型">
|
||||
<el-tag :type="detail.type === 'system' ? '' : 'warning'" size="small">{{ detail.type === 'system' ? '系统镜像' : '数据镜像' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusType(detail.status)" size="small">{{ statusLabel(detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="大小">{{ detail.size ? formatSize(detail.size) : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ detail.path || '-' }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="介绍" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 宿主机同步状态 -->
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-title">宿主机同步状态</span>
|
||||
<el-button size="small" :icon="Refresh" @click="loadHostStatus" :loading="statusLoading">刷新状态</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="hostStatusList" size="small" stripe border v-loading="statusLoading">
|
||||
<el-table-column prop="host_id" label="宿主机ID" width="100" />
|
||||
<el-table-column label="宿主机" min-width="120">
|
||||
<template #default="{ row }">{{ getHostName(row.host_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="本地路径" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-empty v-if="hostStatusList.length === 0 && !statusLoading" description="暂无同步数据" />
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑镜像" width="560px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
|
||||
<el-form-item label="路径" prop="path"><el-input v-model="formData.path" /></el-form-item>
|
||||
<el-form-item label="系统类型">
|
||||
<el-select v-model="formData.os_type" style="width: 100%">
|
||||
<el-option label="Linux" value="linux" /><el-option label="Windows" value="windows" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="镜像类型">
|
||||
<el-select v-model="formData.type" style="width: 100%">
|
||||
<el-option label="系统镜像" value="system" /><el-option label="数据镜像" value="data" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="formData.status" style="width: 100%">
|
||||
<el-option label="等待中" value="pending" /><el-option label="下载中" value="downloading" />
|
||||
<el-option label="就绪" value="ready" /><el-option label="错误" value="error" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmitEdit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 同步到宿主机弹窗 -->
|
||||
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="440px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="镜像">{{ detail?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-select v-model="syncHostId" placeholder="请选择宿主机" 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>
|
||||
<template #footer>
|
||||
<el-button @click="syncDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitSync">确定同步</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重下载到宿主机弹窗 -->
|
||||
<el-dialog v-model="reloadDialogVisible" title="重新下载镜像到宿主机" width="440px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="镜像">{{ detail?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-select v-model="reloadHostId" placeholder="请选择宿主机" 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>
|
||||
<template #footer>
|
||||
<el-button @click="reloadDialogVisible = false">取消</el-button>
|
||||
<el-button type="warning" :loading="actionLoading" @click="submitReload">确定重下载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getImageDetail, getImageHostStatus, updateImage, deleteImage,
|
||||
syncImageToHost, reloadImageOnHost, getRemoteHostList
|
||||
} from '@/api/admin/kvmService'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => route.query.service_name || '')
|
||||
const imageId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const statusLoading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const hostStatusList = ref([])
|
||||
const hostOptions = ref([])
|
||||
const editDialogVisible = ref(false)
|
||||
const syncDialogVisible = ref(false)
|
||||
const reloadDialogVisible = ref(false)
|
||||
const syncHostId = ref('')
|
||||
const reloadHostId = ref('')
|
||||
const formRef = ref(null)
|
||||
|
||||
const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '' })
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
path: [{ required: true, message: '请输入路径', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const statusType = (s) => ({ ready: 'success', downloading: 'warning', pending: 'info', error: 'danger' }[s] || 'info')
|
||||
const statusLabel = (s) => ({ ready: '就绪', downloading: '下载中', pending: '等待中', error: '错误' }[s] || s || '-')
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let size = Number(bytes)
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
||||
return size.toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||
}
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
||||
return '-'
|
||||
}
|
||||
const getHostName = (hid) => { const h = hostOptions.value.find(x => x.id === hid); return h ? h.name : `#${hid}` }
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!imageId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getImageDetail({ service_id: serviceId.value, image_id: imageId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
detail.value = res.data.data.image ?? res.data.data.data ?? res.data.data
|
||||
} else ElMessage.error(res?.data?.message || '加载失败')
|
||||
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const loadHostStatus = async () => {
|
||||
statusLoading.value = true
|
||||
try {
|
||||
const res = await getImageHostStatus({ service_id: serviceId.value, image_id: imageId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
hostStatusList.value = Array.isArray(inner) ? inner : (inner.hosts || inner.data || [])
|
||||
}
|
||||
} catch { /* */ } finally { statusLoading.value = false }
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!detail.value) return
|
||||
const d = detail.value
|
||||
Object.assign(formData, { name: d.name || '', path: d.path || '', os_type: d.os_type || 'linux', type: d.type || 'system', description: d.description || '', status: d.status || '' })
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmitEdit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { image_id: imageId.value, service_id: serviceId.value, image_name: formData.name, path: formData.path, os_type: formData.os_type, type: formData.type, description: formData.description || undefined, status: formData.status || undefined }
|
||||
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
|
||||
const res = await updateImage(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '修改失败')
|
||||
} catch { ElMessage.error('修改失败') } finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleSyncToHost = () => { syncHostId.value = ''; syncDialogVisible.value = true }
|
||||
const handleReloadOnHost = () => { reloadHostId.value = ''; reloadDialogVisible.value = true }
|
||||
|
||||
const submitSync = async () => {
|
||||
if (!syncHostId.value) return ElMessage.warning('请选择宿主机')
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('image_id', imageId.value); fd.append('host_id', syncHostId.value)
|
||||
const res = await syncImageToHost(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('已触发同步'); syncDialogVisible.value = false; loadHostStatus() }
|
||||
else ElMessage.error(res?.data?.message || '同步失败')
|
||||
} catch { ElMessage.error('同步失败') } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const submitReload = async () => {
|
||||
if (!reloadHostId.value) return ElMessage.warning('请选择宿主机')
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('image_id', imageId.value); fd.append('host_id', reloadHostId.value)
|
||||
const res = await reloadImageOnHost(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('已触发重下载'); reloadDialogVisible.value = false; loadHostStatus() }
|
||||
else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch { ElMessage.error('操作失败') } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
ElMessageBox.confirm(`确定要删除镜像「${detail.value?.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteImage({ service_id: serviceId.value, image_id: imageId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
tagsViewStore.delVisitedView(route)
|
||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||
}
|
||||
|
||||
onMounted(() => { loadHostOptions(); loadDetail(); loadHostStatus() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-detail-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; flex-wrap: wrap; gap: 8px; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.back-btn { font-size: 14px; color: #606266; }
|
||||
.back-btn:hover { color: #409eff; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.header-right { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.main-content { padding: 20px; }
|
||||
.info-card { margin-bottom: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
|
||||
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,531 @@
|
||||
<template>
|
||||
<div class="image-manage-container">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
<h3>镜像管理</h3>
|
||||
<span class="sub-info" v-if="serviceName">所属主控服务:{{ serviceName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建镜像</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建镜像</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索镜像名称" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filterHostId" placeholder="选择宿主机" clearable style="width: 180px" @change="handleSearch">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="h.name || h.ip" :value="h.id" />
|
||||
</el-select>
|
||||
<el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option label="Linux" value="linux" />
|
||||
<el-option label="Windows" value="windows" />
|
||||
</el-select>
|
||||
<el-select v-model="filterType" placeholder="镜像类型" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option label="系统镜像" value="system" />
|
||||
<el-option label="数据镜像" value="data" />
|
||||
</el-select>
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option label="等待中" value="pending" />
|
||||
<el-option label="下载中" value="downloading" />
|
||||
<el-option label="就绪" value="ready" />
|
||||
<el-option label="错误" value="error" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 镜像列表 -->
|
||||
<el-table :data="imageList" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="系统类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ row.os_type || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="镜像类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="大小" width="90">
|
||||
<template #default="{ row }">{{ row.size ? formatSize(row.size) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="total > queryParams.count">
|
||||
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
|
||||
@current-change="p => { queryParams.page = p; loadList() }" />
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建镜像' : '编辑镜像'" width="560px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="镜像名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="路径" prop="path">
|
||||
<el-input v-model="formData.path" placeholder="URL 或服务器文件路径" />
|
||||
</el-form-item>
|
||||
<el-form-item label="系统类型" prop="os_type">
|
||||
<el-select v-model="formData.os_type" style="width: 100%">
|
||||
<el-option label="Linux" value="linux" />
|
||||
<el-option label="Windows" value="windows" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="镜像类型" prop="type">
|
||||
<el-select v-model="formData.type" style="width: 100%">
|
||||
<el-option label="系统镜像" value="system" />
|
||||
<el-option label="数据镜像" value="data" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍(可选)" />
|
||||
</el-form-item>
|
||||
<template v-if="dialogType === 'edit'">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="formData.status" style="width: 100%">
|
||||
<el-option label="等待中" value="pending" />
|
||||
<el-option label="下载中" value="downloading" />
|
||||
<el-option label="就绪" value="ready" />
|
||||
<el-option label="错误" value="error" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="大小">
|
||||
<el-input-number v-model="formData.size" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="镜像详情" width="680px" destroy-on-close>
|
||||
<div v-loading="detailLoading">
|
||||
<el-descriptions :column="2" border v-if="currentDetail">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="系统类型">
|
||||
<el-tag :type="currentDetail.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ currentDetail.os_type || '-' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="镜像类型">
|
||||
<el-tag :type="currentDetail.type === 'system' ? '' : 'warning'" size="small">{{ currentDetail.type === 'system' ? '系统镜像' : '数据镜像' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusType(currentDetail.status)" size="small">{{ statusLabel(currentDetail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="大小">{{ currentDetail.size ? formatSize(currentDetail.size) : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="路径" :span="2">
|
||||
<span class="mono-text">{{ currentDetail.path || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 宿主机同步状态 -->
|
||||
<div class="host-status-section" v-if="hostStatusList.length > 0">
|
||||
<h4 style="margin: 16px 0 8px">宿主机同步状态</h4>
|
||||
<el-table :data="hostStatusList" size="small" stripe border>
|
||||
<el-table-column prop="host_id" label="宿主机ID" width="100" />
|
||||
<el-table-column label="宿主机" min-width="120">
|
||||
<template #default="{ row }">{{ getHostName(row.host_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="本地路径" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 同步到宿主机弹窗 -->
|
||||
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="440px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="镜像">
|
||||
<el-input :model-value="syncTarget?.name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-select v-model="syncHostId" placeholder="请选择宿主机" 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>
|
||||
<template #footer>
|
||||
<el-button @click="syncDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="syncLoading" @click="submitSyncToHost">确定同步</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重下载到宿主机弹窗 -->
|
||||
<el-dialog v-model="reloadDialogVisible" title="重新下载镜像到宿主机" width="440px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="镜像">
|
||||
<el-input :model-value="reloadTarget?.name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-select v-model="reloadHostId" placeholder="请选择宿主机" 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>
|
||||
<template #footer>
|
||||
<el-button @click="reloadDialogVisible = false">取消</el-button>
|
||||
<el-button type="warning" :loading="reloadLoading" @click="submitReloadOnHost">确定重下载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getImageList, getImageDetail, getImageHostStatus, createImage, updateImage, deleteImage,
|
||||
reloadImage, syncImageToHost, reloadImageOnHost, getRemoteHostList
|
||||
} from '@/api/admin/kvmService'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const imageList = ref([])
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const filterOsType = ref('')
|
||||
const filterType = ref('')
|
||||
const filterStatus = ref('')
|
||||
const filterHostId = ref('')
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, count: 10 })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const formRef = ref(null)
|
||||
const detailVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
const hostStatusList = ref([])
|
||||
|
||||
// 同步到宿主机
|
||||
const syncDialogVisible = ref(false)
|
||||
const syncTarget = ref(null)
|
||||
const syncHostId = ref('')
|
||||
const syncLoading = ref(false)
|
||||
|
||||
// 重下载到宿主机
|
||||
const reloadDialogVisible = ref(false)
|
||||
const reloadTarget = ref(null)
|
||||
const reloadHostId = ref('')
|
||||
const reloadLoading = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system',
|
||||
description: '', status: '', size: 0, image_name: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入镜像名称', trigger: 'blur' }],
|
||||
path: [{ required: true, message: '请输入镜像路径', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const statusType = (s) => ({ ready: 'success', downloading: 'warning', pending: 'info', error: 'danger' }[s] || 'info')
|
||||
const statusLabel = (s) => ({ ready: '就绪', downloading: '下载中', pending: '等待中', error: '错误' }[s] || s || '-')
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
let size = Number(bytes)
|
||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
|
||||
return size.toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
|
||||
}
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) {
|
||||
return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
if (typeof ts === 'string' || typeof ts === 'number') {
|
||||
const d = new Date(ts)
|
||||
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
const getHostName = (hid) => {
|
||||
if (!hid) return '-'
|
||||
const h = hostOptions.value.find(x => x.id === hid)
|
||||
return h ? `${h.name}` : `#${hid}`
|
||||
}
|
||||
|
||||
// 加载宿主机列表
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
const items = Array.isArray(inner) ? inner : (inner.hosts || inner.data || inner.list || [])
|
||||
hostOptions.value = items
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.count }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterOsType.value) params.os_type = filterOsType.value
|
||||
if (filterType.value) params.type = filterType.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterHostId.value) params.host_id = filterHostId.value
|
||||
const res = await getImageList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
const items = Array.isArray(inner) ? inner : (inner.images || inner.data || inner.list || [])
|
||||
imageList.value = items
|
||||
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? items.length
|
||||
} else {
|
||||
imageList.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取镜像列表失败:', e)
|
||||
ElMessage.error('获取镜像列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
Object.assign(formData, {
|
||||
image_id: row.id, name: row.name, image_name: row.name, path: row.path || '',
|
||||
os_type: row.os_type || 'linux', type: row.type || 'system',
|
||||
description: row.description || '', status: row.status || '', size: row.size || 0
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
let res
|
||||
if (dialogType.value === 'add') {
|
||||
res = await createImage({
|
||||
service_id: serviceId.value, name: formData.name, path: formData.path,
|
||||
os_type: formData.os_type, type: formData.type, description: formData.description || undefined
|
||||
})
|
||||
} else {
|
||||
const payload = {
|
||||
image_id: formData.image_id, service_id: serviceId.value,
|
||||
image_name: formData.name, path: formData.path,
|
||||
os_type: formData.os_type, type: formData.type,
|
||||
description: formData.description || undefined,
|
||||
status: formData.status || undefined, size: formData.size || undefined
|
||||
}
|
||||
// 清除 undefined
|
||||
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
|
||||
res = await updateImage(payload)
|
||||
}
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '修改成功')
|
||||
dialogVisible.value = false
|
||||
loadList()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row) => {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentDetail.value = row
|
||||
hostStatusList.value = []
|
||||
try {
|
||||
const [detailRes, statusRes] = await Promise.allSettled([
|
||||
getImageDetail({ service_id: serviceId.value, image_id: row.id }),
|
||||
getImageHostStatus({ service_id: serviceId.value, image_id: row.id })
|
||||
])
|
||||
// 详情 - API 返回 data.image 嵌套
|
||||
if (detailRes.status === 'fulfilled') {
|
||||
const body = detailRes.value?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
currentDetail.value = body.data.image ?? body.data.data ?? body.data
|
||||
}
|
||||
}
|
||||
// 宿主机同步状态
|
||||
if (statusRes.status === 'fulfilled') {
|
||||
const body = statusRes.value?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostStatusList.value = Array.isArray(inner) ? inner : (inner.hosts || inner.data || inner.list || [])
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取镜像详情失败:', e)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 同步镜像到宿主机
|
||||
const handleSyncToHost = (row) => {
|
||||
syncTarget.value = row
|
||||
syncHostId.value = ''
|
||||
syncDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitSyncToHost = async () => {
|
||||
if (!syncHostId.value) return ElMessage.warning('请选择目标宿主机')
|
||||
syncLoading.value = true
|
||||
try {
|
||||
const formPayload = new FormData()
|
||||
formPayload.append('service_id', serviceId.value)
|
||||
formPayload.append('image_id', syncTarget.value.id)
|
||||
formPayload.append('host_id', syncHostId.value)
|
||||
const res = await syncImageToHost(formPayload)
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('已触发同步到宿主机')
|
||||
syncDialogVisible.value = false
|
||||
loadList()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '同步失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('同步失败: ' + (e?.response?.data?.message || e.message))
|
||||
} finally {
|
||||
syncLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重下载镜像到宿主机
|
||||
const handleReloadOnHost = (row) => {
|
||||
reloadTarget.value = row
|
||||
reloadHostId.value = ''
|
||||
reloadDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitReloadOnHost = async () => {
|
||||
if (!reloadHostId.value) return ElMessage.warning('请选择目标宿主机')
|
||||
reloadLoading.value = true
|
||||
try {
|
||||
const formPayload = new FormData()
|
||||
formPayload.append('service_id', serviceId.value)
|
||||
formPayload.append('image_id', reloadTarget.value.id)
|
||||
formPayload.append('host_id', reloadHostId.value)
|
||||
const res = await reloadImageOnHost(formPayload)
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('已触发重新下载到宿主机')
|
||||
reloadDialogVisible.value = false
|
||||
loadList()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
||||
} finally {
|
||||
reloadLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoDetail = (row) => {
|
||||
router.push({ path: '/virtualization/image-detail', query: { service_id: serviceId.value, service_name: serviceName.value, id: row.id } })
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除镜像「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteImage({ service_id: serviceId.value, image_id: row.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(() => {
|
||||
if (serviceId.value) {
|
||||
loadList()
|
||||
loadHostOptions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
|
||||
.host-status-section { margin-top: 8px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
@@ -51,11 +51,10 @@
|
||||
{{ formatTime(row.CreatedAt || row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="goHostGroupMapping(row)">主机组</el-button>
|
||||
<el-button link type="primary" @click="handleViewDetail(row)">详情/管理</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -104,24 +103,6 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="主控服务详情" width="580px" destroy-on-close>
|
||||
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.Id ?? currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务名称">{{ currentDetail.Name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务地址">{{ currentDetail.Host }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务端口">{{ currentDetail.Port }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认证Token" :span="2">
|
||||
<el-input v-if="currentDetail.Token" :model-value="currentDetail.Token" readonly show-password style="max-width: 300px" />
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ currentDetail.Note || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">{{ formatTime(currentDetail.CreatedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -132,7 +113,6 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getKvmServiceList,
|
||||
getKvmServiceDetail,
|
||||
createKvmService,
|
||||
updateKvmService,
|
||||
deleteKvmService
|
||||
@@ -143,7 +123,6 @@ const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const serviceList = ref([])
|
||||
const total = ref(0)
|
||||
const searchKey = ref('')
|
||||
@@ -159,9 +138,6 @@ const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const formRef = ref(null)
|
||||
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
id: undefined,
|
||||
name: '',
|
||||
@@ -355,45 +331,16 @@ const handleDelete = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = async (row) => {
|
||||
// 优先使用原始 Id(PascalCase),回退到规范化后的 id
|
||||
const rawId = row.Id ?? row.id
|
||||
console.debug('[KvmService] handleViewDetail rawId:', rawId, 'row:', row)
|
||||
if (rawId === undefined || rawId === null || rawId === '') {
|
||||
// 查看详情 —— 跳转到详情页面
|
||||
const handleViewDetail = (row) => {
|
||||
const id = Number(row.Id ?? row.id)
|
||||
const name = row.Name ?? row.name
|
||||
if (!id) {
|
||||
ElMessage.error('无法获取服务ID,请刷新列表后重试')
|
||||
return
|
||||
}
|
||||
detailDialogVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentDetail.value = null
|
||||
try {
|
||||
const res = await getKvmServiceDetail({ id: Number(rawId) })
|
||||
const body = res?.data
|
||||
console.debug('[KvmService] detail response body:', JSON.stringify(body))
|
||||
if (body?.code === 200 && body?.data) {
|
||||
currentDetail.value = normalizeService(body.data)
|
||||
} else {
|
||||
// 接口返回非200,显示错误但仍展示列表行数据
|
||||
ElMessage.error(body?.message || '获取详情失败')
|
||||
currentDetail.value = normalizeService(row)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
const errMsg = error?.response?.data?.message || error?.message || '未知错误'
|
||||
ElMessage.error('获取详情失败: ' + errMsg)
|
||||
currentDetail.value = normalizeService(row)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到宿主机组映射管理
|
||||
const goHostGroupMapping = (row) => {
|
||||
const id = Number(row.Id ?? row.id)
|
||||
const name = row.Name ?? row.name
|
||||
router.push({
|
||||
path: '/virtualization/host-group-mapping',
|
||||
path: '/virtualization/kvm-service-detail',
|
||||
query: { service_id: id, service_name: name }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div class="kvm-detail-page">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回列表
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">主控服务详情</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" plain @click="refreshData" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon> 刷新数据
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<!-- 服务概览卡片 -->
|
||||
<el-card class="profile-card" shadow="never">
|
||||
<div class="profile-header">
|
||||
<div class="profile-basic">
|
||||
<div class="service-icon-wrapper">
|
||||
<el-icon :size="48" color="#409eff"><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="service-identity">
|
||||
<div class="name-row">
|
||||
<h1 class="service-name">{{ serviceInfo.Name || serviceInfo.name || '未命名服务' }}</h1>
|
||||
<el-tag type="success" effect="dark" round size="small" class="status-tag">运行中</el-tag>
|
||||
</div>
|
||||
<div class="id-row">
|
||||
<span class="label">ID:</span>
|
||||
<span class="value">{{ serviceInfo.Id ?? serviceInfo.id }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="label">地址:</span>
|
||||
<span class="value addr-value">{{ serviceInfo.Host || serviceInfo.host }}:{{ serviceInfo.Port || serviceInfo.port }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="label">创建:</span>
|
||||
<span class="value">{{ formatTime(serviceInfo.CreatedAt || serviceInfo.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 数据概览 -->
|
||||
<div class="profile-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">认证Token</div>
|
||||
<div class="stat-value">
|
||||
<el-tag v-if="serviceInfo.Token || serviceInfo.token" type="success" size="small">已设置</el-tag>
|
||||
<el-tag v-else type="info" size="small">未设置</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">备注</div>
|
||||
<div class="stat-value note-value">{{ serviceInfo.Note || serviceInfo.note || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider class="action-divider" />
|
||||
|
||||
<!-- 快捷操作栏 -->
|
||||
<div class="quick-actions">
|
||||
<el-button type="primary" plain :icon="Edit" @click="handleEditService">编辑服务</el-button>
|
||||
<el-button type="danger" plain :icon="Delete" @click="handleDeleteService">删除服务</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 子模块Tab -->
|
||||
<el-card class="tabs-card" shadow="never">
|
||||
<el-tabs v-model="activeTab" @tab-click="handleTabClick" class="custom-tabs">
|
||||
<el-tab-pane label="宿主机组映射" name="host-group">
|
||||
<HostGroupMapping v-if="tabLoaded['host-group']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="远程宿主机组" name="remote-host-group">
|
||||
<RemoteHostGroupManage v-if="tabLoaded['remote-host-group']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="宿主机管理" name="host">
|
||||
<HostManage v-if="tabLoaded['host']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="镜像管理" name="image">
|
||||
<ImageManage v-if="tabLoaded['image']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="网络管理" name="network">
|
||||
<NetworkManage v-if="tabLoaded['network']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="数据卷管理" name="volume">
|
||||
<VolumeManage v-if="tabLoaded['volume']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="虚拟机管理" name="vm">
|
||||
<VmManage v-if="tabLoaded['vm']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="安全组管理" name="security">
|
||||
<SecurityGroupManage v-if="tabLoaded['security']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="VNC节点" name="vnc">
|
||||
<VncNodeManage v-if="tabLoaded['vnc']" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 编辑服务弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑主控服务" width="520px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="服务名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入服务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址" prop="host">
|
||||
<el-input v-model="formData.host" placeholder="请输入服务地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务端口" prop="port">
|
||||
<el-input v-model="formData.port" placeholder="请输入服务端口" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证Token" prop="token">
|
||||
<el-input v-model="formData.token" placeholder="认证Token(可选)" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="note">
|
||||
<el-input v-model="formData.note" type="textarea" :rows="3" placeholder="备注说明(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmitEdit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, provide, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Edit, Delete, Monitor } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getKvmServiceDetail, updateKvmService, deleteKvmService
|
||||
} from '@/api/admin/kvmService'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 子模块组件(懒加载)
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
const HostGroupMapping = defineAsyncComponent(() => import('./HostGroupMapping.vue'))
|
||||
const HostManage = defineAsyncComponent(() => import('./HostManage.vue'))
|
||||
const ImageManage = defineAsyncComponent(() => import('./ImageManage.vue'))
|
||||
const NetworkManage = defineAsyncComponent(() => import('./NetworkManage.vue'))
|
||||
const VolumeManage = defineAsyncComponent(() => import('./VolumeManage.vue'))
|
||||
const VmManage = defineAsyncComponent(() => import('./VmManage.vue'))
|
||||
const SecurityGroupManage = defineAsyncComponent(() => import('./SecurityGroupManage.vue'))
|
||||
const VncNodeManage = defineAsyncComponent(() => import('./VncNodeManage.vue'))
|
||||
const RemoteHostGroupManage = defineAsyncComponent(() => import('./RemoteHostGroupManage.vue'))
|
||||
|
||||
// 引入tagsViewStore
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => route.query.service_name || '')
|
||||
|
||||
// 向子组件注入上下文 —— 子组件通过 inject 获取
|
||||
provide('embedded', true)
|
||||
provide('serviceId', serviceId)
|
||||
provide('serviceName', serviceName)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const serviceInfo = ref({})
|
||||
|
||||
// Tab 管理
|
||||
const activeTab = ref('host-group')
|
||||
const tabLoaded = reactive({
|
||||
'host-group': true, // 默认加载第一个
|
||||
'remote-host-group': false,
|
||||
'host': false,
|
||||
'image': false,
|
||||
'network': false,
|
||||
'volume': false,
|
||||
'vm': false,
|
||||
'security': false,
|
||||
'vnc': false
|
||||
})
|
||||
|
||||
const handleTabClick = (tab) => {
|
||||
const name = tab.props.name
|
||||
if (!tabLoaded[name]) {
|
||||
tabLoaded[name] = true
|
||||
}
|
||||
localStorage.setItem('kvmDetailActiveTab', name)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
// 加载服务详情
|
||||
const fetchServiceInfo = async () => {
|
||||
if (!serviceId.value) {
|
||||
ElMessage.error('服务ID不能为空')
|
||||
goBack()
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getKvmServiceDetail({ id: serviceId.value })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
serviceInfo.value = body.data
|
||||
} else {
|
||||
ElMessage.error(body?.message || '获取服务详情失败')
|
||||
// 使用 query 参数中的基本信息做兜底
|
||||
serviceInfo.value = { Id: serviceId.value, Name: serviceName.value }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取服务详情失败:', error)
|
||||
serviceInfo.value = { Id: serviceId.value, Name: serviceName.value }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新所有数据
|
||||
const refreshData = () => {
|
||||
fetchServiceInfo()
|
||||
}
|
||||
|
||||
// 返回列表
|
||||
const goBack = () => {
|
||||
tagsViewStore.delVisitedView(route)
|
||||
router.push('/virtualization/kvm-service')
|
||||
}
|
||||
|
||||
// 编辑服务
|
||||
const editDialogVisible = ref(false)
|
||||
const formRef = ref(null)
|
||||
const formData = reactive({ name: '', host: '', port: '', token: '', note: '' })
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
||||
host: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
|
||||
port: [{ required: true, message: '请输入服务端口', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleEditService = () => {
|
||||
Object.assign(formData, {
|
||||
name: serviceInfo.value.Name ?? serviceInfo.value.name ?? '',
|
||||
host: serviceInfo.value.Host ?? serviceInfo.value.host ?? '',
|
||||
port: serviceInfo.value.Port ?? serviceInfo.value.port ?? '',
|
||||
token: serviceInfo.value.Token ?? serviceInfo.value.token ?? '',
|
||||
note: serviceInfo.value.Note ?? serviceInfo.value.note ?? ''
|
||||
})
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmitEdit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = await updateKvmService(serviceId.value, {
|
||||
name: formData.name, host: formData.host, port: formData.port,
|
||||
token: formData.token, note: formData.note
|
||||
})
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
ElMessage.success('更新成功')
|
||||
editDialogVisible.value = false
|
||||
fetchServiceInfo()
|
||||
} else {
|
||||
ElMessage.error(body?.message || '更新失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('更新失败: ' + (e?.response?.data?.message || e.message))
|
||||
} finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
// 删除服务
|
||||
const handleDeleteService = () => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除主控服务「${serviceInfo.value.Name || serviceInfo.value.name}」吗?删除后不可恢复。`,
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await deleteKvmService({ id: serviceId.value })
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
goBack()
|
||||
} else {
|
||||
ElMessage.error(body?.message || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchServiceInfo()
|
||||
// 恢复上次选中的 Tab
|
||||
const savedTab = localStorage.getItem('kvmDetailActiveTab')
|
||||
if (savedTab && tabLoaded.hasOwnProperty(savedTab)) {
|
||||
activeTab.value = savedTab
|
||||
tabLoaded[savedTab] = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kvm-detail-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 概览卡片 */
|
||||
.profile-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-basic {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.service-icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #e8f4fd, #d6eaff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.service-identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.id-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.id-row .label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.id-row .value {
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.addr-value {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #409eff !important;
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.note-value {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-divider {
|
||||
margin: 16px 0 12px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Tab 卡片 */
|
||||
.tabs-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.custom-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.custom-tabs :deep(.el-tabs__item) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.custom-tabs :deep(.el-tab-pane) {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 嵌入子模块时去掉内边距 */
|
||||
.custom-tabs :deep(.page-header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-tabs :deep(.host-group-mapping-container),
|
||||
.custom-tabs :deep(.remote-hg-container),
|
||||
.custom-tabs :deep(.host-manage-container),
|
||||
.custom-tabs :deep(.image-manage-container),
|
||||
.custom-tabs :deep(.network-manage-container),
|
||||
.custom-tabs :deep(.volume-manage-container),
|
||||
.custom-tabs :deep(.vm-manage-container),
|
||||
.custom-tabs :deep(.sg-manage-container),
|
||||
.custom-tabs :deep(.vnc-node-container) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<div class="network-manage-container">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
<h3>网络管理</h3>
|
||||
<span class="sub-info" v-if="serviceName">主控服务:{{ serviceName }} | 宿主机:{{ selectedHostName || '请选择' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建网络</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建网络</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索网络" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filterType" placeholder="网络类型" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option label="网桥(Bridge)" value="bridge" />
|
||||
<el-option label="内网(NAT)" value="nat" />
|
||||
</el-select>
|
||||
<el-select v-model="hostIdInput" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="handleSearch">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 网络列表 -->
|
||||
<el-table :data="networkList" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">
|
||||
{{ row.type === 'bridge' ? '网桥' : 'NAT' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="address" label="地址(CIDR)" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="gateway" label="网关" width="140" />
|
||||
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="bridge_name" label="网桥名称" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="target_device" label="目标设备" width="120" show-overflow-tooltip />
|
||||
<el-table-column label="宿主机" width="140">
|
||||
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper" v-if="total > queryParams.count">
|
||||
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
|
||||
@current-change="p => { queryParams.page = p; loadList() }" />
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="网络名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机" prop="host_id">
|
||||
<el-select v-model="formData.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="网络类型" prop="type">
|
||||
<el-select v-model="formData.type" style="width: 100%">
|
||||
<el-option label="网桥(Bridge/外网)" value="bridge" />
|
||||
<el-option label="内网(NAT)" value="nat" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="IP 地址(CIDR)" prop="address">
|
||||
<el-input v-model="formData.address" placeholder="例如 192.168.1.0/24" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关地址" prop="gateway">
|
||||
<el-input v-model="formData.gateway" placeholder="例如 192.168.1.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="DNS 服务器">
|
||||
<el-input v-model="formData.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">高级配置(可选)</el-divider>
|
||||
<el-form-item label="MAC 地址">
|
||||
<el-input v-model="formData.mac_address" placeholder="不填则随机" />
|
||||
</el-form-item>
|
||||
<el-form-item label="虚拟网桥名">
|
||||
<el-input v-model="formData.bridge_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="逻辑网桥名">
|
||||
<el-input v-model="formData.ls_bridge_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="逻辑端口名">
|
||||
<el-input v-model="formData.ls_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="网络详情" width="600px" destroy-on-close>
|
||||
<el-descriptions :column="2" border v-if="currentDetail">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
<el-tag :type="currentDetail.type === 'bridge' ? 'success' : 'warning'" size="small">
|
||||
{{ currentDetail.type === 'bridge' ? '网桥' : 'NAT' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址(CIDR)">{{ currentDetail.address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网关">{{ currentDetail.gateway }}</el-descriptions-item>
|
||||
<el-descriptions-item label="DNS">{{ currentDetail.nameservers || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="MAC 地址">{{ currentDetail.mac_address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="虚拟网桥">{{ currentDetail.bridge_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="逻辑网桥">{{ currentDetail.ls_bridge_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="逻辑端口">{{ currentDetail.ls_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标设备">{{ currentDetail.target_device || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { getRemoteHostList, getNetworkList, getNetworkDetail, createNetwork, updateNetwork, deleteNetwork } from '@/api/admin/kvmService'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const hostId = computed(() => parseInt(route.query.host_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const networkList = ref([])
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const filterType = ref('')
|
||||
const hostIdInput = ref(0)
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, count: 10 })
|
||||
|
||||
const selectedHostName = computed(() => {
|
||||
const h = hostOptions.value.find(x => x.id === hostIdInput.value)
|
||||
return h ? `${h.name} (${h.ip || h.id})` : (hostIdInput.value || '')
|
||||
})
|
||||
|
||||
const getHostLabel = (hid) => {
|
||||
const h = hostOptions.value.find(x => x.id === hid)
|
||||
return h ? `${h.name}` : (hid || '-')
|
||||
}
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const formRef = ref(null)
|
||||
const detailVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
id: undefined, name: '', address: '', gateway: '', nameservers: '',
|
||||
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
address: [{ required: true, message: '请输入IP地址(CIDR)', trigger: 'blur' }],
|
||||
gateway: [{ required: true, message: '请输入网关', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
||||
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
const hid = hostIdInput.value || hostId.value
|
||||
if (!hid) { ElMessage.warning('请先选择宿主机'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, count: queryParams.count }
|
||||
if (keyword.value) params.key = keyword.value
|
||||
if (filterType.value) params.type = filterType.value
|
||||
const res = await getNetworkList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
networkList.value = inner.data || []
|
||||
total.value = inner.meta?.count ?? inner.all_count ?? 0
|
||||
} else {
|
||||
networkList.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取网络列表失败:', e)
|
||||
ElMessage.error('获取网络列表失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
Object.assign(formData, {
|
||||
id: row.id, name: row.name, address: row.address, gateway: row.gateway,
|
||||
nameservers: row.nameservers || '', type: row.type, 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
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...formData, service_id: serviceId.value }
|
||||
// 空高级参数不提交
|
||||
const optionalFields = ['mac_address', 'bridge_name', 'ls_bridge_name', 'ls_name', 'nameservers', 'target_device']
|
||||
optionalFields.forEach(f => { if (!payload[f]) delete payload[f] })
|
||||
let res
|
||||
if (dialogType.value === 'add') {
|
||||
delete payload.id
|
||||
res = await createNetwork(payload)
|
||||
} else {
|
||||
res = await updateNetwork(payload)
|
||||
}
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '修改成功')
|
||||
dialogVisible.value = false
|
||||
loadList()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
||||
} finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row) => {
|
||||
detailVisible.value = true
|
||||
currentDetail.value = row
|
||||
try {
|
||||
const res = await getNetworkDetail({ service_id: serviceId.value, network_id: row.id, host_id: row.host_id })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
currentDetail.value = d.network ?? d.data ?? d
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteNetwork({ service_id: serviceId.value, network_id: row.id, host_id: row.host_id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(async () => {
|
||||
if (serviceId.value) {
|
||||
await loadHostOptions()
|
||||
if (hostId.value) {
|
||||
hostIdInput.value = hostId.value
|
||||
} else if (hostOptions.value.length > 0) {
|
||||
hostIdInput.value = hostOptions.value[0].id
|
||||
}
|
||||
if (hostIdInput.value) loadList()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.network-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="remote-hg-container">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
<h3>宿主机组管理</h3>
|
||||
<span class="sub-info" v-if="serviceName">所属主控服务:{{ serviceName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新建宿主机组</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新建宿主机组</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table :data="groupList" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="note" label="备注" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.note || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="父级ID" width="80">
|
||||
<template #default="{ row }">{{ row.parent_id || '-' }}</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" @click="handleViewDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" @click="handleOptimalHost(row)">最优主机</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新建宿主机组' : '编辑宿主机组'" width="480px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="宿主机组名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="formData.note" type="textarea" :rows="3" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级ID">
|
||||
<el-input-number v-model="formData.parent_id" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="宿主机组详情" width="520px" destroy-on-close>
|
||||
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="父级ID">{{ currentDetail.parent_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ currentDetail.note || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 最优主机弹窗 -->
|
||||
<el-dialog v-model="optimalVisible" title="最优主机配置" width="520px" destroy-on-close>
|
||||
<div v-loading="optimalLoading">
|
||||
<el-descriptions :column="2" border v-if="optimalData">
|
||||
<el-descriptions-item v-for="(val, key) in optimalData" :key="key" :label="key">
|
||||
{{ typeof val === 'object' ? JSON.stringify(val) : val }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</div>
|
||||
<template #footer><el-button @click="optimalVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostGroupList, getRemoteHostGroupDetail, createRemoteHostGroup,
|
||||
updateRemoteHostGroup, deleteRemoteHostGroup, getOptimalHostInfo
|
||||
} from '@/api/admin/kvmService'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const optimalLoading = ref(false)
|
||||
const groupList = ref([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const formRef = ref(null)
|
||||
const detailVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
const optimalVisible = ref(false)
|
||||
const optimalData = ref(null)
|
||||
|
||||
const formData = reactive({ id: undefined, name: '', note: '', parent_id: 0 })
|
||||
const formRules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }] }
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) {
|
||||
const d = new Date(Number(ts.seconds) * 1000)
|
||||
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
if (typeof ts === 'string' || typeof ts === 'number') {
|
||||
const d = new Date(ts)
|
||||
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRemoteHostGroupList({ service_id: serviceId.value })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
groupList.value = inner.host_groups || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
} else {
|
||||
groupList.value = []
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('获取宿主机组列表失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
Object.assign(formData, { id: undefined, name: '', note: '', parent_id: 0 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
Object.assign(formData, { id: row.id, name: row.name || '', note: row.note || '', parent_id: row.parent_id || 0 })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { service_id: serviceId.value, name: formData.name, note: formData.note }
|
||||
if (formData.parent_id > 0) payload.parent_id = formData.parent_id
|
||||
let res
|
||||
if (dialogType.value === 'add') {
|
||||
res = await createRemoteHostGroup(payload)
|
||||
} else {
|
||||
payload.id = formData.id
|
||||
res = await updateRemoteHostGroup(payload)
|
||||
}
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '修改成功')
|
||||
dialogVisible.value = false
|
||||
loadList()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
|
||||
} finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row) => {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentDetail.value = row
|
||||
try {
|
||||
const res = await getRemoteHostGroupDetail({ service_id: serviceId.value, id: row.id })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
currentDetail.value = res.data.data?.data ?? res.data.data
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
const handleOptimalHost = async (row) => {
|
||||
optimalVisible.value = true
|
||||
optimalLoading.value = true
|
||||
optimalData.value = null
|
||||
try {
|
||||
const res = await getOptimalHostInfo({ service_id: serviceId.value, host_group_id: row.id })
|
||||
if (res?.data?.code === 200) {
|
||||
optimalData.value = res.data.data
|
||||
} else {
|
||||
ElMessage.warning(res?.data?.message || '暂无最优主机数据')
|
||||
}
|
||||
} catch { ElMessage.error('获取失败') }
|
||||
finally { optimalLoading.value = false }
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除宿主机组「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteRemoteHostGroup({ service_id: serviceId.value, id: row.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(() => { if (serviceId.value) loadList() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.remote-hg-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<div class="sg-detail-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回安全组列表</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">安全组详情</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<!-- 基本信息 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="锁定">
|
||||
<el-tag :type="detail.lock ? 'danger' : 'success'" size="small">{{ detail.lock ? '是' : '否' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="白名单模式">
|
||||
<el-tag :type="detail.drop_all ? 'warning' : 'info'" size="small">{{ detail.drop_all ? '开启' : '关闭' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机">{{ getHostLabel(detail.host_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="介绍">{{ detail.direction || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">操作</span></template>
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" @click="handleSync">同步到宿主机</el-button>
|
||||
<el-button type="success" @click="handleBind">绑定VM</el-button>
|
||||
<el-button type="warning" @click="handleUnbind">解绑VM</el-button>
|
||||
<el-button :type="detail.drop_all ? 'info' : 'warning'" @click="handleToggleWhitelist">
|
||||
{{ detail.drop_all ? '关闭白名单' : '开启白名单' }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleApply">应用规则</el-button>
|
||||
<el-button type="danger" @click="handleDelete">删除安全组</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 规则管理 -->
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-title">安全组规则</span>
|
||||
<el-button type="primary" size="small" @click="handleAddRule">新增规则</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="detail?.rules || []" stripe size="small" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="protocol" label="协议" width="80">
|
||||
<template #default="{ row }"><el-tag size="small">{{ (row.protocol || '-').toUpperCase() }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="action" label="动作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.action === 'allow' ? 'success' : 'danger'" size="small">{{ row.action === 'allow' ? '允许' : '拒绝' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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="priority" label="优先级" width="80" />
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEditRule(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteRule(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!detail?.rules?.length && !loading" description="暂无规则" />
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 同步弹窗 -->
|
||||
<el-dialog v-model="syncDialogVisible" title="同步到宿主机" width="420px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="安全组">{{ detail?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="目标宿主机">
|
||||
<el-select v-model="syncHostId" 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>
|
||||
<template #footer>
|
||||
<el-button @click="syncDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitSync">同步</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定/解绑弹窗 -->
|
||||
<el-dialog v-model="bindDialogVisible" :title="bindType === 'bind' ? '绑定安全组到虚拟机' : '解绑安全组'" width="420px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="安全组">{{ detail?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="虚拟机">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="bindVmId ? `${bindVmName || ''} (ID: ${bindVmId})` : ''" readonly placeholder="请选择虚拟机" style="flex: 1" />
|
||||
<el-button type="primary" @click="showVmSelector = true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="bindDialogVisible = false">取消</el-button>
|
||||
<el-button :type="bindType === 'bind' ? 'primary' : 'warning'" :loading="actionLoading" @click="submitBind">
|
||||
{{ bindType === 'bind' ? '绑定' : '解绑' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<VmSelectorPopup v-model="showVmSelector" :service-id="serviceId" :current-id="bindVmId" @confirm="vm => { bindVmId = vm.id; bindVmName = vm.name || '' }" />
|
||||
|
||||
<!-- 规则弹窗 -->
|
||||
<el-dialog v-model="ruleDialogVisible" :title="ruleDialogType === 'add' ? '新增规则' : '编辑规则'" width="520px" destroy-on-close>
|
||||
<el-form ref="ruleFormRef" :model="ruleForm" :rules="ruleRules" label-width="100px">
|
||||
<el-form-item label="协议" prop="protocol">
|
||||
<el-select v-model="ruleForm.protocol" style="width: 100%">
|
||||
<el-option label="TCP" value="tcp" /><el-option label="UDP" value="udp" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="动作" prop="action">
|
||||
<el-select v-model="ruleForm.action" style="width: 100%">
|
||||
<el-option label="允许" value="allow" /><el-option label="拒绝" value="deny" />
|
||||
</el-select>
|
||||
</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="优先级"><el-input-number v-model="ruleForm.priority" :min="0" :max="9999" style="width: 100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="ruleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitRule">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getSecurityGroupDetail,
|
||||
syncSecurityGroup, bindSecurityGroup, unbindSecurityGroup,
|
||||
deleteSecurityGroup, enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
|
||||
applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule
|
||||
} from '@/api/admin/kvmService'
|
||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => route.query.service_name || '')
|
||||
const sgId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const hostOptions = ref([])
|
||||
|
||||
// 同步
|
||||
const syncDialogVisible = ref(false)
|
||||
const syncHostId = ref(0)
|
||||
|
||||
// 绑定
|
||||
const bindDialogVisible = ref(false)
|
||||
const bindType = ref('bind')
|
||||
const bindVmId = ref(0)
|
||||
const bindVmName = ref('')
|
||||
const showVmSelector = ref(false)
|
||||
|
||||
// 规则
|
||||
const ruleDialogVisible = ref(false)
|
||||
const ruleDialogType = ref('add')
|
||||
const ruleFormRef = ref(null)
|
||||
const ruleForm = reactive({ id: undefined, group_id: 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 })
|
||||
const ruleRules = {
|
||||
protocol: [{ required: true, message: '请选择协议', trigger: 'change' }],
|
||||
action: [{ required: true, message: '请选择动作', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
||||
return '-'
|
||||
}
|
||||
const getHostLabel = (hid) => { const h = hostOptions.value.find(x => x.id === hid); return h ? h.name : (hid || '-') }
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!sgId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSecurityGroupDetail({ service_id: serviceId.value, id: sgId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
detail.value = inner.group || inner.data || inner
|
||||
} else ElMessage.error(res?.data?.message || '加载失败')
|
||||
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSync = () => { syncHostId.value = detail.value?.host_id || 0; syncDialogVisible.value = true }
|
||||
const submitSync = async () => {
|
||||
if (!syncHostId.value) { ElMessage.warning('请选择宿主机'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await syncSecurityGroup({ service_id: serviceId.value, id: sgId.value, host_id: syncHostId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('同步成功'); syncDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '同步失败')
|
||||
} catch { ElMessage.error('同步失败') } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const handleBind = () => { bindType.value = 'bind'; bindVmId.value = 0; bindVmName.value = ''; bindDialogVisible.value = true }
|
||||
const handleUnbind = () => { bindType.value = 'unbind'; bindVmId.value = 0; bindVmName.value = ''; bindDialogVisible.value = true }
|
||||
const submitBind = async () => {
|
||||
if (!bindVmId.value) { ElMessage.warning('请选择虚拟机'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const api = bindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup
|
||||
const res = await api({ service_id: serviceId.value, id: sgId.value, vm_id: bindVmId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success(bindType.value === 'bind' ? '绑定成功' : '解绑成功'); bindDialogVisible.value = false }
|
||||
else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch { ElMessage.error('操作失败') } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const handleToggleWhitelist = () => {
|
||||
const action = detail.value.drop_all ? '关闭' : '开启'
|
||||
ElMessageBox.confirm(`确定${action}白名单模式?`, `${action}白名单`, { type: 'warning' }).then(async () => {
|
||||
try {
|
||||
const api = detail.value.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist
|
||||
const res = await api({ service_id: serviceId.value, id: sgId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || `${action}失败`)
|
||||
} catch { ElMessage.error(`${action}失败`) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
ElMessageBox.confirm('确定应用安全组规则到所有绑定的虚拟机?', '应用安全组', { type: 'info' }).then(async () => {
|
||||
try {
|
||||
const res = await applySecurityGroup({ service_id: serviceId.value, id: sgId.value })
|
||||
if (res?.data?.code === 200) ElMessage.success('应用成功')
|
||||
else ElMessage.error(res?.data?.message || '应用失败')
|
||||
} catch { ElMessage.error('应用失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
ElMessageBox.confirm(`确定删除安全组「${detail.value?.name}」?`, '删除确认', { type: 'warning' }).then(async () => {
|
||||
try {
|
||||
const res = await deleteSecurityGroup({ service_id: serviceId.value, id: sgId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleAddRule = () => {
|
||||
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 })
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
const handleEditRule = (rule) => {
|
||||
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 })
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
const submitRule = () => {
|
||||
ruleFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const api = ruleDialogType.value === 'add' ? createSecurityGroupRule : updateSecurityGroupRule
|
||||
const res = await api({ service_id: serviceId.value, ...ruleForm })
|
||||
if (res?.data?.code === 200) { ElMessage.success('操作成功'); ruleDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch { ElMessage.error('操作失败') } finally { actionLoading.value = false }
|
||||
})
|
||||
}
|
||||
const handleDeleteRule = (rule) => {
|
||||
ElMessageBox.confirm('确定删除该规则?', '删除确认', { type: 'warning' }).then(async () => {
|
||||
try {
|
||||
const res = await deleteSecurityGroupRule({ service_id: serviceId.value, id: rule.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
tagsViewStore.delVisitedView(route)
|
||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||
}
|
||||
|
||||
onMounted(() => { loadHostOptions(); loadDetail() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sg-detail-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.back-btn { font-size: 14px; color: #606266; }
|
||||
.back-btn:hover { color: #409eff; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.main-content { padding: 20px; }
|
||||
.info-card { margin-bottom: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
|
||||
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div class="sg-manage-container">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
<h3>安全组管理</h3>
|
||||
<span class="sub-info" v-if="serviceName">主控服务:{{ serviceName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建安全组</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建安全组</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索安全组" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filterLock" placeholder="锁定状态" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option label="已锁定" :value="true" />
|
||||
<el-option label="未锁定" :value="false" />
|
||||
</el-select>
|
||||
<el-select v-model="filterDropAll" placeholder="白名单模式" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option label="已开启" :value="true" />
|
||||
<el-option label="未开启" :value="false" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 安全组列表 -->
|
||||
<el-table :data="sgList" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="锁定" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.lock ? 'danger' : 'success'" size="small">{{ row.lock ? '是' : '否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="白名单" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="宿主机" width="140">
|
||||
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="direction" label="介绍" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper" v-if="total > queryParams.page_size">
|
||||
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
|
||||
@current-change="p => { queryParams.page = p; loadList() }" />
|
||||
</div>
|
||||
|
||||
<!-- 创建弹窗 -->
|
||||
<el-dialog v-model="createDialogVisible" title="创建安全组" width="520px" destroy-on-close>
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="createForm.name" placeholder="安全组名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机" prop="host_id">
|
||||
<el-select v-model="createForm.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="介绍">
|
||||
<el-input v-model="createForm.direction" type="textarea" :rows="3" placeholder="安全组介绍(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="锁定">
|
||||
<el-switch v-model="createForm.lock" active-text="用户不可修改" />
|
||||
</el-form-item>
|
||||
<el-form-item label="白名单模式">
|
||||
<el-switch v-model="createForm.drop_all" active-text="开启白名单" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitCreate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 同步弹窗 -->
|
||||
<el-dialog v-model="syncDialogVisible" title="同步安全组到宿主机" width="420px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="安全组">{{ syncTarget?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="目标宿主机">
|
||||
<el-select v-model="syncHostId" 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>
|
||||
<template #footer>
|
||||
<el-button @click="syncDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitSync">同步</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定/解绑虚拟机弹窗 -->
|
||||
<el-dialog v-model="bindDialogVisible" :title="bindType === 'bind' ? '绑定安全组到虚拟机' : '解绑安全组'" width="420px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="安全组">{{ bindTarget?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="虚拟机">
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
|
||||
<el-input :model-value="bindVmId ? `${bindVmName || ''} (ID: ${bindVmId})` : ''" readonly placeholder="请选择虚拟机" style="flex: 1" />
|
||||
<el-button type="primary" @click="showBindVmSelector = true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="bindDialogVisible = false">取消</el-button>
|
||||
<el-button :type="bindType === 'bind' ? 'primary' : 'warning'" :loading="submitLoading" @click="submitBind">
|
||||
{{ bindType === 'bind' ? '绑定' : '解绑' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 虚拟机选择器 -->
|
||||
<VmSelectorPopup v-model="showBindVmSelector" :service-id="serviceId" :current-id="bindVmId" @confirm="handleBindVmSelected" />
|
||||
|
||||
<!-- 详情+规则弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="安全组详情 & 规则" width="800px" destroy-on-close>
|
||||
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading" style="margin-bottom: 20px">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="锁定">
|
||||
<el-tag :type="currentDetail.lock ? 'danger' : 'success'" size="small">{{ currentDetail.lock ? '是' : '否' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="白名单">
|
||||
<el-tag :type="currentDetail.drop_all ? 'warning' : 'info'" size="small">{{ currentDetail.drop_all ? '开启' : '关闭' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="介绍">{{ currentDetail.direction || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 规则列表 -->
|
||||
<div class="rules-section">
|
||||
<div class="rules-header">
|
||||
<h4>安全组规则</h4>
|
||||
<el-button type="primary" size="small" @click="handleAddRule">新增规则</el-button>
|
||||
</div>
|
||||
<el-table :data="currentDetail?.rules || []" stripe size="small" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="protocol" label="协议" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ (row.protocol || '-').toUpperCase() }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="action" label="动作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.action === 'allow' ? 'success' : 'danger'" size="small">{{ row.action === 'allow' ? '允许' : '拒绝' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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="priority" label="优先级" width="80" />
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEditRule(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteRule(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新增/编辑规则弹窗 -->
|
||||
<el-dialog v-model="ruleDialogVisible" :title="ruleDialogType === 'add' ? '新增安全组规则' : '编辑安全组规则'" width="520px" destroy-on-close>
|
||||
<el-form ref="ruleFormRef" :model="ruleForm" :rules="ruleRules" label-width="100px">
|
||||
<el-form-item label="协议" prop="protocol">
|
||||
<el-select v-model="ruleForm.protocol" style="width: 100%">
|
||||
<el-option label="TCP" value="tcp" />
|
||||
<el-option label="UDP" value="udp" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="动作" prop="action">
|
||||
<el-select v-model="ruleForm.action" style="width: 100%">
|
||||
<el-option label="允许 (Allow)" value="allow" />
|
||||
<el-option label="拒绝 (Deny)" value="deny" />
|
||||
</el-select>
|
||||
</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 或 192.168.1.0/24" />
|
||||
</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>
|
||||
<template #footer>
|
||||
<el-button @click="ruleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitRule">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList,
|
||||
getSecurityGroupList, getSecurityGroupDetail, createSecurityGroup,
|
||||
syncSecurityGroup, bindSecurityGroup, unbindSecurityGroup,
|
||||
deleteSecurityGroup, enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
|
||||
applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule
|
||||
} from '@/api/admin/kvmService'
|
||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const sgList = ref([])
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const filterLock = ref('')
|
||||
const filterDropAll = ref('')
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, page_size: 10 })
|
||||
|
||||
const getHostLabel = (hid) => {
|
||||
const h = hostOptions.value.find(x => x.id === hid)
|
||||
return h ? h.name : (hid || '-')
|
||||
}
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
|
||||
// 创建弹窗
|
||||
const createDialogVisible = ref(false)
|
||||
const createFormRef = ref(null)
|
||||
const createForm = reactive({ name: '', host_id: 0, direction: '', lock: false, drop_all: false })
|
||||
const createRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 同步弹窗
|
||||
const syncDialogVisible = ref(false)
|
||||
const syncTarget = ref(null)
|
||||
const syncHostId = ref(0)
|
||||
|
||||
// 绑定弹窗
|
||||
const bindDialogVisible = ref(false)
|
||||
const bindType = ref('bind')
|
||||
const bindTarget = ref(null)
|
||||
const bindVmId = ref(0)
|
||||
const bindVmName = ref('')
|
||||
const showBindVmSelector = ref(false)
|
||||
const handleBindVmSelected = (vm) => { bindVmId.value = vm.id; bindVmName.value = vm.name || '' }
|
||||
|
||||
// 详情弹窗
|
||||
const detailVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
// 规则弹窗
|
||||
const ruleDialogVisible = ref(false)
|
||||
const ruleDialogType = ref('add')
|
||||
const ruleFormRef = ref(null)
|
||||
const ruleForm = reactive({ id: undefined, group_id: 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 })
|
||||
const ruleRules = {
|
||||
protocol: [{ required: true, message: '请选择协议', trigger: 'change' }],
|
||||
action: [{ required: true, message: '请选择动作', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterLock.value !== '') params.lock = filterLock.value
|
||||
if (filterDropAll.value !== '') params.drop_all = filterDropAll.value
|
||||
const res = await getSecurityGroupList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
sgList.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
total.value = inner.total ?? inner.all_count ?? sgList.value.length
|
||||
} else { sgList.value = []; total.value = 0 }
|
||||
} catch (e) {
|
||||
console.error('获取安全组列表失败:', e)
|
||||
ElMessage.error('获取安全组列表失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
Object.assign(createForm, { name: '', host_id: 0, direction: '', lock: false, drop_all: false })
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitCreate = () => {
|
||||
createFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = await createSecurityGroup({ service_id: serviceId.value, ...createForm })
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('创建成功')
|
||||
createDialogVisible.value = false
|
||||
loadList()
|
||||
} else ElMessage.error(res?.data?.message || '创建失败')
|
||||
} catch (e) { ElMessage.error('创建失败') } finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleSync = (row) => {
|
||||
syncTarget.value = row
|
||||
syncHostId.value = row.host_id || 0
|
||||
syncDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitSync = async () => {
|
||||
if (!syncHostId.value) { ElMessage.warning('请选择宿主机'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = await syncSecurityGroup({ service_id: serviceId.value, id: syncTarget.value.id, host_id: syncHostId.value })
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('同步成功')
|
||||
syncDialogVisible.value = false
|
||||
loadList()
|
||||
} else ElMessage.error(res?.data?.message || '同步失败')
|
||||
} catch (e) { ElMessage.error('同步失败') } finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
const handleBind = (row) => {
|
||||
bindType.value = 'bind'
|
||||
bindTarget.value = row
|
||||
bindVmId.value = 0
|
||||
bindDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleUnbind = (row) => {
|
||||
bindType.value = 'unbind'
|
||||
bindTarget.value = row
|
||||
bindVmId.value = 0
|
||||
bindDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitBind = async () => {
|
||||
if (!bindVmId.value) { ElMessage.warning('请输入虚拟机ID'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const api = bindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup
|
||||
const res = await api({ service_id: serviceId.value, id: bindTarget.value.id, vm_id: bindVmId.value })
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success(bindType.value === 'bind' ? '绑定成功' : '解绑成功')
|
||||
bindDialogVisible.value = false
|
||||
} else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch (e) { ElMessage.error('操作失败') } finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
const handleToggleWhitelist = (row) => {
|
||||
const action = row.drop_all ? '关闭' : '开启'
|
||||
ElMessageBox.confirm(`确定要${action}安全组「${row.name}」的白名单模式吗?`, `${action}白名单`, {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const api = row.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist
|
||||
const res = await api({ service_id: serviceId.value, id: row.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadList() }
|
||||
else ElMessage.error(res?.data?.message || `${action}失败`)
|
||||
} catch (e) { ElMessage.error(`${action}失败`) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleApply = (row) => {
|
||||
ElMessageBox.confirm(`确定要应用安全组「${row.name}」的规则到所有已绑定虚拟机吗?`, '应用安全组', {
|
||||
confirmButtonText: '确定应用', cancelButtonText: '取消', type: 'info'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await applySecurityGroup({ service_id: serviceId.value, id: row.id })
|
||||
if (res?.data?.code === 200) ElMessage.success('应用成功')
|
||||
else ElMessage.error(res?.data?.message || '应用失败')
|
||||
} catch (e) { ElMessage.error('应用失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleGoDetail = (row) => {
|
||||
router.push({ path: '/virtualization/security-group-detail', query: { service_id: serviceId.value, service_name: serviceName.value, id: row.id } })
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除安全组「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteSecurityGroup({ service_id: serviceId.value, id: row.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row) => {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentDetail.value = row
|
||||
try {
|
||||
const res = await getSecurityGroupDetail({ service_id: serviceId.value, id: row.id })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
currentDetail.value = inner.group || inner.data || inner
|
||||
}
|
||||
} catch { /* fallback */ } finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 规则操作 ----
|
||||
const handleAddRule = () => {
|
||||
ruleDialogType.value = 'add'
|
||||
Object.assign(ruleForm, { id: undefined, group_id: currentDetail.value?.id || 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: 0 })
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEditRule = (rule) => {
|
||||
ruleDialogType.value = 'edit'
|
||||
Object.assign(ruleForm, {
|
||||
id: rule.id, group_id: currentDetail.value?.id || 0, port_group_id: currentDetail.value?.id || 0,
|
||||
protocol: rule.protocol || 'tcp', action: rule.action || 'allow',
|
||||
port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0
|
||||
})
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitRule = () => {
|
||||
ruleFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
let res
|
||||
if (ruleDialogType.value === 'add') {
|
||||
res = await createSecurityGroupRule({ service_id: serviceId.value, ...ruleForm })
|
||||
} else {
|
||||
res = await updateSecurityGroupRule({ service_id: serviceId.value, ...ruleForm })
|
||||
}
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success(ruleDialogType.value === 'add' ? '规则创建成功' : '规则修改成功')
|
||||
ruleDialogVisible.value = false
|
||||
// 刷新详情
|
||||
handleViewDetail(currentDetail.value)
|
||||
} else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch (e) { ElMessage.error('操作失败') } finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteRule = (rule) => {
|
||||
ElMessageBox.confirm('确定要删除该规则吗?', '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteSecurityGroupRule({ service_id: serviceId.value, id: rule.id })
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
handleViewDetail(currentDetail.value)
|
||||
} else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(async () => {
|
||||
if (serviceId.value) {
|
||||
await loadHostOptions()
|
||||
loadList()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sg-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.rules-section { margin-top: 8px; }
|
||||
.rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.rules-header h4 { margin: 0; font-size: 15px; color: #303133; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="vm-detail-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回虚拟机列表</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">虚拟机详情</span>
|
||||
<el-tag v-if="detail" :type="vmStatusType(detail.status)" size="small" style="margin-left: 8px">{{ vmStatusLabel(detail.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<!-- 基本信息 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="CPU">{{ detail.vcpu ? detail.vcpu + ' 核' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="内存">{{ formatMemory(detail.memory) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="系统盘">{{ detail.system_size ? detail.system_size + ' MB' : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="镜像ID">{{ detail.image_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机">{{ getHostLabel(detail.host_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机组ID">{{ detail.host_group_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下行带宽">{{ detail.rx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="上行带宽">{{ detail.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ detail.user_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP数量">{{ detail.ip_num || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 电源控制 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail">
|
||||
<template #header><span class="card-title">电源控制</span></template>
|
||||
<div class="action-buttons">
|
||||
<el-button type="success" @click="handlePower('start')" :disabled="detail.status === 'running'">启动</el-button>
|
||||
<el-button type="warning" @click="handlePower('stop')" :disabled="detail.status === 'stopped' || detail.status === 'stop'">停止</el-button>
|
||||
<el-button type="primary" @click="handlePower('reboot')">重启</el-button>
|
||||
<el-button type="info" @click="handlePower('suspend')">暂停</el-button>
|
||||
<el-button type="success" @click="handlePower('resume')" v-if="detail.status === 'paused'">恢复</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button @click="handleRebuild">重建</el-button>
|
||||
<el-button type="warning" @click="handleRescue">救援模式</el-button>
|
||||
<el-button @click="handleExitRescue">退出救援</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button @click="fetchVmStatus" :loading="statusLoading">刷新状态</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 实时指标 -->
|
||||
<el-card shadow="never" class="info-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-title">实时指标</span>
|
||||
<el-button size="small" :icon="Refresh" @click="fetchVmMetrics" :loading="metricsLoading">刷新指标</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="metricsLoading">
|
||||
<template v-if="metricsData">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12" v-if="metricsData.cpu">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="使用率">{{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.memory">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-empty v-else description="暂无指标数据,点击刷新加载" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 子表格:网络/数据卷/镜像 -->
|
||||
<el-card shadow="never" class="info-card" v-if="detail && (detail.networks?.length || detail.volumes?.length)">
|
||||
<template #header><span class="card-title">关联资源</span></template>
|
||||
<template v-if="detail.networks?.length">
|
||||
<h4 style="margin: 0 0 8px; color: #303133">网络</h4>
|
||||
<el-table :data="detail.networks" size="small" stripe border style="margin-bottom: 16px">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="mac" label="MAC" min-width="140" />
|
||||
<el-table-column prop="ip" label="IP" min-width="120" />
|
||||
</el-table>
|
||||
</template>
|
||||
<template v-if="detail.volumes?.length">
|
||||
<h4 style="margin: 0 0 8px; color: #303133">数据卷</h4>
|
||||
<el-table :data="detail.volumes" size="small" stripe border>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="size" label="大小" width="100" />
|
||||
<el-table-column prop="path" label="路径" min-width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 重建弹窗 -->
|
||||
<el-dialog v-model="rebuildDialogVisible" title="重建虚拟机" width="480px" destroy-on-close>
|
||||
<el-alert title="重建会清除当前虚拟机数据并使用新镜像重新创建,请谨慎操作!" type="warning" :closable="false" style="margin-bottom: 16px" />
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="虚拟机">{{ detail?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="新镜像" required>
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="rebuildImageId ? `${rebuildImageName || ''} (ID: ${rebuildImageId})` : ''" readonly placeholder="请选择镜像" style="flex: 1" />
|
||||
<el-button type="primary" @click="showImageSelector = true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rebuildDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="actionLoading" @click="submitRebuild">确定重建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<ImageSelectorPopup v-model="showImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="img => { rebuildImageId = img.id; rebuildImageName = img.name }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Monitor, Coin } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getVmDetail, getVmStatus, getVmMetrics,
|
||||
startVm, stopVm, rebootVm, suspendVm, resumeVm,
|
||||
rebuildVm, rescueVm, exitRescueVm, deleteVm, getRemoteHostList
|
||||
} from '@/api/admin/kvmService'
|
||||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tagsViewStore = useTagsViewStore()
|
||||
|
||||
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => route.query.service_name || '')
|
||||
const vmId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const statusLoading = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const metricsData = ref(null)
|
||||
const hostOptions = ref([])
|
||||
const rebuildDialogVisible = ref(false)
|
||||
const rebuildImageId = ref(0)
|
||||
const rebuildImageName = ref('')
|
||||
const showImageSelector = ref(false)
|
||||
|
||||
const vmStatusType = (s) => ({ running: 'success', ready: 'success', creating: 'warning', pending: 'info', stopped: 'danger', stop: 'danger', error: 'danger', paused: 'warning', reboot: 'warning', poweroff: 'info', unknown: 'info' }[s] || 'info')
|
||||
const vmStatusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停', reboot: '重启中', poweroff: '已关机', unknown: '未知' }[s] || s || '-')
|
||||
|
||||
const formatMemory = (kb) => { if (!kb) return '-'; if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'; if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'; return kb + ' KB' }
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
||||
return '-'
|
||||
}
|
||||
const formatBytesRaw = (val) => {
|
||||
if (!val && val !== 0) return '-'; val = Number(val)
|
||||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||||
return val + ' B'
|
||||
}
|
||||
const getHostLabel = (hid) => { const h = hostOptions.value.find(x => x.id === hid); return h ? h.name : (hid || '-') }
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!vmId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVmDetail({ service_id: serviceId.value, vm_id: vmId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
detail.value = d.vm ?? d.data ?? d
|
||||
} else ElMessage.error(res?.data?.message || '加载失败')
|
||||
} catch { ElMessage.error('加载失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const fetchVmStatus = async () => {
|
||||
if (!detail.value) return
|
||||
statusLoading.value = true
|
||||
try {
|
||||
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vmId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const sd = res.data.data
|
||||
detail.value = { ...detail.value, status: sd.status ?? sd }
|
||||
ElMessage.success('状态已刷新: ' + vmStatusLabel(detail.value.status))
|
||||
}
|
||||
} catch { ElMessage.error('获取状态失败') } finally { statusLoading.value = false }
|
||||
}
|
||||
|
||||
const fetchVmMetrics = async () => {
|
||||
if (!detail.value) return
|
||||
metricsLoading.value = true
|
||||
try {
|
||||
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: detail.value.name })
|
||||
if (res?.data?.code === 200) metricsData.value = res.data.data?.data ?? res.data.data
|
||||
else ElMessage.warning('暂无指标数据')
|
||||
} catch { ElMessage.error('获取指标失败') } finally { metricsLoading.value = false }
|
||||
}
|
||||
|
||||
const handlePower = (action) => {
|
||||
const labels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
|
||||
ElMessageBox.confirm(`确定要${labels[action]}虚拟机「${detail.value?.name}」吗?`, `${labels[action]}确认`, {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: action === 'stop' ? 'warning' : 'info'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
|
||||
let res
|
||||
if (action === 'resume') {
|
||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
|
||||
res = await resumeVm(fd)
|
||||
} else {
|
||||
res = await apis[action]({ service_id: serviceId.value, vm_id: vmId.value })
|
||||
}
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || `${labels[action]}失败`)
|
||||
} catch { ElMessage.error(`${labels[action]}失败`) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleRebuild = () => {
|
||||
rebuildImageId.value = detail.value?.image_id || 0
|
||||
rebuildImageName.value = ''
|
||||
rebuildDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitRebuild = async () => {
|
||||
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await rebuildVm({ service_id: serviceId.value, vm_id: vmId.value, image_id: rebuildImageId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('重建成功'); rebuildDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '重建失败')
|
||||
} catch { ElMessage.error('重建失败') } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const handleRescue = () => {
|
||||
ElMessageBox.confirm(`确定让虚拟机「${detail.value?.name}」进入救援模式吗?`, '救援模式', {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
|
||||
try {
|
||||
const res = await rescueVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('已进入救援模式'); loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleExitRescue = () => {
|
||||
ElMessageBox.confirm(`确定让虚拟机「${detail.value?.name}」退出救援模式吗?`, '退出救援', {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
|
||||
}).then(async () => {
|
||||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
|
||||
try {
|
||||
const res = await exitRescueVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('已退出救援模式'); loadDetail() }
|
||||
else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
tagsViewStore.delVisitedView(route)
|
||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||
}
|
||||
|
||||
onMounted(() => { loadHostOptions(); loadDetail() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vm-detail-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.back-btn { font-size: 14px; color: #606266; }
|
||||
.back-btn:hover { color: #409eff; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.main-content { padding: 20px; }
|
||||
.info-card { margin-bottom: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
|
||||
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.action-buttons { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.metrics-card { margin-bottom: 0; }
|
||||
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||||
</style>
|
||||
@@ -0,0 +1,618 @@
|
||||
<template>
|
||||
<div class="vm-manage-container">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
<h3>虚拟机管理</h3>
|
||||
<span class="sub-info" v-if="serviceName">主控服务:{{ serviceName }} | 宿主机:{{ selectedHostName || '请选择' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建虚拟机</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建虚拟机</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索虚拟机" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="hostIdInput" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="handleSearch">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option v-for="s in vmStatuses" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟机列表 -->
|
||||
<el-table :data="vmList" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="配置" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="vm-config">
|
||||
<el-tag size="small" type="info" v-if="row.vcpu">{{ row.vcpu }}核</el-tag>
|
||||
<el-tag size="small" type="info" v-if="row.memory">{{ formatMemory(row.memory) }}</el-tag>
|
||||
<el-tag size="small" type="info" v-if="row.system_size">{{ row.system_size }}MB盘</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="带宽" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.rx_bandwidth || row.tx_bandwidth">
|
||||
↓{{ row.rx_bandwidth || 0 }} Mbps / ↑{{ row.tx_bandwidth || 0 }} Mbps
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="vmStatusType(row.status)" size="small">{{ vmStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="宿主机" width="140">
|
||||
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="user_id" label="用户" width="80" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper" v-if="total > queryParams.count">
|
||||
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
|
||||
@current-change="p => { queryParams.page = p; loadList() }" />
|
||||
</div>
|
||||
|
||||
<!-- 创建弹窗 -->
|
||||
<el-dialog v-model="createDialogVisible" title="创建虚拟机" width="640px" destroy-on-close>
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px">
|
||||
<el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item>
|
||||
<el-form-item label="宿主机" prop="host_id">
|
||||
<el-select v-model="createForm.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="镜像" prop="image_id">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showCreateImageSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">资源配置</el-divider>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="CPU(核)" prop="vcpu">
|
||||
<el-input-number v-model="createForm.vcpu" :min="1" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="内存(KB)" prop="memory">
|
||||
<el-input-number v-model="createForm.memory" :min="65536" :step="65536" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="系统盘(MB)" prop="system_size">
|
||||
<el-input-number v-model="createForm.system_size" :min="1024" :step="1024" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下行带宽(Mbps)">
|
||||
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="上行带宽(Mbps)">
|
||||
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider content-position="left">可选配置</el-divider>
|
||||
<el-form-item label="宿主机组">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.host_group_id ? `宿主机组 #${createForm.host_group_id}${createForm._groupName ? ' - ' + createForm._groupName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = 0; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户ID"><el-input-number v-model="createForm.user_id" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="IP 数量"><el-input-number v-model="createForm.ip_num" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重建弹窗 -->
|
||||
<el-dialog v-model="rebuildDialogVisible" title="重建虚拟机" width="440px" destroy-on-close>
|
||||
<el-alert title="重建会使用新镜像重置虚拟机,原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item>
|
||||
<el-form-item label="新镜像" required>
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="rebuildImageId ? `镜像 #${rebuildImageId}${rebuildImageName ? ' - ' + rebuildImageName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showRebuildImageSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rebuildDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="虚拟机详情" width="720px" destroy-on-close>
|
||||
<div v-loading="detailLoading">
|
||||
<el-descriptions :column="2" border v-if="currentDetail">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="CPU">{{ currentDetail.vcpu }} 核</el-descriptions-item>
|
||||
<el-descriptions-item label="内存">{{ formatMemory(currentDetail.memory) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="系统盘">{{ currentDetail.system_size }} MB</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="vmStatusType(currentDetail.status)" size="small">{{ vmStatusLabel(currentDetail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="下行带宽">{{ currentDetail.rx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="上行带宽">{{ currentDetail.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="镜像ID">{{ currentDetail.image_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ currentDetail.user_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机组ID">{{ currentDetail.host_group_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP" :span="2">{{ currentDetail.ip || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 网络信息 -->
|
||||
<template v-if="currentDetail?.networks && currentDetail.networks.length">
|
||||
<h4 style="margin: 16px 0 8px">🌐 网络</h4>
|
||||
<el-table :data="currentDetail.networks" size="small" stripe border>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="type" label="类型" width="80" />
|
||||
<el-table-column prop="address" label="地址" min-width="140" />
|
||||
<el-table-column prop="gateway" label="网关" width="120" />
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- 数据卷信息 -->
|
||||
<template v-if="currentDetail?.volumes && currentDetail.volumes.length">
|
||||
<h4 style="margin: 16px 0 8px">💿 数据卷</h4>
|
||||
<el-table :data="currentDetail.volumes" size="small" stripe border>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column label="大小" width="80">
|
||||
<template #default="{ row }">{{ row.size }} GB</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="系统卷" width="80">
|
||||
<template #default="{ row }">{{ row.is_system ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="路径" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<!-- 镜像信息 -->
|
||||
<template v-if="currentDetail?.image">
|
||||
<h4 style="margin: 16px 0 8px">🖼️ 镜像</h4>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.image.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ currentDetail.image.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ currentDetail.image.os_type }} / {{ currentDetail.image.type }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ currentDetail.image.status }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
|
||||
<div class="detail-actions" v-if="currentDetail">
|
||||
<el-button size="small" type="primary" @click="fetchVmStatus(currentDetail)">刷新状态</el-button>
|
||||
<el-button size="small" @click="fetchVmMetrics(currentDetail)">查看指标</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 指标 -->
|
||||
<template v-if="vmMetricsData">
|
||||
<h4 style="margin: 16px 0 8px">实时指标</h4>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<template v-if="vmMetricsData.cpu">
|
||||
<el-descriptions-item label="CPU使用率">{{ (vmMetricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
||||
</template>
|
||||
<template v-if="vmMetricsData.memory">
|
||||
<el-descriptions-item label="内存总量">{{ formatBytesRaw(vmMetricsData.memory.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="内存使用">{{ formatBytesRaw(vmMetricsData.memory.used) }} ({{ vmMetricsData.memory.percent || 0 }}%)</el-descriptions-item>
|
||||
</template>
|
||||
<template v-if="vmMetricsData.disk">
|
||||
<el-descriptions-item v-for="(info, path) in vmMetricsData.disk" :key="path" :label="'磁盘 ' + path">
|
||||
{{ formatBytesRaw(info.used) }} / {{ formatBytesRaw(info.total) }} ({{ info.percent || 0 }}%)
|
||||
</el-descriptions-item>
|
||||
</template>
|
||||
<template v-if="vmMetricsData.network">
|
||||
<el-descriptions-item label="网络接收">{{ formatBytesRaw(vmMetricsData.network.rx_bytes) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网络发送">{{ formatBytesRaw(vmMetricsData.network.tx_bytes) }}</el-descriptions-item>
|
||||
</template>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 镜像选择器 (创建) -->
|
||||
<ImageSelectorPopup v-model="showCreateImageSelector" :service-id="serviceId" :current-id="createForm.image_id" @confirm="handleCreateImageSelected" />
|
||||
<!-- 镜像选择器 (重建) -->
|
||||
<ImageSelectorPopup v-model="showRebuildImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="handleRebuildImageSelected" />
|
||||
<!-- 宿主机组选择器 -->
|
||||
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="serviceId" :current-id="createForm.host_group_id" @confirm="handleHostGroupSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getVmMetrics,
|
||||
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
||||
resumeVm, rescueVm, exitRescueVm, deleteVm
|
||||
} from '@/api/admin/kvmService'
|
||||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const hostId = computed(() => parseInt(route.query.host_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const vmList = ref([])
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const filterStatus = ref('')
|
||||
const hostIdInput = ref(0)
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, count: 10 })
|
||||
|
||||
// 选择器
|
||||
const showCreateImageSelector = ref(false)
|
||||
const showRebuildImageSelector = ref(false)
|
||||
const showHostGroupSelector = ref(false)
|
||||
|
||||
const selectedHostName = computed(() => {
|
||||
const h = hostOptions.value.find(x => x.id === hostIdInput.value)
|
||||
return h ? `${h.name} (${h.ip || h.id})` : (hostIdInput.value || '')
|
||||
})
|
||||
|
||||
const getHostLabel = (hid) => {
|
||||
const h = hostOptions.value.find(x => x.id === hid)
|
||||
return h ? `${h.name}` : (hid || '-')
|
||||
}
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
|
||||
const vmStatuses = [
|
||||
{ label: '等待中', value: 'pending' }, { label: '创建中', value: 'creating' },
|
||||
{ label: '就绪', value: 'ready' }, { label: '运行中', value: 'running' },
|
||||
{ label: '已停止', value: 'stopped' }, { label: '已停止', value: 'stop' },
|
||||
{ label: '错误', value: 'error' }, { label: '已暂停', value: 'paused' },
|
||||
{ label: '重启中', value: 'reboot' }, { label: '已关机', value: 'poweroff' },
|
||||
{ label: '未知', value: 'unknown' }
|
||||
]
|
||||
|
||||
const createDialogVisible = ref(false)
|
||||
const createFormRef = ref(null)
|
||||
const rebuildDialogVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
const rebuildTarget = ref(null)
|
||||
const rebuildImageId = ref(0)
|
||||
const rebuildImageName = ref('')
|
||||
const vmMetricsData = ref(null)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '', host_id: 0, image_id: 0, vcpu: 1, memory: 1048576,
|
||||
system_size: 10240, rx_bandwidth: 0, tx_bandwidth: 0,
|
||||
host_group_id: 0, user_id: 0, ip_num: 0,
|
||||
_imageName: '', _groupName: ''
|
||||
})
|
||||
|
||||
const createRules = {
|
||||
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }],
|
||||
image_id: [{ required: true, message: '请选择镜像', trigger: 'blur', type: 'number', min: 1 }],
|
||||
vcpu: [{ required: true, message: '请输入CPU核数', trigger: 'blur' }],
|
||||
memory: [{ required: true, message: '请输入内存(KB)', trigger: 'blur' }],
|
||||
system_size: [{ required: true, message: '请输入系统盘(MB)', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const vmStatusType = (s) => ({
|
||||
running: 'success', ready: 'success', creating: 'warning', pending: 'info',
|
||||
stopped: 'danger', stop: 'danger', error: 'danger', paused: 'warning',
|
||||
reboot: 'warning', poweroff: 'info', unknown: 'info'
|
||||
}[s] || 'info')
|
||||
|
||||
const vmStatusLabel = (s) => ({
|
||||
running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中',
|
||||
stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停',
|
||||
reboot: '重启中', poweroff: '已关机', unknown: '未知'
|
||||
}[s] || s || '-')
|
||||
|
||||
const formatMemory = (kb) => {
|
||||
if (!kb) return '-'
|
||||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
||||
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
|
||||
return kb + ' KB'
|
||||
}
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) {
|
||||
return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
if (typeof ts === 'string' || typeof ts === 'number') {
|
||||
const d = new Date(ts)
|
||||
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
const formatBytesRaw = (val) => {
|
||||
if (!val && val !== 0) return '-'
|
||||
val = Number(val)
|
||||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||||
return val + ' B'
|
||||
}
|
||||
|
||||
// 选择器回调
|
||||
const handleCreateImageSelected = (img) => { createForm.image_id = img.id; createForm._imageName = img.name }
|
||||
const handleRebuildImageSelected = (img) => { rebuildImageId.value = img.id; rebuildImageName.value = img.name }
|
||||
const handleHostGroupSelected = (group) => { createForm.host_group_id = group.id; createForm._groupName = group.name || '' }
|
||||
|
||||
const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
const hid = hostIdInput.value || hostId.value
|
||||
if (!hid) { ElMessage.warning('请先选择宿主机'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, count: queryParams.count }
|
||||
if (keyword.value) params.key = keyword.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
const res = await getVmList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
vmList.value = inner.data || inner.vms || (Array.isArray(inner) ? inner : [])
|
||||
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? vmList.value.length
|
||||
} else { vmList.value = []; total.value = 0 }
|
||||
} catch (e) { ElMessage.error('获取虚拟机列表失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
Object.assign(createForm, {
|
||||
name: '', host_id: hostIdInput.value || hostId.value || 0, image_id: 0,
|
||||
vcpu: 1, memory: 1048576, system_size: 10240,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, user_id: 0, ip_num: 0,
|
||||
_imageName: '', _groupName: ''
|
||||
})
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitCreate = () => {
|
||||
createFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
service_id: serviceId.value,
|
||||
host_id: createForm.host_id, image_id: createForm.image_id,
|
||||
vcpu: createForm.vcpu, memory: createForm.memory, system_size: createForm.system_size
|
||||
}
|
||||
if (createForm.name) payload.name = createForm.name
|
||||
if (createForm.rx_bandwidth) payload.rx_bandwidth = createForm.rx_bandwidth
|
||||
if (createForm.tx_bandwidth) payload.tx_bandwidth = createForm.tx_bandwidth
|
||||
if (createForm.host_group_id) payload.host_group_id = createForm.host_group_id
|
||||
if (createForm.user_id) payload.user_id = createForm.user_id
|
||||
if (createForm.ip_num) payload.ip_num = createForm.ip_num
|
||||
const res = await createVm(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
|
||||
else ElMessage.error(res?.data?.message || '创建失败')
|
||||
} catch (e) { ElMessage.error('创建失败: ' + (e?.response?.data?.message || e.message)) }
|
||||
finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handlePower = (row, action) => {
|
||||
const labels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
|
||||
ElMessageBox.confirm(`确定要${labels[action]}虚拟机「${row.name}」吗?`, `${labels[action]}确认`, {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消',
|
||||
type: action === 'stop' ? 'warning' : 'info'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
|
||||
const payload = { service_id: serviceId.value, vm_id: row.id }
|
||||
// resume uses FormData
|
||||
let res
|
||||
if (action === 'resume') {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', row.id)
|
||||
res = await resumeVm(fd)
|
||||
} else {
|
||||
res = await apis[action](payload)
|
||||
}
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadList() }
|
||||
else ElMessage.error(res?.data?.message || `${labels[action]}失败`)
|
||||
} catch (e) { ElMessage.error(`${labels[action]}失败`) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleMoreAction = (row, command) => {
|
||||
if (command === 'rebuild') handleRebuild(row)
|
||||
else if (command === 'rescue') handleRescue(row)
|
||||
else if (command === 'exit_rescue') handleExitRescue(row)
|
||||
}
|
||||
|
||||
const handleRebuild = (row) => {
|
||||
rebuildTarget.value = row
|
||||
rebuildImageId.value = row.image_id || 0
|
||||
rebuildImageName.value = ''
|
||||
rebuildDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitRebuild = async () => {
|
||||
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = await rebuildVm({ service_id: serviceId.value, vm_id: rebuildTarget.value.id, image_id: rebuildImageId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('重建成功'); rebuildDialogVisible.value = false; loadList() }
|
||||
else ElMessage.error(res?.data?.message || '重建失败')
|
||||
} catch (e) { ElMessage.error('重建失败') } finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
const handleRescue = (row) => {
|
||||
ElMessageBox.confirm(`确定让虚拟机「${row.name}」进入救援模式吗?`, '救援模式', {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', row.id)
|
||||
const res = await rescueVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('已进入救援模式'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleExitRescue = (row) => {
|
||||
ElMessageBox.confirm(`确定让虚拟机「${row.name}」退出救援模式吗?`, '退出救援', {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', row.id)
|
||||
const res = await exitRescueVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('已退出救援模式'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row) => {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentDetail.value = row
|
||||
vmMetricsData.value = null
|
||||
try {
|
||||
const res = await getVmDetail({ service_id: serviceId.value, vm_id: row.id })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
// API may return data.vm nested, or data.data, or flat
|
||||
currentDetail.value = d.vm ?? d.data ?? d
|
||||
}
|
||||
} catch { /* fallback */ } finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
const fetchVmStatus = async (vm) => {
|
||||
try {
|
||||
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vm.id })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const statusData = res.data.data
|
||||
currentDetail.value = { ...currentDetail.value, status: statusData.status ?? statusData }
|
||||
ElMessage.success('状态已刷新: ' + vmStatusLabel(currentDetail.value.status))
|
||||
}
|
||||
} catch { ElMessage.error('获取状态失败') }
|
||||
}
|
||||
|
||||
const fetchVmMetrics = async (vm) => {
|
||||
try {
|
||||
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: vm.name })
|
||||
if (res?.data?.code === 200) vmMetricsData.value = res.data.data?.data ?? res.data.data
|
||||
else ElMessage.warning('暂无指标数据')
|
||||
} catch { ElMessage.error('获取指标失败') }
|
||||
}
|
||||
|
||||
const handleGoDetail = (row) => {
|
||||
router.push({ path: '/virtualization/vm-detail', query: { service_id: serviceId.value, service_name: serviceName.value, id: row.id } })
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除虚拟机「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', row.id)
|
||||
const res = await deleteVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(async () => {
|
||||
if (serviceId.value) {
|
||||
await loadHostOptions()
|
||||
if (hostId.value) {
|
||||
hostIdInput.value = hostId.value
|
||||
} else if (hostOptions.value.length > 0) {
|
||||
hostIdInput.value = hostOptions.value[0].id
|
||||
}
|
||||
if (hostIdInput.value) loadList()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vm-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.vm-config { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.detail-actions { margin-top: 16px; display: flex; gap: 8px; }
|
||||
.bind-selector-row { display: flex; align-items: center; width: 100%; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div class="vnc-node-container">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
<h3>VNC 节点管理</h3>
|
||||
<span class="sub-info" v-if="serviceName">主控服务:{{ serviceName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新增节点</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 嵌入模式下的工具栏 -->
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新增节点</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
<el-input v-model="keyword" placeholder="搜索节点" clearable style="width: 220px; margin-left: auto;" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 独立模式下的筛选栏 -->
|
||||
<div class="filter-bar" v-if="!embedded">
|
||||
<el-input v-model="keyword" placeholder="搜索VNC节点" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 节点列表 -->
|
||||
<el-table :data="nodeList" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="地址" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="host-addr">{{ row.ip || '-' }}:{{ row.port || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Token" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.token" class="token-mask">{{ maskToken(row.token) }}</span>
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="success" @click="handleTest(row)">测试连接</el-button>
|
||||
<el-button link type="primary" @click="handleVmVnc(row)">VM VNC</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper" v-if="total > queryParams.page_size">
|
||||
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
|
||||
@current-change="p => { queryParams.page = p; loadList() }" />
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog v-model="formDialogVisible" :title="formType === 'add' ? '新增 VNC 节点' : '编辑 VNC 节点'" width="520px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="VNC节点名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip">
|
||||
<el-input v-model="formData.ip" placeholder="如 192.168.1.100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="端口" prop="port">
|
||||
<el-input v-model="formData.port" placeholder="如 6080" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Token">
|
||||
<el-input v-model="formData.token" placeholder="认证Token(可选)" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 测试连接弹窗 -->
|
||||
<el-dialog v-model="testDialogVisible" title="测试 VNC 节点连接" width="460px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="VNC节点">{{ testTarget?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="宿主机">
|
||||
<el-select v-model="testHostId" 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>
|
||||
<div v-if="testResult" class="test-result" :class="testResult.success ? 'success' : 'error'">
|
||||
<el-icon v-if="testResult.success"><SuccessFilled /></el-icon>
|
||||
<el-icon v-else><CircleCloseFilled /></el-icon>
|
||||
{{ testResult.message }}
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="testDialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="testLoading" @click="submitTest">测试</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- VM VNC 弹窗 -->
|
||||
<el-dialog v-model="vmVncDialogVisible" title="获取虚拟机 VNC 连接" width="520px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="VNC节点">{{ vmVncTarget?.name || '-' }}</el-form-item>
|
||||
<el-form-item label="虚拟机">
|
||||
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
|
||||
<el-input :model-value="vmVncVmId ? `${vmVncVmName || ''} (ID: ${vmVncVmId})` : ''" readonly placeholder="请选择虚拟机" style="flex: 1" />
|
||||
<el-button type="primary" @click="showVmVncSelector = true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="vmVncResult" class="vnc-result">
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item v-for="(val, key) in vmVncResult" :key="key" :label="key">
|
||||
<template v-if="typeof val === 'string' && (val.startsWith('http') || val.startsWith('ws'))">
|
||||
<el-link type="primary" :href="val" target="_blank">{{ val }}</el-link>
|
||||
</template>
|
||||
<template v-else>{{ val }}</template>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="vmVncDialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="vmVncLoading" @click="submitVmVnc">获取</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 虚拟机选择器 -->
|
||||
<VmSelectorPopup v-model="showVmVncSelector" :service-id="serviceId" :current-id="vmVncVmId" @confirm="handleVmVncSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getVncNodeList, getVmVnc, addVncNode, testVncNode, updateVncNode, deleteVncNode,
|
||||
getRemoteHostList
|
||||
} from '@/api/admin/kvmService'
|
||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const nodeList = ref([])
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, page_size: 10 })
|
||||
|
||||
const maskToken = (token) => {
|
||||
if (!token) return ''
|
||||
if (token.length <= 8) return '****'
|
||||
return token.substring(0, 4) + '****' + token.substring(token.length - 4)
|
||||
}
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
const res = await getVncNodeList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
nodeList.value = inner.items || inner.vnc_nodes || inner.nodes || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
total.value = inner.total ?? inner.all_count ?? nodeList.value.length
|
||||
} else { nodeList.value = []; total.value = 0 }
|
||||
} catch (e) {
|
||||
console.error('获取VNC节点列表失败:', e)
|
||||
ElMessage.error('获取VNC节点列表失败')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
// 表单
|
||||
const formDialogVisible = ref(false)
|
||||
const formType = ref('add')
|
||||
const formRef = ref(null)
|
||||
const formData = reactive({ id: undefined, name: '', ip: '', port: '', token: '' })
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
ip: [{ required: true, message: '请输入IP地址', trigger: 'blur' }],
|
||||
port: [{ required: true, message: '请输入端口', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
formType.value = 'add'
|
||||
Object.assign(formData, { id: undefined, name: '', ip: '', port: '', token: '' })
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
formType.value = 'edit'
|
||||
Object.assign(formData, {
|
||||
id: row.id, name: row.name || '', ip: row.ip || '', port: row.port || '', token: row.token || ''
|
||||
})
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
let res
|
||||
if (formType.value === 'add') {
|
||||
res = await addVncNode({ service_id: serviceId.value, name: formData.name, ip: formData.ip, port: formData.port, token: formData.token })
|
||||
} else {
|
||||
res = await updateVncNode({ service_id: serviceId.value, id: formData.id, name: formData.name, ip: formData.ip, port: formData.port, token: formData.token })
|
||||
}
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success(formType.value === 'add' ? '创建成功' : '修改成功')
|
||||
formDialogVisible.value = false
|
||||
loadList()
|
||||
} else ElMessage.error(res?.data?.message || '操作失败')
|
||||
} catch (e) { ElMessage.error('操作失败') } finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除VNC节点「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteVncNode({ service_id: serviceId.value, id: row.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const testDialogVisible = ref(false)
|
||||
const testTarget = ref(null)
|
||||
const testHostId = ref(null)
|
||||
const testLoading = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
const handleTest = (row) => {
|
||||
testTarget.value = row
|
||||
testHostId.value = null
|
||||
testResult.value = null
|
||||
testDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitTest = async () => {
|
||||
if (!testHostId.value) { ElMessage.warning('请选择宿主机'); return }
|
||||
testLoading.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const res = await testVncNode({ service_id: serviceId.value, id: testTarget.value.id, host_id: testHostId.value })
|
||||
if (res?.data?.code === 200) {
|
||||
testResult.value = { success: true, message: '连接测试成功' }
|
||||
} else {
|
||||
testResult.value = { success: false, message: res?.data?.message || '连接测试失败' }
|
||||
}
|
||||
} catch (e) {
|
||||
testResult.value = { success: false, message: '连接测试异常: ' + (e?.message || '未知错误') }
|
||||
} finally { testLoading.value = false }
|
||||
}
|
||||
|
||||
// VM VNC
|
||||
const vmVncDialogVisible = ref(false)
|
||||
const vmVncTarget = ref(null)
|
||||
const vmVncVmId = ref(0)
|
||||
const vmVncVmName = ref('')
|
||||
const vmVncLoading = ref(false)
|
||||
const vmVncResult = ref(null)
|
||||
const showVmVncSelector = ref(false)
|
||||
const handleVmVncSelected = (vm) => { vmVncVmId.value = vm.id; vmVncVmName.value = vm.name || '' }
|
||||
|
||||
const handleVmVnc = (row) => {
|
||||
vmVncTarget.value = row
|
||||
vmVncVmId.value = 0
|
||||
vmVncVmName.value = ''
|
||||
vmVncResult.value = null
|
||||
vmVncDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitVmVnc = async () => {
|
||||
if (!vmVncVmId.value) { ElMessage.warning('请选择虚拟机'); return }
|
||||
vmVncLoading.value = true
|
||||
vmVncResult.value = null
|
||||
try {
|
||||
const res = await getVmVnc({ service_id: serviceId.value, id: vmVncTarget.value.id, vm_id: vmVncVmId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
vmVncResult.value = res.data.data
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '获取VNC连接信息失败')
|
||||
}
|
||||
} catch (e) { ElMessage.error('获取失败') } finally { vmVncLoading.value = false }
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(async () => {
|
||||
if (serviceId.value) {
|
||||
await loadHostOptions()
|
||||
loadList()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vnc-node-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.host-addr { font-family: 'Consolas', 'Monaco', monospace; color: #409eff; font-size: 13px; }
|
||||
.token-mask { font-family: 'Consolas', 'Monaco', monospace; color: #909399; font-size: 13px; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
.test-result { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 4px; margin-top: 12px; font-size: 14px; }
|
||||
.test-result.success { background: #f0f9eb; color: #67c23a; }
|
||||
.test-result.error { background: #fef0f0; color: #f56c6c; }
|
||||
.vnc-result { margin-top: 12px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,467 @@
|
||||
<template>
|
||||
<div class="volume-manage-container">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
<h3>数据卷管理</h3>
|
||||
<span class="sub-info" v-if="serviceName">主控服务:{{ serviceName }} | 宿主机:{{ selectedHostName || '请选择' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建数据卷</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建数据卷</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option label="等待中" value="pending" />
|
||||
<el-option label="就绪" value="ready" />
|
||||
<el-option label="错误" value="error" />
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
<el-select v-model="hostIdInput" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="handleSearch">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-table :data="volumeList" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="大小" width="90">
|
||||
<template #default="{ row }">{{ row.size ? row.size + ' GB' : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="系统卷" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_system ? 'danger' : 'info'" size="small">{{ row.is_system ? '是' : '否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="volStatusType(row.status)" size="small">{{ volStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="路径" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="宿主机" width="140">
|
||||
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="340" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="handleResize(row)">调整大小</el-button>
|
||||
<el-button link type="primary" @click="handleMount(row)">挂载</el-button>
|
||||
<el-button link type="warning" @click="handleUnmount(row)">卸载</el-button>
|
||||
<el-button link type="success" @click="handleTransfer(row)">迁移</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper" v-if="total > queryParams.count">
|
||||
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
|
||||
@current-change="p => { queryParams.page = p; loadList() }" />
|
||||
</div>
|
||||
|
||||
<!-- 创建弹窗 -->
|
||||
<el-dialog v-model="createDialogVisible" title="创建数据卷" width="560px" destroy-on-close>
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px">
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="createForm.name" placeholder="数据卷名称" /></el-form-item>
|
||||
<el-form-item label="大小(GB)" prop="size"><el-input-number v-model="createForm.size" :min="1" controls-position="right" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="宿主机" prop="host_id">
|
||||
<el-select v-model="createForm.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="系统卷"><el-switch v-model="createForm.is_system" /></el-form-item>
|
||||
<el-form-item label="镜像">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showImageSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="虚拟机">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.vm_id ? `VM #${createForm.vm_id}${createForm._vmName ? ' - ' + createForm._vmName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showVmSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.vm_id" @click="createForm.vm_id = 0; createForm._vmName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标设备名"><el-input v-model="createForm.target_device" placeholder="不填自动生成" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitCreate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 调整大小弹窗 -->
|
||||
<el-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="当前大小">{{ resizeTarget?.size || 0 }} GB</el-form-item>
|
||||
<el-form-item label="新大小(GB)">
|
||||
<el-input-number v-model="newSize" :min="1" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="resizeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitResize">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 挂载弹窗 -->
|
||||
<el-dialog v-model="mountDialogVisible" title="挂载数据卷到虚拟机" width="440px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="数据卷">{{ mountTarget?.name }} ({{ mountTarget?.size }} GB)</el-form-item>
|
||||
<el-form-item label="虚拟机" required>
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="mountVmId ? `VM #${mountVmId}${mountVmName ? ' - ' + mountVmName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showMountVmSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="mountDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitMount">挂载</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 迁移卷弹窗 -->
|
||||
<el-dialog v-model="transferDialogVisible" title="迁移数据卷" width="440px" destroy-on-close>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
|
||||
将数据卷迁移到另一台宿主机上。迁移过程中数据卷将不可用。
|
||||
</el-alert>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="数据卷">{{ transferTarget?.name }} ({{ transferTarget?.size }} GB)</el-form-item>
|
||||
<el-form-item label="当前宿主机">{{ getHostLabel(transferTarget?.host_id) }}</el-form-item>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-select v-model="transferHostId" placeholder="请选择目标宿主机" style="width: 100%" filterable>
|
||||
<el-option v-for="h in hostOptions.filter(x => x.id !== transferTarget?.host_id)" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialogVisible = false">取消</el-button>
|
||||
<el-button type="success" :loading="transferLoading" @click="submitTransfer">确定迁移</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="数据卷详情" width="600px" destroy-on-close>
|
||||
<div v-loading="detailLoading">
|
||||
<el-descriptions :column="2" border v-if="currentDetail">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="大小">{{ currentDetail.size }} GB</el-descriptions-item>
|
||||
<el-descriptions-item label="系统卷">
|
||||
<el-tag :type="currentDetail.is_system ? 'danger' : 'info'" size="small">{{ currentDetail.is_system ? '是' : '否' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="volStatusType(currentDetail.status)" size="small">{{ volStatusLabel(currentDetail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="路径" :span="2">
|
||||
<span class="mono-text">{{ currentDetail.path || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="目标设备" v-if="currentDetail.target_device">{{ currentDetail.target_device }}</el-descriptions-item>
|
||||
<el-descriptions-item label="虚拟机ID" v-if="currentDetail.vm_id">{{ currentDetail.vm_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="镜像ID" v-if="currentDetail.image_id">{{ currentDetail.image_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 镜像选择器 -->
|
||||
<ImageSelectorPopup v-model="showImageSelector" :service-id="serviceId" :current-id="createForm.image_id" @confirm="handleImageSelected" />
|
||||
<!-- 虚拟机选择器 (创建) -->
|
||||
<VmSelectorPopup v-model="showVmSelector" :service-id="serviceId" :host-id="createForm.host_id" :current-id="createForm.vm_id" @confirm="handleVmSelected" />
|
||||
<!-- 虚拟机选择器 (挂载) -->
|
||||
<VmSelectorPopup v-model="showMountVmSelector" :service-id="serviceId" :host-id="mountTarget?.host_id || hostIdInput" :current-id="mountVmId" @confirm="handleMountVmSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getVolumeList, getVolumeDetail,
|
||||
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume
|
||||
} from '@/api/admin/kvmService'
|
||||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const hostId = computed(() => parseInt(route.query.host_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const volumeList = ref([])
|
||||
const total = ref(0)
|
||||
const filterStatus = ref('')
|
||||
const hostIdInput = ref(0)
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, count: 10 })
|
||||
|
||||
const selectedHostName = computed(() => {
|
||||
const h = hostOptions.value.find(x => x.id === hostIdInput.value)
|
||||
return h ? `${h.name} (${h.ip || h.id})` : (hostIdInput.value || '')
|
||||
})
|
||||
|
||||
const getHostLabel = (hid) => {
|
||||
const h = hostOptions.value.find(x => x.id === hid)
|
||||
return h ? `${h.name}` : (hid || '-')
|
||||
}
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) {
|
||||
return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
}
|
||||
if (typeof ts === 'string' || typeof ts === 'number') {
|
||||
const d = new Date(ts)
|
||||
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
|
||||
const createDialogVisible = ref(false)
|
||||
const createFormRef = ref(null)
|
||||
const resizeDialogVisible = ref(false)
|
||||
const mountDialogVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
const resizeTarget = ref(null)
|
||||
const newSize = ref(1)
|
||||
const mountTarget = ref(null)
|
||||
const mountVmId = ref(0)
|
||||
const mountVmName = ref('')
|
||||
|
||||
// 迁移
|
||||
const transferDialogVisible = ref(false)
|
||||
const transferTarget = ref(null)
|
||||
const transferHostId = ref('')
|
||||
const transferLoading = ref(false)
|
||||
|
||||
// 选择器
|
||||
const showImageSelector = ref(false)
|
||||
const showVmSelector = ref(false)
|
||||
const showMountVmSelector = ref(false)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '', size: 10, host_id: 0, is_system: false,
|
||||
image_id: 0, vm_id: 0, target_device: '',
|
||||
_imageName: '', _vmName: ''
|
||||
})
|
||||
const createRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
size: [{ required: true, message: '请输入大小', trigger: 'blur' }],
|
||||
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const volStatusType = (s) => ({ ready: 'success', pending: 'info', error: 'danger', unknown: 'warning' }[s] || 'info')
|
||||
const volStatusLabel = (s) => ({ ready: '就绪', pending: '等待中', error: '错误', unknown: '未知' }[s] || s || '-')
|
||||
|
||||
// 选择器回调
|
||||
const handleImageSelected = (img) => { createForm.image_id = img.id; createForm._imageName = img.name }
|
||||
const handleVmSelected = (vm) => { createForm.vm_id = vm.id; createForm._vmName = vm.name }
|
||||
const handleMountVmSelected = (vm) => { mountVmId.value = vm.id; mountVmName.value = vm.name }
|
||||
|
||||
const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
const hid = hostIdInput.value || hostId.value
|
||||
if (!hid) { ElMessage.warning('请先选择宿主机'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, count: queryParams.count }
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
const res = await getVolumeList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
volumeList.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : [])
|
||||
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? volumeList.value.length
|
||||
} else { volumeList.value = []; total.value = 0 }
|
||||
} catch (e) { ElMessage.error('获取数据卷列表失败') } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
Object.assign(createForm, {
|
||||
name: '', size: 10, host_id: hostIdInput.value || hostId.value || 0,
|
||||
is_system: false, image_id: 0, vm_id: 0, target_device: '',
|
||||
_imageName: '', _vmName: ''
|
||||
})
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitCreate = () => {
|
||||
createFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
service_id: serviceId.value,
|
||||
name: createForm.name, size: createForm.size,
|
||||
host_id: createForm.host_id, is_system: createForm.is_system
|
||||
}
|
||||
if (createForm.image_id) payload.image_id = createForm.image_id
|
||||
if (createForm.vm_id) payload.vm_id = createForm.vm_id
|
||||
if (createForm.target_device) payload.target_device = createForm.target_device
|
||||
const res = await createVolume(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
|
||||
else ElMessage.error(res?.data?.message || '创建失败')
|
||||
} catch (e) { ElMessage.error('创建失败: ' + (e?.response?.data?.message || e.message)) } finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleResize = (row) => { resizeTarget.value = row; newSize.value = row.size || 10; resizeDialogVisible.value = true }
|
||||
|
||||
const submitResize = async () => {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = await resizeVolume({ service_id: serviceId.value, volume_id: resizeTarget.value.id, size: newSize.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadList() }
|
||||
else ElMessage.error(res?.data?.message || '调整失败')
|
||||
} catch (e) { ElMessage.error('调整失败') } finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
const handleMount = (row) => {
|
||||
mountTarget.value = row; mountVmId.value = 0; mountVmName.value = ''
|
||||
mountDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitMount = async () => {
|
||||
if (!mountVmId.value) { ElMessage.warning('请选择虚拟机'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = await mountVolume({ service_id: serviceId.value, volume_id: mountTarget.value.id, vm_id: mountVmId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('挂载成功'); mountDialogVisible.value = false; loadList() }
|
||||
else ElMessage.error(res?.data?.message || '挂载失败')
|
||||
} catch (e) { ElMessage.error('挂载失败') } finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
const handleUnmount = (row) => {
|
||||
ElMessageBox.confirm(`确定要卸载数据卷「${row.name}」吗?`, '卸载确认', {
|
||||
confirmButtonText: '卸载', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await unmountVolume({ service_id: serviceId.value, volume_id: row.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '卸载失败')
|
||||
} catch (e) { ElMessage.error('卸载失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 迁移卷
|
||||
const handleTransfer = (row) => {
|
||||
transferTarget.value = row
|
||||
transferHostId.value = ''
|
||||
transferDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitTransfer = async () => {
|
||||
if (!transferHostId.value) { ElMessage.warning('请选择目标宿主机'); return }
|
||||
transferLoading.value = true
|
||||
try {
|
||||
const formPayload = new FormData()
|
||||
formPayload.append('service_id', serviceId.value)
|
||||
formPayload.append('volume_id', transferTarget.value.id)
|
||||
formPayload.append('host_id', transferHostId.value)
|
||||
const res = await transferVolume(formPayload)
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('迁移已触发')
|
||||
transferDialogVisible.value = false
|
||||
loadList()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '迁移失败')
|
||||
}
|
||||
} catch (e) { ElMessage.error('迁移失败: ' + (e?.response?.data?.message || e.message)) }
|
||||
finally { transferLoading.value = false }
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row) => {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentDetail.value = row
|
||||
try {
|
||||
const res = await getVolumeDetail({ service_id: serviceId.value, volume_id: row.id })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
currentDetail.value = d.volume ?? d.data ?? d
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除数据卷「${row.name}」吗?此操作不可恢复!`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteVolume({ service_id: serviceId.value, volume_id: row.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(res?.data?.message || '删除失败')
|
||||
} catch (e) { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(async () => {
|
||||
if (serviceId.value) {
|
||||
await loadHostOptions()
|
||||
if (hostId.value) {
|
||||
hostIdInput.value = hostId.value
|
||||
} else if (hostOptions.value.length > 0) {
|
||||
hostIdInput.value = hostOptions.value[0].id
|
||||
}
|
||||
if (hostIdInput.value) loadList()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.volume-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.bind-selector-row { display: flex; align-items: center; width: 100%; }
|
||||
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
## 管理员前端控制台 - 问题跟踪
|
||||
|
||||
### 最新一轮 (图一到图七)
|
||||
|
||||
1. ✅已完成 - 图一:安全组列表 "暂无数据" → 修复 `SecurityGroupManage.vue` 数据映射优先 `inner.groups`
|
||||
2. ✅已完成 - 图二:安全组详情弹窗字段为空 → 修复 `SecurityGroupManage.vue` 详情映射优先 `res.data.data.group`
|
||||
3. ✅已完成 - 图三:安全组绑定VM使用 `el-input-number` → 改用 `VmSelectorPopup` 组件
|
||||
4. ✅已完成 - 图四:安全组解绑VM使用 `el-input-number` → 改用 `VmSelectorPopup` 组件
|
||||
5. ✅已完成 - 图五:VNC 获取VM连接使用 `el-input-number` → 改用 `VmSelectorPopup` 组件
|
||||
6. ✅已完成 - 图六:VNC 测试宿主机默认值显示 "0" → 改为 `null` (空选择)
|
||||
7. ✅已完成 - 图七:宿主机、镜像、虚拟机、安全组四个模块表格操作只保留"编辑"和"删除"按钮
|
||||
- 创建 `HostDetail.vue` 宿主机独立详情页 (含编辑、指标、详情)
|
||||
- 创建 `ImageDetail.vue` 镜像独立详情页 (含编辑、同步到宿主机、重下载、宿主机状态)
|
||||
- 创建 `VmDetail.vue` 虚拟机独立详情页 (含电源操作、重建、救援、指标)
|
||||
- 创建 `SecurityGroupDetail.vue` 安全组独立详情页 (含同步、绑定/解绑VM、白名单切换、规则管理)
|
||||
- 添加4个详情页路由 (`host-detail`, `image-detail`, `vm-detail`, `security-group-detail`)
|
||||
- `HostManage.vue` 表格操作改为 "编辑"→跳转详情页 + "删除"
|
||||
- `ImageManage.vue` 表格操作改为 "编辑"→跳转详情页 + "删除"
|
||||
- `VmManage.vue` 表格操作改为 "编辑"→跳转详情页 + "删除",补充 `deleteVm` API
|
||||
- `SecurityGroupManage.vue` 表格操作改为 "编辑"→跳转详情页 + "删除"
|
||||
|
||||
---
|
||||
|
||||
### 前一轮 (图一到图五 - 列表数据显示)
|
||||
|
||||
1. ✅已完成 - 远程宿主机组列表 "暂无数据" → 修复 `RemoteHostGroupManage.vue` 优先 `inner.host_groups`
|
||||
2. ✅已完成 - 宿主机指标弹窗 emoji 图标 → 改为 Element Plus 图标 (`Monitor`, `Coin`, `Box`, `Connection`)
|
||||
3. ✅已完成 - 虚拟机管理 "更多" 下拉按钮错位 → 修复 `el-dropdown` 内联样式对齐
|
||||
4. ✅已完成 - 安全组列表 "暂无数据" → 修复 `SecurityGroupManage.vue` 优先 `inner.groups`
|
||||
5. ✅已完成 - VNC 节点列表 "暂无数据" → 修复 `VncNodeManage.vue` 优先 `inner.items`
|
||||
|
||||
---
|
||||
|
||||
### 虚拟化平台管理 17项问题 (已全部完成)
|
||||
|
||||
1. ✅已完成 - 宿主机管理数据无法正常显示
|
||||
2. ✅已完成 - 宿主机创建/编辑:数字输入框样式优化 + 带宽单位 Mbps
|
||||
3. ✅已完成 - 宿主机创建:宿主机组ID改为选择器
|
||||
4. ✅已完成 - 宿主机详情:SSH密码显示 + 指标美化 + 时间戳格式化
|
||||
5. ✅已完成 - 远程宿主机组管理页面
|
||||
6. ✅已完成 - 镜像详情:data.image 嵌套映射
|
||||
7. ✅已完成 - 镜像同步到宿主机 + 重下载功能
|
||||
8. ✅已完成 - 网络管理:空高级参数不提交 + host_id 改为下拉选择
|
||||
9. ✅已完成 - 数据卷:host_id 改为下拉选择
|
||||
10. ✅已完成 - 数据卷:迁移功能 + 选择器组件
|
||||
11. ✅已完成 - 数据卷详情:data.volume 嵌套映射 + 时间戳
|
||||
12. ✅已完成 - 虚拟机:带宽显示 Mbps 单位
|
||||
13. ✅已完成 - 虚拟机:完整中文状态映射
|
||||
14. ✅已完成 - 虚拟机详情:data.vm 嵌套映射 + 子表格
|
||||
15. ✅已完成 - 虚拟机:恢复/救援/退出救援操作
|
||||
16. ✅已完成 - 虚拟机创建:镜像选择器 + 宿主机组选择器
|
||||
17. ✅已完成 - 虚拟机指标:CPU/内存/磁盘/网络卡片美化
|
||||
Reference in New Issue
Block a user