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

This commit is contained in:
2026-03-24 18:57:52 +08:00
parent 3357566b02
commit 40a5e486a6
29 changed files with 1895 additions and 9381 deletions
+1 -1
View File
@@ -618,7 +618,7 @@ const fetchPlanList = async () => {
try {
const response = await getServerPlan({
server_id: props.ID,
count: 100
count: 10
});
if (response && response.data && response.data.code === 200) {
+1 -1
View File
@@ -2407,7 +2407,7 @@ const fetchContainerPlanList = async () => {
try {
const response = await getServerPlan({
server_id: route.query.server_id,
count: 100
count: 10
});
console.log("获取容器套餐列表1111",response);
@@ -224,6 +224,8 @@ const handleProgress = async (row) => {
}
onMounted(() => { loadList() })
defineExpose({ loadList })
</script>
<style scoped>
+39 -14
View File
@@ -36,9 +36,19 @@
<span class="status-value">{{ detail.ip || '-' }}</span>
</div>
<div class="status-item">
<span class="status-label">资源</span>
<span class="status-value">{{ detail.max_cpu || 0 }} | {{ formatMemKB(detail.max_memory) }} | {{ formatDiskGB(detail.max_disk) }}</span>
<span class="status-label">CPU</span>
<span class="status-value">{{ detail.max_cpu || 0 }}</span>
</div>
<div class="status-item">
<span class="status-label">内存</span>
<span class="status-value">{{ formatMemKB(detail.max_memory) }}</span>
</div>
<div class="status-item">
<span class="status-label">磁盘</span>
<span class="status-value">{{ formatDiskGB(detail.max_disk) }}</span>
</div>
</div>
<div class="status-bar" v-if="detail">
<div class="status-item">
<span class="status-label">带宽</span>
<span class="status-value">{{ detail.rx_bandwidth || 0 }} / {{ detail.tx_bandwidth || 0 }} Mbps</span>
@@ -174,22 +184,22 @@
</el-tab-pane>
<el-tab-pane label="镜像管理" name="image">
<ImageManage v-if="hostTabLoaded['image']" />
<ImageManage v-if="hostTabLoaded['image']" ref="imageManageRef" />
</el-tab-pane>
<el-tab-pane label="网络管理" name="network">
<NetworkManage v-if="hostTabLoaded['network']" />
<NetworkManage v-if="hostTabLoaded['network']" ref="networkManageRef" />
</el-tab-pane>
<el-tab-pane label="数据卷管理" name="volume">
<VolumeManage v-if="hostTabLoaded['volume']" />
<VolumeManage v-if="hostTabLoaded['volume']" ref="volumeManageRef" />
</el-tab-pane>
<el-tab-pane label="虚拟机管理" name="vm">
<VmManage v-if="hostTabLoaded['vm']" />
<VmManage v-if="hostTabLoaded['vm']" ref="vmManageRef" />
</el-tab-pane>
<el-tab-pane label="快照管理" name="snapshot">
<SnapshotManage v-if="hostTabLoaded['snapshot']" />
<SnapshotManage v-if="hostTabLoaded['snapshot']" ref="snapshotManageRef" />
</el-tab-pane>
<el-tab-pane label="备份管理" name="backup">
<BackupManage v-if="hostTabLoaded['backup']" />
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
</el-tab-pane>
</el-tabs>
</div>
@@ -283,21 +293,36 @@ const hostId = computed(() => parseInt(route.query.id) || 0)
const activeTab = ref('info')
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false })
const imageManageRef = ref(null)
const networkManageRef = ref(null)
const volumeManageRef = ref(null)
const vmManageRef = ref(null)
const snapshotManageRef = ref(null)
const backupManageRef = ref(null)
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef }
watch(activeTab, (tab) => {
if (!['info', 'monitor'].includes(tab) && !hostTabLoaded[tab]) hostTabLoaded[tab] = true
if (!['info', 'monitor'].includes(tab)) {
if (!hostTabLoaded[tab]) {
hostTabLoaded[tab] = true
} else {
nextTick(() => { tabRefMap[tab]?.value?.loadList?.() })
}
}
if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() }
else stopPolling()
})
provide('embedded', true)
provide('serviceId', serviceId)
provide('serviceName', serviceName)
provide('hostId', hostId)
const loading = ref(false)
const submitLoading = ref(false)
const metricsLoading = ref(false)
const detail = ref(null)
provide('embedded', true)
provide('serviceId', serviceId)
provide('serviceName', serviceName)
provide('hostId', hostId)
provide('hostDetail', detail)
const showToken = ref(false)
const showPassword = ref(false)
const showPrivateKey = ref(false)
+1 -1
View File
@@ -416,7 +416,7 @@ const loadTreeData = async () => {
try {
const [groupRes, hostRes] = await Promise.all([
getRemoteHostGroupTree({ service_id: serviceId.value }),
getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 500 })
getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
])
if (groupRes?.data?.code === 200 && groupRes?.data?.data) {
const inner = groupRes.data.data
+5 -6
View File
@@ -2,7 +2,7 @@
<div class="image-detail-page">
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回镜像列表</el-button>
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回上一页</el-button>
<el-divider direction="vertical" />
<span class="page-title">镜像详情</span>
</div>
@@ -96,9 +96,8 @@
<!-- 同步到宿主机弹窗 -->
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="440px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="镜像">{{ detail?.name || '-' }}</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="syncHostId" placeholder="请选择宿主机" style="width: 100%">
<el-select v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
@@ -184,7 +183,7 @@ const getHostName = (hid) => { const h = hostOptions.value.find(x => x.id === hi
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
@@ -274,7 +273,7 @@ const submitSync = async () => {
if (!syncHostId.value) return ElMessage.warning('请选择宿主机')
actionLoading.value = true
try {
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('image_id', imageId.value); fd.append('host_id', syncHostId.value)
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('host_id', syncHostId.value)
const res = await syncImageToHost(fd)
if (res?.data?.code === 200) { ElMessage.success('已触发同步'); syncDialogVisible.value = false; loadHostStatus() }
else ElMessage.error(extractApiError(res?.data, '同步失败'))
@@ -306,7 +305,7 @@ const handleDelete = () => {
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
router.back()
}
let loadedImageId = null
+56 -24
View File
@@ -14,7 +14,8 @@
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建镜像</el-button>
<el-button v-if="isEmbeddedHost" type="primary" @click="handleSyncToHostBatch"><el-icon><Refresh /></el-icon>同步镜像</el-button>
<el-button v-else type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建镜像</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
@@ -60,7 +61,7 @@
</el-table-column>
<el-table-column label="同步状态" width="100">
<template #default="{ row }">
<el-tag :type="row.sync_status === 'synced' ? 'success' : 'warning'" size="small">{{ row.sync_status === 'synced' ? '同步' : '同步' }}</el-tag>
<el-tag :type="row.sync_status === 'synced' ? 'success' : 'warning'" size="small">{{ row.sync_status === 'synced' ? '同步' : '同步' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
@@ -70,8 +71,8 @@
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleGoDetail(row)">详情</el-button>
<el-button link type="success" @click="handleSyncToHost(row)">同步</el-button>
<el-button link type="warning" @click="handleReloadOnHost(row)">宿主机重下载</el-button>
<el-button v-if="!isEmbeddedHost" link type="success" @click="handleSyncToHost()">同步</el-button>
<el-button link type="warning" @click="handleReloadOnHost(row)">下载</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
@@ -178,11 +179,9 @@
<!-- 同步到宿主机弹窗 -->
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="440px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="镜像">
<el-input :model-value="syncTarget?.name" disabled />
</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="syncHostId" placeholder="请选择宿主机" style="width: 100%">
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%" v-loading="hostOptionsLoading">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
@@ -200,7 +199,8 @@
<el-input :model-value="reloadTarget?.name" disabled />
</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%">
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%" v-loading="hostOptionsLoading">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
@@ -230,6 +230,7 @@ const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const injectedHostId = inject('hostId', null)
const injectedHostDetail = inject('hostDetail', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
@@ -255,7 +256,6 @@ const hostStatusList = ref([])
// 同步到宿主机
const syncDialogVisible = ref(false)
const syncTarget = ref(null)
const syncHostId = ref('')
const syncLoading = ref(false)
@@ -265,6 +265,17 @@ const reloadTarget = ref(null)
const reloadHostId = ref('')
const reloadLoading = ref(false)
const hostOptionsLoading = ref(false)
const isEmbeddedHost = computed(() => !!(embedded && injectedHostId?.value))
const currentHostLabel = computed(() => {
const hid = injectedHostId?.value
if (!hid) return '-'
const hd = injectedHostDetail?.value
if (hd) return `${hd.name || '宿主机'} (${hd.ip || '#' + hid})`
const h = hostOptions.value.find(x => x.id === hid)
return h ? `${h.name} (${h.ip || '#' + h.id})` : `宿主机 #${hid}`
})
const formData = reactive({
image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system',
description: '', status: '', size: 0, image_name: ''
@@ -310,7 +321,7 @@ const getHostName = (hid) => {
// 加载宿主机列表
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -320,17 +331,16 @@ const loadHostOptions = async () => {
} catch (e) { /* ignore */ }
}
const resolveHostId = async () => {
const resolveHostId = () => {
if (injectedHostId?.value) return injectedHostId.value
if (!hostOptions.value.length) await loadHostOptions()
return hostOptions.value.length ? hostOptions.value[0].id : null
return null
}
const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const hostId = await resolveHostId()
const hostId = resolveHostId()
let res
if (hostId) {
res = await getImageCompareHost({ service_id: serviceId.value, host_id: hostId })
@@ -477,11 +487,23 @@ const handleViewDetail = async (row) => {
}
// 同步镜像到宿主机
const handleSyncToHost = async (row) => {
syncTarget.value = row
syncHostId.value = ''
if (!hostOptions.value.length) await loadHostOptions()
const handleSyncToHost = () => {
if (embedded && injectedHostId?.value) {
syncHostId.value = injectedHostId.value
} else {
syncHostId.value = ''
}
syncDialogVisible.value = true
if (!embedded || !injectedHostId?.value) {
if (!hostOptions.value.length) {
hostOptionsLoading.value = true
loadHostOptions().finally(() => { hostOptionsLoading.value = false })
}
}
}
const handleSyncToHostBatch = () => {
handleSyncToHost()
}
const submitSyncToHost = async () => {
@@ -490,7 +512,6 @@ const submitSyncToHost = async () => {
try {
const formPayload = new FormData()
formPayload.append('service_id', serviceId.value)
formPayload.append('image_id', syncTarget.value.id)
formPayload.append('host_id', syncHostId.value)
const res = await syncImageToHost(formPayload)
if (res?.data?.code === 200) {
@@ -501,7 +522,7 @@ const submitSyncToHost = async () => {
ElMessage.error(extractApiError(res?.data, '同步失败'))
}
} catch (e) {
ElMessage.error('同步失败: ' + (e?.response?.data?.message || e.message))
ElMessage.error(extractApiError(e?.response?.data, '同步失败'))
} finally {
syncLoading.value = false
}
@@ -522,11 +543,20 @@ const handleReloadMaster = (row) => {
}).catch(() => {})
}
const handleReloadOnHost = async (row) => {
const handleReloadOnHost = (row) => {
reloadTarget.value = row
reloadHostId.value = ''
if (!hostOptions.value.length) await loadHostOptions()
if (embedded && injectedHostId?.value) {
reloadHostId.value = injectedHostId.value
} else {
reloadHostId.value = ''
}
reloadDialogVisible.value = true
if (!embedded || !injectedHostId?.value) {
if (!hostOptions.value.length) {
hostOptionsLoading.value = true
loadHostOptions().finally(() => { hostOptionsLoading.value = false })
}
}
}
const submitReloadOnHost = async () => {
@@ -575,6 +605,8 @@ onMounted(() => {
loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>
@@ -75,12 +75,12 @@
<el-tab-pane label="镜像管理" name="image">
<ImageManage v-if="tabLoaded['image']" />
</el-tab-pane>
<el-tab-pane label="网络管理" name="network">
<!-- <el-tab-pane label="网络管理" name="network">
<NetworkManage v-if="tabLoaded['network']" />
</el-tab-pane>
<el-tab-pane label="数据卷管理" name="volume">
</el-tab-pane> -->
<!-- <el-tab-pane label="数据卷管理" name="volume">
<VolumeManage v-if="tabLoaded['volume']" />
</el-tab-pane>
</el-tab-pane> -->
<el-tab-pane label="虚拟机管理" name="vm">
<VmManage v-if="tabLoaded['vm']" />
</el-tab-pane>
@@ -90,12 +90,12 @@
<el-tab-pane label="VNC节点" name="vnc">
<VncNodeManage v-if="tabLoaded['vnc']" />
</el-tab-pane>
<el-tab-pane label="快照管理" name="snapshot">
<!-- <el-tab-pane label="快照管理" name="snapshot">
<SnapshotManage v-if="tabLoaded['snapshot']" />
</el-tab-pane>
<el-tab-pane label="备份管理" name="backup">
</el-tab-pane> -->
<!-- <el-tab-pane label="备份管理" name="backup">
<BackupManage v-if="tabLoaded['backup']" />
</el-tab-pane>
</el-tab-pane> -->
<el-tab-pane label="用户组网" name="networking">
<UserNetworkingManage v-if="tabLoaded['networking']" />
</el-tab-pane>
+3 -1
View File
@@ -176,7 +176,7 @@ const getHostLabel = (hid) => {
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -322,6 +322,8 @@ onMounted(async () => {
if (hostIdInput.value) loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>
@@ -240,7 +240,7 @@ const getHostLabel = (hid) => { const h = hostOptions.value.find(x => x.id === h
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10})
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
@@ -402,7 +402,8 @@ const handleDeleteRule = (rule) => {
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
router.back()
// router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
}
let loadedSgId = null
@@ -270,7 +270,7 @@ const getHostLabel = (hid) => {
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -224,6 +224,8 @@ const handleProgress = async (row) => {
}
onMounted(() => { loadList() })
defineExpose({ loadList })
</script>
<style scoped>
@@ -19,9 +19,14 @@
</div>
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索组网" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
<el-input v-model="keyword" placeholder="关键词搜索" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="filterHostId" placeholder="按宿主机筛选" clearable filterable style="width: 200px" @change="handleSearch">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name || ''} (${h.ip || h.id})`" :value="h.id" />
</el-select>
<el-input v-model="filterUserId" placeholder="按用户ID筛选" clearable style="width: 160px" @keyup.enter="handleSearch" @clear="handleSearch" />
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon>搜索</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
@@ -47,9 +52,9 @@
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
@@ -62,19 +67,22 @@
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
<el-form-item label="用户ID" prop="user_id">
<el-input-number v-model="createForm.user_id" :min="1" style="width: 100%" placeholder="用户 ID" />
<el-form-item label="用户" prop="user_id">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-input :model-value="createForm.user_id ? `${createUserName} (ID: ${createForm.user_id})` : ''" readonly placeholder="请选择用户" style="flex: 1" />
<el-button type="primary" @click="showCreateUserSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable clearable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name || ''} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="网桥名称" prop="bridge_name">
<el-input v-model="createForm.bridge_name" placeholder=" br0" />
<el-form-item label="网桥名称">
<el-input v-model="createForm.bridge_name" placeholder=" br0可选" />
</el-form-item>
<el-form-item label="网关地址" prop="gateway">
<el-input v-model="createForm.gateway" placeholder=" 10.0.0.1" />
<el-form-item label="网关地址">
<el-input v-model="createForm.gateway" placeholder=" 10.0.0.1可选" />
</el-form-item>
</el-form>
<template #footer>
@@ -108,6 +116,9 @@
<!-- 虚拟机选择器 -->
<VmSelectorPopup v-model="showAssignVmSelector" :service-id="serviceId" :current-id="assignForm.vm_id" @confirm="handleAssignVmSelected" />
<!-- 用户选择器(创建组网用) -->
<UserListSelector v-model="showCreateUserSelector" :current-user-id="createForm.user_id" @confirm="handleCreateUserSelected" />
<!-- 组网详情弹窗 -->
<el-dialog v-model="detailVisible" title="组网详情" width="800px" destroy-on-close>
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading" style="margin-bottom: 20px">
@@ -116,32 +127,47 @@
<el-descriptions-item label="描述">{{ currentDetail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentDetail.user_id }}</el-descriptions-item>
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
<el-descriptions-item label="网桥">{{ currentDetail.bridge_name }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ currentDetail.gateway }}</el-descriptions-item>
<el-descriptions-item label="网桥">{{ currentDetail.bridge_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ currentDetail.gateway || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(currentDetail.updated_at) }}</el-descriptions-item>
</el-descriptions>
<div class="networks-section" v-if="detailNetworks.length">
<div class="networks-header">
<h4>组网下的网络</h4>
<h4>组网下的网络 ({{ detailNetworks.length }})</h4>
</div>
<el-table :data="detailNetworks" stripe size="small">
<el-table-column prop="vm_id" label="虚拟机ID" width="100" />
<el-table-column prop="vm_name" label="虚拟机名称" min-width="120" show-overflow-tooltip />
<el-table-column label="虚拟机状态" width="100">
<el-table-column label="网络ID" width="80">
<template #default="{ row }">{{ row.network?.id || '-' }}</template>
</el-table-column>
<el-table-column label="IP 地址" min-width="150">
<template #default="{ row }">
<el-tag :type="vmStatusType(row.vm_status)" size="small">{{ row.vm_status || '-' }}</el-tag>
<span class="mono-text">{{ row.network?.address || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="网络信息" min-width="200">
<el-table-column label="MAC 地址" min-width="160">
<template #default="{ row }">
<template v-if="row.network">
<div>IP: {{ row.network.ip || '-' }}</div>
<div>MAC: {{ row.network.mac || '-' }}</div>
</template>
<span v-else>-</span>
<span class="mono-text">{{ row.network?.mac_address || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="networkTypeTagType(row.network?.type)" size="small">{{ networkTypeLabel(row.network?.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定虚拟机" width="110">
<template #default="{ row }">
<span v-if="row.network?.vm_id">ID: {{ row.network.vm_id }}</span>
<el-tag v-else type="info" size="small">未绑定</el-tag>
</template>
</el-table-column>
<el-table-column label="网桥" width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.network?.bridge_name || '-' }}</template>
</el-table-column>
<el-table-column label="目标设备" width="90" show-overflow-tooltip>
<template #default="{ row }">{{ row.network?.target_device || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleRemoveNetwork(row)">移除</el-button>
@@ -167,6 +193,7 @@ import {
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import UserListSelector from '@/components/admin/UserListSelector.vue'
import dayjs from 'dayjs'
const route = useRoute()
@@ -183,20 +210,33 @@ const detailLoading = ref(false)
const list = ref([])
const total = ref(0)
const keyword = ref('')
const filterHostId = ref(null)
const filterUserId = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 })
const queryParams = reactive({ page: 1, count: 10 })
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const vmStatusType = (s) => ({ running: 'success', stopped: 'info', suspended: 'warning', error: 'danger' }[s] || 'info')
const formatTime = (t) => {
if (!t) return '-'
if (t.seconds) return dayjs.unix(t.seconds).format('YYYY-MM-DD HH:mm:ss')
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
}
const NETWORK_TYPE_MAP = {
nat: { label: 'NAT', type: '' },
bridge: { label: '桥接', type: 'success' },
direct: { label: '直连', type: 'warning' }
}
const networkTypeLabel = (t) => NETWORK_TYPE_MAP[t]?.label || t || '-'
const networkTypeTagType = (t) => NETWORK_TYPE_MAP[t]?.type ?? 'info'
const getHostLabel = (hid) => {
const h = hostOptions.value.find(x => x.id === hid)
return h ? h.name : (hid || '-')
return h ? `${h.name || ''} (${h.ip || h.id})` : (hid || '-')
}
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -208,8 +248,14 @@ const loadHostOptions = async () => {
const loadList = async () => {
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
const params = {
service_id: serviceId.value,
page: queryParams.page,
count: queryParams.count
}
if (keyword.value) params.keyword = keyword.value
if (filterHostId.value) params.host_id = filterHostId.value
if (filterUserId.value) params.user_id = parseInt(filterUserId.value) || undefined
const res = await getUserNetworkingList(params)
if (res?.data?.code === 200) {
const d = res.data.data
@@ -230,19 +276,26 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const createDialogVisible = ref(false)
const createFormRef = ref(null)
const createForm = reactive({ name: '', description: '', user_id: null, host_id: null, bridge_name: '', gateway: '' })
const createUserName = ref('')
const showCreateUserSelector = ref(false)
const createRules = {
name: [{ required: true, message: '请输入组网名称', trigger: 'blur' }],
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }],
bridge_name: [{ required: true, message: '请输入网桥名称', trigger: 'blur' }],
gateway: [{ required: true, message: '请输入网关地址', trigger: 'blur' }]
user_id: [{ required: true, message: '请选择用户', trigger: 'change' }],
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
}
const handleAdd = () => {
Object.assign(createForm, { name: '', description: '', user_id: null, host_id: null, bridge_name: '', gateway: '' })
createUserName.value = ''
createDialogVisible.value = true
}
const handleCreateUserSelected = (user) => {
createForm.user_id = user.user_id || user.id
createUserName.value = user.user_name || user.name || ''
createFormRef.value?.validateField('user_id')
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
@@ -254,8 +307,8 @@ const submitCreate = () => {
if (createForm.description) fd.append('description', createForm.description)
fd.append('user_id', createForm.user_id)
fd.append('host_id', createForm.host_id)
fd.append('bridge_name', createForm.bridge_name)
fd.append('gateway', createForm.gateway)
if (createForm.bridge_name) fd.append('bridge_name', createForm.bridge_name)
if (createForm.gateway) fd.append('gateway', createForm.gateway)
const res = await createUserNetworking(fd)
if (res?.data?.code === 200) {
ElMessage.success('组网创建成功')
@@ -323,7 +376,7 @@ const handleViewDetail = async (row) => {
const res = await getUserNetworkingDetail({ service_id: serviceId.value, networking_id: row.id })
if (res?.data?.code === 200) {
const d = res.data.data
if (d?.data) currentDetail.value = d.data
currentDetail.value = d?.data || d || row
detailNetworks.value = d?.networks || []
}
} catch { ElMessage.warning('获取详情失败') } finally { detailLoading.value = false }
@@ -331,7 +384,7 @@ const handleViewDetail = async (row) => {
// ---- 删除组网 ----
const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除组网「${row.name}」吗?此操作不可恢复。`, '删除确认', {
ElMessageBox.confirm(`确定要删除组网「${row.name || row.id}」吗?此操作不可恢复。`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
@@ -344,15 +397,18 @@ const handleDelete = (row) => {
// ---- 移除组网下的网络 ----
const handleRemoveNetwork = (netItem) => {
ElMessageBox.confirm(`确定要从组网中移除该网络吗?`, '移除确认', {
const net = netItem.network || {}
const addrLabel = net.address ? ` (${net.address})` : ''
const vmLabel = net.vm_id ? `,关联虚拟机 ID: ${net.vm_id}` : ''
ElMessageBox.confirm(`确定要从组网中移除网络 #${net.id || '?'}${addrLabel}${vmLabel} 吗?`, '移除确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('networking_id', currentDetail.value.id)
fd.append('network_id', netItem.network?.id || netItem.id)
fd.append('vm_id', netItem.vm_id)
fd.append('network_id', net.id)
fd.append('vm_id', net.vm_id || 0)
const res = await removeUserNetworkingNetwork(fd)
if (res?.data?.code === 200) {
ElMessage.success('移除成功')
@@ -380,11 +436,12 @@ onMounted(async () => {
.sub-info { font-size: 13px; color: #909399; }
.header-right { display: flex; gap: 8px; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.networks-section { margin-top: 8px; }
.networks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.networks-header h4 { margin: 0; font-size: 15px; color: #303133; }
.mono-text { font-family: 'Cascadia Code', 'Consolas', 'Monaco', monospace; font-size: 12px; color: #303133; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
File diff suppressed because it is too large Load Diff
+74 -52
View File
@@ -54,10 +54,10 @@
<el-tag :type="vmStatusType(row.status)" size="small">{{ vmStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="宿主机" width="140">
<!-- <el-table-column label="宿主机" width="140">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column>
<el-table-column prop="user_id" label="用户" width="80" />
</el-table-column> -->
<!-- <el-table-column prop="user_id" label="用户" width="80" /> -->
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleGoDetail(row)">详情</el-button>
@@ -70,7 +70,7 @@
<el-dropdown-item command="reboot">重启</el-dropdown-item>
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
<el-dropdown-item command="resume" v-if="row.status === 'paused'">恢复</el-dropdown-item>
<el-dropdown-item command="rebuild" divided></el-dropdown-item>
<el-dropdown-item command="rebuild" divided></el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exit_rescue">退出救援</el-dropdown-item>
<el-dropdown-item command="detail" divided>查看详情</el-dropdown-item>
@@ -109,24 +109,31 @@
</el-form-item>
<el-divider content-position="left">宿主机配置(二选一)</el-divider>
<el-form-item label="分配方式">
<el-radio-group v-model="hostMode">
<el-radio value="host">指定宿主机</el-radio>
<el-radio value="group">指定宿主机组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="宿主机" v-if="hostMode === 'host'">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="宿主机组" v-if="hostMode === 'group'">
<div class="bind-selector-row">
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<template v-if="isEmbeddedHost">
<el-form-item label="宿主机">
<el-input :model-value="embeddedHostLabel" disabled />
</el-form-item>
</template>
<template v-else>
<el-form-item label="分配方式">
<el-radio-group v-model="hostMode">
<el-radio value="host">指定宿主机</el-radio>
<el-radio value="group">指定宿主机组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="宿主机" v-if="hostMode === 'host'">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="宿主机组" v-if="hostMode === 'group'">
<div class="bind-selector-row">
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
</template>
<el-divider content-position="left">资源配置</el-divider>
<div class="resource-row">
@@ -183,9 +190,9 @@
</template>
</el-dialog>
<!-- 重弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="虚拟机" width="440px" destroy-on-close>
<el-alert title="会使用新镜像重置虚拟机原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<!-- 重弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="虚拟机" width="440px" destroy-on-close>
<el-alert title="会使用新镜像重置虚拟机原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<el-form label-width="100px">
<el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item>
<el-form-item label="新镜像" required>
@@ -197,7 +204,7 @@
</el-form>
<template #footer>
<el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重</el-button>
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重</el-button>
</template>
</el-dialog>
@@ -234,7 +241,7 @@
<!-- 网络信息 -->
<template v-if="currentDetail?.networks && currentDetail.networks.length">
<h4 style="margin: 16px 0 8px">🌐 网络</h4>
<h4 style="margin: 16px 0 8px">网络</h4>
<el-table :data="currentDetail.networks" size="small" stripe border>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" />
@@ -246,7 +253,7 @@
<!-- 数据卷信息 -->
<template v-if="currentDetail?.volumes && currentDetail.volumes.length">
<h4 style="margin: 16px 0 8px">💿 数据卷</h4>
<h4 style="margin: 16px 0 8px">数据卷</h4>
<el-table :data="currentDetail.volumes" size="small" stripe border>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" />
@@ -262,7 +269,7 @@
<!-- 镜像信息 -->
<template v-if="currentDetail?.image">
<h4 style="margin: 16px 0 8px">🖼️ 镜像</h4>
<h4 style="margin: 16px 0 8px">镜像</h4>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="ID">{{ currentDetail.image.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentDetail.image.name }}</el-descriptions-item>
@@ -299,7 +306,7 @@
<!-- 镜像选择器 (创建) -->
<ImageSelectorPopup v-model="showCreateImageSelector" :service-id="serviceId" :current-id="createForm.image_id" @confirm="handleCreateImageSelected" />
<!-- 镜像选择器 (重) -->
<!-- 镜像选择器 (重) -->
<ImageSelectorPopup v-model="showRebuildImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="handleRebuildImageSelected" />
<!-- 宿主机组选择器 -->
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="serviceId" :current-id="createForm.host_group_id" @confirm="handleHostGroupSelected" />
@@ -329,8 +336,15 @@ const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const injectedHostId = inject('hostId', null)
const injectedHostDetail = inject('hostDetail', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const hostId = computed(() => injectedHostId?.value || parseInt(route.query.host_id) || 0)
const isEmbeddedHost = computed(() => !!(embedded && injectedHostId?.value))
const embeddedHostLabel = computed(() => {
const d = injectedHostDetail?.value
if (!d) return `宿主机 (ID: ${injectedHostId?.value || ''})`
return `${d.name || ''} (${d.ip || d.id || ''})`
})
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
@@ -381,10 +395,12 @@ const diskDisplay = computed({
set: (v) => { createForm.system_size = Math.round(v * getDiskFactor()) }
})
const loadNetworkOptions = async (hostId) => {
if (!hostId) return
const loadNetworkOptions = async (hid) => {
if (!hid) return
try {
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, used: false, page: 1, page_size: 200 })
const params = { service_id: serviceId.value, host_id: hid, used: false, page: 1, page_size: 10 }
if (injectedHostId?.value) params.type = 'bridge'
const res = await getNetworkList(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -400,7 +416,7 @@ const getHostLabel = (hid) => {
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -513,7 +529,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
Object.assign(createForm, {
name: '', host_id: null, image_id: 0,
name: '', host_id: injectedHostId?.value || null, image_id: 0,
vcpu: 0, memory: 0, system_size: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: null, user_id: 0, ip_num: 0, network_ids: [],
_imageName: '', _groupName: '', _userName: ''
@@ -524,6 +540,9 @@ const handleAdd = () => {
ipMode.value = 'num'
networkOptions.value = []
createDialogVisible.value = true
if (injectedHostId?.value) {
loadNetworkOptions(injectedHostId.value)
}
}
const submitCreate = () => {
@@ -536,20 +555,21 @@ const submitCreate = () => {
if (!valid) return
submitLoading.value = true
try {
const payload = {
service_id: serviceId.value,
image_id: createForm.image_id,
vcpu: createForm.vcpu, memory: createForm.memory, system_size: createForm.system_size,
user_id: createForm.user_id
}
if (createForm.name) payload.name = createForm.name
if (createForm.rx_bandwidth) payload.rx_bandwidth = createForm.rx_bandwidth
if (createForm.tx_bandwidth) payload.tx_bandwidth = createForm.tx_bandwidth
if (hostMode.value === 'host') payload.host_id = createForm.host_id
else payload.host_group_id = createForm.host_group_id
if (ipMode.value === 'num') payload.ip_num = createForm.ip_num
else payload.network_ids = createForm.network_ids
const res = await createVm(payload)
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('image_id', createForm.image_id)
fd.append('vcpu', createForm.vcpu)
fd.append('memory', createForm.memory)
fd.append('system_size', createForm.system_size)
fd.append('user_id', createForm.user_id)
if (createForm.name) fd.append('name', createForm.name)
if (createForm.rx_bandwidth) fd.append('rx_bandwidth', createForm.rx_bandwidth)
if (createForm.tx_bandwidth) fd.append('tx_bandwidth', createForm.tx_bandwidth)
if (hostMode.value === 'host') fd.append('host_id', createForm.host_id)
else fd.append('host_group_id', createForm.host_group_id)
if (ipMode.value === 'num') fd.append('ip_num', createForm.ip_num)
else createForm.network_ids.forEach(id => fd.append('network_ids', id))
const res = await createVm(fd)
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) }
@@ -604,9 +624,9 @@ const submitRebuild = async () => {
submitLoading.value = true
try {
const res = await rebuildVm({ service_id: serviceId.value, vm_id: rebuildTarget.value.id, image_id: rebuildImageId.value })
if (res?.data?.code === 200) { ElMessage.success('重成功'); rebuildDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '重失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重失败')) } finally { submitLoading.value = false }
if (res?.data?.code === 200) { ElMessage.success('重成功'); rebuildDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '重失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重失败')) } finally { submitLoading.value = false }
}
const handleRescue = (row) => {
@@ -702,6 +722,8 @@ onMounted(async () => {
loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>
+1 -1
View File
@@ -190,7 +190,7 @@ const formatDelay = (ns) => {
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
+291
View File
@@ -0,0 +1,291 @@
<template>
<div class="volume-detail-page">
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回上一页</el-button>
<el-divider direction="vertical" />
<span class="page-title">数据卷详情</span>
</div>
<div class="header-right">
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
</div>
</div>
<div class="main-content" v-loading="loading">
<!-- 基本信息 -->
<el-card shadow="never" class="info-card" v-if="detail">
<template #header><span class="card-title">基本信息</span></template>
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ detail.size ? detail.size + ' GB' : '-' }}</el-descriptions-item>
<el-descriptions-item label="系统卷">
<el-tag :type="detail.is_system ? 'danger' : 'info'" size="small">{{ detail.is_system ? '是' : '否' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="挂载状态">
<el-tag :type="detail.is_mount ? 'success' : 'info'" size="small">{{ detail.is_mount ? '已挂载' : '未挂载' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="volStatusType(detail.status)" size="small">{{ volStatusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="宿主机ID">{{ detail.host_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="宿主机卷ID">{{ detail.host_volume_id || '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ detail.path || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="目标设备" v-if="detail.target_device">{{ detail.target_device }}</el-descriptions-item>
<el-descriptions-item label="虚拟机ID" v-if="detail.vm_id">{{ detail.vm_id }}</el-descriptions-item>
<el-descriptions-item label="镜像ID" v-if="detail.image_id">{{ detail.image_id }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 操作 -->
<el-card shadow="never" class="info-card" v-if="detail">
<template #header><span class="card-title">操作</span></template>
<div class="action-buttons">
<el-button type="primary" plain @click="handleResize">调整大小</el-button>
<el-button type="success" plain @click="handleMount" v-if="!detail.is_mount">挂载</el-button>
<el-button type="warning" plain @click="handleUnmount" v-if="detail.is_mount">卸载</el-button>
<el-button type="info" plain @click="handleTransfer">迁移</el-button>
<el-button type="danger" plain @click="handleDelete">删除</el-button>
</div>
</el-card>
</div>
<!-- 调整大小弹窗 -->
<el-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="当前大小">{{ detail?.size || 0 }} GB</el-form-item>
<el-form-item label="新大小(GB)">
<el-input-number v-model="newSize" :min="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resizeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitResize">确定</el-button>
</template>
</el-dialog>
<!-- 挂载弹窗 -->
<el-dialog v-model="mountDialogVisible" title="挂载数据卷到虚拟机" width="440px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="数据卷">{{ detail?.name }} ({{ detail?.size }} GB)</el-form-item>
<el-form-item label="虚拟机" required>
<div class="bind-selector-row">
<el-input :model-value="mountVmId ? `VM #${mountVmId}${mountVmName ? ' - ' + mountVmName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showMountVmSelector = true" style="margin-left: 8px">选择</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="mountDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitMount">挂载</el-button>
</template>
</el-dialog>
<!-- 迁移弹窗 -->
<el-dialog v-model="transferDialogVisible" title="迁移数据卷" width="440px" destroy-on-close>
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
将数据卷迁移到另一台宿主机上迁移过程中数据卷将不可用
</el-alert>
<el-form label-width="100px" v-loading="transferLoading">
<el-form-item label="数据卷">{{ detail?.name }} ({{ detail?.size }} GB)</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="transferHostId" placeholder="请选择目标宿主机" style="width: 100%" filterable>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" :disabled="h.id === detail?.host_id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialogVisible = false">取消</el-button>
<el-button type="success" :loading="actionLoading" @click="submitTransfer">确定迁移</el-button>
</template>
</el-dialog>
<VmSelectorPopup v-model="showMountVmSelector" :service-id="serviceId" :host-id="detail?.host_id || 0" :current-id="mountVmId" @confirm="vm => { mountVmId = vm.id; mountVmName = vm.name || '' }" />
</div>
</template>
<script setup>
import { ref, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
import {
getVolumeDetail, getRemoteHostList,
resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { useTagsViewStore } from '@/store/tagsViewStore'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
const route = useRoute()
const router = useRouter()
const tagsViewStore = useTagsViewStore()
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
const volumeId = computed(() => parseInt(route.query.volume_id) || 0)
const loading = ref(false)
const actionLoading = ref(false)
const detail = ref(null)
const hostOptions = ref([])
const volStatusType = (s) => ({ ready: 'success', pending: 'info', error: 'danger', unknown: 'warning' }[s] || 'info')
const volStatusLabel = (s) => ({ ready: '就绪', pending: '等待中', error: '错误', unknown: '未知' }[s] || s || '-')
const formatTimestamp = (ts) => {
if (!ts) return '-'
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
return '-'
}
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
}
} catch { /* */ }
}
const loadDetail = async () => {
if (!volumeId.value) return
loading.value = true
try {
const res = await getVolumeDetail({ service_id: serviceId.value, volume_id: volumeId.value })
if (res?.data?.code === 200 && res?.data?.data) {
detail.value = res.data.data.volume ?? res.data.data.data ?? res.data.data
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
}
//
const resizeDialogVisible = ref(false)
const newSize = ref(1)
const handleResize = () => {
if (!detail.value) return
newSize.value = detail.value.size || 10
resizeDialogVisible.value = true
}
const submitResize = async () => {
actionLoading.value = true
try {
const res = await resizeVolume({ service_id: serviceId.value, volume_id: volumeId.value, size: newSize.value })
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '调整失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { actionLoading.value = false }
}
//
const mountDialogVisible = ref(false)
const mountVmId = ref(0)
const mountVmName = ref('')
const showMountVmSelector = ref(false)
const handleMount = () => {
mountVmId.value = 0; mountVmName.value = ''
mountDialogVisible.value = true
}
const submitMount = async () => {
if (!mountVmId.value) { ElMessage.warning('请选择虚拟机'); return }
actionLoading.value = true
try {
const res = await mountVolume({ service_id: serviceId.value, volume_id: volumeId.value, vm_id: mountVmId.value })
if (res?.data?.code === 200) { ElMessage.success('挂载成功'); mountDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '挂载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) } finally { actionLoading.value = false }
}
//
const handleUnmount = () => {
if (!detail.value) return
ElMessageBox.confirm(`确定要卸载数据卷「${detail.value.name}」吗?`, '卸载确认', {
confirmButtonText: '卸载', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
actionLoading.value = true
try {
const res = await unmountVolume({ service_id: serviceId.value, volume_id: volumeId.value })
if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '卸载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '卸载失败')) } finally { actionLoading.value = false }
}).catch(() => {})
}
//
const transferDialogVisible = ref(false)
const transferHostId = ref('')
const transferLoading = ref(false)
const handleTransfer = async () => {
transferHostId.value = ''
transferDialogVisible.value = true
if (!hostOptions.value.length) {
transferLoading.value = true
try { await loadHostOptions() } finally { transferLoading.value = false }
}
}
const submitTransfer = async () => {
if (!transferHostId.value) { ElMessage.warning('请选择目标宿主机'); return }
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('volume_id', volumeId.value)
fd.append('host_id', transferHostId.value)
const res = await transferVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('迁移已触发'); transferDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '迁移失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
}
//
const handleDelete = () => {
if (!detail.value) return
ElMessageBox.confirm(`确定要删除数据卷「${detail.value.name}」吗?此操作不可恢复!`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteVolume({ service_id: serviceId.value, volume_id: volumeId.value })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const goBack = () => {
tagsViewStore.delVisitedView(route)
router.back()
}
let loadedVolumeId = null
let isPageActive = false
const initPage = () => {
if (!volumeId.value || loadedVolumeId === volumeId.value) return
loadedVolumeId = volumeId.value
loadDetail()
}
watch(volumeId, () => { if (isPageActive) initPage() })
onActivated(() => { isPageActive = true; if (loadedVolumeId !== volumeId.value) initPage() })
onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() })
</script>
<style scoped>
.volume-detail-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; flex-wrap: wrap; gap: 8px; }
.header-left { display: flex; align-items: center; gap: 0; }
.back-btn { font-size: 14px; color: #606266; }
.back-btn:hover { color: #409eff; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; gap: 8px; }
.main-content { padding: 20px; }
.info-card { margin-bottom: 20px; }
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
.bind-selector-row { display: flex; align-items: center; width: 100%; }
.action-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
</style>
+52 -60
View File
@@ -47,14 +47,21 @@
<el-table-column label="宿主机" width="140">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column>
<el-table-column label="操作" width="340" fixed="right">
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
<el-button link type="primary" @click="handleResize(row)">调整大小</el-button>
<el-button link type="primary" @click="handleMount(row)">挂载</el-button>
<el-button link type="warning" @click="handleUnmount(row)">卸载</el-button>
<el-button link type="success" @click="handleTransfer(row)">迁移</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
<el-button link type="primary" size="small" @click="handleViewDetail(row)">详情</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<el-dropdown trigger="click" @command="cmd => handleRowMore(row, cmd)" style="margin-left: 4px; margin-top: 4.5px">
<el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="resize">调整大小</el-dropdown-item>
<el-dropdown-item command="mount">挂载</el-dropdown-item>
<el-dropdown-item command="unmount">卸载</el-dropdown-item>
<!-- <el-dropdown-item command="transfer">迁移</el-dropdown-item> -->
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
@@ -137,10 +144,12 @@
</el-alert>
<el-form label-width="100px">
<el-form-item label="数据卷">{{ transferTarget?.name }} ({{ transferTarget?.size }} GB)</el-form-item>
<el-form-item label="当前宿主机">{{ getHostLabel(transferTarget?.host_id) }}</el-form-item>
<el-form-item label="当前宿主机">
<el-input :model-value="currentHostLabel" disabled style="width: 100%" />
</el-form-item>
<el-form-item label="目标宿主机" required>
<el-select v-model="transferHostId" placeholder="请选择目标宿主机" style="width: 100%" filterable>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" :disabled="h.id === transferTarget?.host_id" />
<el-select v-model="transferHostId" placeholder="请选择目标宿主机" style="width: 100%" filterable v-loading="hostOptionsLoading" disabled>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
</el-form>
@@ -150,32 +159,6 @@
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="数据卷详情" width="600px" destroy-on-close>
<div v-loading="detailLoading">
<el-descriptions :column="2" border v-if="currentDetail">
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ currentDetail.size }} GB</el-descriptions-item>
<el-descriptions-item label="系统卷">
<el-tag :type="currentDetail.is_system ? 'danger' : 'info'" size="small">{{ currentDetail.is_system ? '是' : '否' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="volStatusType(currentDetail.status)" size="small">{{ volStatusLabel(currentDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2">
<span class="mono-text">{{ currentDetail.path || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="目标设备" v-if="currentDetail.target_device">{{ currentDetail.target_device }}</el-descriptions-item>
<el-descriptions-item label="虚拟机ID" v-if="currentDetail.vm_id">{{ currentDetail.vm_id }}</el-descriptions-item>
<el-descriptions-item label="镜像ID" v-if="currentDetail.image_id">{{ currentDetail.image_id }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 镜像选择器 -->
<ImageSelectorPopup v-model="showImageSelector" :service-id="serviceId" :current-id="createForm.image_id" @confirm="handleImageSelected" />
@@ -190,9 +173,9 @@
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, ArrowLeft } from '@element-plus/icons-vue'
import { Plus, Refresh, ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
import {
getRemoteHostList, getVolumeList, getVolumeDetail,
getRemoteHostList, getVolumeList,
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
@@ -205,13 +188,13 @@ const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const injectedHostId = inject('hostId', null)
const injectedHostDetail = inject('hostDetail', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const hostId = computed(() => injectedHostId?.value || parseInt(route.query.host_id) || 0)
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
const submitLoading = ref(false)
const detailLoading = ref(false)
const volumeList = ref([])
const total = ref(0)
const filterStatus = ref('')
@@ -237,7 +220,7 @@ const formatTimestamp = (ts) => {
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
@@ -252,8 +235,6 @@ const createDialogVisible = ref(false)
const createFormRef = ref(null)
const resizeDialogVisible = ref(false)
const mountDialogVisible = ref(false)
const detailVisible = ref(false)
const currentDetail = ref(null)
const resizeTarget = ref(null)
const newSize = ref(1)
const mountTarget = ref(null)
@@ -265,6 +246,15 @@ const transferDialogVisible = ref(false)
const transferTarget = ref(null)
const transferHostId = ref('')
const transferLoading = ref(false)
const hostOptionsLoading = ref(false)
const currentHostLabel = computed(() => {
const hid = transferTarget.value?.host_id || injectedHostId?.value
if (!hid) return '-'
const hd = injectedHostDetail?.value
if (hd && hd.id === hid) return `${hd.name || '宿主机'} (${hd.ip || '#' + hid})`
const h = hostOptions.value.find(x => x.id === hid)
return h ? `${h.name} (${h.ip || '#' + h.id})` : `宿主机 #${hid}`
})
//
const showImageSelector = ref(false)
@@ -294,7 +284,7 @@ const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.page_size,host_id:hostId.value }
if (hostId.value) params.host_id = hostId.value
if (filterStatus.value) params.status = filterStatus.value
const res = await getVolumeList(params)
@@ -311,8 +301,8 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
Object.assign(createForm, {
name: '', size: 10, host_id: hostId.value || 0,
is_system: false, image_id: 0, vm_id: 0, target_device: '',
name: '', size: 10, host_id: hostId.value || '',
is_system: false, image_id: '', vm_id: '', target_device: '',
_imageName: '', _vmName: ''
})
createDialogVisible.value = true
@@ -377,11 +367,14 @@ const handleUnmount = (row) => {
}
//
const handleTransfer = async (row) => {
const handleTransfer = (row) => {
transferTarget.value = row
transferHostId.value = ''
if (!hostOptions.value.length) await loadHostOptions()
transferHostId.value = row.host_id || hostId.value || ''
transferDialogVisible.value = true
if (!hostOptions.value.length) {
hostOptionsLoading.value = true
loadHostOptions().finally(() => { hostOptionsLoading.value = false })
}
}
const submitTransfer = async () => {
@@ -404,18 +397,8 @@ const submitTransfer = async () => {
finally { transferLoading.value = false }
}
const handleViewDetail = async (row) => {
detailVisible.value = true
detailLoading.value = true
currentDetail.value = row
try {
const res = await getVolumeDetail({ service_id: serviceId.value, volume_id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
currentDetail.value = d.volume ?? d.data ?? d
}
} catch { /* fallback */ }
finally { detailLoading.value = false }
const handleViewDetail = (row) => {
router.push({ path: '/virtualization/volume-detail', query: { service_id: serviceId.value, volume_id: row.id } })
}
const handleDelete = (row) => {
@@ -430,6 +413,13 @@ const handleDelete = (row) => {
}).catch(() => {})
}
const handleRowMore = (row, command) => {
if (command === 'resize') handleResize(row)
else if (command === 'mount') handleMount(row)
else if (command === 'unmount') handleUnmount(row)
else if (command === 'transfer') handleTransfer(row)
}
const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(() => {
@@ -438,6 +428,8 @@ onMounted(() => {
loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>