fex: 样式修改
Build and Deploy Vue3 / build (push) Successful in 1m39s
Build and Deploy Vue3 / deploy (push) Successful in 1m0s

This commit is contained in:
2026-03-21 19:07:04 +08:00
parent 25d782b050
commit 3357566b02
7 changed files with 665 additions and 200 deletions
@@ -0,0 +1,113 @@
<template>
<el-dialog v-model="visible" title="选择网络" width="800px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<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="typeFilter" placeholder="网络类型" clearable style="width: 130px" @change="handleSearch">
<el-option label="网桥(Bridge)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select>
<el-button :icon="Refresh" @click="loadList" circle />
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
:height="340" :row-class-name="rowClassName" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">
{{ row.type === 'bridge' ? '网桥' : 'NAT' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="address" label="地址(CIDR)" min-width="150" show-overflow-tooltip />
<el-table-column prop="gateway" label="网关" width="130" />
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
<el-table-column prop="bridge_name" label="网桥名称" width="100" />
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getNetworkList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 },
hostId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const typeFilter = ref('')
const selectedItem = ref(null)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
page.value = 1
keyword.value = ''
typeFilter.value = ''
selectedItem.value = null
loadList()
}
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
if (!props.serviceId || !props.hostId) return
loading.value = true
try {
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value }
if (keyword.value) params.keyword = keyword.value
if (typeFilter.value) params.type = typeFilter.value
const res = await getNetworkList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
list.value = inner.data || inner.networks || (Array.isArray(inner) ? inner : [])
total.value = inner.meta?.count ?? inner.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) {
emit('confirm', selectedItem.value)
visible.value = false
}
}
const handleClose = () => { selectedItem.value = null }
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
</style>
@@ -0,0 +1,102 @@
<template>
<el-dialog v-model="visible" title="选择安全组" width="700px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<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-button :icon="Refresh" @click="loadList" circle />
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
:height="340" :row-class-name="rowClassName" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="方向" width="80">
<template #default="{ row }">
<el-tag :type="row.direction === 'in' ? 'primary' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : '出站' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="锁定" width="80">
<template #default="{ row }">
<el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="白名单" width="80">
<template #default="{ row }">
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip />
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getSecurityGroupList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const selectedItem = ref(null)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) { page.value = 1; keyword.value = ''; selectedItem.value = null; loadList() }
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
if (!props.serviceId) return
loading.value = true
try {
const params = { service_id: props.serviceId, page: page.value, page_size: pageSize.value }
if (keyword.value) params.keyword = keyword.value
const res = await getSecurityGroupList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
list.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : [])
total.value = inner.meta?.count ?? inner.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) { emit('confirm', selectedItem.value); visible.value = false }
}
const handleClose = () => { selectedItem.value = null }
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
</style>
@@ -0,0 +1,124 @@
<template>
<el-dialog v-model="visible" title="选择数据卷" width="750px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<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="statusFilter" placeholder="状态" clearable style="width: 120px" @change="handleSearch">
<el-option label="就绪" value="ready" />
<el-option label="等待中" value="pending" />
</el-select>
<el-button :icon="Refresh" @click="loadList" circle />
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
:height="340" :row-class-name="rowClassName" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="大小" width="90">
<template #default="{ row }">{{ row.size ? row.size + ' GB' : '-' }}</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">
<el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="挂载" width="80">
<template #default="{ row }">
<el-tag :type="row.is_mount ? 'warning' : 'success'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="180" show-overflow-tooltip />
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getVolumeList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 },
hostId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const statusFilter = ref('')
const selectedItem = ref(null)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
page.value = 1
keyword.value = ''
statusFilter.value = ''
selectedItem.value = null
loadList()
}
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
if (!props.serviceId || !props.hostId) return
loading.value = true
try {
const params = { service_id: props.serviceId, host_id: props.hostId, page: page.value, page_size: pageSize.value }
if (keyword.value) params.keyword = keyword.value
if (statusFilter.value) params.status = statusFilter.value
const res = await getVolumeList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
list.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : [])
total.value = inner.meta?.count ?? inner.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const statusType = (s) => ({ ready: 'success', pending: 'warning', error: 'danger' }[s] || 'info')
const statusLabel = (s) => ({ ready: '就绪', pending: '等待中', creating: '创建中', error: '错误' }[s] || s || '-')
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) {
emit('confirm', selectedItem.value)
visible.value = false
}
}
const handleClose = () => { selectedItem.value = null }
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
:deep(.selected-row) { background-color: #ecf5ff !important; }
</style>
+17 -11
View File
@@ -27,9 +27,7 @@
<el-option label="网桥(Bridge)" value="bridge" /> <el-option label="网桥(Bridge)" value="bridge" />
<el-option label="内网(NAT)" value="nat" /> <el-option label="内网(NAT)" value="nat" />
</el-select> </el-select>
<el-select v-if="!injectedHostId?.value" v-model="hostIdInput" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="handleSearch">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</div> </div>
<!-- 网络列表 --> <!-- 网络列表 -->
@@ -255,16 +253,24 @@ const handleSubmit = () => {
if (!valid) return if (!valid) return
submitLoading.value = true submitLoading.value = true
try { try {
const payload = { ...formData, service_id: serviceId.value } const fd = new FormData()
// 空高级参数不提交 fd.append('service_id', serviceId.value)
const optionalFields = ['mac_address', 'bridge_name', 'ls_bridge_name', 'ls_name', 'nameservers', 'target_device'] fd.append('name', formData.name)
optionalFields.forEach(f => { if (!payload[f]) delete payload[f] }) fd.append('address', formData.address)
fd.append('gateway', formData.gateway)
fd.append('type', formData.type)
fd.append('host_id', formData.host_id)
if (formData.nameservers) fd.append('nameservers', formData.nameservers)
if (formData.mac_address) fd.append('mac_address', formData.mac_address)
if (formData.bridge_name) fd.append('bridge_name', formData.bridge_name)
if (formData.ls_bridge_name) fd.append('ls_bridge_name', formData.ls_bridge_name)
if (formData.ls_name) fd.append('ls_name', formData.ls_name)
let res let res
if (dialogType.value === 'add') { if (dialogType.value === 'add') {
delete payload.id res = await createNetwork(fd)
res = await createNetwork(payload)
} else { } else {
res = await updateNetwork(payload) fd.append('network_id', formData.id)
res = await updateNetwork(fd)
} }
if (res?.data?.code === 200) { if (res?.data?.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '修改成功') ElMessage.success(dialogType.value === 'add' ? '创建成功' : '修改成功')
@@ -274,7 +280,7 @@ const handleSubmit = () => {
ElMessage.error(extractApiError(res?.data, '操作失败')) ElMessage.error(extractApiError(res?.data, '操作失败'))
} }
} catch (e) { } catch (e) {
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message)) ElMessage.error(extractApiError(e?.response?.data, '操作失败'))
} finally { submitLoading.value = false } } finally { submitLoading.value = false }
}) })
} }
@@ -57,7 +57,7 @@
<el-button link type="primary" size="small" @click="handleGoDetail(row)">编辑</el-button> <el-button link type="primary" size="small" @click="handleGoDetail(row)">编辑</el-button>
<el-button link type="success" size="small" @click="handleSync(row)">同步</el-button> <el-button link type="success" size="small" @click="handleSync(row)">同步</el-button>
<el-button link type="warning" size="small" @click="handleApply(row)">应用</el-button> <el-button link type="warning" size="small" @click="handleApply(row)">应用</el-button>
<el-dropdown trigger="click" @command="cmd => handleRowMore(row, cmd)" style="margin-left: 4px"> <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> <el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
+286 -179
View File
@@ -154,7 +154,10 @@
<div class="section-block"> <div class="section-block">
<div class="section-header"> <div class="section-header">
<h3 class="section-title">网络信息</h3> <h3 class="section-title">网络信息</h3>
<el-button size="small" type="primary" @click="handleAddNetwork">添加网络</el-button> <div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="showCreateNetworkDialog">创建网络</el-button>
<el-button size="small" @click="handleAddNetwork">添加已有网络</el-button>
</div>
</div> </div>
<el-table v-if="vmNetworks.length" :data="pagedNetworks" size="small" stripe> <el-table v-if="vmNetworks.length" :data="pagedNetworks" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" /> <el-table-column prop="id" label="ID" width="60" />
@@ -187,7 +190,10 @@
<div class="section-block"> <div class="section-block">
<div class="section-header"> <div class="section-header">
<h3 class="section-title">磁盘卷信息</h3> <h3 class="section-title">磁盘卷信息</h3>
<el-button size="small" type="primary" @click="handleAddVolume">添加数据卷</el-button> <div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="showCreateVolumeDialog">创建数据卷</el-button>
<el-button size="small" @click="handleAddVolume">挂载已有数据卷</el-button>
</div>
</div> </div>
<el-table v-if="vmVolumes.length" :data="pagedVolumes" size="small" stripe> <el-table v-if="vmVolumes.length" :data="pagedVolumes" size="small" stripe>
<el-table-column prop="id" label="ID" width="60" /> <el-table-column prop="id" label="ID" width="60" />
@@ -232,20 +238,31 @@
<div class="section-block"> <div class="section-block">
<div class="section-header"> <div class="section-header">
<h3 class="section-title">安全组管理</h3> <h3 class="section-title">安全组管理</h3>
<el-button size="small" type="primary" @click="handleBindSgFromTab">绑定安全组</el-button> <div style="display: flex; gap: 8px; align-items: center">
<el-select v-model="sgDirectionFilter" placeholder="方向" clearable style="width: 100px" size="small">
<el-option label="入站" value="in" />
<el-option label="出站" value="out" />
</el-select>
<el-button size="small" type="primary" @click="handleBindSgFromTab">绑定安全组</el-button>
</div>
</div> </div>
<el-table v-if="vmSecurityGroups.length" :data="pagedSecurityGroups" size="small" stripe> <el-table v-if="filteredSecurityGroups.length" :data="pagedSecurityGroups" size="small" stripe>
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="120" /> <el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="note" label="备注" min-width="160" show-overflow-tooltip /> <el-table-column label="方向" width="80">
<template #default="{ row }">
<el-tag :type="row.direction === 'in' ? 'primary' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : '出站' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="140" show-overflow-tooltip />
<el-table-column label="锁定" width="80"> <el-table-column label="锁定" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '已锁定' : '未锁定' }}</el-tag> <el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '已锁定' : '未锁定' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="白名单" width="90"> <el-table-column label="白名单" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '未开启' }}</el-tag> <el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="共享" width="80"> <el-table-column label="共享" width="80">
@@ -260,9 +277,9 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<el-empty v-else description="暂无绑定的安全组" :image-size="60" /> <el-empty v-else description="暂无绑定的安全组" :image-size="60" />
<div class="pagination-wrapper" v-if="vmSecurityGroups.length > 0"> <div class="pagination-wrapper" v-if="filteredSecurityGroups.length > 0">
<el-pagination v-model:current-page="securityPage" v-model:page-size="securityPageSize" <el-pagination v-model:current-page="securityPage" v-model:page-size="securityPageSize"
:page-sizes="[10, 20, 50]" :total="vmSecurityGroups.length" layout="total, sizes, prev, pager, next" small :page-sizes="[10, 20, 50]" :total="filteredSecurityGroups.length" layout="total, sizes, prev, pager, next" small
@size-change="s => { securityPageSize = s; securityPage = 1 }" @size-change="s => { securityPageSize = s; securityPage = 1 }"
@current-change="p => { securityPage = p }" /> @current-change="p => { securityPage = p }" />
</div> </div>
@@ -547,13 +564,21 @@
</el-dialog> </el-dialog>
<!-- 重构虚拟机弹窗 --> <!-- 重构虚拟机弹窗 -->
<el-dialog v-model="refactorDialogVisible" title="重构虚拟机" width="600px" destroy-on-close> <el-dialog v-model="refactorDialogVisible" title="重构虚拟机" width="650px" destroy-on-close>
<el-alert title="重构会修改虚拟机的底层配置参数请谨慎操作" type="warning" :closable="false" style="margin-bottom: 16px" /> <el-alert title="重构会修改虚拟机的底层配置参数请谨慎操作" type="warning" :closable="false" style="margin-bottom: 16px" />
<el-form ref="refactorFormRef" :model="refactorForm" label-width="120px"> <el-form ref="refactorFormRef" :model="refactorForm" label-width="130px">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="内存(KB)"> <el-form-item label="内存">
<el-input-number v-model="refactorForm.memory" :min="0" :step="1024" controls-position="right" style="width: 100%" /> <div style="display: flex; align-items: center; gap: 6px; width: 100%">
<el-input-number v-model="refactorMemDisplay" :min="0" :step="refactorMemUnit === 1048576 ? 1 : (refactorMemUnit === 1024 ? 512 : 1024)" :precision="refactorMemUnit === 1048576 ? 2 : 0" controls-position="right" style="flex: 1" />
<el-select v-model="refactorMemUnit" style="width: 80px" @change="onRefactorMemUnitChange">
<el-option label="KB" :value="1" />
<el-option label="MB" :value="1024" />
<el-option label="GB" :value="1048576" />
</el-select>
</div>
<span style="color: #909399; font-size: 12px">{{ refactorForm.memory }} KB</span>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
@@ -564,12 +589,12 @@
</el-row> </el-row>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="下行带宽"> <el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" /> <el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="上行带宽"> <el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" /> <el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item> </el-form-item>
</el-col> </el-col>
@@ -577,6 +602,18 @@
<el-form-item label="Root密码"> <el-form-item label="Root密码">
<el-input v-model="refactorForm.root_password" placeholder="不修改留空" show-password /> <el-input v-model="refactorForm.root_password" placeholder="不修改留空" show-password />
</el-form-item> </el-form-item>
<el-form-item label="UUID">
<el-input v-model="refactorForm.uuid" placeholder="虚拟机UUID" />
</el-form-item>
<el-form-item label="Mate Data ID">
<el-input v-model="refactorForm.mate_data_id" placeholder="元数据ID" />
</el-form-item>
<el-form-item label="物理机名称">
<el-input v-model="refactorForm.physical_name" placeholder="不修改留空" />
</el-form-item>
<el-form-item label="配置路径">
<el-input v-model="refactorForm.config_path" placeholder="不修改留空" />
</el-form-item>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="SSH端口"> <el-form-item label="SSH端口">
@@ -592,6 +629,21 @@
<el-form-item label="VNC密码"> <el-form-item label="VNC密码">
<el-input v-model="refactorForm.vnc_password" placeholder="不填随机" show-password /> <el-input v-model="refactorForm.vnc_password" placeholder="不填随机" show-password />
</el-form-item> </el-form-item>
<el-form-item label="网络">
<div style="width: 100%">
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px" v-if="refactorSelectedNetworks.length">
<el-tag v-for="n in refactorSelectedNetworks" :key="n.id" closable size="small" @close="removeRefactorNetwork(n.id)">
{{ n.name }} (ID:{{ n.id }})
</el-tag>
</div>
<el-button size="small" @click="showRefactorNetworkSelector = true">选择网络</el-button>
</div>
</el-form-item>
<el-form-item label="外网网络">
<el-select v-model="refactorForm.internet_network_id" placeholder="选择外网网络可选" clearable filterable style="width: 100%">
<el-option v-for="n in refactorSelectedNetworks" :key="n.id" :label="`${n.name} (ID: ${n.id})`" :value="n.id" />
</el-select>
</el-form-item>
<el-form-item label="安全组"> <el-form-item label="安全组">
<el-select v-model="refactorForm.port_group_id" placeholder="选择安全组可选" filterable clearable style="width: 100%"> <el-select v-model="refactorForm.port_group_id" placeholder="选择安全组可选" filterable clearable style="width: 100%">
<el-option v-for="g in sgOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" /> <el-option v-for="g in sgOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
@@ -604,6 +656,9 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 重构用网络选择器 -->
<NetworkSelectorPopup v-model="showRefactorNetworkSelector" :service-id="serviceId" :host-id="vmHostId" @confirm="handleRefactorNetworkConfirm" />
<!-- VNC 连接弹窗 --> <!-- VNC 连接弹窗 -->
<el-dialog v-model="vncDialogVisible" title="获取 VNC 连接" width="560px" destroy-on-close> <el-dialog v-model="vncDialogVisible" title="获取 VNC 连接" width="560px" destroy-on-close>
<el-form label-width="100px"> <el-form label-width="100px">
@@ -629,22 +684,8 @@
</el-dialog> </el-dialog>
<!-- 绑定/解绑安全组弹窗 --> <!-- 绑定/解绑安全组弹窗 -->
<el-dialog v-model="sgDialogVisible" :title="sgDialogType === 'bind' ? '绑定安全组' : '解绑安全组'" width="480px" destroy-on-close> <!-- 绑定安全组(弹窗选择组件) -->
<el-form label-width="100px"> <SecurityGroupSelectorPopup v-model="sgBindSelectorVisible" :service-id="serviceId" @confirm="handleSgBindConfirm" />
<el-form-item label="虚拟机">{{ detail?.name || '-' }}</el-form-item>
<el-form-item label="安全组">
<el-select v-model="sgSelectedId" placeholder="选择安全组" filterable style="width: 100%">
<el-option v-for="g in sgOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sgDialogVisible = false">取消</el-button>
<el-button :type="sgDialogType === 'bind' ? 'success' : 'warning'" :loading="actionLoading" @click="submitSgAction">
{{ sgDialogType === 'bind' ? '绑定' : '解绑' }}
</el-button>
</template>
</el-dialog>
<!-- 编辑网络弹窗 --> <!-- 编辑网络弹窗 -->
<el-dialog v-model="netEditVisible" title="编辑网络" width="560px" destroy-on-close> <el-dialog v-model="netEditVisible" title="编辑网络" width="560px" destroy-on-close>
@@ -695,62 +736,78 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 添加网络弹窗(从已有列表选择) --> <!-- 创建网络弹窗 -->
<el-dialog v-model="netAddVisible" title="添加网络到虚拟机" width="600px" destroy-on-close> <el-dialog v-model="netCreateVisible" title="创建网络" width="600px" destroy-on-close>
<el-form label-width="100px"> <el-form ref="netCreateFormRef" :model="netCreateForm" :rules="netCreateRules" label-width="120px">
<el-form-item label="选择宿主机"> <el-form-item label="名称" prop="name">
<el-select v-model="netAddHostId" placeholder="选择宿主机以加载网络列表" filterable style="width: 100%" @change="loadAvailableNetworks"> <el-input v-model="netCreateForm.name" placeholder="网络名称" />
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" /> </el-form-item>
<el-form-item label="网络类型" prop="type">
<el-select v-model="netCreateForm.type" style="width: 100%">
<el-option label="网桥(Bridge/外网)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="选择网络"> <el-form-item label="IP 地址(CIDR)" prop="address">
<el-select v-model="netAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="netOptionsLoading" :disabled="!netAddHostId"> <el-input v-model="netCreateForm.address" placeholder="例如 192.168.1.0/24" />
<el-option v-for="n in availableNetworks" :key="n.id" :label="`${n.name} - ${n.address || ''}`" :value="n.id" /> </el-form-item>
</el-select> <el-form-item label="网关地址" prop="gateway">
<el-input v-model="netCreateForm.gateway" placeholder="例如 192.168.1.1" />
</el-form-item>
<el-form-item label="DNS 服务器">
<el-input v-model="netCreateForm.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" />
</el-form-item>
<el-divider content-position="left">高级配置(可选)</el-divider>
<el-form-item label="MAC 地址">
<el-input v-model="netCreateForm.mac_address" placeholder="不填则随机" />
</el-form-item>
<el-form-item label="虚拟网桥名">
<el-input v-model="netCreateForm.bridge_name" placeholder="不填使用默认" />
</el-form-item>
<el-form-item label="逻辑网桥名">
<el-input v-model="netCreateForm.ls_bridge_name" placeholder="不填使用默认" />
</el-form-item>
<el-form-item label="逻辑端口名">
<el-input v-model="netCreateForm.ls_name" placeholder="不填使用默认" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<div v-if="netAddSelectedId" style="margin-top: 8px">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="名称">{{ selectedNetworkInfo?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{ selectedNetworkInfo?.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ selectedNetworkInfo?.gateway || '-' }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ selectedNetworkInfo?.type || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer> <template #footer>
<el-button @click="netAddVisible = false">取消</el-button> <el-button @click="netCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitAddNetwork" :disabled="!netAddSelectedId">添加</el-button> <el-button type="primary" :loading="actionLoading" @click="submitCreateNetwork">创建</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 挂载数据卷弹窗(从已有列表选择) --> <!-- 添加已有网络弹窗 -->
<el-dialog v-model="volAddVisible" title="挂载数据卷到虚拟机" width="600px" destroy-on-close> <NetworkSelectorPopup v-model="netAddVisible" :service-id="serviceId" :host-id="vmHostId" @confirm="handleNetworkSelectorConfirm" />
<el-form label-width="100px">
<el-form-item label="选择宿主机"> <!-- 创建数据卷弹窗 -->
<el-select v-model="volAddHostId" placeholder="选择宿主机以加载数据卷列表" filterable style="width: 100%" @change="loadAvailableVolumes"> <el-dialog v-model="volCreateVisible" title="创建数据卷" width="550px" destroy-on-close>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" /> <el-form ref="volCreateFormRef" :model="volCreateForm" :rules="volCreateRules" label-width="120px">
</el-select> <el-form-item label="名称" prop="name">
<el-input v-model="volCreateForm.name" placeholder="数据卷名称" />
</el-form-item> </el-form-item>
<el-form-item label="选择数据卷"> <el-form-item label="大小(GB)" prop="size">
<el-select v-model="volAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="volOptionsLoading" :disabled="!volAddHostId"> <el-input-number v-model="volCreateForm.size" :min="1" :step="10" style="width: 100%" />
<el-option v-for="v in availableVolumes" :key="v.id" :label="`${v.name} (${v.size || 0} GB, ID: ${v.id})`" :value="v.id" /> </el-form-item>
</el-select> <el-form-item label="是否系统镜像">
<el-switch v-model="volCreateForm.is_system" />
</el-form-item>
<el-form-item label="挂载设备名">
<el-input v-model="volCreateForm.target_device" placeholder="不填自动生成" />
</el-form-item>
<el-form-item label="所属镜像ID">
<el-input-number v-model="volCreateForm.image_id" :min="0" style="width: 100%" placeholder="镜像ID" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<div v-if="volAddSelectedId" style="margin-top: 8px">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="名称">{{ selectedVolumeInfo?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="大小">{{ selectedVolumeInfo?.size ? selectedVolumeInfo.size + ' GB' : '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ selectedVolumeInfo?.status || '-' }}</el-descriptions-item>
<el-descriptions-item label="路径">{{ selectedVolumeInfo?.path || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer> <template #footer>
<el-button @click="volAddVisible = false">取消</el-button> <el-button @click="volCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitAddVolume" :disabled="!volAddSelectedId">挂载</el-button> <el-button type="primary" :loading="actionLoading" @click="submitCreateVolume">创建</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 挂载已有数据卷弹窗 -->
<VolumeSelectorPopup v-model="volAddVisible" :service-id="serviceId" :host-id="vmHostId" @confirm="handleVolumeSelectorConfirm" />
<!-- 迁移卷弹窗 --> <!-- 迁移卷弹窗 -->
<el-dialog v-model="volTransferVisible" title="迁移数据卷" width="480px" destroy-on-close> <el-dialog v-model="volTransferVisible" title="迁移数据卷" width="480px" destroy-on-close>
<el-form :model="volTransferForm" label-width="120px"> <el-form :model="volTransferForm" label-width="120px">
@@ -808,6 +865,9 @@ import {
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue' import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
import VolumeSelectorPopup from '@/components/admin/VolumeSelectorPopup.vue'
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
import SecurityGroupSelectorPopup from '@/components/admin/SecurityGroupSelectorPopup.vue'
import { useTagsViewStore } from '@/store/tagsViewStore' import { useTagsViewStore } from '@/store/tagsViewStore'
const route = useRoute() const route = useRoute()
@@ -823,6 +883,7 @@ const actionLoading = ref(false)
const statusLoading = ref(false) const statusLoading = ref(false)
const metricsLoading = ref(false) const metricsLoading = ref(false)
const detail = ref(null) const detail = ref(null)
const vmHostId = ref(0)
const vmNetworks = ref([]) const vmNetworks = ref([])
const vmVolumes = ref([]) const vmVolumes = ref([])
const vmImage = ref(null) const vmImage = ref(null)
@@ -919,13 +980,14 @@ const loadDetail = async () => {
vmVolumes.value = d.volumes || [] vmVolumes.value = d.volumes || []
vmImage.value = d.image || null vmImage.value = d.image || null
vmPortGroup.value = d.in_port_group || null vmPortGroup.value = d.in_port_group || null
vmHostId.value = detail.value?.host_id || vmVolumes.value[0]?.host_id || vmNetworks.value[0]?.host_id || 0
} else ElMessage.error(extractApiError(res?.data, '加载失败')) } else ElMessage.error(extractApiError(res?.data, '加载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
} }
const loadVmVolumes = async () => { const loadVmVolumes = async () => {
if (!detail.value) return if (!detail.value) return
const hid = detail.value.host_id const hid = vmHostId.value
if (!hid) return if (!hid) return
try { try {
const res = await getVolumeList({ service_id: serviceId.value, host_id: hid, vm_id: vmId.value, page: 1, count: 200 }) const res = await getVolumeList({ service_id: serviceId.value, host_id: hid, vm_id: vmId.value, page: 1, count: 200 })
@@ -938,7 +1000,7 @@ const loadVmVolumes = async () => {
const loadVmNetworks = async () => { const loadVmNetworks = async () => {
if (!detail.value) return if (!detail.value) return
const hid = detail.value.host_id const hid = vmHostId.value
if (!hid) return if (!hid) return
try { try {
const res = await getNetworkList({ service_id: serviceId.value, host_id: hid, page: 1, page_size: 200 }) const res = await getNetworkList({ service_id: serviceId.value, host_id: hid, page: 1, page_size: 200 })
@@ -1226,7 +1288,31 @@ const submitEditVm = async () => {
// ---- 重构虚拟机 ---- // ---- 重构虚拟机 ----
const refactorDialogVisible = ref(false) const refactorDialogVisible = ref(false)
const refactorFormRef = ref(null) const refactorFormRef = ref(null)
const refactorForm = reactive({ memory: 0, vcpu: 0, rx_bandwidth: 0, tx_bandwidth: 0, root_password: '', ssh_port: 0, vnc_port: 0, vnc_password: '', port_group_id: 0 }) const refactorForm = reactive({
memory: 0, vcpu: 0, rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '', uuid: '', mate_data_id: '', physical_name: '', config_path: '',
ssh_port: 0, vnc_port: 0, vnc_password: '',
internet_network_id: 0, port_group_id: 0
})
const refactorMemUnit = ref(1048576)
const refactorMemDisplay = ref(0)
const refactorSelectedNetworks = ref([])
const showRefactorNetworkSelector = ref(false)
const onRefactorMemUnitChange = () => {
refactorMemDisplay.value = refactorForm.memory ? Math.round(refactorForm.memory / refactorMemUnit.value * 100) / 100 : 0
}
watch(refactorMemDisplay, (v) => { refactorForm.memory = Math.round(v * refactorMemUnit.value) })
const handleRefactorNetworkConfirm = (network) => {
if (!refactorSelectedNetworks.value.find(n => n.id === network.id)) {
refactorSelectedNetworks.value.push({ id: network.id, name: network.name })
}
}
const removeRefactorNetwork = (id) => {
refactorSelectedNetworks.value = refactorSelectedNetworks.value.filter(n => n.id !== id)
if (refactorForm.internet_network_id === id) refactorForm.internet_network_id = 0
}
const handleRefactorVm = async () => { const handleRefactorVm = async () => {
if (!detail.value) return if (!detail.value) return
@@ -1234,10 +1320,18 @@ const handleRefactorVm = async () => {
Object.assign(refactorForm, { Object.assign(refactorForm, {
memory: d.memory || 0, vcpu: d.vcpu || 0, memory: d.memory || 0, vcpu: d.vcpu || 0,
rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0, rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
root_password: '', ssh_port: d.ssh_port || 0, root_password: d.root_password || '',
vnc_port: 0, vnc_password: '', uuid: d.uuid || '', mate_data_id: d.mate_data_id || '',
port_group_id: vmPortGroup.value?.id || null physical_name: d.physical_name || '', config_path: d.config_path || '',
ssh_port: d.ssh_port || 0, vnc_port: 0, vnc_password: '',
internet_network_id: 0,
port_group_id: vmPortGroup.value?.id || 0
}) })
refactorSelectedNetworks.value = vmNetworks.value.map(n => ({ id: n.id, name: n.name }))
const mem = d.memory || 0
if (mem >= 1048576 && mem % 1048576 === 0) { refactorMemUnit.value = 1048576; refactorMemDisplay.value = mem / 1048576 }
else if (mem >= 1024 && mem % 1024 === 0) { refactorMemUnit.value = 1024; refactorMemDisplay.value = mem / 1024 }
else { refactorMemUnit.value = 1; refactorMemDisplay.value = mem }
if (!sgOptions.value.length) await loadSgOptions() if (!sgOptions.value.length) await loadSgOptions()
refactorDialogVisible.value = true refactorDialogVisible.value = true
} }
@@ -1253,9 +1347,15 @@ const submitRefactorVm = async () => {
fd.append('rx_bandwidth', refactorForm.rx_bandwidth) fd.append('rx_bandwidth', refactorForm.rx_bandwidth)
fd.append('tx_bandwidth', refactorForm.tx_bandwidth) fd.append('tx_bandwidth', refactorForm.tx_bandwidth)
if (refactorForm.root_password) fd.append('root_password', refactorForm.root_password) if (refactorForm.root_password) fd.append('root_password', refactorForm.root_password)
if (refactorForm.uuid) fd.append('uuid', refactorForm.uuid)
if (refactorForm.mate_data_id) fd.append('mate_data_id', refactorForm.mate_data_id)
if (refactorForm.physical_name) fd.append('physical_name', refactorForm.physical_name)
if (refactorForm.config_path) fd.append('config_path', refactorForm.config_path)
if (refactorForm.ssh_port) fd.append('ssh_port', refactorForm.ssh_port) if (refactorForm.ssh_port) fd.append('ssh_port', refactorForm.ssh_port)
if (refactorForm.vnc_port) fd.append('vnc_port', refactorForm.vnc_port) if (refactorForm.vnc_port) fd.append('vnc_port', refactorForm.vnc_port)
if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password) if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password)
if (refactorSelectedNetworks.value.length) fd.append('network_ids', refactorSelectedNetworks.value.map(n => n.id).join(','))
if (refactorForm.internet_network_id) fd.append('internet_network_id', refactorForm.internet_network_id)
if (refactorForm.port_group_id) fd.append('port_group_id', refactorForm.port_group_id) if (refactorForm.port_group_id) fd.append('port_group_id', refactorForm.port_group_id)
const res = await refactorVm(fd) const res = await refactorVm(fd)
if (res?.data?.code === 200) { ElMessage.success('重构成功'); refactorDialogVisible.value = false; loadDetail() } if (res?.data?.code === 200) { ElMessage.success('重构成功'); refactorDialogVisible.value = false; loadDetail() }
@@ -1317,7 +1417,6 @@ const handleGetVnc = async () => {
} }
const submitGetVnc = async () => { const submitGetVnc = async () => {
if (!vncNodeId.value) { ElMessage.warning('请选择VNC节点'); return }
vncLoading.value = true vncLoading.value = true
vncResult.value = null vncResult.value = null
try { try {
@@ -1333,9 +1432,14 @@ const vmSecurityGroups = ref([])
const sgListLoading = ref(false) const sgListLoading = ref(false)
const securityPage = ref(1) const securityPage = ref(1)
const securityPageSize = ref(10) const securityPageSize = ref(10)
const sgDirectionFilter = ref('')
const filteredSecurityGroups = computed(() => {
if (!sgDirectionFilter.value) return vmSecurityGroups.value
return vmSecurityGroups.value.filter(g => g.direction === sgDirectionFilter.value)
})
const pagedSecurityGroups = computed(() => { const pagedSecurityGroups = computed(() => {
const start = (securityPage.value - 1) * securityPageSize.value const start = (securityPage.value - 1) * securityPageSize.value
return vmSecurityGroups.value.slice(start, start + securityPageSize.value) return filteredSecurityGroups.value.slice(start, start + securityPageSize.value)
}) })
const loadVmSecurityGroups = async () => { const loadVmSecurityGroups = async () => {
@@ -1350,9 +1454,7 @@ const loadVmSecurityGroups = async () => {
} }
// ---- 绑定/解绑安全组 ---- // ---- 绑定/解绑安全组 ----
const sgDialogVisible = ref(false) const sgBindSelectorVisible = ref(false)
const sgDialogType = ref('bind')
const sgSelectedId = ref(null)
const sgOptions = ref([]) const sgOptions = ref([])
const loadSgOptions = async () => { const loadSgOptions = async () => {
@@ -1365,21 +1467,22 @@ const loadSgOptions = async () => {
} catch { /* */ } } catch { /* */ }
} }
const handleBindSg = async () => { const handleBindSg = () => { sgBindSelectorVisible.value = true }
sgDialogType.value = 'bind'; sgSelectedId.value = null const handleUnbindSg = () => { sgBindSelectorVisible.value = true }
if (!sgOptions.value.length) await loadSgOptions() const handleBindSgFromTab = () => { sgBindSelectorVisible.value = true }
sgDialogVisible.value = true
}
const handleUnbindSg = async () => {
sgDialogType.value = 'unbind'; sgSelectedId.value = null
if (!sgOptions.value.length) await loadSgOptions()
sgDialogVisible.value = true
}
const handleBindSgFromTab = async () => { const handleSgBindConfirm = async (sg) => {
sgDialogType.value = 'bind'; sgSelectedId.value = null if (!sg?.id) return
if (!sgOptions.value.length) await loadSgOptions() actionLoading.value = true
sgDialogVisible.value = true try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('id', sg.id)
fd.append('vm_id', vmId.value)
const res = await bindSecurityGroup(fd)
if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadVmSecurityGroups() }
else ElMessage.error(extractApiError(res?.data, '绑定失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '绑定失败')) } finally { actionLoading.value = false }
} }
const handleUnbindSgFromTab = async (row) => { const handleUnbindSgFromTab = async (row) => {
@@ -1398,67 +1501,62 @@ const handleUnbindSgFromTab = async (row) => {
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '解绑失败')) } finally { actionLoading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '解绑失败')) } finally { actionLoading.value = false }
} }
const submitSgAction = async () => { // ---- 创建网络(表单弹窗) ----
if (!sgSelectedId.value) { ElMessage.warning('请选择安全组'); return } const netCreateVisible = ref(false)
actionLoading.value = true const netCreateFormRef = ref(null)
try { const netCreateForm = reactive({ name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '' })
const api = sgDialogType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup const netCreateRules = {
const fd = new FormData() name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
fd.append('service_id', serviceId.value) address: [{ required: true, message: '请输入IP地址(CIDR)', trigger: 'blur' }],
fd.append('id', sgSelectedId.value) gateway: [{ required: true, message: '请输入网关地址', trigger: 'blur' }],
fd.append('vm_id', vmId.value) type: [{ required: true, message: '请选择网络类型', trigger: 'change' }]
const res = await api(fd)
if (res?.data?.code === 200) {
ElMessage.success(sgDialogType.value === 'bind' ? '绑定成功' : '解绑成功')
sgDialogVisible.value = false
loadVmSecurityGroups()
}
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
} }
// ---- 添加网络(从已有列表选择) ---- const showCreateNetworkDialog = () => {
Object.assign(netCreateForm, { name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '' })
netCreateVisible.value = true
}
const submitCreateNetwork = () => {
netCreateFormRef.value?.validate(async (valid) => {
if (!valid) return
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('name', netCreateForm.name)
fd.append('address', netCreateForm.address)
fd.append('gateway', netCreateForm.gateway)
fd.append('type', netCreateForm.type)
fd.append('host_id', vmHostId.value)
if (netCreateForm.nameservers) fd.append('nameservers', netCreateForm.nameservers)
if (netCreateForm.mac_address) fd.append('mac_address', netCreateForm.mac_address)
if (netCreateForm.bridge_name) fd.append('bridge_name', netCreateForm.bridge_name)
if (netCreateForm.ls_bridge_name) fd.append('ls_bridge_name', netCreateForm.ls_bridge_name)
if (netCreateForm.ls_name) fd.append('ls_name', netCreateForm.ls_name)
const res = await createNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('网络创建成功'); netCreateVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
})
}
// ---- 添加已有网络(弹窗选择组件) ----
const netAddVisible = ref(false) const netAddVisible = ref(false)
const netAddHostId = ref(null)
const netAddSelectedId = ref(null)
const availableNetworks = ref([])
const netOptionsLoading = ref(false)
const selectedNetworkInfo = computed(() => availableNetworks.value.find(n => n.id === netAddSelectedId.value) || null) const handleAddNetwork = () => { netAddVisible.value = true }
const loadAvailableNetworks = async (hostId) => { const handleNetworkSelectorConfirm = async (network) => {
netAddSelectedId.value = null if (!network?.id) return
availableNetworks.value = []
if (!hostId) return
netOptionsLoading.value = true
try {
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, used: false, page: 1, page_size: 200 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
availableNetworks.value = inner.networks || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch { /* */ } finally { netOptionsLoading.value = false }
}
const handleAddNetwork = () => {
netAddHostId.value = null
netAddSelectedId.value = null
availableNetworks.value = []
netAddVisible.value = true
}
const submitAddNetwork = async () => {
if (!netAddSelectedId.value) { ElMessage.warning('请选择网络'); return }
actionLoading.value = true actionLoading.value = true
try { try {
const net = selectedNetworkInfo.value
const fd = new FormData() const fd = new FormData()
fd.append('service_id', serviceId.value) fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value) fd.append('vm_id', vmId.value)
fd.append('network_id', netAddSelectedId.value) fd.append('network_id', network.id)
if (net?.host_id) fd.append('host_id', net.host_id) if (network.host_id) fd.append('host_id', network.host_id)
const res = await createNetwork(fd) const res = await createNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success('网络添加成功'); netAddVisible.value = false; loadDetail() } if (res?.data?.code === 200) { ElMessage.success('网络添加成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '添加失败')) else ElMessage.error(extractApiError(res?.data, '添加失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '添加失败')) } finally { actionLoading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '添加失败')) } finally { actionLoading.value = false }
} }
@@ -1508,47 +1606,56 @@ const handleDeleteNetwork = (row) => {
}).catch(() => {}) }).catch(() => {})
} }
// ---- 挂载数据卷(从已有列表选择 ---- // ---- 创建数据卷(表单弹窗 ----
const volCreateVisible = ref(false)
const volCreateFormRef = ref(null)
const volCreateForm = reactive({ name: '', size: 10, is_system: false, target_device: '', image_id: 0 })
const volCreateRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
size: [{ required: true, message: '请输入大小', trigger: 'blur' }]
}
const showCreateVolumeDialog = () => {
Object.assign(volCreateForm, { name: '', size: 10, is_system: false, target_device: '', image_id: 0 })
volCreateVisible.value = true
}
const submitCreateVolume = () => {
volCreateFormRef.value?.validate(async (valid) => {
if (!valid) return
actionLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('name', volCreateForm.name)
fd.append('size', volCreateForm.size)
fd.append('is_system', volCreateForm.is_system)
fd.append('vm_id', vmId.value)
fd.append('host_id', vmHostId.value)
if (volCreateForm.target_device) fd.append('target_device', volCreateForm.target_device)
if (volCreateForm.image_id) fd.append('image_id', volCreateForm.image_id)
const res = await createVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('数据卷创建成功'); volCreateVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
})
}
// ---- 挂载已有数据卷(弹窗选择组件) ----
const volAddVisible = ref(false) const volAddVisible = ref(false)
const volAddHostId = ref(null)
const volAddSelectedId = ref(null)
const availableVolumes = ref([])
const volOptionsLoading = ref(false)
const selectedVolumeInfo = computed(() => availableVolumes.value.find(v => v.id === volAddSelectedId.value) || null) const handleAddVolume = () => { volAddVisible.value = true }
const loadAvailableVolumes = async (hostId) => { const handleVolumeSelectorConfirm = async (volume) => {
volAddSelectedId.value = null if (!volume?.id) return
availableVolumes.value = []
if (!hostId) return
volOptionsLoading.value = true
try {
const res = await getVolumeList({ service_id: serviceId.value, host_id: hostId, page: 1, page_size: 200 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const list = inner.volumes || inner.data || (Array.isArray(inner) ? inner : [])
availableVolumes.value = list.filter(v => !v.is_mount)
}
} catch { /* */ } finally { volOptionsLoading.value = false }
}
const handleAddVolume = () => {
volAddHostId.value = null
volAddSelectedId.value = null
availableVolumes.value = []
volAddVisible.value = true
}
const submitAddVolume = async () => {
if (!volAddSelectedId.value) { ElMessage.warning('请选择数据卷'); return }
actionLoading.value = true actionLoading.value = true
try { try {
const fd = new FormData() const fd = new FormData()
fd.append('service_id', serviceId.value) fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value) fd.append('vm_id', vmId.value)
fd.append('volume_id', volAddSelectedId.value) fd.append('volume_id', volume.id)
const res = await mountVolume(fd) const res = await mountVolume(fd)
if (res?.data?.code === 200) { ElMessage.success('数据卷挂载成功'); volAddVisible.value = false; loadDetail() } if (res?.data?.code === 200) { ElMessage.success('数据卷挂载成功'); loadDetail() }
else ElMessage.error(extractApiError(res?.data, '挂载失败')) else ElMessage.error(extractApiError(res?.data, '挂载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) } finally { actionLoading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) } finally { actionLoading.value = false }
} }
+22 -9
View File
@@ -63,7 +63,7 @@
<el-button link type="primary" size="small" @click="handleGoDetail(row)">详情</el-button> <el-button link type="primary" size="small" @click="handleGoDetail(row)">详情</el-button>
<el-button link type="success" size="small" @click="handlePower(row, 'start')" :disabled="row.status === 'running'">启动</el-button> <el-button link type="success" size="small" @click="handlePower(row, 'start')" :disabled="row.status === 'running'">启动</el-button>
<el-button link type="warning" size="small" @click="handlePower(row, 'stop')" :disabled="row.status === 'stopped' || row.status === 'stop'">关机</el-button> <el-button link type="warning" size="small" @click="handlePower(row, 'stop')" :disabled="row.status === 'stopped' || row.status === 'stop'">关机</el-button>
<el-dropdown trigger="click" @command="cmd => handleMoreAction(row, cmd)" style="margin-left: 4px"> <el-dropdown trigger="click" @command="cmd => handleMoreAction(row, cmd)" style="margin-left: 10px;margin-top: 4.5px">
<el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button> <el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
@@ -209,17 +209,25 @@
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item> <el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="CPU">{{ currentDetail.vcpu }} 核</el-descriptions-item> <el-descriptions-item label="CPU">{{ currentDetail.vcpu }} 核</el-descriptions-item>
<el-descriptions-item label="内存">{{ formatMemory(currentDetail.memory) }}</el-descriptions-item> <el-descriptions-item label="内存">{{ formatMemory(currentDetail.memory) }}</el-descriptions-item>
<el-descriptions-item label="系统盘">{{ currentDetail.system_size }} GB</el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
<el-tag :type="vmStatusType(currentDetail.status)" size="small">{{ vmStatusLabel(currentDetail.status) }}</el-tag> <el-tag :type="vmStatusType(currentDetail.status)" size="small">{{ vmStatusLabel(currentDetail.status) }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="下行带宽">{{ currentDetail.rx_bandwidth || 0 }} Mbps</el-descriptions-item> <el-descriptions-item label="下行带宽">{{ currentDetail.rx_bandwidth || 0 }} Mbps</el-descriptions-item>
<el-descriptions-item label="上行带宽">{{ currentDetail.tx_bandwidth || 0 }} Mbps</el-descriptions-item> <el-descriptions-item label="上行带宽">{{ currentDetail.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item> <el-descriptions-item label="SSH端口">{{ currentDetail.ssh_port || '-' }}</el-descriptions-item>
<el-descriptions-item label="镜像ID">{{ currentDetail.image_id || '-' }}</el-descriptions-item> <el-descriptions-item label="IP" :span="2">{{ currentDetail.ips || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentDetail.user_id || '-' }}</el-descriptions-item> <el-descriptions-item label="UUID" :span="2">
<el-descriptions-item label="宿主机组ID">{{ currentDetail.host_group_id || '-' }}</el-descriptions-item> <span style="font-family: Consolas, monospace; font-size: 12px">{{ currentDetail.uuid || '-' }}</span>
<el-descriptions-item label="IP" :span="2">{{ currentDetail.ip || '-' }}</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="Mate Data ID" :span="2">
<span style="font-family: Consolas, monospace; font-size: 12px">{{ currentDetail.mate_data_id || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Root密码" v-if="currentDetail.root_password">
<span style="font-family: Consolas, monospace">{{ currentDetail.root_password }}</span>
</el-descriptions-item>
<el-descriptions-item label="流量上限" v-if="currentDetail.traffic_max">{{ currentDetail.traffic_max }} GB</el-descriptions-item>
<el-descriptions-item label="快照上限" v-if="currentDetail.snapshot_num">{{ currentDetail.snapshot_num }}</el-descriptions-item>
<el-descriptions-item label="备份上限" v-if="currentDetail.backup_num">{{ currentDetail.backup_num }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</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-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
@@ -640,8 +648,13 @@ const handleViewDetail = async (row) => {
const res = await getVmDetail({ service_id: serviceId.value, vm_id: row.id }) const res = await getVmDetail({ service_id: serviceId.value, vm_id: row.id })
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data const d = res.data.data
// API may return data.vm nested, or data.data, or flat const vm = d.data ?? d.vm ?? d
currentDetail.value = d.vm ?? d.data ?? d vm.networks = d.networks || []
vm.volumes = d.volumes || []
vm.image = d.image || null
vm.in_port_group = d.in_port_group || null
if (!vm.ips && vm.networks?.length) vm.ips = vm.networks.map(n => n.address?.split('/')[0]).filter(Boolean).join(', ')
currentDetail.value = vm
} }
} catch { /* fallback */ } finally { detailLoading.value = false } } catch { /* fallback */ } finally { detailLoading.value = false }
} }