feat: 添加用户虚拟机商品管理
Build and Deploy Vue3 / build (push) Successful in 1m40s
Build and Deploy Vue3 / deploy (push) Successful in 1m8s

This commit is contained in:
2026-03-31 15:13:04 +08:00
parent 71d3605f4f
commit c07e09c151
28 changed files with 7143 additions and 621 deletions
+104 -11
View File
@@ -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)
+1 -1
View File
@@ -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 />
+90 -33
View File
@@ -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()
+62 -29
View File
@@ -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, '获取状态失败')) }
}