@@ -1945,10 +1945,13 @@ const initPage = () => {
|
||||
if (activeTab.value === 'monitor') loadHistoricalMetrics()
|
||||
}
|
||||
|
||||
watch(hostId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => {
|
||||
const isCurrentRoute = () => route.name === 'VirtHostDetail'
|
||||
|
||||
watch(hostId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
onActivated(async () => {
|
||||
isPageActive = true
|
||||
if (loadedHostId !== hostId.value) initPage()
|
||||
await nextTick()
|
||||
if (isCurrentRoute() && loadedHostId !== hostId.value) initPage()
|
||||
})
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
||||
@@ -319,8 +319,10 @@ const initPage = async () => {
|
||||
loadHostStatus()
|
||||
}
|
||||
|
||||
watch(imageId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => { isPageActive = true; if (loadedImageId !== imageId.value) initPage() })
|
||||
const isCurrentRoute = () => route.name === 'VirtImageDetail'
|
||||
|
||||
watch(imageId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedImageId !== imageId.value) initPage() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
</script>
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
<el-option label="IPv4" value="ipv4" />
|
||||
<el-option label="IPv6" value="ipv6" />
|
||||
</el-select>
|
||||
<el-select v-model="filterUsed" placeholder="使用状态" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option label="已用" value="true" />
|
||||
<el-option label="未用" value="false" />
|
||||
</el-select>
|
||||
<el-select v-model="filterDisable" placeholder="禁用状态" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option label="已禁用" value="true" />
|
||||
<el-option label="已启用" value="false" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 网络列表 -->
|
||||
@@ -54,10 +62,34 @@
|
||||
<el-table-column label="宿主机" width="140">
|
||||
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<el-table-column label="使用状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="isNetworkUsed(row) ? 'success' : 'info'" size="small">
|
||||
{{ isNetworkUsed(row) ? '已用' : '未用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="虚拟机" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="isNetworkUsed(row)" link type="primary" @click="goToVmDetail(row.vm_id)">
|
||||
{{ row.vm_id }}
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="禁用" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.disable" type="danger" size="small">已禁用</el-tag>
|
||||
<el-tag v-else type="success" size="small">启用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link :type="row.disable ? 'success' : 'warning'" @click="handleToggleDisable(row)">
|
||||
{{ row.disable ? '启用' : '禁用' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -113,6 +145,10 @@
|
||||
<el-form-item label="逻辑端口名">
|
||||
<el-input v-model="formData.ls_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="禁用网络" v-if="dialogType === 'edit'">
|
||||
<el-switch v-model="formData.disable" active-text="禁用" inactive-text="启用" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">禁用后该网络不参与自动分配</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -142,6 +178,27 @@
|
||||
<el-descriptions-item label="逻辑网桥">{{ currentDetail.ls_bridge_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="逻辑端口">{{ currentDetail.ls_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标设备">{{ currentDetail.target_device || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用状态">
|
||||
<el-tag :type="isNetworkUsed(currentDetail) ? 'success' : 'info'" size="small">
|
||||
{{ isNetworkUsed(currentDetail) ? '已用' : '未用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="虚拟机">
|
||||
<el-button v-if="isNetworkUsed(currentDetail)" link type="primary" @click="goToVmDetail(currentDetail.vm_id)">
|
||||
{{ currentDetail.vm_id }}
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="主网卡">
|
||||
<el-tag :type="currentDetail.is_primary ? 'warning' : 'info'" size="small">
|
||||
{{ currentDetail.is_primary ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="禁用状态">
|
||||
<el-tag :type="currentDetail.disable ? 'danger' : 'success'" size="small">
|
||||
{{ currentDetail.disable ? '已禁用' : '启用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
@@ -221,6 +278,15 @@ const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const filterType = ref('')
|
||||
const filterIpVersion = ref('')
|
||||
const filterUsed = ref('')
|
||||
const filterDisable = ref('')
|
||||
|
||||
const isNetworkUsed = (row) => !!row?.vm_id
|
||||
|
||||
const goToVmDetail = (vmId) => {
|
||||
if (!vmId) return
|
||||
router.push({ path: '/virtualization/vm-detail', query: { service_id: serviceId.value, service_name: serviceName.value, vm_id: vmId } })
|
||||
}
|
||||
const hostIdInput = ref(0)
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, page_size: 10 })
|
||||
@@ -254,7 +320,8 @@ const currentDetail = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
id: undefined, name: '', address: '', gateway: '', nameservers: '',
|
||||
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0
|
||||
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0,
|
||||
disable: false
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
@@ -275,6 +342,8 @@ const loadList = async () => {
|
||||
if (keyword.value) params.key = keyword.value
|
||||
if (filterType.value) params.type = filterType.value
|
||||
if (filterIpVersion.value) params.ip_version = filterIpVersion.value
|
||||
if (filterUsed.value !== '') params.used = filterUsed.value
|
||||
if (filterDisable.value !== '') params.disable = filterDisable.value
|
||||
const res = await getNetworkList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
@@ -295,7 +364,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0 })
|
||||
Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0, disable: false })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -305,7 +374,7 @@ const handleEdit = (row) => {
|
||||
id: row.id, name: row.name, address: row.address, gateway: row.gateway,
|
||||
nameservers: row.nameservers || '', type: row.type, mac_address: row.mac_address || '',
|
||||
bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '',
|
||||
ls_name: row.ls_name || '', host_id: row.host_id
|
||||
ls_name: row.ls_name || '', host_id: row.host_id, disable: !!row.disable
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -332,6 +401,7 @@ const handleSubmit = () => {
|
||||
res = await createNetwork(fd)
|
||||
} else {
|
||||
fd.append('id', formData.id)
|
||||
fd.append('disable', formData.disable)
|
||||
res = await updateNetwork(fd)
|
||||
}
|
||||
if (res?.data?.code === 200) {
|
||||
@@ -359,6 +429,25 @@ const handleViewDetail = async (row) => {
|
||||
} catch { /* fallback */ }
|
||||
}
|
||||
|
||||
const handleToggleDisable = (row) => {
|
||||
const nextState = !row.disable
|
||||
const actionText = nextState ? '禁用' : '启用'
|
||||
ElMessageBox.confirm(`确定要${actionText}网络「${row.name}」吗?${nextState ? '禁用后该网络不参与自动分配。' : ''}`, `${actionText}确认`, {
|
||||
confirmButtonText: `确定${actionText}`, cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('id', row.id)
|
||||
fd.append('host_id', row.host_id)
|
||||
fd.append('disable', nextState)
|
||||
const res = await updateNetwork(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${actionText}成功`); loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, `${actionText}失败`))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${actionText}失败`)) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
|
||||
<el-button link type="success" size="small" @click="handleRestore(row)"
|
||||
:disabled="row.status !== 'archived'">恢复</el-button>
|
||||
:disabled="!canRestore(row.status)">恢复</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)"
|
||||
:disabled="row.status === 'restoring' || row.status === 'purging'">永久删除</el-button>
|
||||
</template>
|
||||
@@ -99,16 +99,33 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 恢复弹窗(可选指定网络) -->
|
||||
<el-dialog v-model="restoreVisible" title="恢复虚拟机" width="480px" destroy-on-close>
|
||||
<el-dialog v-model="restoreVisible" title="恢复虚拟机" width="520px" destroy-on-close>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="虚拟机名称">
|
||||
<span>{{ restoreRow?.vm_name }} (ID: {{ restoreRow?.vm_id }})</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="内网网络ID">
|
||||
<el-input v-model="restoreForm.network_ids" placeholder="可选,多个用逗号分隔" />
|
||||
<el-form-item label="外网网络">
|
||||
<div class="net-select-area">
|
||||
<div v-if="selectedBridgeNetwork" class="net-selected-item">
|
||||
<el-tag closable @close="selectedBridgeNetwork = null">
|
||||
{{ selectedBridgeNetwork.name }} ({{ selectedBridgeNetwork.address }})
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button size="small" @click="showBridgeNetSelector = true">
|
||||
{{ selectedBridgeNetwork ? '更换' : '选择外网' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="外网网络ID">
|
||||
<el-input v-model="restoreForm.internet_network_id" placeholder="可选" />
|
||||
<el-form-item label="内网网络">
|
||||
<div class="net-select-area">
|
||||
<div v-if="selectedNatNetworks.length" class="net-selected-list">
|
||||
<el-tag v-for="net in selectedNatNetworks" :key="net.id" closable
|
||||
@close="removeNatNetwork(net.id)" style="margin-right: 4px; margin-bottom: 4px">
|
||||
{{ net.name }} ({{ net.address }})
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button size="small" @click="showNatNetSelector = true">添加内网</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -116,6 +133,17 @@
|
||||
<el-button type="primary" :loading="restoreLoading" @click="submitRestore">确认恢复</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 恢复 - 外网选择器 -->
|
||||
<NetworkSelectorPopup v-model="showBridgeNetSelector"
|
||||
:service-id="serviceId" :host-id="restoreRow?.host_id || 0"
|
||||
filter-type="bridge" filter-used="false"
|
||||
@confirm="handleBridgeNetConfirm" />
|
||||
<!-- 恢复 - 内网选择器 -->
|
||||
<NetworkSelectorPopup v-model="showNatNetSelector"
|
||||
:service-id="serviceId" :host-id="restoreRow?.host_id || 0"
|
||||
filter-type="nat" filter-used="false"
|
||||
@confirm="handleNatNetConfirm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -128,6 +156,7 @@ import {
|
||||
restoreRecycleBin, deleteRecycleBin, cleanRecycleBin
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
|
||||
|
||||
const serviceId = inject('serviceId')
|
||||
const hostId = inject('hostId')
|
||||
@@ -143,21 +172,25 @@ const filterStatus = ref('')
|
||||
const statusOptions = [
|
||||
{ value: 'pending', label: '等待归档' },
|
||||
{ value: 'archiving', label: '归档中' },
|
||||
{ value: 'ready', label: '就绪' },
|
||||
{ value: 'archived', label: '已归档' },
|
||||
{ value: 'restoring', label: '恢复中' },
|
||||
{ value: 'purging', label: '清理中' }
|
||||
]
|
||||
|
||||
const statusLabelMap = {
|
||||
pending: '等待归档', archiving: '归档中', archived: '已归档',
|
||||
pending: '等待归档', archiving: '归档中', ready: '就绪', archived: '已归档',
|
||||
restoring: '恢复中', purging: '清理中', failed: '失败', error: '错误'
|
||||
}
|
||||
const statusLabel = (s) => statusLabelMap[s] || s || '-'
|
||||
const statusTagType = (s) => ({
|
||||
archived: 'success', pending: 'info', archiving: 'warning',
|
||||
ready: 'success', archived: 'success', pending: 'info', archiving: 'warning',
|
||||
restoring: 'primary', purging: 'danger', failed: 'danger', error: 'danger'
|
||||
}[s] || 'info')
|
||||
|
||||
// ready 和 archived 都表示归档完成,可执行恢复
|
||||
const canRestore = (status) => status === 'ready' || status === 'archived'
|
||||
|
||||
const formatTs = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
@@ -236,14 +269,30 @@ const handleDetail = async (row) => {
|
||||
const restoreVisible = ref(false)
|
||||
const restoreLoading = ref(false)
|
||||
const restoreRow = ref(null)
|
||||
const restoreForm = reactive({ network_ids: '', internet_network_id: '' })
|
||||
|
||||
// 网络选择器
|
||||
const showBridgeNetSelector = ref(false)
|
||||
const showNatNetSelector = ref(false)
|
||||
const selectedBridgeNetwork = ref(null)
|
||||
const selectedNatNetworks = ref([])
|
||||
|
||||
const handleRestore = (row) => {
|
||||
restoreRow.value = row
|
||||
Object.assign(restoreForm, { network_ids: '', internet_network_id: '' })
|
||||
selectedBridgeNetwork.value = null
|
||||
selectedNatNetworks.value = []
|
||||
restoreVisible.value = true
|
||||
}
|
||||
|
||||
const handleBridgeNetConfirm = (net) => { selectedBridgeNetwork.value = net }
|
||||
const handleNatNetConfirm = (net) => {
|
||||
if (!selectedNatNetworks.value.find(n => n.id === net.id)) {
|
||||
selectedNatNetworks.value.push(net)
|
||||
}
|
||||
}
|
||||
const removeNatNetwork = (id) => {
|
||||
selectedNatNetworks.value = selectedNatNetworks.value.filter(n => n.id !== id)
|
||||
}
|
||||
|
||||
const submitRestore = async () => {
|
||||
restoreLoading.value = true
|
||||
try {
|
||||
@@ -251,13 +300,11 @@ const submitRestore = async () => {
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('host_id', hostId.value)
|
||||
fd.append('recycle_id', restoreRow.value.id)
|
||||
if (restoreForm.network_ids) {
|
||||
restoreForm.network_ids.split(',').map(s => s.trim()).filter(Boolean).forEach(id => {
|
||||
fd.append('network_ids', id)
|
||||
})
|
||||
if (selectedNatNetworks.value.length) {
|
||||
selectedNatNetworks.value.forEach(n => fd.append('network_ids', n.id))
|
||||
}
|
||||
if (restoreForm.internet_network_id) {
|
||||
fd.append('internet_network_id', restoreForm.internet_network_id)
|
||||
if (selectedBridgeNetwork.value) {
|
||||
fd.append('internet_network_id', selectedBridgeNetwork.value.id)
|
||||
}
|
||||
const res = await restoreRecycleBin(fd)
|
||||
if (res?.data?.code === 200) {
|
||||
@@ -322,4 +369,6 @@ defineExpose({ loadList })
|
||||
.mono-text { font-family: Consolas, Monaco, monospace; font-size: 12px; }
|
||||
.snapshot-title { font-size: 13px; font-weight: 600; color: #606266; margin: 12px 0 6px; }
|
||||
.pagination-wrapper { margin-top: 12px; display: flex; justify-content: flex-end; }
|
||||
.net-select-area { display: flex; flex-direction: column; gap: 6px; }
|
||||
.net-selected-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
</style>
|
||||
|
||||
@@ -3956,11 +3956,14 @@ const loadSgLockInfo = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(vmId, () => { if (isPageActive) initPage() })
|
||||
const isCurrentRoute = () => route.name === 'VirtVmDetail'
|
||||
|
||||
watch(vmId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
|
||||
onActivated(() => {
|
||||
onActivated(async () => {
|
||||
isPageActive = true
|
||||
if (loadedVmId !== vmId.value) initPage()
|
||||
await nextTick()
|
||||
if (isCurrentRoute() && loadedVmId !== vmId.value) initPage()
|
||||
})
|
||||
onDeactivated(() => { isPageActive = false; stopMigratePolling() })
|
||||
onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() })
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
||||
@@ -273,8 +273,10 @@ const initPage = () => {
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
watch(volumeId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => { isPageActive = true; if (loadedVolumeId !== volumeId.value) initPage() })
|
||||
const isCurrentRoute = () => route.name === 'VirtVolumeDetail'
|
||||
|
||||
watch(volumeId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedVolumeId !== volumeId.value) initPage() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user