fix: 修改内存的基础单位为kb
Build and Deploy Vue3 / build (push) Successful in 2m38s
Build and Deploy Vue3 / deploy (push) Successful in 1m3s

This commit is contained in:
2026-03-21 15:25:38 +08:00
parent cf19956b88
commit 9edb59d16e
14 changed files with 819 additions and 155 deletions
+500 -34
View File
@@ -96,9 +96,10 @@
<div class="config-cell">
<span class="config-label">安全组</span>
<span class="config-value">
<el-link type="primary" @click="handleBindSg">绑定</el-link>
<el-divider direction="vertical" />
<el-link type="info" @click="handleUnbindSg">解绑</el-link>
<template v-if="vmPortGroup">
<el-tag size="small" type="success">{{ vmPortGroup.name }}</el-tag>
</template>
<span v-else style="color: #909399">未绑定</span>
</span>
</div>
</div>
@@ -120,6 +121,20 @@
</span>
</div>
</div>
<div class="config-row">
<div class="config-cell">
<span class="config-label">流量上限(GB)</span>
<span class="config-value">{{ detail.traffic_max ?? '-' }}</span>
</div>
<div class="config-cell">
<span class="config-label">快照配额</span>
<span class="config-value">{{ detail.snapshot_num ?? '-' }}</span>
</div>
<div class="config-cell">
<span class="config-label">备份配额</span>
<span class="config-value">{{ detail.backup_num ?? '-' }}</span>
</div>
</div>
<div class="config-row">
<div class="config-cell" style="flex: 2">
<span class="config-label">UUID</span>
@@ -236,6 +251,72 @@
</div>
</el-tab-pane>
<el-tab-pane label="快照" name="snapshot">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">快照管理</h3>
<div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="handleCreateSnapshot">创建快照</el-button>
<el-button size="small" @click="loadSnapshots">刷新</el-button>
</div>
</div>
<el-table :data="snapshotList" v-loading="snapshotLoading" stripe size="small" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)" size="small">{{ snapshotStatusLabel(row.status) }}</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="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestoreSnapshot(row)">恢复</el-button>
<el-button link type="info" size="small" @click="handleSnapshotProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
<el-button link type="danger" size="small" @click="handleDeleteSnapshot(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!snapshotList.length && !snapshotLoading" description="暂无快照" :image-size="60" />
</div>
</el-tab-pane>
<el-tab-pane label="备份" name="backup">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">备份管理</h3>
<div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="handleCreateBackup">创建备份</el-button>
<el-button size="small" @click="loadBackups">刷新</el-button>
</div>
</div>
<el-table :data="backupList" v-loading="backupLoading" stripe size="small" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)" size="small">{{ snapshotStatusLabel(row.status) }}</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="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestoreBackup(row)">恢复</el-button>
<el-button link type="info" size="small" @click="handleBackupProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
<el-button link type="danger" size="small" @click="handleDeleteBackup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!backupList.length && !backupLoading" description="暂无备份" :image-size="60" />
</div>
</el-tab-pane>
<el-tab-pane label="监控" name="monitor">
<div class="section-block">
<div class="section-header">
@@ -246,11 +327,17 @@
</div>
</div>
<div v-if="metricsData" class="metrics-summary">
<div class="metric-card">
<div class="metric-title">CPU 使用率</div>
<div class="metric-num">{{ (metricsData.cpu_usage_percent ?? 0).toFixed(1) }}%</div>
</div>
<div class="metric-card" v-if="metricsData.internet_speed && Object.keys(metricsData.internet_speed).length">
<div class="metric-title">网络速率</div>
<div v-for="(val, key) in metricsData.internet_speed" :key="key" class="metric-num" style="font-size: 14px; margin-bottom: 2px">
{{ key }}: {{ val }}
<div class="net-speed-items">
<div v-for="(val, key) in metricsData.internet_speed" :key="key" class="net-speed-item">
<span class="net-speed-label">{{ key === 'rx_bytes' ? '↓ 接收' : key === 'tx_bytes' ? '↑ 发送' : key }}</span>
<span class="net-speed-value">{{ formatNetSpeed(val) }}</span>
</div>
</div>
</div>
</div>
@@ -259,7 +346,10 @@
<h4 class="chart-label">CPU 使用率</h4>
<div ref="cpuChartRef" class="chart-container"></div>
</div>
<div class="chart-wrapper" v-if="metricsHistory.netKeys.length">
<h4 class="chart-label">网络速率</h4>
<div ref="netChartRef" class="chart-container"></div>
</div>
</div>
<el-empty v-if="!metricsData && !metricsLoading" description="暂无指标数据,切换到此标签页后自动开始采集" />
</div>
@@ -287,21 +377,121 @@
<ImageSelectorPopup v-model="showImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="img => { rebuildImageId = img.id; rebuildImageName = img.name }" />
<!-- 创建快照弹窗 -->
<el-dialog v-model="snapshotCreateVisible" title="创建快照" width="480px" destroy-on-close>
<el-form :model="snapshotForm" label-width="100px">
<el-form-item label="虚拟机">{{ detail?.name || '-' }} (ID: {{ vmId }})</el-form-item>
<el-form-item label="快照名称" required>
<el-input v-model="snapshotForm.name" placeholder="请输入快照名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="snapshotForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="snapshotCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateSnapshot">创建</el-button>
</template>
</el-dialog>
<!-- 创建备份弹窗 -->
<el-dialog v-model="backupCreateVisible" title="创建备份" width="480px" destroy-on-close>
<el-form :model="backupForm" label-width="100px">
<el-form-item label="虚拟机">{{ detail?.name || '-' }} (ID: {{ vmId }})</el-form-item>
<el-form-item label="备份名称" required>
<el-input v-model="backupForm.name" placeholder="请输入备份名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="backupForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="backupCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateBackup">创建</el-button>
</template>
</el-dialog>
<!-- 快照/备份进度弹窗 -->
<el-dialog v-model="taskProgressVisible" :title="taskProgressTitle" width="520px" destroy-on-close>
<div v-loading="taskProgressLoading">
<el-descriptions :column="1" border size="small" v-if="taskProgressData">
<el-descriptions-item label="任务ID">
<span class="mono-text">{{ taskProgressData.task_id || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="taskStatusType(taskProgressData.status)" size="small">{{ snapshotStatusLabel(taskProgressData.status) }}</el-tag>
</el-descriptions-item>
<template v-if="taskProgressMeta">
<el-descriptions-item v-for="(val, key) in taskProgressMeta" :key="key" :label="taskMetaLabel(key)">
<span :style="key.includes('path') ? 'font-family: Consolas, monospace; font-size: 13px; word-break: break-all' : ''">{{ val }}</span>
</el-descriptions-item>
</template>
</el-descriptions>
<el-empty v-else description="暂无进度信息" />
</div>
<template #footer>
<el-button @click="taskProgressVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 编辑虚拟机弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑虚拟机" width="560px" destroy-on-close>
<el-dialog v-model="editDialogVisible" title="编辑虚拟机" width="640px" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" label-width="120px">
<el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="editForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
<el-form-item label="名称">
<el-input v-model="editForm.name" placeholder="虚拟机名称" />
</el-form-item>
<el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="editForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
<el-form-item label="内存">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-input-number v-model="editMemoryDisplay" :min="1" :precision="editMemoryUnit === 'GB' ? 2 : 0" controls-position="right" style="width: 200px" />
<el-select v-model="editMemoryUnit" style="width: 80px">
<el-option label="MB" value="MB" />
<el-option label="GB" value="GB" />
</el-select>
<span style="color: #909399; font-size: 12px">{{ editForm.memory }} KB</span>
</div>
</el-form-item>
<el-form-item label="CPU(核)">
<el-input-number v-model="editForm.vcpu" :min="1" controls-position="right" style="width: 200px" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="editForm.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="editForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Root密码">
<el-input v-model="editForm.root_password" placeholder="不修改留空" show-password />
</el-form-item>
<el-form-item label="SSH端口">
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width: 100%" />
<el-input v-model="editForm.root_password" placeholder="留空则不修改" show-password />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="SSH端口">
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" 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="editForm.traffic_max" :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="快照配额">
<el-input-number v-model="editForm.snapshot_num" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="备份配额">
<el-input-number v-model="editForm.backup_num" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="安全组">
<el-select v-model="editForm.port_group_id" placeholder="选择安全组(可选)" filterable clearable style="width: 100%">
<el-option v-for="g in sgOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
@@ -321,7 +511,7 @@
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="内存(KB)">
<el-input-number v-model="refactorForm.memory" :min="0" :step="65536" controls-position="right" style="width: 100%" />
<el-input-number v-model="refactorForm.memory" :min="0" :step="1024" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
@@ -472,8 +662,8 @@
</el-select>
</el-form-item>
<el-form-item label="选择网络">
<el-select v-model="netAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="netOptionsLoading">
<el-option v-for="n in availableNetworks" :key="n.id" :label="`${n.name} - ${n.address || ''} (ID: ${n.id})`" :value="n.id" />
<el-select v-model="netAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="netOptionsLoading" :disabled="!netAddHostId">
<el-option v-for="n in availableNetworks" :key="n.id" :label="`${n.name} - ${n.address || ''}`" :value="n.id" />
</el-select>
</el-form-item>
</el-form>
@@ -500,7 +690,7 @@
</el-select>
</el-form-item>
<el-form-item label="选择数据卷">
<el-select v-model="volAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="volOptionsLoading">
<el-select v-model="volAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="volOptionsLoading" :disabled="!volAddHostId">
<el-option v-for="v in availableVolumes" :key="v.id" :label="`${v.name} (${v.size || 0} GB, ID: ${v.id})`" :value="v.id" />
</el-select>
</el-form-item>
@@ -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}<br/>${params[0].marker} CPU: ${(params[0].value * 100).toFixed(1)}%` },
tooltip: { trigger: 'axis', formatter: (params) => `${params[0].axisValue}<br/>${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 += `<br/>${p.marker} ${p.seriesName}: ${p.value}` })
params.forEach(p => {
const label = p.seriesName === 'rx_bytes' ? '↓ 接收' : p.seriesName === 'tx_bytes' ? '↑ 发送' : p.seriesName
s += `<br/>${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; }
</style>