feat: 对接用户组网管理
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -35,8 +35,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -43,8 +43,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
const handleSyncToHost = () => {
|
||||
if (embedded && injectedHostId?.value) {
|
||||
syncHostId.value = injectedHostId.value
|
||||
} else {
|
||||
syncHostId.value = ''
|
||||
if (!hostOptions.value.length) await loadHostOptions()
|
||||
}
|
||||
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
|
||||
if (embedded && injectedHostId?.value) {
|
||||
reloadHostId.value = injectedHostId.value
|
||||
} else {
|
||||
reloadHostId.value = ''
|
||||
if (!hostOptions.value.length) await loadHostOptions()
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
<span class="mono-text">{{ row.network?.mac_address || '-' }}</span>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</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
@@ -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,6 +109,12 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">宿主机配置(二选一)</el-divider>
|
||||
<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>
|
||||
@@ -127,6 +133,7 @@
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user