diff --git a/src/api/admin/kvmService.js b/src/api/admin/kvmService.js index 8fb1fe1..51dbfc9 100644 --- a/src/api/admin/kvmService.js +++ b/src/api/admin/kvmService.js @@ -225,6 +225,11 @@ export const reloadImageOnHost = (data) => { }) } +/** 获取宿主机镜像列表与状态(对比) */ +export const getImageCompareHost = (params) => { + return http2.get('/api/v1/admin/server/host_service/point/image/compare_host', { params }) +} + /** * ================================ * 主控服务接口 - 网络管理 diff --git a/src/views/virtualization/BackupManage.vue b/src/views/virtualization/BackupManage.vue index 69cb67b..dce1861 100644 --- a/src/views/virtualization/BackupManage.vue +++ b/src/views/virtualization/BackupManage.vue @@ -11,7 +11,7 @@ @@ -22,12 +22,18 @@ +
+ +
@@ -49,15 +55,26 @@ - +
- {{ val }} + + {{ progressData.task_id || '-' }} + + + {{ taskStatusLabel(progressData.status) }} + +
@@ -74,8 +91,11 @@ const serviceId = inject('serviceId') const loading = ref(false) const submitLoading = ref(false) const list = ref([]) +const total = ref(0) +const currentPage = ref(1) +const pageSize = ref(10) -const statusLabel = (s) => ({ completed: '完成', pending: '等待', running: '运行中', failed: '失败' }[s] || s || '-') +const statusLabel = (s) => ({ completed: '完成', ready: '完成', success: '成功', pending: '等待', running: '运行中', failed: '失败', error: '错误' }[s] || s || '-') const formatTs = (ts) => { if (!ts) return '-' if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN') @@ -85,12 +105,13 @@ const formatTs = (ts) => { const loadList = async () => { loading.value = true try { - const res = await getBackupList({ service_id: serviceId.value }) + const res = await getBackupList({ service_id: serviceId.value, page: currentPage.value, page_size: pageSize.value }) if (res?.data?.code === 200 && res?.data?.data) { const d = res.data.data list.value = d.backups || d.data || d.list || (Array.isArray(d) ? d : []) - } else list.value = [] - } catch { list.value = [] } finally { loading.value = false } + total.value = d.meta?.count ?? d.all_count ?? d.total ?? list.value.length + } else { list.value = []; total.value = 0 } + } catch { list.value = []; total.value = 0 } finally { loading.value = false } } const vmOptions = ref([]) @@ -166,15 +187,35 @@ const handleDelete = (row) => { const progressVisible = ref(false) const progressLoading = ref(false) const progressData = ref(null) +const progressRow = ref(null) + +const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', success: 'success', failed: 'danger', error: 'danger', pending: 'info', cancelled: 'warning' }[s] || 'info') +const taskStatusLabel = (s) => ({ running: '运行中', completed: '已完成', ready: '已完成', success: '成功', failed: '失败', error: '错误', pending: '等待中', cancelled: '已取消' }[s] || s || '-') +const metaLabelMap = { vm_name: '虚拟机名称', backup_path: '备份路径', snapshot_path: '快照路径', path: '路径', progress: '进度', message: '信息', error: '错误信息' } + +const parsedMeta = computed(() => { + if (!progressData.value?.meta) return null + const raw = progressData.value.meta + if (typeof raw === 'object') return raw + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed || trimmed === '""' || trimmed === '{}') return null + try { return JSON.parse(trimmed) } catch { return { 信息: raw } } + } + return null +}) const handleProgress = async (row) => { + progressRow.value = row progressData.value = null progressVisible.value = true progressLoading.value = true try { const res = await getBackupProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) }) - if (res?.data?.code === 200) progressData.value = res.data.data - else ElMessage.warning('暂无进度信息') + if (res?.data?.code === 200) { + const d = res.data.data + progressData.value = d?.data ?? d + } else ElMessage.warning('暂无进度信息') } catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false } } @@ -184,4 +225,5 @@ onMounted(() => { loadList() }) diff --git a/src/views/virtualization/HostDetail.vue b/src/views/virtualization/HostDetail.vue index a0d7d01..5e400e2 100644 --- a/src/views/virtualization/HostDetail.vue +++ b/src/views/virtualization/HostDetail.vue @@ -72,27 +72,36 @@
认证Token - + 未设置
SSH 密码 - + 未设置
- 私钥路径 - {{ detail.private_key_path || '-' }} + 私钥 + + + 未设置 +
@@ -176,6 +185,12 @@ + + + + + +
@@ -190,7 +205,7 @@ - + 资源限制 @@ -252,6 +267,8 @@ import ImageManage from '@/views/virtualization/ImageManage.vue' import NetworkManage from '@/views/virtualization/NetworkManage.vue' import VolumeManage from '@/views/virtualization/VolumeManage.vue' import VmManage from '@/views/virtualization/VmManage.vue' +import SnapshotManage from '@/views/virtualization/SnapshotManage.vue' +import BackupManage from '@/views/virtualization/BackupManage.vue' import { useTagsViewStore } from '@/store/tagsViewStore' import * as echarts from 'echarts' @@ -264,7 +281,7 @@ const serviceName = computed(() => route.query.service_name || '') const hostId = computed(() => parseInt(route.query.id) || 0) const activeTab = ref('info') -const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false }) +const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false }) watch(activeTab, (tab) => { if (!['info', 'monitor'].includes(tab) && !hostTabLoaded[tab]) hostTabLoaded[tab] = true @@ -283,13 +300,34 @@ const metricsLoading = ref(false) const detail = ref(null) const showToken = ref(false) const showPassword = ref(false) +const showPrivateKey = ref(false) +const copyText = (text) => { + if (!text) return + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制到剪贴板')).catch(() => fallbackCopy(text)) + } else { + fallbackCopy(text) + } +} +const fallbackCopy = (text) => { + const ta = document.createElement('textarea') + ta.value = text + ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px' + document.body.appendChild(ta) + ta.select() + try { + document.execCommand('copy') + ElMessage.success('已复制到剪贴板') + } catch { ElMessage.error('复制失败') } + document.body.removeChild(ta) +} 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: '', + name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' }) const formRules = { @@ -501,7 +539,7 @@ const handleEdit = () => { 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 || '', + port: d.port || 22, user: d.user || '', password: d.password || '', private_key: d.private_key || '', 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 || '' @@ -517,7 +555,7 @@ const handleSubmit = () => { 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.private_key) delete payload.private_key if (!payload.description) delete payload.description if (!payload.host_group_id) delete payload.host_group_id const res = await updateRemoteHost(payload) @@ -554,6 +592,7 @@ const initPage = () => { detail.value = null showToken.value = false showPassword.value = false + showPrivateKey.value = false metricsData.value = null metricsHistory.times.length = 0 metricsHistory.cpu.length = 0 @@ -616,6 +655,8 @@ onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() }) .config-cell:last-child { border-right: none; } .config-label { display: block; font-size: 12px; color: #86909c; margin-bottom: 4px; } .config-value { display: block; font-size: 14px; color: #1d2129; word-break: break-all; } +.secret-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } +.secret-cell code { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono-text { font-family: 'Consolas', 'Monaco', monospace; } .text-muted { color: #c0c4cc; } diff --git a/src/views/virtualization/ImageDetail.vue b/src/views/virtualization/ImageDetail.vue index 359b674..d0e4bc8 100644 --- a/src/views/virtualization/ImageDetail.vue +++ b/src/views/virtualization/ImageDetail.vue @@ -187,7 +187,7 @@ const loadHostOptions = async () => { 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 : []) + hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || []) } } catch { /* */ } } diff --git a/src/views/virtualization/ImageManage.vue b/src/views/virtualization/ImageManage.vue index df8202b..d3a71ea 100644 --- a/src/views/virtualization/ImageManage.vue +++ b/src/views/virtualization/ImageManage.vue @@ -23,9 +23,9 @@ - + @@ -61,6 +61,17 @@ {{ statusLabel(row.status) }}
+ + + + + + @@ -69,7 +80,6 @@ @@ -218,7 +228,7 @@ 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, + getImageList, getImageCompareHost, getImageDetail, getImageHostStatus, createImage, updateImage, deleteImage, reloadImage, syncImageToHost, reloadImageOnHost, getRemoteHostList } from '@/api/admin/kvmService' import { extractApiError } from '@/utils/kvmErrorUtil' @@ -228,6 +238,7 @@ const router = useRouter() const embedded = inject('embedded', false) const injectedServiceId = inject('serviceId', null) const injectedServiceName = inject('serviceName', null) +const injectedHostId = inject('hostId', null) const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0) const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '') @@ -275,6 +286,8 @@ const formRules = { const statusType = (s) => ({ ready: 'success', success: 'success', downloading: 'warning', pending: 'info', error: 'danger', failed: 'danger', not_found: 'warning' }[s] || 'info') const statusLabel = (s) => ({ ready: '就绪', success: '已同步', downloading: '下载中', pending: '等待中', error: '错误', failed: '失败', not_found: '未同步' }[s] || s || '-') +const syncStatusType = (s) => ({ synced: 'success', not_synced: 'warning', downloading: 'primary', error: 'danger', pending: 'info' }[s] || 'info') +const syncStatusLabel = (s) => ({ synced: '已同步', not_synced: '未同步', downloading: '同步中', error: '同步错误', pending: '等待同步' }[s] || s || '-') const formatSize = (bytes) => { if (!bytes) return '0 B' const units = ['B', 'KB', 'MB', 'GB', 'TB'] @@ -320,19 +333,33 @@ 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) + let res + if (injectedHostId?.value) { + res = await getImageCompareHost({ service_id: serviceId.value, host_id: injectedHostId.value }) + } else { + 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 + 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 + if (injectedHostId?.value && Array.isArray(inner.data)) { + imageList.value = inner.data.map(item => ({ + ...(item.image || {}), + sync_status: item.sync_status || '', + host_status: item.host_status || '' + })) + total.value = inner.total ?? imageList.value.length + } else { + 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 @@ -449,9 +476,10 @@ const handleViewDetail = async (row) => { } // 同步镜像到宿主机 -const handleSyncToHost = (row) => { +const handleSyncToHost = async (row) => { syncTarget.value = row syncHostId.value = '' + if (!hostOptions.value.length) await loadHostOptions() syncDialogVisible.value = true } @@ -493,9 +521,10 @@ const handleReloadMaster = (row) => { }).catch(() => {}) } -const handleReloadOnHost = (row) => { +const handleReloadOnHost = async (row) => { reloadTarget.value = row reloadHostId.value = '' + if (!hostOptions.value.length) await loadHostOptions() reloadDialogVisible.value = true } @@ -543,7 +572,7 @@ const goBack = () => { router.push('/virtualization/kvm-service') } onMounted(() => { if (serviceId.value) { loadList() - loadHostOptions() + if (!injectedHostId?.value) loadHostOptions() } }) diff --git a/src/views/virtualization/KvmServiceDetail.vue b/src/views/virtualization/KvmServiceDetail.vue index 8a4aa4f..1729f40 100644 --- a/src/views/virtualization/KvmServiceDetail.vue +++ b/src/views/virtualization/KvmServiceDetail.vue @@ -178,7 +178,9 @@ const tabLoaded = reactive({ 'volume': false, 'vm': false, 'security': false, - 'vnc': false + 'vnc': false, + 'snapshot': false, + 'backup': false }) diff --git a/src/views/virtualization/NetworkManage.vue b/src/views/virtualization/NetworkManage.vue index d21349b..9f3ddfc 100644 --- a/src/views/virtualization/NetworkManage.vue +++ b/src/views/virtualization/NetworkManage.vue @@ -27,7 +27,7 @@ - + @@ -151,8 +151,9 @@ const router = useRouter() const embedded = inject('embedded', false) const injectedServiceId = inject('serviceId', null) const injectedServiceName = inject('serviceName', null) +const injectedHostId = inject('hostId', null) const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0) -const hostId = computed(() => parseInt(route.query.host_id) || 0) +const hostId = computed(() => injectedHostId?.value || parseInt(route.query.host_id) || 0) const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '') const loading = ref(false) @@ -181,7 +182,7 @@ const loadHostOptions = async () => { const body = res?.data if (body?.code === 200 && body?.data) { const inner = body.data - hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : []) + hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || []) } } catch (e) { console.error('加载宿主机列表失败:', e) } } diff --git a/src/views/virtualization/SecurityGroupDetail.vue b/src/views/virtualization/SecurityGroupDetail.vue index c38101c..1246665 100644 --- a/src/views/virtualization/SecurityGroupDetail.vue +++ b/src/views/virtualization/SecurityGroupDetail.vue @@ -19,10 +19,9 @@ {{ detail.id }} {{ detail.name }} -
- {{ detail.drop_all ? '开启(拦截所有未放行流量)' : '关闭' }} - 开启白名单 - 关闭白名单 +
+ + 拦截所有未放行流量
@@ -244,7 +243,7 @@ const loadHostOptions = async () => { 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 : []) + hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || []) } } catch { /* */ } } diff --git a/src/views/virtualization/SecurityGroupManage.vue b/src/views/virtualization/SecurityGroupManage.vue index 6fe4e69..3a21e67 100644 --- a/src/views/virtualization/SecurityGroupManage.vue +++ b/src/views/virtualization/SecurityGroupManage.vue @@ -52,10 +52,23 @@ {{ row.direction === 'in' ? '入站' : row.direction === 'out' ? '出站' : (row.direction || '-') }} - + @@ -220,7 +233,7 @@ import { ref, reactive, computed, inject, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue' +import { Plus, Refresh, Search, ArrowLeft, ArrowDown } from '@element-plus/icons-vue' import { getRemoteHostList, getSecurityGroupList, getSecurityGroupDetail, createSecurityGroup, @@ -261,7 +274,7 @@ const loadHostOptions = async () => { const body = res?.data if (body?.code === 200 && body?.data) { const inner = body.data - hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : []) + hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || []) } } catch (e) { console.error('加载宿主机列表失败:', e) } } @@ -500,6 +513,14 @@ const handleDeleteRule = (rule) => { }).catch(() => {}) } +const handleRowMore = (row, command) => { + if (command === 'bind') handleBind(row) + else if (command === 'unbind') handleUnbind(row) + else if (command === 'whitelist') handleToggleWhitelist(row) + else if (command === 'detail') handleViewDetail(row) + else if (command === 'delete') handleDelete(row) +} + const goBack = () => { router.push('/virtualization/kvm-service') } onMounted(async () => { diff --git a/src/views/virtualization/SnapshotManage.vue b/src/views/virtualization/SnapshotManage.vue index 59d4392..6273fce 100644 --- a/src/views/virtualization/SnapshotManage.vue +++ b/src/views/virtualization/SnapshotManage.vue @@ -11,7 +11,7 @@ @@ -22,12 +22,18 @@ +
+ +
@@ -49,15 +55,26 @@ - +
- {{ val }} + + {{ progressData.task_id || '-' }} + + + {{ taskStatusLabel(progressData.status) }} + +
@@ -74,8 +91,11 @@ const serviceId = inject('serviceId') const loading = ref(false) const submitLoading = ref(false) const list = ref([]) +const total = ref(0) +const currentPage = ref(1) +const pageSize = ref(10) -const statusLabel = (s) => ({ completed: '完成', pending: '等待', running: '运行中', failed: '失败' }[s] || s || '-') +const statusLabel = (s) => ({ completed: '完成', ready: '完成', success: '成功', pending: '等待', running: '运行中', failed: '失败', error: '错误' }[s] || s || '-') const formatTs = (ts) => { if (!ts) return '-' if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN') @@ -85,12 +105,13 @@ const formatTs = (ts) => { const loadList = async () => { loading.value = true try { - const res = await getSnapshotList({ service_id: serviceId.value }) + const res = await getSnapshotList({ service_id: serviceId.value, page: currentPage.value, page_size: pageSize.value }) if (res?.data?.code === 200 && res?.data?.data) { const d = res.data.data list.value = d.snapshots || d.data || d.list || (Array.isArray(d) ? d : []) - } else list.value = [] - } catch { list.value = [] } finally { loading.value = false } + total.value = d.meta?.count ?? d.all_count ?? d.total ?? list.value.length + } else { list.value = []; total.value = 0 } + } catch { list.value = []; total.value = 0 } finally { loading.value = false } } const vmOptions = ref([]) @@ -166,15 +187,35 @@ const handleDelete = (row) => { const progressVisible = ref(false) const progressLoading = ref(false) const progressData = ref(null) +const progressRow = ref(null) + +const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', success: 'success', failed: 'danger', error: 'danger', pending: 'info', cancelled: 'warning' }[s] || 'info') +const taskStatusLabel = (s) => ({ running: '运行中', completed: '已完成', ready: '已完成', success: '成功', failed: '失败', error: '错误', pending: '等待中', cancelled: '已取消' }[s] || s || '-') +const metaLabelMap = { vm_name: '虚拟机名称', backup_path: '备份路径', snapshot_path: '快照路径', path: '路径', progress: '进度', message: '信息', error: '错误信息' } + +const parsedMeta = computed(() => { + if (!progressData.value?.meta) return null + const raw = progressData.value.meta + if (typeof raw === 'object') return raw + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed || trimmed === '""' || trimmed === '{}') return null + try { return JSON.parse(trimmed) } catch { return { 信息: raw } } + } + return null +}) const handleProgress = async (row) => { + progressRow.value = row progressData.value = null progressVisible.value = true progressLoading.value = true try { const res = await getSnapshotProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) }) - if (res?.data?.code === 200) progressData.value = res.data.data - else ElMessage.warning('暂无进度信息') + if (res?.data?.code === 200) { + const d = res.data.data + progressData.value = d?.data ?? d + } else ElMessage.warning('暂无进度信息') } catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false } } @@ -184,4 +225,5 @@ onMounted(() => { loadList() }) diff --git a/src/views/virtualization/VmDetail.vue b/src/views/virtualization/VmDetail.vue index 36fcd17..2554a6b 100644 --- a/src/views/virtualization/VmDetail.vue +++ b/src/views/virtualization/VmDetail.vue @@ -96,9 +96,10 @@
安全组 - 绑定 - - 解绑 + + 未绑定
@@ -120,6 +121,20 @@ +
+
+ 流量上限(GB) + {{ detail.traffic_max ?? '-' }} +
+
+ 快照配额 + {{ detail.snapshot_num ?? '-' }} +
+
+ 备份配额 + {{ detail.backup_num ?? '-' }} +
+
UUID @@ -236,6 +251,72 @@
+ +
+
+

快照管理

+
+ 创建快照 + 刷新 +
+
+ + + + + + + + + + + + + + + +
+
+ + +
+
+

备份管理

+
+ 创建备份 + 刷新 +
+
+ + + + + + + + + + + + + + + +
+
+
@@ -246,11 +327,17 @@
- +
+
CPU 使用率
+
{{ (metricsData.cpu_usage_percent ?? 0).toFixed(1) }}%
+
网络速率
-
- {{ key }}: {{ val }} +
+
+ {{ key === 'rx_bytes' ? '↓ 接收' : key === 'tx_bytes' ? '↑ 发送' : key }} + {{ formatNetSpeed(val) }} +
@@ -259,7 +346,10 @@

CPU 使用率

- +
+

网络速率

+
+
@@ -287,21 +377,121 @@ + + + + {{ detail?.name || '-' }} (ID: {{ vmId }}) + + + + + + + + + + + + + + {{ detail?.name || '-' }} (ID: {{ vmId }}) + + + + + + + + + + + + +
+ + + {{ taskProgressData.task_id || '-' }} + + + {{ snapshotStatusLabel(taskProgressData.status) }} + + + + +
+ +
+ - + - - + + - - + +
+ + + + + + ({{ editForm.memory }} KB) +
+ + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -321,7 +511,7 @@ - + @@ -472,8 +662,8 @@ - - + +
@@ -500,7 +690,7 @@
- + @@ -569,7 +759,9 @@ import { bindSecurityGroup, unbindSecurityGroup, getSecurityGroupList, createNetwork, updateNetwork, deleteNetwork, getNetworkList, createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume, getVolumeList, - getVmList + getVmList, + getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, + getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress } from '@/api/admin/kvmService' import { extractApiError } from '@/utils/kvmErrorUtil' import * as echarts from 'echarts' @@ -582,7 +774,7 @@ const tagsViewStore = useTagsViewStore() const serviceId = computed(() => parseInt(route.query.service_id) || 0) const serviceName = computed(() => route.query.service_name || '') -const vmId = computed(() => parseInt(route.query.id) || 0) +const vmId = computed(() => parseInt(route.query.vm_id) || parseInt(route.query.id) || 0) const loading = ref(false) const actionLoading = ref(false) @@ -592,6 +784,7 @@ const detail = ref(null) const vmNetworks = ref([]) const vmVolumes = ref([]) const vmImage = ref(null) +const vmPortGroup = ref(null) const metricsData = ref(null) const hostOptions = ref([]) const rebuildDialogVisible = ref(false) @@ -602,8 +795,24 @@ const activeTab = ref('info') const showPassword = ref(false) const copyText = (text) => { - if (!text) return - navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制')).catch(() => ElMessage.error('复制失败')) + if (!text) { ElMessage.warning('无内容可复制'); return } + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制到剪贴板')).catch(() => fallbackCopy(text)) + } else { + fallbackCopy(text) + } +} +const fallbackCopy = (text) => { + const ta = document.createElement('textarea') + ta.value = text + ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0' + document.body.appendChild(ta) + ta.select() + try { + document.execCommand('copy') + ElMessage.success('已复制到剪贴板') + } catch { ElMessage.error('复制失败,请手动复制') } + document.body.removeChild(ta) } const handleMoreCommand = (cmd) => { @@ -622,7 +831,8 @@ const vmStatusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: const imgStatusType = (s) => ({ ready: 'success', downloading: 'warning', pending: 'info', error: 'danger' }[s] || 'info') const imgStatusLabel = (s) => ({ ready: '就绪', downloading: '下载中', pending: '等待中', error: '错误' }[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 formatMemory = (kb) => { if (!kb) return '-'; kb = Number(kb); if (kb >= 1073741824) return (kb / 1073741824).toFixed(1) + ' TB'; if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'; if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'; return kb + ' KB' } +const formatNetSpeed = (bytes) => { if (bytes == null) return '-'; const n = Number(bytes); if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB/s'; if (n >= 1048576) return (n / 1048576).toFixed(2) + ' MB/s'; if (n >= 1024) return (n / 1024).toFixed(2) + ' KB/s'; return n + ' B/s' } const formatTimestamp = (ts) => { if (!ts) return '-' if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN') @@ -636,7 +846,7 @@ const loadHostOptions = async () => { 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 : []) + hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || []) } } catch { /* */ } } @@ -652,10 +862,37 @@ const loadDetail = async () => { vmNetworks.value = d.networks || [] vmVolumes.value = d.volumes || [] vmImage.value = d.image || null + vmPortGroup.value = d.in_port_group || null } else ElMessage.error(extractApiError(res?.data, '加载失败')) } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false } } +const loadVmVolumes = async () => { + if (!detail.value) return + const hid = detail.value.host_id + if (!hid) return + try { + const res = await getVolumeList({ service_id: serviceId.value, host_id: hid, vm_id: vmId.value, page: 1, count: 200 }) + if (res?.data?.code === 200 && res?.data?.data) { + const inner = res.data.data + vmVolumes.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : []) + } + } catch { /* */ } +} + +const loadVmNetworks = async () => { + if (!detail.value) return + const hid = detail.value.host_id + if (!hid) return + try { + const res = await getNetworkList({ service_id: serviceId.value, host_id: hid, page: 1, page_size: 200 }) + if (res?.data?.code === 200 && res?.data?.data) { + const inner = res.data.data + vmNetworks.value = inner.data || inner.networks || (Array.isArray(inner) ? inner : []) + } + } catch { /* */ } +} + const fetchVmStatus = async () => { if (!detail.value) return statusLoading.value = true @@ -697,7 +934,7 @@ const fetchVmMetrics = async () => { const pushHistory = (d) => { const now = new Date().toLocaleTimeString('zh-CN', { hour12: false }) metricsHistory.times.push(now) - metricsHistory.cpu.push(d.cpu_usage_percent /100 ?? 0) + metricsHistory.cpu.push(d.cpu_usage_percent ?? 0) if (d.internet_speed && typeof d.internet_speed === 'object') { for (const key of Object.keys(d.internet_speed)) { if (!metricsHistory.netSeries[key]) { @@ -744,10 +981,10 @@ const renderCharts = () => { if (cpuChartRef.value) { if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value) cpuChart.setOption({ - tooltip: { trigger: 'axis', formatter: (params) => `${params[0].axisValue}
${params[0].marker} CPU: ${(params[0].value * 100).toFixed(1)}%` }, + tooltip: { trigger: 'axis', formatter: (params) => `${params[0].axisValue}
${params[0].marker} CPU: ${Number(params[0].value).toFixed(2)}%` }, grid: { top: 10, right: 16, bottom: 24, left: 50 }, xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } }, - yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => (v * 100).toFixed(0) + '%' } }, + yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v.toFixed(1) + '%' } }, series: [{ name: 'CPU', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' }, data: cpuData }] }, true) } @@ -762,7 +999,10 @@ const renderCharts = () => { netChart.setOption({ tooltip: { trigger: 'axis', formatter: (params) => { let s = params[0]?.axisValue || '' - params.forEach(p => { s += `
${p.marker} ${p.seriesName}: ${p.value}` }) + params.forEach(p => { + const label = p.seriesName === 'rx_bytes' ? '↓ 接收' : p.seriesName === 'tx_bytes' ? '↑ 发送' : p.seriesName + s += `
${p.marker} ${label}: ${formatNetSpeed(p.value)}` + }) return s }}, grid: { top: 10, right: 16, bottom: 24, left: 50 }, @@ -865,12 +1105,41 @@ const handleExitRescue = () => { // ---- 编辑虚拟机 ---- const editDialogVisible = ref(false) const editFormRef = ref(null) -const editForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, root_password: '', ssh_port: 22, port_group_id: 0 }) +const editForm = reactive({ + name: '', memory: 0, vcpu: 1, + rx_bandwidth: 0, tx_bandwidth: 0, + root_password: '', ssh_port: 22, + traffic_max: 0, snapshot_num: 0, backup_num: 0, + port_group_id: 0 +}) +const editMemoryUnit = ref('MB') +const editMemUnitFactor = () => editMemoryUnit.value === 'GB' ? 1048576 : 1024 +const editMemoryDisplay = computed({ + get: () => { + const f = editMemUnitFactor() + const v = editForm.memory / f + return f === 1048576 ? parseFloat(v.toFixed(2)) : Math.round(v) + }, + set: (v) => { editForm.memory = Math.round(v * editMemUnitFactor()) } +}) const handleEditVm = async () => { if (!detail.value) return const d = detail.value - Object.assign(editForm, { rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0, root_password: '', ssh_port: d.ssh_port || 22, port_group_id: null }) + const mem = d.memory || 0 + editMemoryUnit.value = mem >= 1048576 ? 'GB' : 'MB' + Object.assign(editForm, { + name: d.name || '', + memory: mem, vcpu: d.vcpu || 1, + rx_bandwidth: d.rx_bandwidth || 0, + tx_bandwidth: d.tx_bandwidth || 0, + root_password: d.root_password || '', + ssh_port: d.ssh_port || 22, + traffic_max: d.traffic_max || 0, + snapshot_num: d.snapshot_num || 0, + backup_num: d.backup_num || 0, + port_group_id: vmPortGroup.value?.id || null + }) if (!sgOptions.value.length) await loadSgOptions() editDialogVisible.value = true } @@ -881,9 +1150,15 @@ const submitEditVm = async () => { const fd = new FormData() fd.append('service_id', serviceId.value) fd.append('vm_id', vmId.value) + if (editForm.name) fd.append('name', editForm.name) + fd.append('memory', editForm.memory) + fd.append('vcpu', editForm.vcpu) fd.append('rx_bandwidth', editForm.rx_bandwidth) fd.append('tx_bandwidth', editForm.tx_bandwidth) fd.append('ssh_port', editForm.ssh_port) + fd.append('traffic_max', editForm.traffic_max) + fd.append('snapshot_num', editForm.snapshot_num) + fd.append('backup_num', editForm.backup_num) if (editForm.root_password) fd.append('root_password', editForm.root_password) if (editForm.port_group_id) fd.append('port_group_id', editForm.port_group_id) const res = await updateVm(fd) @@ -900,7 +1175,13 @@ const refactorForm = reactive({ memory: 0, vcpu: 0, rx_bandwidth: 0, tx_bandwidt const handleRefactorVm = async () => { if (!detail.value) return const d = detail.value - Object.assign(refactorForm, { memory: d.memory || 0, vcpu: d.vcpu || 0, rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0, root_password: '', ssh_port: d.ssh_port || 0, vnc_port: 0, vnc_password: '', port_group_id: null }) + Object.assign(refactorForm, { + memory: d.memory || 0, vcpu: d.vcpu || 0, + rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0, + root_password: '', ssh_port: d.ssh_port || 0, + vnc_port: 0, vnc_password: '', + port_group_id: vmPortGroup.value?.id || null + }) if (!sgOptions.value.length) await loadSgOptions() refactorDialogVisible.value = true } @@ -1089,7 +1370,7 @@ const loadAvailableNetworks = async (hostId) => { if (!hostId) return netOptionsLoading.value = true try { - const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, page: 1, page_size: 200 }) + const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, used: false, page: 1, page_size: 200 }) if (res?.data?.code === 200 && res?.data?.data) { const inner = res.data.data availableNetworks.value = inner.networks || inner.data || (Array.isArray(inner) ? inner : []) @@ -1311,6 +1592,182 @@ const submitTransferVolume = async () => { } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false } } +// ---- 快照/备份管理 ---- +const snapshotList = ref([]) +const snapshotLoading = ref(false) +const backupList = ref([]) +const backupLoading = ref(false) +const snapshotCreateVisible = ref(false) +const backupCreateVisible = ref(false) +const snapshotForm = reactive({ name: '', description: '' }) +const backupForm = reactive({ name: '', description: '' }) +const taskProgressVisible = ref(false) +const taskProgressLoading = ref(false) +const taskProgressData = ref(null) +const taskProgressTitle = ref('') + +const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', success: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info') +const snapshotStatusLabel = (s) => ({ completed: '完成', ready: '完成', success: '成功', pending: '等待', running: '运行中', failed: '失败', error: '错误' }[s] || s || '-') +const taskMetaLabel = (key) => ({ vm_name: '虚拟机名称', backup_path: '备份路径', snapshot_path: '快照路径', path: '路径', progress: '进度', message: '信息', error: '错误信息' }[key] || key) + +const taskProgressMeta = computed(() => { + if (!taskProgressData.value?.meta) return null + const raw = taskProgressData.value.meta + if (typeof raw === 'object') return raw + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed || trimmed === '""' || trimmed === '{}') return null + try { return JSON.parse(trimmed) } catch { return { 信息: raw } } + } + return null +}) + +const loadSnapshots = async () => { + snapshotLoading.value = true + try { + const res = await getSnapshotList({ service_id: serviceId.value }) + if (res?.data?.code === 200 && res?.data?.data) { + const d = res.data.data + const all = d.snapshots || d.data || d.list || (Array.isArray(d) ? d : []) + snapshotList.value = all.filter(s => s.vm_id === vmId.value || s.vm_id === String(vmId.value)) + } else snapshotList.value = [] + } catch { snapshotList.value = [] } finally { snapshotLoading.value = false } +} + +const loadBackups = async () => { + backupLoading.value = true + try { + const res = await getBackupList({ service_id: serviceId.value }) + if (res?.data?.code === 200 && res?.data?.data) { + const d = res.data.data + const all = d.backups || d.data || d.list || (Array.isArray(d) ? d : []) + backupList.value = all.filter(b => b.vm_id === vmId.value || b.vm_id === String(vmId.value)) + } else backupList.value = [] + } catch { backupList.value = [] } finally { backupLoading.value = false } +} + +const handleCreateSnapshot = () => { + Object.assign(snapshotForm, { name: '', description: '' }) + snapshotCreateVisible.value = true +} +const submitCreateSnapshot = async () => { + if (!snapshotForm.name) { ElMessage.warning('请输入快照名称'); return } + actionLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('vm_id', vmId.value) + fd.append('name', snapshotForm.name) + if (snapshotForm.description) fd.append('description', snapshotForm.description) + const res = await createSnapshot(fd) + if (res?.data?.code === 200) { ElMessage.success('快照创建成功'); snapshotCreateVisible.value = false; loadSnapshots() } + else ElMessage.error(extractApiError(res?.data, '创建失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false } +} + +const handleRestoreSnapshot = (row) => { + ElMessageBox.confirm(`确定要恢复快照「${row.name}」吗?`, '恢复确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }) + .then(async () => { + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('snapshot_id', row.id) + fd.append('vm_id', vmId.value) + const res = await restoreSnapshot(fd) + if (res?.data?.code === 200) ElMessage.success('恢复操作已提交') + else ElMessage.error(extractApiError(res?.data, '恢复失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复失败')) } + }).catch(() => {}) +} + +const handleDeleteSnapshot = (row) => { + ElMessageBox.confirm(`确定要删除快照「${row.name}」吗?`, '删除确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }) + .then(async () => { + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('snapshot_id', row.id) + fd.append('vm_id', row.vm_id) + const res = await deleteSnapshot(fd) + if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSnapshots() } + else ElMessage.error(extractApiError(res?.data, '删除失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) } + }).catch(() => {}) +} + +const handleSnapshotProgress = async (row) => { + taskProgressTitle.value = '快照任务进度' + taskProgressData.value = null + taskProgressVisible.value = true + taskProgressLoading.value = true + try { + const res = await getSnapshotProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) }) + if (res?.data?.code === 200) taskProgressData.value = res.data.data?.data ?? res.data.data + else ElMessage.warning('暂无进度信息') + } catch { ElMessage.warning('获取进度失败') } finally { taskProgressLoading.value = false } +} + +const handleCreateBackup = () => { + Object.assign(backupForm, { name: '', description: '' }) + backupCreateVisible.value = true +} +const submitCreateBackup = async () => { + if (!backupForm.name) { ElMessage.warning('请输入备份名称'); return } + actionLoading.value = true + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('vm_id', vmId.value) + fd.append('name', backupForm.name) + if (backupForm.description) fd.append('description', backupForm.description) + const res = await createBackup(fd) + if (res?.data?.code === 200) { ElMessage.success('备份创建成功'); backupCreateVisible.value = false; loadBackups() } + else ElMessage.error(extractApiError(res?.data, '创建失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false } +} + +const handleRestoreBackup = (row) => { + ElMessageBox.confirm(`确定要恢复备份「${row.name}」吗?`, '恢复确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }) + .then(async () => { + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('backup_id', row.id) + fd.append('vm_id', vmId.value) + const res = await restoreBackup(fd) + if (res?.data?.code === 200) ElMessage.success('恢复操作已提交') + else ElMessage.error(extractApiError(res?.data, '恢复失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复失败')) } + }).catch(() => {}) +} + +const handleDeleteBackup = (row) => { + ElMessageBox.confirm(`确定要删除备份「${row.name}」吗?`, '删除确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }) + .then(async () => { + try { + const fd = new FormData() + fd.append('service_id', serviceId.value) + fd.append('backup_id', row.id) + fd.append('vm_id', row.vm_id) + const res = await deleteBackup(fd) + if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadBackups() } + else ElMessage.error(extractApiError(res?.data, '删除失败')) + } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) } + }).catch(() => {}) +} + +const handleBackupProgress = async (row) => { + taskProgressTitle.value = '备份任务进度' + taskProgressData.value = null + taskProgressVisible.value = true + taskProgressLoading.value = true + try { + const res = await getBackupProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) }) + if (res?.data?.code === 200) taskProgressData.value = res.data.data?.data ?? res.data.data + else ElMessage.warning('暂无进度信息') + } catch { ElMessage.warning('获取进度失败') } finally { taskProgressLoading.value = false } +} + const goBack = () => { tagsViewStore.delVisitedView(route) router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } }) @@ -1333,7 +1790,11 @@ watch(vmId, () => { if (isPageActive) initPage() }) watch(activeTab, (tab) => { if (tab === 'monitor' && detail.value) startPolling() else stopPolling() + if (tab === 'network') loadVmNetworks() + if (tab === 'volume') loadVmVolumes() if (tab === 'security') loadVmSecurityGroups() + if (tab === 'snapshot') loadSnapshots() + if (tab === 'backup') loadBackups() }) onActivated(() => { isPageActive = true @@ -1405,5 +1866,10 @@ onMounted(() => { isPageActive = true; initPage() }) .chart-label { margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #4e5969; } .chart-container { width: 100%; height: 220px; } +.net-speed-items { display: flex; gap: 20px; flex-wrap: wrap; } +.net-speed-item { display: flex; flex-direction: column; gap: 2px; } +.net-speed-label { font-size: 12px; color: #86909c; } +.net-speed-value { font-size: 20px; font-weight: 600; color: #1d2129; } + .vnc-result { margin-top: 12px; } diff --git a/src/views/virtualization/VmManage.vue b/src/views/virtualization/VmManage.vue index 1ac1dd2..8454934 100644 --- a/src/views/virtualization/VmManage.vue +++ b/src/views/virtualization/VmManage.vue @@ -37,7 +37,7 @@
{{ row.vcpu }}核 {{ formatMemory(row.memory) }} - {{ row.system_size }}MB盘 + {{ row.system_size }}GB盘
@@ -58,10 +58,26 @@
- + @@ -108,7 +124,7 @@
选择 - 清除 + 清除
@@ -119,20 +135,20 @@ - +
* 系统盘 - +
* CPU(核) - +
下行带宽(Mbps) @@ -155,10 +171,10 @@ - - + + -
请先选择宿主机以加载可用网络
+
请先选择宿主机以加载可用网络(仅显示未使用的网络)
@@ -207,8 +204,9 @@ const router = useRouter() const embedded = inject('embedded', false) const injectedServiceId = inject('serviceId', null) const injectedServiceName = inject('serviceName', null) +const injectedHostId = inject('hostId', null) const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0) -const hostId = computed(() => parseInt(route.query.host_id) || 0) +const hostId = computed(() => injectedHostId?.value || parseInt(route.query.host_id) || 0) const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '') const loading = ref(false) @@ -217,15 +215,9 @@ 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 || '-') @@ -245,11 +237,13 @@ const formatTimestamp = (ts) => { const loadHostOptions = async () => { try { - const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 }) + 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 - hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : []) + const items = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || []) + hostOptions.value = items + if (!items.length) console.warn('[VolumeManage] host list empty, raw:', JSON.stringify(inner).slice(0, 500)) } } catch (e) { console.error('加载宿主机列表失败:', e) } } @@ -298,11 +292,10 @@ const handleMountVmSelected = (vm) => { mountVmId.value = vm.id; mountVmName.val 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 } + const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.count } + if (hostId.value) params.host_id = hostId.value if (filterStatus.value) params.status = filterStatus.value const res = await getVolumeList(params) const body = res?.data @@ -318,7 +311,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() } const handleAdd = () => { Object.assign(createForm, { - name: '', size: 10, host_id: hostIdInput.value || hostId.value || 0, + name: '', size: 10, host_id: hostId.value || 0, is_system: false, image_id: 0, vm_id: 0, target_device: '', _imageName: '', _vmName: '' }) @@ -384,9 +377,10 @@ const handleUnmount = (row) => { } // 迁移卷 -const handleTransfer = (row) => { +const handleTransfer = async (row) => { transferTarget.value = row transferHostId.value = '' + if (!hostOptions.value.length) await loadHostOptions() transferDialogVisible.value = true } @@ -438,15 +432,10 @@ const handleDelete = (row) => { const goBack = () => { router.push('/virtualization/kvm-service') } -onMounted(async () => { +onMounted(() => { 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() + loadHostOptions() + loadList() } })