feat: 对接主控服务接口
Build and Deploy Vue3 / build (push) Successful in 2m29s
Build and Deploy Vue3 / deploy (push) Successful in 1m3s

This commit is contained in:
2026-03-14 15:45:07 +08:00
parent 25975c8b29
commit f4dbf17ce9
21 changed files with 6323 additions and 67 deletions
+425 -1
View File
@@ -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>
+109
View File
@@ -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>
+121
View File
@@ -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'
}
}
+319
View File
@@ -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>
+18 -5
View File
@@ -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;
+581
View File
@@ -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>
+294
View File
@@ -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>
+531
View File
@@ -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>
+8 -61
View File
@@ -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>
+331
View File
@@ -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>
+319
View File
@@ -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>
+618
View File
@@ -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>
+352
View File
@@ -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>
+467
View File
@@ -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>
+52
View File
@@ -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/内存/磁盘/网络卡片美化