feat: 添加用户虚拟机商品管理
This commit is contained in:
@@ -96,9 +96,10 @@
|
||||
disabled
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-button type="primary" @click="showGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button type="primary" @click="showGroupSelector = true" style="margin-left: 8px" :disabled="!!bindForm.good_id">选择</el-button>
|
||||
<el-button v-if="bindForm.good_group_id" @click="clearBindGroup" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
<div v-if="bindForm.good_id" style="font-size: 12px; color: #e6a23c; margin-top: 4px">已绑定商品,请先清除商品后再绑定商品组</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="绑定商品">
|
||||
<div class="bind-selector-row">
|
||||
@@ -107,9 +108,10 @@
|
||||
disabled
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-button type="primary" @click="showProductSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button type="primary" @click="showProductSelector = true" style="margin-left: 8px" :disabled="!!bindForm.good_group_id">选择</el-button>
|
||||
<el-button v-if="bindForm.good_id" @click="clearBindProduct" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
<div v-if="bindForm.good_group_id" style="font-size: 12px; color: #e6a23c; margin-top: 4px">已绑定商品组,请先清除商品组后再绑定商品</div>
|
||||
</el-form-item>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 12px;">
|
||||
<template #title>
|
||||
@@ -123,7 +125,34 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 商品组选择器 -->
|
||||
<!-- 生成商品 - 父级商品组选择器 -->
|
||||
<ProductGroupSelector
|
||||
v-model="showGenerateGroupSelector"
|
||||
:current-group-id="generateForm.parent_group_id"
|
||||
@confirm="g => { generateForm.parent_group_id = g.id; generateForm._parentGroupName = g.name }"
|
||||
/>
|
||||
|
||||
<!-- 生成商品 - 标签选择器 -->
|
||||
<el-dialog v-model="showGenerateTagSelector" title="选择标签" width="560px" append-to-body destroy-on-close>
|
||||
<div style="margin-bottom: 12px; display: flex; gap: 8px">
|
||||
<el-input v-model="tagKeyword" placeholder="搜索标签名称" clearable style="width: 220px" @input="filterTags">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button :icon="Refresh" @click="() => { tagOptions.value = []; fetchTagOptions() }" :loading="tagLoading">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="filteredTagOptions" v-loading="tagLoading" highlight-current-row
|
||||
@current-change="row => selectedTagRow = row" :height="300" stripe size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-empty v-if="!filteredTagOptions.length && !tagLoading" description="暂无标签" :image-size="60" />
|
||||
<template #footer>
|
||||
<el-button @click="showGenerateTagSelector = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedTagRow" @click="confirmTagSelect">确定选择</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定弹窗用商品组选择器 -->
|
||||
<ProductGroupSelector
|
||||
v-model="showGroupSelector"
|
||||
:current-group-id="bindForm.good_group_id"
|
||||
@@ -146,13 +175,29 @@
|
||||
</el-alert>
|
||||
<el-form ref="generateFormRef" :model="generateForm" :rules="generateFormRules" label-width="120px">
|
||||
<el-form-item label="起始主机组ID" prop="id">
|
||||
<el-input-number v-model="generateForm.id" :min="1" disabled style="width: 100%" />
|
||||
<el-input :model-value="generateForm.id" disabled style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级GoodGroup">
|
||||
<el-input-number v-model="generateForm.parent_group_id" :min="0" placeholder="挂载到已有父级(可选)" style="width: 100%" />
|
||||
<div class="bind-selector-row">
|
||||
<el-input
|
||||
:model-value="generateForm.parent_group_id ? `商品组 #${generateForm.parent_group_id}${generateForm._parentGroupName ? ' - ' + generateForm._parentGroupName : ''}` : '不挂载父级'"
|
||||
disabled
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-button type="primary" @click="showGenerateGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="generateForm.parent_group_id" @click="generateForm.parent_group_id = 0; generateForm._parentGroupName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签ID">
|
||||
<el-input-number v-model="generateForm.tag_id" :min="0" placeholder="根节点标签ID(可选)" style="width: 100%" />
|
||||
<el-form-item label="标签">
|
||||
<div class="bind-selector-row">
|
||||
<el-input
|
||||
:model-value="generateForm.tag_id ? `标签 #${generateForm.tag_id}${generateForm._tagName ? ' - ' + generateForm._tagName : ''}` : '不设置标签'"
|
||||
disabled
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-button type="primary" @click="showGenerateTagSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="generateForm.tag_id" @click="generateForm.tag_id = 0; generateForm._tagName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="Table标识" prop="table">
|
||||
<el-input v-model="generateForm.table" placeholder="如 kvm_service" />
|
||||
@@ -167,7 +212,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, inject, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, RefreshRight, Search, ArrowLeft } from '@element-plus/icons-vue'
|
||||
@@ -181,6 +226,7 @@ import {
|
||||
getKvmServiceList
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { getProductGroupTagList } from '@/api/admin/product'
|
||||
import ProductGroupSelector from '@/components/admin/ProductGroupSelector.vue'
|
||||
import ProductSelector from '@/components/admin/ProductSelector.vue'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -211,7 +257,7 @@ const normalizeService = (s) => ({
|
||||
|
||||
const loadServiceOptions = async () => {
|
||||
try {
|
||||
const res = await getKvmServiceList({ page: 1, count: 100, key: '' })
|
||||
const res = await getKvmServiceList({ page: 1, count: 10, key: '' })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||
@@ -471,7 +517,9 @@ const generateFormRef = ref(null)
|
||||
const generateForm = reactive({
|
||||
id: undefined,
|
||||
parent_group_id: 0,
|
||||
_parentGroupName: '',
|
||||
tag_id: 0,
|
||||
_tagName: '',
|
||||
table: 'kvm_service'
|
||||
})
|
||||
|
||||
@@ -479,11 +527,56 @@ const generateFormRules = {
|
||||
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 父级商品组选择器
|
||||
const showGenerateGroupSelector = ref(false)
|
||||
|
||||
// 标签选择器
|
||||
const showGenerateTagSelector = ref(false)
|
||||
const tagOptions = ref([])
|
||||
const tagLoading = ref(false)
|
||||
const tagKeyword = ref('')
|
||||
const selectedTagRow = ref(null)
|
||||
const filteredTagOptions = computed(() =>
|
||||
tagKeyword.value
|
||||
? tagOptions.value.filter(t => t.name?.includes(tagKeyword.value))
|
||||
: tagOptions.value
|
||||
)
|
||||
const filterTags = () => { /* computed 自动响应 */ }
|
||||
const confirmTagSelect = () => {
|
||||
if (!selectedTagRow.value) return
|
||||
generateForm.tag_id = selectedTagRow.value.id
|
||||
generateForm._tagName = selectedTagRow.value.name
|
||||
showGenerateTagSelector.value = false
|
||||
selectedTagRow.value = null
|
||||
tagKeyword.value = ''
|
||||
}
|
||||
|
||||
const loadTagOptions = async () => {
|
||||
if (tagOptions.value.length) return
|
||||
await fetchTagOptions()
|
||||
}
|
||||
|
||||
const fetchTagOptions = async () => {
|
||||
tagLoading.value = true
|
||||
try {
|
||||
const res = await getProductGroupTagList()
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
tagOptions.value = Array.isArray(inner) ? inner : (inner.data || inner.list || [])
|
||||
}
|
||||
} catch { /* */ } finally { tagLoading.value = false }
|
||||
}
|
||||
|
||||
// 监听标签选择器打开时加载数据
|
||||
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
|
||||
|
||||
const handleGenerateGoods = (row) => {
|
||||
Object.assign(generateForm, {
|
||||
id: Number(row.Id ?? row.id),
|
||||
parent_group_id: 0,
|
||||
_parentGroupName: '',
|
||||
tag_id: 0,
|
||||
_tagName: '',
|
||||
table: 'kvm_service'
|
||||
})
|
||||
generateDialogVisible.value = true
|
||||
@@ -501,8 +594,8 @@ const submitGenerate = () => {
|
||||
generateSubmitLoading.value = true
|
||||
try {
|
||||
const payload = { id: generateForm.id }
|
||||
if (generateForm.parent_group_id > 0) payload.parent_group_id = generateForm.parent_group_id
|
||||
if (generateForm.tag_id > 0) payload.tag_id = generateForm.tag_id
|
||||
if (generateForm.parent_group_id) payload.parent_group_id = generateForm.parent_group_id
|
||||
if (generateForm.tag_id) payload.tag_id = generateForm.tag_id
|
||||
if (generateForm.table) payload.table = generateForm.table
|
||||
|
||||
const res = await generateGoodsByHostGroup(payload)
|
||||
|
||||
@@ -61,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="syncStatusType(row.sync_status)" size="small">{{ syncStatusLabel(row.sync_status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
|
||||
|
||||
@@ -161,7 +161,8 @@
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">网络列表</h3>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<el-button size="small" type="primary" @click="showNetBindSelector = true">绑定网络</el-button>
|
||||
<el-button size="small" type="primary" @click="showNetBindBridgeSelector = true">绑定外网</el-button>
|
||||
<el-button size="small" type="success" @click="showNetBindNatSelector = true">绑定内网</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,7 +262,7 @@
|
||||
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="sgTableData" size="small" stripe>
|
||||
<el-table :data="pagedSecurityGroups" size="small" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="锁定" width="80">
|
||||
@@ -301,6 +302,20 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!sgTableData.length" description="暂无绑定的安全组" :image-size="60" />
|
||||
|
||||
<div class="pagination-wrapper" v-if="sgTableData.length > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="sgPage"
|
||||
v-model:page-size="sgPageSize"
|
||||
:page-size="[10,20,50]"
|
||||
:total="sgTableData.length"
|
||||
layout="total,sizes,prev,pager,next"
|
||||
small
|
||||
@size-change="s => {sgPageSize = s; sgPage = 1}"
|
||||
@current-change="p => {sgPage = p}"
|
||||
>
|
||||
</el-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -826,8 +841,10 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定网络选择器 -->
|
||||
<NetworkSelectorPopup v-model="showNetBindSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleNetBindConfirm" @create="() => handleNetCreate('bind')" />
|
||||
<!-- 绑定外网选择器(bridge) -->
|
||||
<NetworkSelectorPopup v-model="showNetBindBridgeSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleNetBindBridgeConfirm" @create="() => handleNetCreate('bindBridge')" />
|
||||
<!-- 绑定内网选择器(nat) -->
|
||||
<NetworkSelectorPopup v-model="showNetBindNatSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleNetBindNatConfirm" @create="() => handleNetCreate('bindNat')" />
|
||||
|
||||
<!-- 创建/编辑网络弹窗 -->
|
||||
<el-dialog v-model="netDialogVisible" :title="netDialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close>
|
||||
@@ -1249,8 +1266,8 @@ const handleMoreCommand = (cmd) => {
|
||||
if (actionMap[cmd]) actionMap[cmd]()
|
||||
}
|
||||
|
||||
const vmStatusType = (s) => ({ running: 'success', ready: 'success', creating: 'warning', pending: 'info', stopped: 'danger', stop: 'danger', error: 'danger', paused: 'warning', reboot: 'warning', poweroff: 'info', unknown: 'info' }[s] || 'info')
|
||||
const vmStatusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停', reboot: '重启中', poweroff: '已关机', unknown: '未知' }[s] || s || '-')
|
||||
const vmStatusType = (s) => ({ running: 'success', ready: 'success', creating: 'warning', pending: 'info', stopped: 'danger', stop: 'danger', shutoff: 'danger', error: 'danger', paused: 'warning', reboot: 'warning', poweroff: 'info', unknown: 'info' }[s] || 'info')
|
||||
const vmStatusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', shutoff: '已关闭', error: '错误', paused: '已暂停', reboot: '重启中', poweroff: '已关机', unknown: '未知' }[s] || s || '-')
|
||||
const imgStatusType = (s) => ({ ready: 'success', downloading: 'warning', pending: 'info', error: 'danger' }[s] || 'info')
|
||||
const imgStatusLabel = (s) => ({ ready: '就绪', downloading: '下载中', pending: '等待中', error: '错误' }[s] || s || '-')
|
||||
|
||||
@@ -1298,9 +1315,12 @@ const fetchVmStatus = async () => {
|
||||
try {
|
||||
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vmId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const sd = res.data.data
|
||||
detail.value = { ...detail.value, status: sd.status ?? sd }
|
||||
ElMessage.success('状态已刷新: ' + vmStatusLabel(detail.value.status))
|
||||
const outer = res.data.data
|
||||
const inner = outer.data ?? outer
|
||||
const state = inner.state ?? inner.status ?? inner
|
||||
const desc = inner.desc || ''
|
||||
detail.value = { ...detail.value, status: state }
|
||||
ElMessage.success(`状态已刷新: ${desc || vmStatusLabel(state)}`)
|
||||
}
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) } finally { statusLoading.value = false }
|
||||
}
|
||||
@@ -1476,7 +1496,11 @@ const submitRebuild = async () => {
|
||||
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await rebuildVm({ service_id: serviceId.value, vm_id: vmId.value, image_id: rebuildImageId.value })
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', vmId.value)
|
||||
fd.append('image_id', rebuildImageId.value)
|
||||
const res = await rebuildVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('重装成功'); rebuildDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '重装失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重装失败')) } finally { actionLoading.value = false }
|
||||
@@ -1542,7 +1566,7 @@ const handleEditVm = async () => {
|
||||
Object.assign(editForm, {
|
||||
rx_bandwidth: d.rx_bandwidth || 0,
|
||||
tx_bandwidth: d.tx_bandwidth || 0,
|
||||
root_password: '',
|
||||
root_password: d.root_password || '',
|
||||
ssh_port: d.ssh_port || 22,
|
||||
port_group_id: vmPortGroup.value?.id || ''
|
||||
})
|
||||
@@ -1570,7 +1594,7 @@ const submitEditVm = async () => {
|
||||
if (editForm.root_password) fd.append('root_password', editForm.root_password)
|
||||
fd.append('ssh_port', editForm.ssh_port)
|
||||
editSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id))
|
||||
editSelectedInternalNetworks.value.forEach(n => fd.append('internet_network_id', n.id))
|
||||
if (editSelectedInternalNetworks.value.length) fd.append('internet_network_id', editSelectedInternalNetworks.value[0].id)
|
||||
if (editForm.port_group_id) fd.append('port_group_id', editForm.port_group_id)
|
||||
const res = await updateVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||
@@ -1662,7 +1686,7 @@ const submitRefactorVm = async () => {
|
||||
if (refactorForm.vnc_port) fd.append('vnc_port', refactorForm.vnc_port)
|
||||
if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password)
|
||||
refactorSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id))
|
||||
refactorSelectedInternalNetworks.value.forEach(n => fd.append('internet_network_id', n.id))
|
||||
if (refactorSelectedInternalNetworks.value.length) fd.append('internet_network_id', refactorSelectedInternalNetworks.value[0].id)
|
||||
if (refactorForm.port_group_id) fd.append('port_group_id', refactorForm.port_group_id)
|
||||
const res = await refactorVm(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('重构成功'); refactorDialogVisible.value = false; loadDetail() }
|
||||
@@ -1798,20 +1822,25 @@ const loadSgOptions = async () => {
|
||||
}
|
||||
|
||||
// ---- 绑定网络(通过 updateVm 接口) ----
|
||||
const showNetBindSelector = ref(false)
|
||||
const handleNetBindConfirm = async (selectedNetwork) => {
|
||||
const existingIds = vmNetworks.value.map(n => n.id)
|
||||
if (existingIds.includes(selectedNetwork.id)) {
|
||||
ElMessage.warning('该网络已绑定')
|
||||
return
|
||||
}
|
||||
const showNetBindBridgeSelector = ref(false)
|
||||
const showNetBindNatSelector = ref(false)
|
||||
|
||||
const submitNetBind = async (paramName, newId, existingIds) => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const allNetworkIds = [...existingIds, selectedNetwork.id]
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', vmId.value)
|
||||
allNetworkIds.forEach(id => fd.append('network_ids', id))
|
||||
const bridgeIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
|
||||
const natNet = vmNetworks.value.find(n => n.type === 'nat')
|
||||
if (paramName === 'network_ids') {
|
||||
const allBridge = [...bridgeIds, newId]
|
||||
allBridge.forEach(id => fd.append('network_ids', id))
|
||||
if (natNet) fd.append('internet_network_id', natNet.id)
|
||||
} else {
|
||||
bridgeIds.forEach(id => fd.append('network_ids', id))
|
||||
fd.append('internet_network_id', newId)
|
||||
}
|
||||
if (detail.value?.rx_bandwidth) fd.append('rx_bandwidth', detail.value.rx_bandwidth)
|
||||
if (detail.value?.tx_bandwidth) fd.append('tx_bandwidth', detail.value.tx_bandwidth)
|
||||
if (detail.value?.ssh_port) fd.append('ssh_port', detail.value.ssh_port)
|
||||
@@ -1821,15 +1850,33 @@ const handleNetBindConfirm = async (selectedNetwork) => {
|
||||
ElMessage.success('绑定网络成功')
|
||||
loadDetail()
|
||||
} else {
|
||||
ElMessage.error(extractApiError(res, '绑定网络失败'))
|
||||
ElMessage.error(extractApiError(res?.data, '绑定网络失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e, '绑定网络失败'))
|
||||
ElMessage.error(extractApiError(e?.response?.data, '绑定网络失败'))
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleNetBindBridgeConfirm = (selectedNetwork) => {
|
||||
const existingIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
|
||||
if (existingIds.includes(selectedNetwork.id)) {
|
||||
ElMessage.warning('该网络已绑定')
|
||||
return
|
||||
}
|
||||
submitNetBind('network_ids', selectedNetwork.id)
|
||||
}
|
||||
|
||||
const handleNetBindNatConfirm = (selectedNetwork) => {
|
||||
const existingNat = vmNetworks.value.find(n => n.type === 'nat')
|
||||
if (existingNat?.id === selectedNetwork.id) {
|
||||
ElMessage.warning('该内网已绑定')
|
||||
return
|
||||
}
|
||||
submitNetBind('internet_network_id', selectedNetwork.id)
|
||||
}
|
||||
|
||||
// ---- 网络操作(创建/编辑/删除/详情) ----
|
||||
const netDialogVisible = ref(false)
|
||||
const netDialogType = ref('add')
|
||||
@@ -1859,7 +1906,8 @@ const handleNetDialogCancel = () => {
|
||||
else if (src === 'editInternal') showEditInternalNetworkSelector.value = true
|
||||
else if (src === 'refactor') showRefactorNetworkSelector.value = true
|
||||
else if (src === 'refactorInternal') showRefactorInternalNetworkSelector.value = true
|
||||
else if (src === 'bind') showNetBindSelector.value = true
|
||||
else if (src === 'bindBridge') showNetBindBridgeSelector.value = true
|
||||
else if (src === 'bindNat') showNetBindNatSelector.value = true
|
||||
}
|
||||
|
||||
const handleNetEdit = (row) => {
|
||||
@@ -2045,6 +2093,13 @@ const sgTableData = computed(() => {
|
||||
if (vmOutPortGroup.value) list.push(vmOutPortGroup.value)
|
||||
return list
|
||||
})
|
||||
//安全组分页
|
||||
const sgPage = ref(1)
|
||||
const sgPageSize = ref(10)
|
||||
const pagedSecurityGroups = computed(() =>{
|
||||
const start = (sgPage.value -1) * sgPageSize.value
|
||||
return sgTableData.value.slice(start,start + sgPageSize.value)
|
||||
})
|
||||
const sgSubmitLoading = ref(false)
|
||||
const sgDetailLoading = ref(false)
|
||||
const sgHostOptions = ref([])
|
||||
@@ -2609,7 +2664,6 @@ const vnCreateForm = reactive({ name: '', bridge_name: '', gateway: '' })
|
||||
const vnCreateHostName = ref('')
|
||||
const vnCreateUserName = ref('')
|
||||
const vnCreateRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
bridge_name: [{ required: true, message: '请输入网桥名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
@@ -2717,7 +2771,7 @@ const handleLeaveNetworking = (row) => {
|
||||
}
|
||||
|
||||
let loadedVmId = null
|
||||
const initPage = () => {
|
||||
const initPage = async () => {
|
||||
if (!vmId.value || loadedVmId === vmId.value) return
|
||||
loadedVmId = vmId.value
|
||||
metricsData.value = null
|
||||
@@ -2725,18 +2779,21 @@ const initPage = () => {
|
||||
disposeCharts()
|
||||
clearHistory()
|
||||
loadHostOptions()
|
||||
loadDetail()
|
||||
if (activeTab.value === 'monitor') startPolling()
|
||||
// 先加载详情,详情加载完后再触发当前 tab 的数据
|
||||
await loadDetail()
|
||||
triggerTabLoad(activeTab.value)
|
||||
}
|
||||
|
||||
watch(vmId, () => { if (isPageActive) initPage() })
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'monitor' && detail.value) startPolling()
|
||||
const triggerTabLoad = (tab) => {
|
||||
if (tab === 'monitor') startPolling()
|
||||
else stopPolling()
|
||||
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
|
||||
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
|
||||
if (tab === 'userNetworking') loadVmNetworkingList()
|
||||
})
|
||||
}
|
||||
|
||||
watch(vmId, () => { if (isPageActive) initPage() })
|
||||
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
|
||||
onActivated(() => {
|
||||
isPageActive = true
|
||||
if (loadedVmId !== vmId.value) initPage()
|
||||
|
||||
@@ -321,6 +321,26 @@
|
||||
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="serviceId" :current-id="createForm.host_group_id" @confirm="handleHostGroupSelected" />
|
||||
<!-- 用户选择器 -->
|
||||
<UserListSelector v-model="showUserSelector" :current-user-id="createForm.user_id" @confirm="handleUserSelected" />
|
||||
|
||||
<!-- 电源操作确认弹窗 -->
|
||||
<el-dialog v-model="powerDialogVisible" :title="`${powerLabels[powerAction] || ''}虚拟机`" width="400px" destroy-on-close>
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px; padding: 8px 0">
|
||||
<el-icon :size="22" :style="{ color: powerAction === 'stop' ? '#F56C6C' : powerAction === 'reboot' ? '#E6A23C' : '#409EFF', flexShrink: 0, marginTop: '2px' }">
|
||||
<WarningFilled />
|
||||
</el-icon>
|
||||
<div>
|
||||
<div style="font-size: 15px; font-weight: 500; color: #303133; margin-bottom: 12px">
|
||||
确定要{{ powerLabels[powerAction] }}虚拟机「{{ powerRow?.name }}」吗?
|
||||
</div>
|
||||
<el-checkbox v-model="powerForce" style="margin-bottom: 4px">强制执行</el-checkbox>
|
||||
<div style="font-size: 12px; color: #909399; padding-left: 24px">勾选后将强制{{ powerLabels[powerAction] }},可能导致数据丢失</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="powerDialogVisible = false">取消</el-button>
|
||||
<el-button :type="powerAction === 'stop' ? 'danger' : 'primary'" @click="submitPower">确定{{ powerLabels[powerAction] }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -328,7 +348,7 @@
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getVmMetrics,
|
||||
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
||||
@@ -438,6 +458,7 @@ const vmStatuses = [
|
||||
{ label: '等待中', value: 'pending' }, { label: '创建中', value: 'creating' },
|
||||
{ label: '就绪', value: 'ready' }, { label: '运行中', value: 'running' },
|
||||
{ label: '已停止', value: 'stopped' }, { label: '已停止', value: 'stop' },
|
||||
{ label: '已关闭', value: 'shutoff' },
|
||||
{ label: '错误', value: 'error' }, { label: '已暂停', value: 'paused' },
|
||||
{ label: '重启中', value: 'reboot' }, { label: '已关机', value: 'poweroff' },
|
||||
{ label: '未知', value: 'unknown' }
|
||||
@@ -470,13 +491,13 @@ const createRules = {
|
||||
|
||||
const vmStatusType = (s) => ({
|
||||
running: 'success', ready: 'success', creating: 'warning', pending: 'info',
|
||||
stopped: 'danger', stop: 'danger', error: 'danger', paused: 'warning',
|
||||
stopped: 'danger', stop: 'danger', shutoff: 'danger', error: 'danger', paused: 'warning',
|
||||
reboot: 'warning', poweroff: 'info', unknown: 'info'
|
||||
}[s] || 'info')
|
||||
|
||||
const vmStatusLabel = (s) => ({
|
||||
running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中',
|
||||
stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停',
|
||||
stopped: '已停止', stop: '已停止', shutoff: '已关闭', error: '错误', paused: '已暂停',
|
||||
reboot: '重启中', poweroff: '已关机', unknown: '未知'
|
||||
}[s] || s || '-')
|
||||
|
||||
@@ -589,29 +610,34 @@ const submitCreate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const powerDialogVisible = ref(false)
|
||||
const powerAction = ref('')
|
||||
const powerRow = ref(null)
|
||||
const powerForce = ref(false)
|
||||
const powerLabels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
|
||||
|
||||
const handlePower = (row, action) => {
|
||||
const labels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
|
||||
ElMessageBox.confirm(`确定要${labels[action]}虚拟机「${row.name}」吗?`, `${labels[action]}确认`, {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消',
|
||||
type: action === 'stop' ? 'warning' : 'info'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
|
||||
const payload = { service_id: serviceId.value, vm_id: row.id }
|
||||
// resume uses FormData
|
||||
let res
|
||||
if (action === 'resume') {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', row.id)
|
||||
res = await resumeVm(fd)
|
||||
} else {
|
||||
res = await apis[action](payload)
|
||||
}
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, `${labels[action]}失败`))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${labels[action]}失败`)) }
|
||||
}).catch(() => {})
|
||||
powerRow.value = row
|
||||
powerAction.value = action
|
||||
powerForce.value = false
|
||||
powerDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitPower = async () => {
|
||||
const action = powerAction.value
|
||||
const row = powerRow.value
|
||||
const label = powerLabels[action]
|
||||
powerDialogVisible.value = false
|
||||
try {
|
||||
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', row.id)
|
||||
if (powerForce.value) fd.append('force', true)
|
||||
const res = await apis[action](fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${label}成功`); loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, `${label}失败`))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${label}失败`)) }
|
||||
}
|
||||
|
||||
const handleMoreAction = (row, command) => {
|
||||
@@ -635,7 +661,11 @@ const submitRebuild = async () => {
|
||||
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = await rebuildVm({ service_id: serviceId.value, vm_id: rebuildTarget.value.id, image_id: rebuildImageId.value })
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', rebuildTarget.value.id)
|
||||
fd.append('image_id', rebuildImageId.value)
|
||||
const res = await rebuildVm(fd)
|
||||
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 }
|
||||
@@ -695,9 +725,12 @@ const fetchVmStatus = async (vm) => {
|
||||
try {
|
||||
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vm.id })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const statusData = res.data.data
|
||||
currentDetail.value = { ...currentDetail.value, status: statusData.status ?? statusData }
|
||||
ElMessage.success('状态已刷新: ' + vmStatusLabel(currentDetail.value.status))
|
||||
const outer = res.data.data
|
||||
const inner = outer.data ?? outer
|
||||
const state = inner.state ?? inner.status ?? inner
|
||||
const desc = inner.desc || ''
|
||||
currentDetail.value = { ...currentDetail.value, status: state }
|
||||
ElMessage.success(`状态已刷新: ${desc || vmStatusLabel(state)}`)
|
||||
}
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user