feat: 对接用户组网管理
Build and Deploy Vue3 / build (push) Successful in 1m43s
Build and Deploy Vue3 / deploy (push) Successful in 1m7s

This commit is contained in:
2026-03-24 18:57:52 +08:00
parent 3357566b02
commit 40a5e486a6
29 changed files with 1895 additions and 9381 deletions
+6 -7
View File
@@ -672,42 +672,41 @@ export const setBackupLimit = (data) => {
/**
* ================================
* 用户组网管理 (UserNetworking)
* 注意:此模块接口前缀为 /api/v1/admins/service/
* ================================
*/
/** 获取组网列表 */
export const getUserNetworkingList = (params) => {
return http2.get('/api/v1/admins/service/host_service/point/networking/list', { params })
return http2.get('/api/v1/admin/server/host_service/point/networking/list', { params })
}
/** 获取组网详情 */
export const getUserNetworkingDetail = (params) => {
return http2.get('/api/v1/admins/service/host_service/point/networking/detail', { params })
return http2.get('/api/v1/admin/server/host_service/point/networking/detail', { params })
}
/** 创建用户组网 */
export const createUserNetworking = (data) => {
return http2.post('/api/v1/admins/service/host_service/point/networking/create', data, {
return http2.post('/api/v1/admin/server/host_service/point/networking/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 为虚拟机分配组网 IP */
export const assignUserNetworking = (data) => {
return http2.post('/api/v1/admins/service/host_service/point/networking/assign', data, {
return http2.post('/api/v1/admin/server/host_service/point/networking/assign', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除组网 */
export const deleteUserNetworking = (params) => {
return http2.delete('/api/v1/admins/service/host_service/point/networking/delete', { params })
return http2.delete('/api/v1/admin/server/host_service/point/networking/delete', { params })
}
/** 删除组网下的指定网络 */
export const removeUserNetworkingNetwork = (data) => {
return http2.post('/api/v1/admins/service/host_service/point/networking/remove_network', data, {
return http2.post('/api/v1/admin/server/host_service/point/networking/remove_network', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
@@ -70,4 +70,5 @@ const handleClose = () => { selectedItem.value = null }
<style scoped>
.selector-container { min-height: 200px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+2 -1
View File
@@ -68,7 +68,7 @@ watch(visible, (val) => emit('update:modelValue', val))
const loadList = async () => {
loading.value = true
try {
const params = { service_id: props.serviceId, page: 1, count: 100 }
const params = { service_id: props.serviceId, page: 1, count: 10 }
if (keyword.value) params.keyword = keyword.value
if (filterOsType.value) params.os_type = filterOsType.value
const res = await getImageList(params)
@@ -96,4 +96,5 @@ const handleClose = () => { selectedItem.value = null }
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+15 -4
View File
@@ -35,8 +35,13 @@
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
<div style="display: flex; justify-content: space-between; width: 100%">
<el-button type="success" @click="handleCreate">创建网络</el-button>
<div style="display: flex; gap: 8px">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
@@ -52,7 +57,7 @@ const props = defineProps({
hostId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
const visible = ref(false)
const loading = ref(false)
@@ -63,6 +68,7 @@ const pageSize = ref(10)
const keyword = ref('')
const typeFilter = ref('')
const selectedItem = ref(null)
const type = ref('bridge')
watch(() => props.modelValue, (val) => {
visible.value = val
@@ -82,7 +88,7 @@ const loadList = async () => {
if (!props.serviceId || !props.hostId) return
loading.value = true
try {
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value }
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value,type: type.value }
if (keyword.value) params.keyword = keyword.value
if (typeFilter.value) params.type = typeFilter.value
const res = await getNetworkList(params)
@@ -103,6 +109,10 @@ const handleConfirm = () => {
}
}
const handleClose = () => { selectedItem.value = null }
const handleCreate = () => {
visible.value = false
emit('create')
}
</script>
<style scoped>
@@ -110,4 +120,5 @@ const handleClose = () => { selectedItem.value = null }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
@@ -36,8 +36,13 @@
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
<div style="display: flex; justify-content: space-between; width: 100%">
<el-button type="success" @click="handleCreate">创建安全组</el-button>
<div style="display: flex; gap: 8px">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
@@ -52,7 +57,7 @@ const props = defineProps({
serviceId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
const visible = ref(false)
const loading = ref(false)
@@ -92,6 +97,10 @@ const handleConfirm = () => {
if (selectedItem.value) { emit('confirm', selectedItem.value); visible.value = false }
}
const handleClose = () => { selectedItem.value = null }
const handleCreate = () => {
visible.value = false
emit('create')
}
</script>
<style scoped>
@@ -99,4 +108,5 @@ const handleClose = () => { selectedItem.value = null }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+3 -2
View File
@@ -45,7 +45,7 @@ const visible = ref(false)
const loading = ref(false)
const list = ref([])
const selectedItem = ref(null)
const hostIdFilter = ref(0)
const hostIdFilter = ref('')
const hostOptions = ref([])
watch(() => props.modelValue, (val) => {
@@ -56,7 +56,7 @@ watch(visible, (val) => emit('update:modelValue', val))
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: props.serviceId, page: 1, page_size: 100 })
const res = await getRemoteHostList({ service_id: props.serviceId, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -106,4 +106,5 @@ const handleClose = () => { selectedItem.value = null }
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+14 -4
View File
@@ -43,8 +43,13 @@
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
<div style="display: flex; justify-content: space-between; width: 100%">
<el-button type="success" @click="handleCreate">创建数据卷</el-button>
<div style="display: flex; gap: 8px">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
@@ -60,7 +65,7 @@ const props = defineProps({
hostId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
const visible = ref(false)
const loading = ref(false)
@@ -90,7 +95,7 @@ const loadList = async () => {
if (!props.serviceId || !props.hostId) return
loading.value = true
try {
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value }
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, count: pageSize.value }
if (keyword.value) params.keyword = keyword.value
if (statusFilter.value) params.status = statusFilter.value
const res = await getVolumeList(params)
@@ -114,6 +119,10 @@ const handleConfirm = () => {
}
}
const handleClose = () => { selectedItem.value = null }
const handleCreate = () => {
visible.value = false
emit('create')
}
</script>
<style scoped>
@@ -121,4 +130,5 @@ const handleClose = () => { selectedItem.value = null }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+10
View File
@@ -534,6 +534,16 @@ const routes = [
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
},
{
path: 'volume-detail',
name: 'VirtVolumeDetail',
component: () => import('../views/virtualization/VolumeDetail.vue'),
meta: {
title: '数据卷详情',
hidden: true,
activeMenu: '/virtualization/kvm-service'
}
}
]
},
+17
View File
@@ -114,6 +114,23 @@ body {
padding-right: 10px;
}
/* 可点击元素统一手型光标 */
.el-button,
.el-button--link,
.el-tag.is-closable .el-tag__close,
.el-dropdown,
.el-dropdown-menu__item,
.el-switch,
.el-checkbox,
.el-radio,
.el-select .el-input__wrapper,
.el-table__body tr.el-table__row {
cursor: pointer;
}
.back-btn {
cursor: pointer;
}
/* 响应式工具类 */
@media (max-width: 768px) {
.hidden-xs {
+1 -1
View File
@@ -618,7 +618,7 @@ const fetchPlanList = async () => {
try {
const response = await getServerPlan({
server_id: props.ID,
count: 100
count: 10
});
if (response && response.data && response.data.code === 200) {
+1 -1
View File
@@ -2407,7 +2407,7 @@ const fetchContainerPlanList = async () => {
try {
const response = await getServerPlan({
server_id: route.query.server_id,
count: 100
count: 10
});
console.log("获取容器套餐列表1111",response);
@@ -224,6 +224,8 @@ const handleProgress = async (row) => {
}
onMounted(() => { loadList() })
defineExpose({ loadList })
</script>
<style scoped>
+39 -14
View File
@@ -36,9 +36,19 @@
<span class="status-value">{{ detail.ip || '-' }}</span>
</div>
<div class="status-item">
<span class="status-label">资源</span>
<span class="status-value">{{ detail.max_cpu || 0 }} | {{ formatMemKB(detail.max_memory) }} | {{ formatDiskGB(detail.max_disk) }}</span>
<span class="status-label">CPU</span>
<span class="status-value">{{ detail.max_cpu || 0 }}</span>
</div>
<div class="status-item">
<span class="status-label">内存</span>
<span class="status-value">{{ formatMemKB(detail.max_memory) }}</span>
</div>
<div class="status-item">
<span class="status-label">磁盘</span>
<span class="status-value">{{ formatDiskGB(detail.max_disk) }}</span>
</div>
</div>
<div class="status-bar" v-if="detail">
<div class="status-item">
<span class="status-label">带宽</span>
<span class="status-value">{{ detail.rx_bandwidth || 0 }} / {{ detail.tx_bandwidth || 0 }} Mbps</span>
@@ -174,22 +184,22 @@
</el-tab-pane>
<el-tab-pane label="镜像管理" name="image">
<ImageManage v-if="hostTabLoaded['image']" />
<ImageManage v-if="hostTabLoaded['image']" ref="imageManageRef" />
</el-tab-pane>
<el-tab-pane label="网络管理" name="network">
<NetworkManage v-if="hostTabLoaded['network']" />
<NetworkManage v-if="hostTabLoaded['network']" ref="networkManageRef" />
</el-tab-pane>
<el-tab-pane label="数据卷管理" name="volume">
<VolumeManage v-if="hostTabLoaded['volume']" />
<VolumeManage v-if="hostTabLoaded['volume']" ref="volumeManageRef" />
</el-tab-pane>
<el-tab-pane label="虚拟机管理" name="vm">
<VmManage v-if="hostTabLoaded['vm']" />
<VmManage v-if="hostTabLoaded['vm']" ref="vmManageRef" />
</el-tab-pane>
<el-tab-pane label="快照管理" name="snapshot">
<SnapshotManage v-if="hostTabLoaded['snapshot']" />
<SnapshotManage v-if="hostTabLoaded['snapshot']" ref="snapshotManageRef" />
</el-tab-pane>
<el-tab-pane label="备份管理" name="backup">
<BackupManage v-if="hostTabLoaded['backup']" />
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
</el-tab-pane>
</el-tabs>
</div>
@@ -283,21 +293,36 @@ const hostId = computed(() => parseInt(route.query.id) || 0)
const activeTab = ref('info')
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false })
const imageManageRef = ref(null)
const networkManageRef = ref(null)
const volumeManageRef = ref(null)
const vmManageRef = ref(null)
const snapshotManageRef = ref(null)
const backupManageRef = ref(null)
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef }
watch(activeTab, (tab) => {
if (!['info', 'monitor'].includes(tab) && !hostTabLoaded[tab]) hostTabLoaded[tab] = true
if (!['info', 'monitor'].includes(tab)) {
if (!hostTabLoaded[tab]) {
hostTabLoaded[tab] = true
} else {
nextTick(() => { tabRefMap[tab]?.value?.loadList?.() })
}
}
if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() }
else stopPolling()
})
provide('embedded', true)
provide('serviceId', serviceId)
provide('serviceName', serviceName)
provide('hostId', hostId)
const loading = ref(false)
const submitLoading = ref(false)
const metricsLoading = ref(false)
const detail = ref(null)
provide('embedded', true)
provide('serviceId', serviceId)
provide('serviceName', serviceName)
provide('hostId', hostId)
provide('hostDetail', detail)
const showToken = ref(false)
const showPassword = ref(false)
const showPrivateKey = ref(false)
+1 -1
View File
@@ -416,7 +416,7 @@ const loadTreeData = async () => {
try {
const [groupRes, hostRes] = await Promise.all([
getRemoteHostGroupTree({ service_id: serviceId.value }),
getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 500 })
getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
])
if (groupRes?.data?.code === 200 && groupRes?.data?.data) {
const inner = groupRes.data.data
+5 -6
View File
@@ -2,7 +2,7 @@
<div class="image-detail-page">
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回镜像列表</el-button>
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回上一页</el-button>
<el-divider direction="vertical" />
<span class="page-title">镜像详情</span>
</div>
@@ -96,9 +96,8 @@
<!-- 同步到宿主机弹窗 -->
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="440px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="镜像">{{ detail?.name || '-' }}</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="syncHostId" placeholder="请选择宿主机" style="width: 100%">
<el-select v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
@@ -184,7 +183,7 @@ const getHostName = (hid) => { const h = hostOptions.value.find(x => x.id === hi
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
@@ -274,7 +273,7 @@ const submitSync = async () => {
if (!syncHostId.value) return ElMessage.warning('请选择宿主机')
actionLoading.value = true
try {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('image_id', imageId.value); fd.append('host_id', syncHostId.value)
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('host_id', syncHostId.value)
const res = await syncImageToHost(fd)
if (res?.data?.code === 200) { ElMessage.success('已触发同步'); syncDialogVisible.value = false; loadHostStatus() }
else ElMessage.error(extractApiError(res?.data, '同步失败'))
@@ -306,7 +305,7 @@ const handleDelete = () => {
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
router.back()
}
let loadedImageId = null
+56 -24
View File
@@ -14,7 +14,8 @@
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建镜像</el-button>
<el-button v-if="isEmbeddedHost" type="primary" @click="handleSyncToHostBatch"><el-icon><Refresh /></el-icon>同步镜像</el-button>
<el-button v-else type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建镜像</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
@@ -60,7 +61,7 @@
</el-table-column>
<el-table-column label="同步状态" width="100">
<template #default="{ row }">
<el-tag :type="row.sync_status === 'synced' ? 'success' : 'warning'" size="small">{{ row.sync_status === 'synced' ? '同步' : '同步' }}</el-tag>
<el-tag :type="row.sync_status === 'synced' ? 'success' : 'warning'" size="small">{{ row.sync_status === 'synced' ? '同步' : '同步' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
@@ -70,8 +71,8 @@
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleGoDetail(row)">详情</el-button>
<el-button link type="success" @click="handleSyncToHost(row)">同步</el-button>
<el-button link type="warning" @click="handleReloadOnHost(row)">宿主机重下载</el-button>
<el-button v-if="!isEmbeddedHost" link type="success" @click="handleSyncToHost()">同步</el-button>
<el-button link type="warning" @click="handleReloadOnHost(row)">下载</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
@@ -178,11 +179,9 @@
<!-- 同步到宿主机弹窗 -->
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="440px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="镜像">
<el-input :model-value="syncTarget?.name" disabled />
</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="syncHostId" placeholder="请选择宿主机" style="width: 100%">
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%" v-loading="hostOptionsLoading">
<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>
@@ -200,7 +199,8 @@
<el-input :model-value="reloadTarget?.name" disabled />
</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%">
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%" v-loading="hostOptionsLoading">
<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>
@@ -230,6 +230,7 @@ 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 serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
@@ -255,7 +256,6 @@ const hostStatusList = ref([])
// 同步到宿主机
const syncDialogVisible = ref(false)
const syncTarget = ref(null)
const syncHostId = ref('')
const syncLoading = ref(false)
@@ -265,6 +265,17 @@ const reloadTarget = ref(null)
const reloadHostId = ref('')
const reloadLoading = ref(false)
const hostOptionsLoading = ref(false)
const isEmbeddedHost = computed(() => !!(embedded && injectedHostId?.value))
const currentHostLabel = computed(() => {
const hid = injectedHostId?.value
if (!hid) return '-'
const hd = injectedHostDetail?.value
if (hd) 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 formData = reactive({
image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system',
description: '', status: '', size: 0, image_name: ''
@@ -310,7 +321,7 @@ const getHostName = (hid) => {
// 加载宿主机列表
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
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
@@ -320,17 +331,16 @@ const loadHostOptions = async () => {
} catch (e) { /* ignore */ }
}
const resolveHostId = async () => {
const resolveHostId = () => {
if (injectedHostId?.value) return injectedHostId.value
if (!hostOptions.value.length) await loadHostOptions()
return hostOptions.value.length ? hostOptions.value[0].id : null
return null
}
const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const hostId = await resolveHostId()
const hostId = resolveHostId()
let res
if (hostId) {
res = await getImageCompareHost({ service_id: serviceId.value, host_id: hostId })
@@ -477,11 +487,23 @@ const handleViewDetail = async (row) => {
}
// 同步镜像到宿主机
const handleSyncToHost = async (row) => {
syncTarget.value = row
syncHostId.value = ''
if (!hostOptions.value.length) await loadHostOptions()
const handleSyncToHost = () => {
if (embedded && injectedHostId?.value) {
syncHostId.value = injectedHostId.value
} else {
syncHostId.value = ''
}
syncDialogVisible.value = true
if (!embedded || !injectedHostId?.value) {
if (!hostOptions.value.length) {
hostOptionsLoading.value = true
loadHostOptions().finally(() => { hostOptionsLoading.value = false })
}
}
}
const handleSyncToHostBatch = () => {
handleSyncToHost()
}
const submitSyncToHost = async () => {
@@ -490,7 +512,6 @@ const submitSyncToHost = async () => {
try {
const formPayload = new FormData()
formPayload.append('service_id', serviceId.value)
formPayload.append('image_id', syncTarget.value.id)
formPayload.append('host_id', syncHostId.value)
const res = await syncImageToHost(formPayload)
if (res?.data?.code === 200) {
@@ -501,7 +522,7 @@ const submitSyncToHost = async () => {
ElMessage.error(extractApiError(res?.data, '同步失败'))
}
} catch (e) {
ElMessage.error('同步失败: ' + (e?.response?.data?.message || e.message))
ElMessage.error(extractApiError(e?.response?.data, '同步失败'))
} finally {
syncLoading.value = false
}
@@ -522,11 +543,20 @@ const handleReloadMaster = (row) => {
}).catch(() => {})
}
const handleReloadOnHost = async (row) => {
const handleReloadOnHost = (row) => {
reloadTarget.value = row
reloadHostId.value = ''
if (!hostOptions.value.length) await loadHostOptions()
if (embedded && injectedHostId?.value) {
reloadHostId.value = injectedHostId.value
} else {
reloadHostId.value = ''
}
reloadDialogVisible.value = true
if (!embedded || !injectedHostId?.value) {
if (!hostOptions.value.length) {
hostOptionsLoading.value = true
loadHostOptions().finally(() => { hostOptionsLoading.value = false })
}
}
}
const submitReloadOnHost = async () => {
@@ -575,6 +605,8 @@ onMounted(() => {
loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>
@@ -75,12 +75,12 @@
<el-tab-pane label="镜像管理" name="image">
<ImageManage v-if="tabLoaded['image']" />
</el-tab-pane>
<el-tab-pane label="网络管理" name="network">
<!-- <el-tab-pane label="网络管理" name="network">
<NetworkManage v-if="tabLoaded['network']" />
</el-tab-pane>
<el-tab-pane label="数据卷管理" name="volume">
</el-tab-pane> -->
<!-- <el-tab-pane label="数据卷管理" name="volume">
<VolumeManage v-if="tabLoaded['volume']" />
</el-tab-pane>
</el-tab-pane> -->
<el-tab-pane label="虚拟机管理" name="vm">
<VmManage v-if="tabLoaded['vm']" />
</el-tab-pane>
@@ -90,12 +90,12 @@
<el-tab-pane label="VNC节点" name="vnc">
<VncNodeManage v-if="tabLoaded['vnc']" />
</el-tab-pane>
<el-tab-pane label="快照管理" name="snapshot">
<!-- <el-tab-pane label="快照管理" name="snapshot">
<SnapshotManage v-if="tabLoaded['snapshot']" />
</el-tab-pane>
<el-tab-pane label="备份管理" name="backup">
</el-tab-pane> -->
<!-- <el-tab-pane label="备份管理" name="backup">
<BackupManage v-if="tabLoaded['backup']" />
</el-tab-pane>
</el-tab-pane> -->
<el-tab-pane label="用户组网" name="networking">
<UserNetworkingManage v-if="tabLoaded['networking']" />
</el-tab-pane>
+3 -1
View File
@@ -176,7 +176,7 @@ const getHostLabel = (hid) => {
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: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -322,6 +322,8 @@ onMounted(async () => {
if (hostIdInput.value) loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>
@@ -240,7 +240,7 @@ const getHostLabel = (hid) => { const h = hostOptions.value.find(x => x.id === h
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: 10})
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
@@ -402,7 +402,8 @@ const handleDeleteRule = (rule) => {
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
router.back()
// router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
}
let loadedSgId = null
@@ -270,7 +270,7 @@ const getHostLabel = (hid) => {
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: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -224,6 +224,8 @@ const handleProgress = async (row) => {
}
onMounted(() => { loadList() })
defineExpose({ loadList })
</script>
<style scoped>
@@ -19,9 +19,14 @@
</div>
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索组网" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
<el-input v-model="keyword" placeholder="关键词搜索" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="filterHostId" placeholder="按宿主机筛选" clearable filterable style="width: 200px" @change="handleSearch">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name || ''} (${h.ip || h.id})`" :value="h.id" />
</el-select>
<el-input v-model="filterUserId" placeholder="按用户ID筛选" clearable style="width: 160px" @keyup.enter="handleSearch" @clear="handleSearch" />
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
@@ -47,9 +52,9 @@
</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"
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
@@ -62,19 +67,22 @@
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
<el-form-item label="用户ID" prop="user_id">
<el-input-number v-model="createForm.user_id" :min="1" style="width: 100%" placeholder="用户 ID" />
<el-form-item label="用户" prop="user_id">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-input :model-value="createForm.user_id ? `${createUserName} (ID: ${createForm.user_id})` : ''" readonly placeholder="请选择用户" style="flex: 1" />
<el-button type="primary" @click="showCreateUserSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable clearable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name || ''} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="网桥名称" prop="bridge_name">
<el-input v-model="createForm.bridge_name" placeholder=" br0" />
<el-form-item label="网桥名称">
<el-input v-model="createForm.bridge_name" placeholder=" br0可选" />
</el-form-item>
<el-form-item label="网关地址" prop="gateway">
<el-input v-model="createForm.gateway" placeholder=" 10.0.0.1" />
<el-form-item label="网关地址">
<el-input v-model="createForm.gateway" placeholder=" 10.0.0.1可选" />
</el-form-item>
</el-form>
<template #footer>
@@ -108,6 +116,9 @@
<!-- 虚拟机选择器 -->
<VmSelectorPopup v-model="showAssignVmSelector" :service-id="serviceId" :current-id="assignForm.vm_id" @confirm="handleAssignVmSelected" />
<!-- 用户选择器(创建组网用) -->
<UserListSelector v-model="showCreateUserSelector" :current-user-id="createForm.user_id" @confirm="handleCreateUserSelected" />
<!-- 组网详情弹窗 -->
<el-dialog v-model="detailVisible" title="组网详情" width="800px" destroy-on-close>
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading" style="margin-bottom: 20px">
@@ -116,32 +127,47 @@
<el-descriptions-item label="描述">{{ currentDetail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentDetail.user_id }}</el-descriptions-item>
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
<el-descriptions-item label="网桥">{{ currentDetail.bridge_name }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ currentDetail.gateway }}</el-descriptions-item>
<el-descriptions-item label="网桥">{{ currentDetail.bridge_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ currentDetail.gateway || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(currentDetail.updated_at) }}</el-descriptions-item>
</el-descriptions>
<div class="networks-section" v-if="detailNetworks.length">
<div class="networks-header">
<h4>组网下的网络</h4>
<h4>组网下的网络 ({{ detailNetworks.length }})</h4>
</div>
<el-table :data="detailNetworks" stripe size="small">
<el-table-column prop="vm_id" label="虚拟机ID" width="100" />
<el-table-column prop="vm_name" label="虚拟机名称" min-width="120" show-overflow-tooltip />
<el-table-column label="虚拟机状态" width="100">
<el-table-column label="网络ID" width="80">
<template #default="{ row }">{{ row.network?.id || '-' }}</template>
</el-table-column>
<el-table-column label="IP 地址" min-width="150">
<template #default="{ row }">
<el-tag :type="vmStatusType(row.vm_status)" size="small">{{ row.vm_status || '-' }}</el-tag>
<span class="mono-text">{{ row.network?.address || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="网络信息" min-width="200">
<el-table-column label="MAC 地址" min-width="160">
<template #default="{ row }">
<template v-if="row.network">
<div>IP: {{ row.network.ip || '-' }}</div>
<div>MAC: {{ row.network.mac || '-' }}</div>
</template>
<span v-else>-</span>
<span class="mono-text">{{ row.network?.mac_address || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="networkTypeTagType(row.network?.type)" size="small">{{ networkTypeLabel(row.network?.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定虚拟机" width="110">
<template #default="{ row }">
<span v-if="row.network?.vm_id">ID: {{ row.network.vm_id }}</span>
<el-tag v-else type="info" size="small">未绑定</el-tag>
</template>
</el-table-column>
<el-table-column label="网桥" width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.network?.bridge_name || '-' }}</template>
</el-table-column>
<el-table-column label="目标设备" width="90" show-overflow-tooltip>
<template #default="{ row }">{{ row.network?.target_device || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleRemoveNetwork(row)">移除</el-button>
@@ -167,6 +193,7 @@ import {
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import dayjs from 'dayjs'
const route = useRoute()
@@ -183,20 +210,33 @@ const detailLoading = ref(false)
const list = ref([])
const total = ref(0)
const keyword = ref('')
const filterHostId = ref(null)
const filterUserId = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 })
const queryParams = reactive({ page: 1, count: 10 })
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const vmStatusType = (s) => ({ running: 'success', stopped: 'info', suspended: 'warning', error: 'danger' }[s] || 'info')
const formatTime = (t) => {
if (!t) return '-'
if (t.seconds) return dayjs.unix(t.seconds).format('YYYY-MM-DD HH:mm:ss')
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
}
const NETWORK_TYPE_MAP = {
nat: { label: 'NAT', type: '' },
bridge: { label: '桥接', type: 'success' },
direct: { label: '直连', type: 'warning' }
}
const networkTypeLabel = (t) => NETWORK_TYPE_MAP[t]?.label || t || '-'
const networkTypeTagType = (t) => NETWORK_TYPE_MAP[t]?.type ?? 'info'
const getHostLabel = (hid) => {
const h = hostOptions.value.find(x => x.id === hid)
return h ? h.name : (hid || '-')
return h ? `${h.name || ''} (${h.ip || h.id})` : (hid || '-')
}
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
@@ -208,8 +248,14 @@ const loadHostOptions = async () => {
const loadList = async () => {
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
const params = {
service_id: serviceId.value,
page: queryParams.page,
count: queryParams.count
}
if (keyword.value) params.keyword = keyword.value
if (filterHostId.value) params.host_id = filterHostId.value
if (filterUserId.value) params.user_id = parseInt(filterUserId.value) || undefined
const res = await getUserNetworkingList(params)
if (res?.data?.code === 200) {
const d = res.data.data
@@ -230,19 +276,26 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const createDialogVisible = ref(false)
const createFormRef = ref(null)
const createForm = reactive({ name: '', description: '', user_id: null, host_id: null, bridge_name: '', gateway: '' })
const createUserName = ref('')
const showCreateUserSelector = ref(false)
const createRules = {
name: [{ required: true, message: '请输入组网名称', trigger: 'blur' }],
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }],
bridge_name: [{ required: true, message: '请输入网桥名称', trigger: 'blur' }],
gateway: [{ required: true, message: '请输入网关地址', trigger: 'blur' }]
user_id: [{ required: true, message: '请选择用户', trigger: 'change' }],
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
}
const handleAdd = () => {
Object.assign(createForm, { name: '', description: '', user_id: null, host_id: null, bridge_name: '', gateway: '' })
createUserName.value = ''
createDialogVisible.value = true
}
const handleCreateUserSelected = (user) => {
createForm.user_id = user.user_id || user.id
createUserName.value = user.user_name || user.name || ''
createFormRef.value?.validateField('user_id')
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
@@ -254,8 +307,8 @@ const submitCreate = () => {
if (createForm.description) fd.append('description', createForm.description)
fd.append('user_id', createForm.user_id)
fd.append('host_id', createForm.host_id)
fd.append('bridge_name', createForm.bridge_name)
fd.append('gateway', createForm.gateway)
if (createForm.bridge_name) fd.append('bridge_name', createForm.bridge_name)
if (createForm.gateway) fd.append('gateway', createForm.gateway)
const res = await createUserNetworking(fd)
if (res?.data?.code === 200) {
ElMessage.success('组网创建成功')
@@ -323,7 +376,7 @@ const handleViewDetail = async (row) => {
const res = await getUserNetworkingDetail({ service_id: serviceId.value, networking_id: row.id })
if (res?.data?.code === 200) {
const d = res.data.data
if (d?.data) currentDetail.value = d.data
currentDetail.value = d?.data || d || row
detailNetworks.value = d?.networks || []
}
} catch { ElMessage.warning('获取详情失败') } finally { detailLoading.value = false }
@@ -331,7 +384,7 @@ const handleViewDetail = async (row) => {
// ---- 删除组网 ----
const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除组网「${row.name}」吗?此操作不可恢复。`, '删除确认', {
ElMessageBox.confirm(`确定要删除组网「${row.name || row.id}」吗?此操作不可恢复。`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
@@ -344,15 +397,18 @@ const handleDelete = (row) => {
// ---- 移除组网下的网络 ----
const handleRemoveNetwork = (netItem) => {
ElMessageBox.confirm(`确定要从组网中移除该网络吗?`, '移除确认', {
const net = netItem.network || {}
const addrLabel = net.address ? ` (${net.address})` : ''
const vmLabel = net.vm_id ? `,关联虚拟机 ID: ${net.vm_id}` : ''
ElMessageBox.confirm(`确定要从组网中移除网络 #${net.id || '?'}${addrLabel}${vmLabel} 吗?`, '移除确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('networking_id', currentDetail.value.id)
fd.append('network_id', netItem.network?.id || netItem.id)
fd.append('vm_id', netItem.vm_id)
fd.append('network_id', net.id)
fd.append('vm_id', net.vm_id || 0)
const res = await removeUserNetworkingNetwork(fd)
if (res?.data?.code === 200) {
ElMessage.success('移除成功')
@@ -380,11 +436,12 @@ onMounted(async () => {
.sub-info { font-size: 13px; color: #909399; }
.header-right { display: flex; gap: 8px; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.networks-section { margin-top: 8px; }
.networks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.networks-header h4 { margin: 0; font-size: 15px; color: #303133; }
.mono-text { font-family: 'Cascadia Code', 'Consolas', 'Monaco', monospace; font-size: 12px; color: #303133; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
File diff suppressed because it is too large Load Diff
+74 -52
View File
@@ -54,10 +54,10 @@
<el-tag :type="vmStatusType(row.status)" size="small">{{ vmStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="宿主机" width="140">
<!-- <el-table-column label="宿主机" width="140">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column>
<el-table-column prop="user_id" label="用户" width="80" />
</el-table-column> -->
<!-- <el-table-column prop="user_id" label="用户" width="80" /> -->
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleGoDetail(row)">详情</el-button>
@@ -70,7 +70,7 @@
<el-dropdown-item command="reboot">重启</el-dropdown-item>
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
<el-dropdown-item command="resume" v-if="row.status === 'paused'">恢复</el-dropdown-item>
<el-dropdown-item command="rebuild" divided></el-dropdown-item>
<el-dropdown-item command="rebuild" divided></el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exit_rescue">退出救援</el-dropdown-item>
<el-dropdown-item command="detail" divided>查看详情</el-dropdown-item>
@@ -109,24 +109,31 @@
</el-form-item>
<el-divider content-position="left">宿主机配置(二选一)</el-divider>
<el-form-item label="分配方式">
<el-radio-group v-model="hostMode">
<el-radio value="host">指定宿主机</el-radio>
<el-radio value="group">指定宿主机组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="宿主机" v-if="hostMode === 'host'">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
<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="宿主机组" v-if="hostMode === 'group'">
<div class="bind-selector-row">
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<template v-if="isEmbeddedHost">
<el-form-item label="宿主机">
<el-input :model-value="embeddedHostLabel" disabled />
</el-form-item>
</template>
<template v-else>
<el-form-item label="分配方式">
<el-radio-group v-model="hostMode">
<el-radio value="host">指定宿主机</el-radio>
<el-radio value="group">指定宿主机组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="宿主机" v-if="hostMode === 'host'">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
<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="宿主机组" v-if="hostMode === 'group'">
<div class="bind-selector-row">
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
</template>
<el-divider content-position="left">资源配置</el-divider>
<div class="resource-row">
@@ -183,9 +190,9 @@
</template>
</el-dialog>
<!-- 重弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="虚拟机" width="440px" destroy-on-close>
<el-alert title="会使用新镜像重置虚拟机原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<!-- 重弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="虚拟机" width="440px" destroy-on-close>
<el-alert title="会使用新镜像重置虚拟机原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<el-form label-width="100px">
<el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item>
<el-form-item label="新镜像" required>
@@ -197,7 +204,7 @@
</el-form>
<template #footer>
<el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重</el-button>
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重</el-button>
</template>
</el-dialog>
@@ -234,7 +241,7 @@
<!-- 网络信息 -->
<template v-if="currentDetail?.networks && currentDetail.networks.length">
<h4 style="margin: 16px 0 8px">🌐 网络</h4>
<h4 style="margin: 16px 0 8px">网络</h4>
<el-table :data="currentDetail.networks" size="small" stripe border>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" />
@@ -246,7 +253,7 @@
<!-- 数据卷信息 -->
<template v-if="currentDetail?.volumes && currentDetail.volumes.length">
<h4 style="margin: 16px 0 8px">💿 数据卷</h4>
<h4 style="margin: 16px 0 8px">数据卷</h4>
<el-table :data="currentDetail.volumes" size="small" stripe border>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" />
@@ -262,7 +269,7 @@
<!-- 镜像信息 -->
<template v-if="currentDetail?.image">
<h4 style="margin: 16px 0 8px">🖼️ 镜像</h4>
<h4 style="margin: 16px 0 8px">镜像</h4>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="ID">{{ currentDetail.image.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentDetail.image.name }}</el-descriptions-item>
@@ -299,7 +306,7 @@
<!-- 镜像选择器 (创建) -->
<ImageSelectorPopup v-model="showCreateImageSelector" :service-id="serviceId" :current-id="createForm.image_id" @confirm="handleCreateImageSelected" />
<!-- 镜像选择器 (重) -->
<!-- 镜像选择器 (重) -->
<ImageSelectorPopup v-model="showRebuildImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="handleRebuildImageSelected" />
<!-- 宿主机组选择器 -->
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="serviceId" :current-id="createForm.host_group_id" @confirm="handleHostGroupSelected" />
@@ -329,8 +336,15 @@ 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 isEmbeddedHost = computed(() => !!(embedded && injectedHostId?.value))
const embeddedHostLabel = computed(() => {
const d = injectedHostDetail?.value
if (!d) return `宿主机 (ID: ${injectedHostId?.value || ''})`
return `${d.name || ''} (${d.ip || d.id || ''})`
})
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
@@ -381,10 +395,12 @@ const diskDisplay = computed({
set: (v) => { createForm.system_size = Math.round(v * getDiskFactor()) }
})
const loadNetworkOptions = async (hostId) => {
if (!hostId) return
const loadNetworkOptions = async (hid) => {
if (!hid) return
try {
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, used: false, page: 1, page_size: 200 })
const params = { service_id: serviceId.value, host_id: hid, used: false, page: 1, page_size: 10 }
if (injectedHostId?.value) params.type = 'bridge'
const res = await getNetworkList(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -400,7 +416,7 @@ const getHostLabel = (hid) => {
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: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -513,7 +529,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
Object.assign(createForm, {
name: '', host_id: null, image_id: 0,
name: '', host_id: injectedHostId?.value || null, image_id: 0,
vcpu: 0, memory: 0, system_size: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: null, user_id: 0, ip_num: 0, network_ids: [],
_imageName: '', _groupName: '', _userName: ''
@@ -524,6 +540,9 @@ const handleAdd = () => {
ipMode.value = 'num'
networkOptions.value = []
createDialogVisible.value = true
if (injectedHostId?.value) {
loadNetworkOptions(injectedHostId.value)
}
}
const submitCreate = () => {
@@ -536,20 +555,21 @@ const submitCreate = () => {
if (!valid) return
submitLoading.value = true
try {
const payload = {
service_id: serviceId.value,
image_id: createForm.image_id,
vcpu: createForm.vcpu, memory: createForm.memory, system_size: createForm.system_size,
user_id: createForm.user_id
}
if (createForm.name) payload.name = createForm.name
if (createForm.rx_bandwidth) payload.rx_bandwidth = createForm.rx_bandwidth
if (createForm.tx_bandwidth) payload.tx_bandwidth = createForm.tx_bandwidth
if (hostMode.value === 'host') payload.host_id = createForm.host_id
else payload.host_group_id = createForm.host_group_id
if (ipMode.value === 'num') payload.ip_num = createForm.ip_num
else payload.network_ids = createForm.network_ids
const res = await createVm(payload)
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('image_id', createForm.image_id)
fd.append('vcpu', createForm.vcpu)
fd.append('memory', createForm.memory)
fd.append('system_size', createForm.system_size)
fd.append('user_id', createForm.user_id)
if (createForm.name) fd.append('name', createForm.name)
if (createForm.rx_bandwidth) fd.append('rx_bandwidth', createForm.rx_bandwidth)
if (createForm.tx_bandwidth) fd.append('tx_bandwidth', createForm.tx_bandwidth)
if (hostMode.value === 'host') fd.append('host_id', createForm.host_id)
else fd.append('host_group_id', createForm.host_group_id)
if (ipMode.value === 'num') fd.append('ip_num', createForm.ip_num)
else createForm.network_ids.forEach(id => fd.append('network_ids', id))
const res = await createVm(fd)
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, '创建失败')) }
@@ -604,9 +624,9 @@ const submitRebuild = async () => {
submitLoading.value = true
try {
const res = await rebuildVm({ service_id: serviceId.value, vm_id: rebuildTarget.value.id, image_id: rebuildImageId.value })
if (res?.data?.code === 200) { ElMessage.success('重成功'); rebuildDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '重失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重失败')) } finally { submitLoading.value = false }
if (res?.data?.code === 200) { ElMessage.success('重成功'); rebuildDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '重失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重失败')) } finally { submitLoading.value = false }
}
const handleRescue = (row) => {
@@ -702,6 +722,8 @@ onMounted(async () => {
loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>
+1 -1
View File
@@ -190,7 +190,7 @@ const formatDelay = (ns) => {
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: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
+291
View File
@@ -0,0 +1,291 @@
<template>
<div class="volume-detail-page">
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回上一页</el-button>
<el-divider direction="vertical" />
<span class="page-title">数据卷详情</span>
</div>
<div class="header-right">
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
</div>
</div>
<div class="main-content" v-loading="loading">
<!-- 基本信息 -->
<el-card shadow="never" class="info-card" v-if="detail">
<template #header><span class="card-title">基本信息</span></template>
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ detail.size ? detail.size + ' GB' : '-' }}</el-descriptions-item>
<el-descriptions-item label="系统卷">
<el-tag :type="detail.is_system ? 'danger' : 'info'" size="small">{{ detail.is_system ? '是' : '否' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="挂载状态">
<el-tag :type="detail.is_mount ? 'success' : 'info'" size="small">{{ detail.is_mount ? '已挂载' : '未挂载' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="volStatusType(detail.status)" size="small">{{ volStatusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="宿主机ID">{{ detail.host_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="宿主机卷ID">{{ detail.host_volume_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ detail.path || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="目标设备" v-if="detail.target_device">{{ detail.target_device }}</el-descriptions-item>
<el-descriptions-item label="虚拟机ID" v-if="detail.vm_id">{{ detail.vm_id }}</el-descriptions-item>
<el-descriptions-item label="镜像ID" v-if="detail.image_id">{{ detail.image_id }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 操作 -->
<el-card shadow="never" class="info-card" v-if="detail">
<template #header><span class="card-title">操作</span></template>
<div class="action-buttons">
<el-button type="primary" plain @click="handleResize">调整大小</el-button>
<el-button type="success" plain @click="handleMount" v-if="!detail.is_mount">挂载</el-button>
<el-button type="warning" plain @click="handleUnmount" v-if="detail.is_mount">卸载</el-button>
<el-button type="info" plain @click="handleTransfer">迁移</el-button>
<el-button type="danger" plain @click="handleDelete">删除</el-button>
</div>
</el-card>
</div>
<!-- 调整大小弹窗 -->
<el-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="当前大小">{{ detail?.size || 0 }} GB</el-form-item>
<el-form-item label="新大小(GB)">
<el-input-number v-model="newSize" :min="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resizeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @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="数据卷">{{ detail?.name }} ({{ detail?.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="actionLoading" @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" v-loading="transferLoading">
<el-form-item label="数据卷">{{ detail?.name }} ({{ detail?.size }} GB)</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="transferHostId" placeholder="请选择目标宿主机" style="width: 100%" filterable>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" :disabled="h.id === detail?.host_id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialogVisible = false">取消</el-button>
<el-button type="success" :loading="actionLoading" @click="submitTransfer">确定迁移</el-button>
</template>
</el-dialog>
<VmSelectorPopup v-model="showMountVmSelector" :service-id="serviceId" :host-id="detail?.host_id || 0" :current-id="mountVmId" @confirm="vm => { mountVmId = vm.id; mountVmName = vm.name || '' }" />
</div>
</template>
<script setup>
import { ref, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
import {
getVolumeDetail, getRemoteHostList,
resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { useTagsViewStore } from '@/store/tagsViewStore'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
const route = useRoute()
const router = useRouter()
const tagsViewStore = useTagsViewStore()
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
const volumeId = computed(() => parseInt(route.query.volume_id) || 0)
const loading = ref(false)
const actionLoading = ref(false)
const detail = ref(null)
const hostOptions = ref([])
const volStatusType = (s) => ({ ready: 'success', pending: 'info', error: 'danger', unknown: 'warning' }[s] || 'info')
const volStatusLabel = (s) => ({ ready: '就绪', pending: '等待中', error: '错误', unknown: '未知' }[s] || s || '-')
const formatTimestamp = (ts) => {
if (!ts) return '-'
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
return '-'
}
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
}
} catch { /* */ }
}
const loadDetail = async () => {
if (!volumeId.value) return
loading.value = true
try {
const res = await getVolumeDetail({ service_id: serviceId.value, volume_id: volumeId.value })
if (res?.data?.code === 200 && res?.data?.data) {
detail.value = res.data.data.volume ?? res.data.data.data ?? res.data.data
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
}
//
const resizeDialogVisible = ref(false)
const newSize = ref(1)
const handleResize = () => {
if (!detail.value) return
newSize.value = detail.value.size || 10
resizeDialogVisible.value = true
}
const submitResize = async () => {
actionLoading.value = true
try {
const res = await resizeVolume({ service_id: serviceId.value, volume_id: volumeId.value, size: newSize.value })
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '调整失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { actionLoading.value = false }
}
//
const mountDialogVisible = ref(false)
const mountVmId = ref(0)
const mountVmName = ref('')
const showMountVmSelector = ref(false)
const handleMount = () => {
mountVmId.value = 0; mountVmName.value = ''
mountDialogVisible.value = true
}
const submitMount = async () => {
if (!mountVmId.value) { ElMessage.warning('请选择虚拟机'); return }
actionLoading.value = true
try {
const res = await mountVolume({ service_id: serviceId.value, volume_id: volumeId.value, vm_id: mountVmId.value })
if (res?.data?.code === 200) { ElMessage.success('挂载成功'); mountDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '挂载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) } finally { actionLoading.value = false }
}
//
const handleUnmount = () => {
if (!detail.value) return
ElMessageBox.confirm(`确定要卸载数据卷「${detail.value.name}」吗?`, '卸载确认', {
confirmButtonText: '卸载', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
actionLoading.value = true
try {
const res = await unmountVolume({ service_id: serviceId.value, volume_id: volumeId.value })
if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '卸载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '卸载失败')) } finally { actionLoading.value = false }
}).catch(() => {})
}
//
const transferDialogVisible = ref(false)
const transferHostId = ref('')
const transferLoading = ref(false)
const handleTransfer = async () => {
transferHostId.value = ''
transferDialogVisible.value = true
if (!hostOptions.value.length) {
transferLoading.value = true
try { await loadHostOptions() } finally { transferLoading.value = false }
}
}
const submitTransfer = async () => {
if (!transferHostId.value) { ElMessage.warning('请选择目标宿主机'); return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('volume_id', volumeId.value)
fd.append('host_id', transferHostId.value)
const res = await transferVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('迁移已触发'); transferDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '迁移失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
}
//
const handleDelete = () => {
if (!detail.value) return
ElMessageBox.confirm(`确定要删除数据卷「${detail.value.name}」吗?此操作不可恢复!`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteVolume({ service_id: serviceId.value, volume_id: volumeId.value })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.back()
}
let loadedVolumeId = null
let isPageActive = false
const initPage = () => {
if (!volumeId.value || loadedVolumeId === volumeId.value) return
loadedVolumeId = volumeId.value
loadDetail()
}
watch(volumeId, () => { if (isPageActive) initPage() })
onActivated(() => { isPageActive = true; if (loadedVolumeId !== volumeId.value) initPage() })
onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() })
</script>
<style scoped>
.volume-detail-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; flex-wrap: wrap; gap: 8px; }
.header-left { display: flex; align-items: center; gap: 0; }
.back-btn { font-size: 14px; color: #606266; }
.back-btn:hover { color: #409eff; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; gap: 8px; }
.main-content { padding: 20px; }
.info-card { margin-bottom: 20px; }
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
.bind-selector-row { display: flex; align-items: center; width: 100%; }
.action-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
</style>
+52 -60
View File
@@ -47,14 +47,21 @@
<el-table-column label="宿主机" width="140">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column>
<el-table-column label="操作" width="340" fixed="right">
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
<el-button link type="primary" @click="handleResize(row)">调整大小</el-button>
<el-button link type="primary" @click="handleMount(row)">挂载</el-button>
<el-button link type="warning" @click="handleUnmount(row)">卸载</el-button>
<el-button link type="success" @click="handleTransfer(row)">迁移</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
<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>
@@ -137,10 +144,12 @@
</el-alert>
<el-form label-width="100px">
<el-form-item label="数据卷">{{ transferTarget?.name }} ({{ transferTarget?.size }} GB)</el-form-item>
<el-form-item label="当前宿主机">{{ getHostLabel(transferTarget?.host_id) }}</el-form-item>
<el-form-item label="当前宿主机">
<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>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" :disabled="h.id === transferTarget?.host_id" />
<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>
@@ -150,32 +159,6 @@
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="数据卷详情" width="600px" destroy-on-close>
<div v-loading="detailLoading">
<el-descriptions :column="2" border v-if="currentDetail">
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ currentDetail.size }} GB</el-descriptions-item>
<el-descriptions-item label="系统卷">
<el-tag :type="currentDetail.is_system ? 'danger' : 'info'" size="small">{{ currentDetail.is_system ? '是' : '否' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="volStatusType(currentDetail.status)" size="small">{{ volStatusLabel(currentDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2">
<span class="mono-text">{{ currentDetail.path || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="目标设备" v-if="currentDetail.target_device">{{ currentDetail.target_device }}</el-descriptions-item>
<el-descriptions-item label="虚拟机ID" v-if="currentDetail.vm_id">{{ currentDetail.vm_id }}</el-descriptions-item>
<el-descriptions-item label="镜像ID" v-if="currentDetail.image_id">{{ currentDetail.image_id }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 镜像选择器 -->
<ImageSelectorPopup v-model="showImageSelector" :service-id="serviceId" :current-id="createForm.image_id" @confirm="handleImageSelected" />
@@ -190,9 +173,9 @@
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, ArrowLeft } from '@element-plus/icons-vue'
import { Plus, Refresh, ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
import {
getRemoteHostList, getVolumeList, getVolumeDetail,
getRemoteHostList, getVolumeList,
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
@@ -205,13 +188,13 @@ 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 detailLoading = ref(false)
const volumeList = ref([])
const total = ref(0)
const filterStatus = ref('')
@@ -237,7 +220,7 @@ const formatTimestamp = (ts) => {
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
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
@@ -252,8 +235,6 @@ const createDialogVisible = ref(false)
const createFormRef = ref(null)
const resizeDialogVisible = ref(false)
const mountDialogVisible = ref(false)
const detailVisible = ref(false)
const currentDetail = ref(null)
const resizeTarget = ref(null)
const newSize = ref(1)
const mountTarget = ref(null)
@@ -265,6 +246,15 @@ 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)
@@ -294,7 +284,7 @@ const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
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)
@@ -311,8 +301,8 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
Object.assign(createForm, {
name: '', size: 10, host_id: hostId.value || 0,
is_system: false, image_id: 0, vm_id: 0, target_device: '',
name: '', size: 10, host_id: hostId.value || '',
is_system: false, image_id: '', vm_id: '', target_device: '',
_imageName: '', _vmName: ''
})
createDialogVisible.value = true
@@ -377,11 +367,14 @@ const handleUnmount = (row) => {
}
//
const handleTransfer = async (row) => {
const handleTransfer = (row) => {
transferTarget.value = row
transferHostId.value = ''
if (!hostOptions.value.length) await loadHostOptions()
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 () => {
@@ -404,18 +397,8 @@ const submitTransfer = async () => {
finally { transferLoading.value = false }
}
const handleViewDetail = async (row) => {
detailVisible.value = true
detailLoading.value = true
currentDetail.value = row
try {
const res = await getVolumeDetail({ service_id: serviceId.value, volume_id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
currentDetail.value = d.volume ?? d.data ?? d
}
} catch { /* fallback */ }
finally { detailLoading.value = false }
const handleViewDetail = (row) => {
router.push({ path: '/virtualization/volume-detail', query: { service_id: serviceId.value, volume_id: row.id } })
}
const handleDelete = (row) => {
@@ -430,6 +413,13 @@ const handleDelete = (row) => {
}).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(() => {
@@ -438,6 +428,8 @@ onMounted(() => {
loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>
+271
View File
@@ -0,0 +1,271 @@
# 虚拟化平台管理 — 页面关系图谱
## 一、目录结构
```
src/
├── views/virtualization/ ← 页面组件(19个)
│ ├── KvmService.vue # 主控服务管理(入口列表页)
│ ├── KvmServiceDetail.vue # 主控服务详情(核心容器页)
│ ├── HostTreeManage.vue # 宿主机树形管理
│ ├── HostManage.vue # 宿主机列表管理(独立路由)
│ ├── HostDetail.vue # 宿主机详情(容器页)
│ ├── HostGroupMapping.vue # 宿主机组映射管理
│ ├── RemoteHostGroupManage.vue # 远程宿主机组管理
│ ├── ImageManage.vue # 镜像管理
│ ├── ImageDetail.vue # 镜像详情
│ ├── NetworkManage.vue # 网络管理
│ ├── VolumeManage.vue # 数据卷管理
│ ├── VmManage.vue # 虚拟机管理
│ ├── VmDetail.vue # 虚拟机详情(容器页)
│ ├── SecurityGroupManage.vue # 安全组管理
│ ├── SecurityGroupDetail.vue # 安全组详情
│ ├── VncNodeManage.vue # VNC节点管理
│ ├── SnapshotManage.vue # 快照管理
│ ├── BackupManage.vue # 备份管理
│ └── UserNetworkingManage.vue # 用户组网管理
└── components/admin/ ← 公共弹窗选择组件(6个)
├── VmSelectorPopup.vue # 虚拟机选择器
├── VolumeSelectorPopup.vue # 数据卷选择器
├── NetworkSelectorPopup.vue # 网络选择器
├── ImageSelectorPopup.vue # 镜像选择器
├── SecurityGroupSelectorPopup.vue # 安全组选择器
└── HostGroupSelectorPopup.vue # 宿主机组选择器
```
---
## 二、路由结构
所有页面都在 `/virtualization` 路由下,层级扁平(非嵌套路由),通过 `query` 参数传递上下文。
```
/virtualization
├── /kvm-service → KvmService.vue (菜单入口)
├── /kvm-service-detail → KvmServiceDetail.vue hidden
├── /host-group-mapping → HostGroupMapping.vue (菜单可见)
├── /host-manage → HostManage.vue hidden
├── /image-manage → ImageManage.vue hidden
├── /network-manage → NetworkManage.vue hidden
├── /volume-manage → VolumeManage.vue hidden
├── /vm-manage → VmManage.vue hidden
├── /security-group → SecurityGroupManage.vue hidden
├── /vnc-node → VncNodeManage.vue hidden
├── /host-detail → HostDetail.vue hidden
├── /image-detail → ImageDetail.vue hidden
├── /vm-detail → VmDetail.vue hidden
└── /security-group-detail → SecurityGroupDetail.vue hidden
```
> 标记 `hidden` 的路由不在侧边菜单显示,用户通过页面内操作跳转进入。
---
## 三、页面嵌套机制:`provide / inject` 上下文注入
嵌套不通过路由嵌套实现,而是通过 **父页面直接引入子组件** + **Vue3 `provide/inject`** 传递上下文。
### 3.1 provide 注入方
| 父页面 | provide 的 key | 说明 |
|---|---|---|
| `KvmServiceDetail.vue` | `embedded`, `serviceId`, `serviceName` | 主控服务详情作为容器 |
| `HostDetail.vue` | `embedded`, `serviceId`, `serviceName`, `hostId` | 宿主机详情作为容器,额外注入 `hostId` |
### 3.2 inject 接收方
以下组件都同时支持 **独立路由访问****被嵌入到父页面的标签页中**
| 组件 | inject 的 key | 嵌入后行为变化 |
|---|---|---|
| `HostTreeManage.vue` | `embedded`, `serviceId`, `serviceName` | 隐藏返回按钮/主控选择器 |
| `HostManage.vue` | `embedded`, `serviceId`, `serviceName` | 隐藏返回按钮/主控选择器 |
| `ImageManage.vue` | `embedded`, `serviceId`, `serviceName`, `hostId` | 隐藏返回按钮;有 hostId 时隐藏宿主机筛选 |
| `NetworkManage.vue` | `embedded`, `serviceId`, `serviceName`, `hostId` | 隐藏返回按钮;有 hostId 时隐藏宿主机筛选 |
| `VolumeManage.vue` | `embedded`, `serviceId`, `serviceName`, `hostId` | 隐藏返回按钮;有 hostId 时隐藏宿主机筛选 |
| `VmManage.vue` | `embedded`, `serviceId`, `serviceName`, `hostId` | 隐藏返回按钮;有 hostId 时筛选该宿主机下的虚拟机 |
| `SecurityGroupManage.vue` | `embedded`, `serviceId`, `serviceName` | 隐藏返回按钮/主控选择器 |
| `VncNodeManage.vue` | `embedded`, `serviceId`, `serviceName` | 隐藏返回按钮/主控选择器 |
| `SnapshotManage.vue` | `serviceId` | 仅嵌入使用,依赖父级 serviceId |
| `BackupManage.vue` | `serviceId` | 仅嵌入使用,依赖父级 serviceId |
| `UserNetworkingManage.vue` | `embedded`, `serviceId`, `serviceName` | 隐藏返回按钮/主控选择器 |
| `RemoteHostGroupManage.vue` | `embedded`, `serviceId`, `serviceName` | 隐藏返回按钮/主控选择器 |
| `HostGroupMapping.vue` | `embedded`, `serviceId`, `serviceName` | 隐藏返回按钮/主控选择器 |
---
## 四、完整关系图谱
```
┌─────────────────────────────────────────────────────────────────────────┐
│ KvmService.vue(主控服务列表) │
│ 路由: /virtualization/kvm-service │
│ │
│ 点击「查看详情」 ──router.push──→ KvmServiceDetail │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ KvmServiceDetail.vue(主控服务详情 · 容器页) │
│ 路由: /virtualization/kvm-service-detail │
│ provide: embedded, serviceId, serviceName │
│ │
│ ┌─ Tabs ─────────────────────────────────────────────────────────┐ │
│ │ 宿主机管理 │ 镜像管理 │ 网络管理 │ 数据卷管理 │ 虚拟机管理 │ │ │
│ │ 安全组管理 │ VNC节点 │ 快照管理 │ 备份管理 │ 用户组网 │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 标签页内嵌组件(defineAsyncComponent 懒加载): │
│ ├── HostTreeManage.vue ─── 点击宿主机 ──router.push──→ HostDetail │
│ ├── ImageManage.vue ────── 点击镜像 ──router.push──→ ImageDetail │
│ ├── NetworkManage.vue │
│ ├── VolumeManage.vue │
│ ├── VmManage.vue ──────── 点击虚拟机 ──router.push──→ VmDetail │
│ ├── SecurityGroupManage.vue ── 点击安全组 ──router.push──→ SGDetail │
│ ├── VncNodeManage.vue │
│ ├── SnapshotManage.vue │
│ ├── BackupManage.vue │
│ └── UserNetworkingManage.vue │
└─────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────┐ ┌──────────────────────────┐
│ HostDetail.vue │ │ ImageDetail.vue │ │ SecurityGroupDetail.vue │
│ (宿主机详情·容器页) │ │ (镜像详情) │ │ (安全组详情) │
│ provide: embedded, │ │ │ │ │
│ serviceId, │ │ ← 返回主控详情 │ │ ← 返回主控详情 │
│ serviceName, hostId │ └──────────────────┘ └──────────────────────────┘
│ │
│ ┌─ Tabs ──────────────────────────────────┐
│ │ 基本信息 │ 监控 │ 镜像管理 │ 网络管理 │ │
│ │ 数据卷管理 │ 虚拟机管理 │ 快照 │ 备份 │ │
│ └──────────────────────────────────────────┘
│ │
│ 标签页内嵌组件(直接 import):
│ ├── ImageManage.vue ← 同一组件,inject hostId 后隐藏宿主机筛选
│ ├── NetworkManage.vue ← 同一组件,inject hostId 后隐藏宿主机筛选
│ ├── VolumeManage.vue ← 同一组件,inject hostId 后隐藏宿主机筛选
│ ├── VmManage.vue ← 同一组件,inject hostId 后筛选范围缩小
│ ├── SnapshotManage.vue
│ └── BackupManage.vue
│ │
│ VmManage 内点击「查看详情」──router.push──→ VmDetail
└──────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ VmDetail.vue(虚拟机详情 · 容器页) │
│ 路由: /virtualization/vm-detail │
│ │
│ ┌─ Tabs ─────────────────────────────────────────────────────────┐ │
│ │ 实例详情 │ 网络信息 │ 磁盘卷信息 │ 安全组 │ 快照 │ 备份 │ 监控 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 不嵌入外部页面组件,内联自行管理所有标签页内容 │
│ 使用弹窗选择组件: │
│ ├── ImageSelectorPopup (重建虚拟机时选择镜像) │
│ ├── VolumeSelectorPopup (挂载已有数据卷) │
│ ├── NetworkSelectorPopup (添加已有网络 / 重构时选择网络) │
│ └── SecurityGroupSelectorPopup(绑定安全组) │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 五、组件复用关系汇总
### 5.1 同一页面组件在多个容器中被嵌入
| 管理组件 | 独立路由 | 嵌入 KvmServiceDetail | 嵌入 HostDetail |
|---|:---:|:---:|:---:|
| `HostTreeManage.vue` | ✗ | ✔ | ✗ |
| `HostManage.vue` | ✔ | ✗ | ✗ |
| `ImageManage.vue` | ✔ | ✔ | ✔ |
| `NetworkManage.vue` | ✔ | ✔ | ✔ |
| `VolumeManage.vue` | ✔ | ✔ | ✔ |
| `VmManage.vue` | ✔ | ✔ | ✔ |
| `SecurityGroupManage.vue` | ✔ | ✔ | ✗ |
| `VncNodeManage.vue` | ✔ | ✔ | ✗ |
| `SnapshotManage.vue` | ✗ | ✔ | ✔ |
| `BackupManage.vue` | ✗ | ✔ | ✔ |
| `UserNetworkingManage.vue` | ✗ | ✔ | ✗ |
| `RemoteHostGroupManage.vue` | ✗ | ✗(独立路由可能存在) | ✗ |
| `HostGroupMapping.vue` | ✔ | ✗ | ✗ |
### 5.2 弹窗选择组件使用关系
| 选择组件 | 被哪些页面使用 |
|---|---|
| `VmSelectorPopup` | VolumeManage、VncNodeManage、UserNetworkingManage、SecurityGroupManage、SecurityGroupDetail |
| `ImageSelectorPopup` | VmManage、VolumeManage、VmDetail |
| `HostGroupSelectorPopup` | VmManage、HostManage、HostDetail |
| `VolumeSelectorPopup` | VmDetail |
| `NetworkSelectorPopup` | VmDetail |
| `SecurityGroupSelectorPopup` | VmDetail |
---
## 六、页面跳转路径总览
```
KvmService(列表)
└──→ KvmServiceDetail(详情)
├──→ HostDetail(宿主机详情)──→ 返回 KvmServiceDetail
│ └──→ VmDetail(虚拟机详情)──→ 返回 KvmServiceDetail
├──→ ImageDetail(镜像详情)──→ 返回 KvmServiceDetail
├──→ VmDetail(虚拟机详情)──→ 返回 KvmServiceDetail
└──→ SecurityGroupDetail(安全组详情)──→ 返回 KvmServiceDetail
```
---
## 七、嵌入机制核心代码模式
### 父级容器 provide(以 KvmServiceDetail 为例)
```javascript
provide('embedded', true)
provide('serviceId', serviceId) // ref<number>
provide('serviceName', serviceName) // ref<string>
```
### 子组件 inject + 自适应
```javascript
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedHostId = inject('hostId', null) // 仅 HostDetail 下有
// 根据 embedded 控制 UI
// - 隐藏顶部返回栏和主控选择器
// - 自动使用注入的 serviceId / hostId 替代手动选择
```
### KvmServiceDetail 使用 defineAsyncComponent 懒加载
```javascript
const HostTreeManage = defineAsyncComponent(() => import('./HostTreeManage.vue'))
const ImageManage = defineAsyncComponent(() => import('./ImageManage.vue'))
// ... 其余同理
```
### HostDetail 使用直接 import
```javascript
import ImageManage from '@/views/virtualization/ImageManage.vue'
import NetworkManage from '@/views/virtualization/NetworkManage.vue'
// ... 其余同理
```
---
## 八、设计特点总结
1. **双模式组件**ImageManage、NetworkManage、VolumeManage、VmManage 等组件同时支持独立路由和嵌入模式,通过 `inject('embedded')` 判断当前上下文,动态调整 UI 和数据来源。
2. **扁平路由 + 组件嵌入**:路由层级扁平,页面间嵌套关系通过组件引入(import)实现,而非路由嵌套(children)。
3. **上下文逐层传递**KvmServiceDetail 传递 `serviceId`HostDetail 在此基础上追加 `hostId`,子组件根据可用 key 自动收窄数据范围。
4. **弹窗选择组件解耦**:选择器作为独立的 Popup 组件放在 `components/admin/` 下,通过 `v-model` 控制显隐、`emit('confirm')` 回传选中数据,各页面按需引用。
+20 -8556
View File
File diff suppressed because it is too large Load Diff