Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/VolumeManage.vue
T
lin b3ed406f84
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m9s
fix: 提交修改
2026-04-15 16:02:36 +08:00

455 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 }}</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>
</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="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleViewDetail(row)">详情</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<el-dropdown trigger="click" @command="cmd => handleRowMore(row, cmd)" style="margin-left: 4px; margin-top: 4.5px">
<el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="resize">调整大小</el-dropdown-item>
<el-dropdown-item command="mount">挂载</el-dropdown-item>
<el-dropdown-item command="unmount">卸载</el-dropdown-item>
<!-- <el-dropdown-item command="transfer">迁移</el-dropdown-item> -->
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<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="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="大小" prop="size">
<div class="unit-input-row">
<el-input-number v-model="createForm.size" :min="1" controls-position="right" style="flex:1" />
<el-select v-model="createForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</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 v-if="createForm.is_system" 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="新大小">
<div class="unit-input-row">
<el-input-number v-model="resizeForm.size" :min="1" controls-position="right" style="flex:1" />
<el-select v-model="resizeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</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="当前宿主机">
<el-input :model-value="currentHostLabel" disabled style="width: 100%" />
</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="transferHostId" placeholder="请选择目标宿主机" style="width: 100%" filterable v-loading="hostOptionsLoading" disabled>
<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="transferDialogVisible = false">取消</el-button>
<el-button type="success" :loading="transferLoading" @click="submitTransfer">确定迁移</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 || 0" :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, ArrowDown } from '@element-plus/icons-vue'
import {
getRemoteHostList, getVolumeList,
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
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 injectedHostId = inject('hostId', null)
const injectedHostDetail = inject('hostDetail', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_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)
const submitLoading = ref(false)
const volumeList = ref([])
const total = ref(0)
const filterStatus = 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 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: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
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) }
}
const createDialogVisible = ref(false)
const createFormRef = ref(null)
const resizeDialogVisible = ref(false)
const mountDialogVisible = ref(false)
const resizeTarget = ref(null)
const resizeForm = reactive({ size: 1, _sizeUnit: 'GB' })
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 hostOptionsLoading = ref(false)
const currentHostLabel = computed(() => {
const hid = transferTarget.value?.host_id || injectedHostId?.value
if (!hid) return '-'
const hd = injectedHostDetail?.value
if (hd && hd.id === hid) return `${hd.name || '宿主机'} (${hd.ip || '#' + hid})`
const h = hostOptions.value.find(x => x.id === hid)
return h ? `${h.name} (${h.ip || '#' + h.id})` : `宿主机 #${hid}`
})
// 选择器
const showImageSelector = ref(false)
const showVmSelector = ref(false)
const showMountVmSelector = ref(false)
const createForm = reactive({
name: '', size: 10, _sizeUnit: 'GB', 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
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.page_size,host_id:hostId.value }
if (hostId.value) params.host_id = hostId.value
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(extractApiError(e?.response?.data, '获取数据卷列表失败')) } finally { loading.value = false }
}
const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
Object.assign(createForm, {
name: '', size: 10, _sizeUnit: 'GB', host_id: hostId.value || '',
is_system: false, image_id: '', vm_id: '', target_device: '',
_imageName: '', _vmName: ''
})
createDialogVisible.value = true
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const sizeGb = createForm._sizeUnit === 'TB' ? createForm.size * 1024 : createForm.size
const payload = {
service_id: serviceId.value,
name: createForm.name, size: sizeGb,
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(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { submitLoading.value = false }
})
}
const handleResize = (row) => {
resizeTarget.value = row
resizeForm.size = row.size || 10
resizeForm._sizeUnit = 'GB'
resizeDialogVisible.value = true
}
const submitResize = async () => {
submitLoading.value = true
try {
const sizeGb = resizeForm._sizeUnit === 'TB' ? resizeForm.size * 1024 : resizeForm.size
const res = await resizeVolume({ service_id: serviceId.value, volume_id: resizeTarget.value.id, size: sizeGb })
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '调整失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } 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(extractApiError(res?.data, '挂载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) } 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(extractApiError(res?.data, '卸载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '卸载失败')) }
}).catch(() => {})
}
// 迁移卷
const handleTransfer = (row) => {
transferTarget.value = row
transferHostId.value = row.host_id || hostId.value || ''
transferDialogVisible.value = true
if (!hostOptions.value.length) {
hostOptionsLoading.value = true
loadHostOptions().finally(() => { hostOptionsLoading.value = false })
}
}
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(extractApiError(res?.data, '迁移失败'))
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) }
finally { transferLoading.value = false }
}
const handleViewDetail = (row) => {
router.push({ path: '/virtualization/volume-detail', query: { service_id: serviceId.value, volume_id: row.id } })
}
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(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const handleRowMore = (row, command) => {
if (command === 'resize') handleResize(row)
else if (command === 'mount') handleMount(row)
else if (command === 'unmount') handleUnmount(row)
else if (command === 'transfer') handleTransfer(row)
}
const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(() => {
if (serviceId.value) {
loadHostOptions()
loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>
.volume-manage-container { padding: 20px; }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-select { width: 90px; flex-shrink: 0; }
</style>