feat: 将页面添加分页
Build and Deploy Vue3 / build (push) Successful in 1m35s
Build and Deploy Vue3 / deploy (push) Successful in 1m5s

This commit is contained in:
2026-03-21 17:37:06 +08:00
parent 9edb59d16e
commit 25d782b050
18 changed files with 2220 additions and 154 deletions
+67
View File
@@ -644,3 +644,70 @@ export const deleteBackup = (data) => {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 获取快照数量与上限 */
export const getSnapshotCount = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/snapshot/count', { params })
}
/** 设置快照数量上限 */
export const setSnapshotLimit = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/snapshot/set_limit', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 获取备份数量与上限 */
export const getBackupCount = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/backup/count', { params })
}
/** 设置备份数量上限 */
export const setBackupLimit = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/backup/set_limit', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* ================================
* 用户组网管理 (UserNetworking)
* 注意:此模块接口前缀为 /api/v1/admins/service/
* ================================
*/
/** 获取组网列表 */
export const getUserNetworkingList = (params) => {
return http2.get('/api/v1/admins/service/host_service/point/networking/list', { params })
}
/** 获取组网详情 */
export const getUserNetworkingDetail = (params) => {
return http2.get('/api/v1/admins/service/host_service/point/networking/detail', { params })
}
/** 创建用户组网 */
export const createUserNetworking = (data) => {
return http2.post('/api/v1/admins/service/host_service/point/networking/create', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 为虚拟机分配组网 IP */
export const assignUserNetworking = (data) => {
return http2.post('/api/v1/admins/service/host_service/point/networking/assign', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/** 删除组网 */
export const deleteUserNetworking = (params) => {
return http2.delete('/api/v1/admins/service/host_service/point/networking/delete', { params })
}
/** 删除组网下的指定网络 */
export const removeUserNetworkingNetwork = (data) => {
return http2.post('/api/v1/admins/service/host_service/point/networking/remove_network', data, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
+15 -11
View File
@@ -8,7 +8,7 @@
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="vm_id" label="虚拟机ID" width="90" />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="host_id" label="宿主机ID" width="90" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)" size="small">
@@ -16,9 +16,17 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="任务ID" width="160">
<template #default="{ row }">
<span style="font-family: Consolas, monospace; font-size: 12px">{{ row.task_id || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTs(row.created_at) }}</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ formatTs(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestore(row)">恢复</el-button>
@@ -28,7 +36,7 @@
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" description="暂无备份数据" />
<div class="pagination-wrapper" v-if="total > pageSize">
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { pageSize = s; currentPage = 1; loadList() }"
@@ -45,9 +53,6 @@
<el-form-item label="备份名称" required>
<el-input v-model="createForm.name" placeholder="请输入备份名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
@@ -108,8 +113,8 @@ const loadList = async () => {
const res = await getBackupList({ service_id: serviceId.value, page: currentPage.value, page_size: pageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.backups || d.data || d.list || (Array.isArray(d) ? d : [])
total.value = d.meta?.count ?? d.all_count ?? d.total ?? list.value.length
list.value = d.data || d.list || (Array.isArray(d) ? d : [])
total.value = d.meta?.count ?? d.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
@@ -129,10 +134,10 @@ const loadVmOptions = async () => {
}
const createVisible = ref(false)
const createForm = reactive({ vm_id: null, name: '', description: '' })
const createForm = reactive({ vm_id: null, name: '' })
const handleCreate = async () => {
Object.assign(createForm, { vm_id: null, name: '', description: '' })
Object.assign(createForm, { vm_id: null, name: '' })
if (!vmOptions.value.length) await loadVmOptions()
createVisible.value = true
}
@@ -145,7 +150,6 @@ const submitCreate = async () => {
fd.append('service_id', serviceId.value)
fd.append('vm_id', createForm.vm_id)
fd.append('name', createForm.name)
if (createForm.description) fd.append('description', createForm.description)
const res = await createBackup(fd)
if (res?.data?.code === 200) { ElMessage.success('备份创建成功'); createVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
@@ -224,6 +228,6 @@ onMounted(() => { loadList() })
<style scoped>
.backup-manage { padding: 0; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
.toolbar { display: flex; gap: 8px; margin-top: 12px; margin-bottom: 16px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
</style>
@@ -265,17 +265,17 @@ const treeGroupList = computed(() => {
const normalizeHostGroup = (item) => {
if (!item) return item
return {
...item, // 保留原始字段
...item,
id: item.Id ?? item.id,
serviceId: item.ServiceId ?? item.serviceId ?? item.service_id,
remoteId: item.ServiceHostGroupId ?? item.remoteId ?? item.remote_id,
parentRemoteId: item.ServiceParentHostGroupId ?? item.parentRemoteId,
remoteId: item.ServiceHostGroupId ?? item.serviceHostGroupId ?? item.remoteId ?? item.remote_id,
parentRemoteId: item.ServiceParentHostGroupId ?? item.serviceParentHostGroupId ?? item.parentRemoteId ?? item.parent_remote_id ?? 0,
goodGroupId: item.GoodGroupId ?? item.goodGroupId ?? item.good_group_id ?? 0,
goodId: item.GoodId ?? item.goodId ?? item.good_id ?? 0,
name: item.Name ?? item.name,
note: item.Note ?? item.note,
CreatedAt: item.CreatedAt ?? item.created_at,
UpdatedAt: item.UpdatedAt ?? item.updated_at,
CreatedAt: item.CreatedAt ?? item.createdAt ?? item.created_at,
UpdatedAt: item.UpdatedAt ?? item.updatedAt ?? item.updated_at,
}
}
+1 -1
View File
@@ -88,7 +88,7 @@
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > queryParams.page_size">
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange" @current-change="handlePageChange" />
+30 -30
View File
@@ -18,15 +18,12 @@
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<!-- 筛选栏宿主机详情下不显示 -->
<div class="filter-bar" v-if="!injectedHostId?.value">
<el-input v-model="keyword" placeholder="搜索镜像名称" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<!-- <el-select v-if="!injectedHostId?.value" v-model="filterHostId" placeholder="选择宿主机" clearable style="width: 180px" @change="handleSearch">
<el-option v-for="h in hostOptions" :key="h.id" :label="h.name || h.ip" :value="h.id" />
</el-select> -->
<el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 130px" @change="handleSearch">
<!-- <el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 130px" @change="handleSearch">
<el-option label="Linux" value="linux" />
<el-option label="Windows" value="windows" />
</el-select>
@@ -39,7 +36,7 @@
<el-option label="下载中" value="downloading" />
<el-option label="就绪" value="ready" />
<el-option label="错误" value="error" />
</el-select>
</el-select> -->
</div>
<!-- 镜像列表 -->
@@ -56,20 +53,14 @@
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<el-table-column label="主控状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="injectedHostId?.value" label="同步状态" width="110">
<el-table-column label="同步状态" width="100">
<template #default="{ row }">
<el-tag :type="syncStatusType(row.sync_status)" size="small">{{ syncStatusLabel(row.sync_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="injectedHostId?.value" label="宿主机状态" width="110">
<template #default="{ row }">
<el-tag v-if="row.host_status" :type="statusType(row.host_status)" size="small">{{ statusLabel(row.host_status) }}</el-tag>
<span v-else>-</span>
<el-tag :type="row.sync_status === 'synced' ? 'success' : 'warning'" size="small">{{ row.sync_status === 'synced' ? '同步' : '不同步' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
@@ -87,10 +78,10 @@
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > queryParams.count">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
@@ -253,7 +244,7 @@ const filterType = ref('')
const filterStatus = ref('')
const filterHostId = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, count: 10 })
const queryParams = reactive({ page: 1, page_size: 10 })
const dialogVisible = ref(false)
const dialogType = ref('add')
@@ -329,32 +320,42 @@ const loadHostOptions = async () => {
} catch (e) { /* ignore */ }
}
const resolveHostId = async () => {
if (injectedHostId?.value) return injectedHostId.value
if (!hostOptions.value.length) await loadHostOptions()
return hostOptions.value.length ? hostOptions.value[0].id : null
}
const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const hostId = await resolveHostId()
let res
if (injectedHostId?.value) {
res = await getImageCompareHost({ service_id: serviceId.value, host_id: injectedHostId.value })
if (hostId) {
res = await getImageCompareHost({ service_id: serviceId.value, host_id: hostId })
} else {
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.count }
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
if (keyword.value) params.keyword = keyword.value
if (filterOsType.value) params.os_type = filterOsType.value
if (filterType.value) params.type = filterType.value
if (filterStatus.value) params.status = filterStatus.value
if (filterHostId.value) params.host_id = filterHostId.value
res = await getImageList(params)
}
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
if (injectedHostId?.value && Array.isArray(inner.data)) {
imageList.value = inner.data.map(item => ({
if (hostId && Array.isArray(inner.data)) {
let items = inner.data.map(item => ({
...(item.image || {}),
sync_status: item.sync_status || '',
host_status: item.host_status || ''
sync_status: item.sync_status || 'not_synced',
}))
total.value = inner.total ?? imageList.value.length
if (keyword.value) {
const kw = keyword.value.toLowerCase()
items = items.filter(img => img.name?.toLowerCase().includes(kw))
}
imageList.value = items
total.value = inner.total ?? items.length
} else {
const items = Array.isArray(inner) ? inner : (inner.images || inner.data || inner.list || [])
imageList.value = items
@@ -572,7 +573,6 @@ const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(() => {
if (serviceId.value) {
loadList()
if (!injectedHostId?.value) loadHostOptions()
}
})
</script>
+27 -33
View File
@@ -29,21 +29,21 @@
<!-- 服务列表 -->
<el-table :data="serviceList" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="Name" label="服务名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="name" label="服务名称" min-width="160" show-overflow-tooltip />
<el-table-column label="服务地址" min-width="220">
<template #default="{ row }">
<span class="host-addr">{{ row.Host }}:{{ row.Port }}</span>
<span class="host-addr">{{ row.host }}:{{ row.port }}</span>
</template>
</el-table-column>
<el-table-column label="认证Token" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.Token" class="token-mask">{{ maskToken(row.Token) }}</span>
<span v-if="row.token" class="token-mask">{{ maskToken(row.token) }}</span>
<span v-else class="text-muted">未设置</span>
</template>
</el-table-column>
<el-table-column prop="Note" label="备注" min-width="160" show-overflow-tooltip>
<el-table-column prop="note" label="备注" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.Note || '-' }}
{{ row.note || '-' }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
@@ -61,10 +61,10 @@
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > queryParams.count">
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@@ -130,7 +130,7 @@ const searchKey = ref('')
const queryParams = reactive({
page: 1,
count: 10,
page_size: 10,
key: ''
})
@@ -154,18 +154,16 @@ const formRules = {
port: [{ required: true, message: '请输入服务端口', trigger: 'blur' }]
}
// 规范化后端 PascalCase 字段为前端 camelCase
// 同时保留原始字段以便在需要时直接访问
const normalizeService = (item) => {
if (!item) return item
return {
...item, // 保留原始字段(如 Id、Name 等)
id: item.Id ?? item.id,
name: item.Name ?? item.name,
host: item.Host ?? item.host,
port: item.Port ?? item.port,
token: item.Token ?? item.token,
note: item.Note ?? item.note,
...item,
id: item.id ?? item.Id,
name: item.name ?? item.Name,
host: item.host ?? item.Host,
port: item.port ?? item.Port,
token: item.token ?? item.Token,
note: item.note ?? item.Note,
CreatedAt: item.CreatedAt ?? item.created_at,
UpdatedAt: item.UpdatedAt ?? item.updated_at,
}
@@ -207,7 +205,7 @@ const handleSearch = () => {
}
const handleSizeChange = (size) => {
queryParams.count = size
queryParams.page_size = size
queryParams.page = 1
loadList()
}
@@ -249,16 +247,15 @@ const handleAdd = () => {
dialogVisible.value = true
}
// 编辑
const handleEdit = (row) => {
dialogType.value = 'edit'
Object.assign(formData, {
id: Number(row.Id ?? row.id),
name: row.Name ?? row.name,
host: row.Host ?? row.host,
port: row.Port ?? row.port,
token: row.Token ?? row.token ?? '',
note: row.Note ?? row.note ?? ''
id: Number(row.id),
name: row.name,
host: row.host,
port: row.port,
token: row.token ?? '',
note: row.note ?? ''
})
dialogVisible.value = true
}
@@ -305,20 +302,18 @@ const handleSubmit = () => {
})
}
// 删除
const handleDelete = (row) => {
const rawId = row.Id ?? row.id
if (!rawId) {
if (!row.id) {
ElMessage.error('无法获取服务ID')
return
}
ElMessageBox.confirm(`确定要删除主控服务「${row.Name}」吗?删除后不可恢复。`, '删除确认', {
ElMessageBox.confirm(`确定要删除主控服务「${row.name}」吗?删除后不可恢复。`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteKvmService({ id: Number(rawId) })
const res = await deleteKvmService({ id: Number(row.id) })
const body = res?.data
if (body?.code === 200) {
ElMessage.success('删除成功')
@@ -332,10 +327,9 @@ const handleDelete = (row) => {
}).catch(() => {})
}
// 查看详情 —— 跳转到详情页面
const handleViewDetail = (row) => {
const id = Number(row.Id ?? row.id)
const name = row.Name ?? row.name
const id = Number(row.id)
const name = row.name
if (!id) {
ElMessage.error('无法获取服务ID,请刷新列表后重试')
return
@@ -96,6 +96,9 @@
<el-tab-pane label="备份管理" name="backup">
<BackupManage v-if="tabLoaded['backup']" />
</el-tab-pane>
<el-tab-pane label="用户组网" name="networking">
<UserNetworkingManage v-if="tabLoaded['networking']" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
@@ -149,6 +152,7 @@ const SecurityGroupManage = defineAsyncComponent(() => import('./SecurityGroupMa
const VncNodeManage = defineAsyncComponent(() => import('./VncNodeManage.vue'))
const SnapshotManage = defineAsyncComponent(() => import('./SnapshotManage.vue'))
const BackupManage = defineAsyncComponent(() => import('./BackupManage.vue'))
const UserNetworkingManage = defineAsyncComponent(() => import('./UserNetworkingManage.vue'))
// 引入tagsViewStore
import { useTagsViewStore } from '@/store/tagsViewStore'
@@ -180,7 +184,8 @@ const tabLoaded = reactive({
'security': false,
'vnc': false,
'snapshot': false,
'backup': false
'backup': false,
'networking': false
})
+5 -5
View File
@@ -60,10 +60,10 @@
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > queryParams.count">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
@@ -164,7 +164,7 @@ const keyword = ref('')
const filterType = ref('')
const hostIdInput = ref(0)
const hostOptions = ref([])
const queryParams = reactive({ page: 1, count: 10 })
const queryParams = reactive({ page: 1, page_size: 10 })
const selectedHostName = computed(() => {
const h = hostOptions.value.find(x => x.id === hostIdInput.value)
@@ -212,7 +212,7 @@ const loadList = async () => {
if (!hid) { ElMessage.warning('请先选择宿主机'); return }
loading.value = true
try {
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, count: queryParams.count }
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, page_size: queryParams.page_size }
if (keyword.value) params.key = keyword.value
if (filterType.value) params.type = filterType.value
const res = await getNetworkList(params)
@@ -41,6 +41,13 @@
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新建宿主机组' : '编辑宿主机组'" width="480px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="80px">
@@ -160,6 +167,8 @@ const submitLoading = ref(false)
const detailLoading = ref(false)
const optimalLoading = ref(false)
const groupList = ref([])
const total = ref(0)
const queryParams = reactive({ page: 1, page_size: 10 })
const dialogVisible = ref(false)
const dialogType = ref('add')
@@ -219,13 +228,15 @@ const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const res = await getRemoteHostGroupList({ service_id: serviceId.value })
const res = await getRemoteHostGroupList({ service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
groupList.value = inner.host_groups || inner.data || (Array.isArray(inner) ? inner : [])
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? groupList.value.length
} else {
groupList.value = []
total.value = 0
}
} catch (e) {
ElMessage.error('获取宿主机组列表失败')
@@ -330,6 +341,7 @@ onMounted(() => { if (serviceId.value) loadList() })
.sub-info { font-size: 13px; color: #909399; }
.header-right { display: flex; gap: 8px; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
@@ -73,7 +73,7 @@
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > queryParams.page_size">
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
+15 -11
View File
@@ -8,7 +8,7 @@
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="vm_id" label="虚拟机ID" width="90" />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="host_id" label="宿主机ID" width="90" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)" size="small">
@@ -16,9 +16,17 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="任务ID" width="160">
<template #default="{ row }">
<span style="font-family: Consolas, monospace; font-size: 12px">{{ row.task_id || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTs(row.created_at) }}</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ formatTs(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestore(row)">恢复</el-button>
@@ -28,7 +36,7 @@
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" description="暂无快照数据" />
<div class="pagination-wrapper" v-if="total > pageSize">
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { pageSize = s; currentPage = 1; loadList() }"
@@ -45,9 +53,6 @@
<el-form-item label="快照名称" required>
<el-input v-model="createForm.name" placeholder="请输入快照名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
@@ -108,8 +113,8 @@ const loadList = async () => {
const res = await getSnapshotList({ service_id: serviceId.value, page: currentPage.value, page_size: pageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.snapshots || d.data || d.list || (Array.isArray(d) ? d : [])
total.value = d.meta?.count ?? d.all_count ?? d.total ?? list.value.length
list.value = d.data || d.list || (Array.isArray(d) ? d : [])
total.value = d.meta?.count ?? d.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
@@ -129,10 +134,10 @@ const loadVmOptions = async () => {
}
const createVisible = ref(false)
const createForm = reactive({ vm_id: null, name: '', description: '' })
const createForm = reactive({ vm_id: null, name: '' })
const handleCreate = async () => {
Object.assign(createForm, { vm_id: null, name: '', description: '' })
Object.assign(createForm, { vm_id: null, name: '' })
if (!vmOptions.value.length) await loadVmOptions()
createVisible.value = true
}
@@ -145,7 +150,6 @@ const submitCreate = async () => {
fd.append('service_id', serviceId.value)
fd.append('vm_id', createForm.vm_id)
fd.append('name', createForm.name)
if (createForm.description) fd.append('description', createForm.description)
const res = await createSnapshot(fd)
if (res?.data?.code === 200) { ElMessage.success('快照创建成功'); createVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
@@ -224,6 +228,6 @@ onMounted(() => { loadList() })
<style scoped>
.snapshot-manage { padding: 0; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
.toolbar { display: flex; gap: 8px; margin-top: 12px; margin-bottom: 16px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
</style>
@@ -0,0 +1,390 @@
<template>
<div class="networking-manage-container">
<div class="page-header" v-if="!embedded">
<div class="header-left">
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
<div class="header-info">
<h3>用户组网管理</h3>
<span class="sub-info" v-if="serviceName">主控服务{{ serviceName }}</span>
</div>
</div>
<div class="header-right">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建组网</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建组网</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索组网" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<el-table :data="list" v-loading="loading" 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 prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="user_id" label="用户ID" width="90" />
<el-table-column label="宿主机" width="130">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column>
<el-table-column prop="bridge_name" label="网桥" width="120" show-overflow-tooltip />
<el-table-column prop="gateway" label="网关" width="140" show-overflow-tooltip />
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleViewDetail(row)">详情</el-button>
<el-button link type="success" size="small" @click="handleAssign(row)">分配IP</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
<!-- 创建组网弹窗 -->
<el-dialog v-model="createDialogVisible" title="创建用户组网" width="520px" destroy-on-close>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="createForm.name" placeholder="组网名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
<el-form-item label="用户ID" prop="user_id">
<el-input-number v-model="createForm.user_id" :min="1" style="width: 100%" placeholder="用户 ID" />
</el-form-item>
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable clearable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="网桥名称" prop="bridge_name">
<el-input v-model="createForm.bridge_name" placeholder=" br0" />
</el-form-item>
<el-form-item label="网关地址" prop="gateway">
<el-input v-model="createForm.gateway" placeholder=" 10.0.0.1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">确定</el-button>
</template>
</el-dialog>
<!-- 分配组网 IP 弹窗 -->
<el-dialog v-model="assignDialogVisible" title="为虚拟机分配组网 IP" width="480px" destroy-on-close>
<el-form ref="assignFormRef" :model="assignForm" :rules="assignRules" label-width="100px">
<el-form-item label="组网">
<el-input :model-value="assignTarget?.name || '-'" disabled />
</el-form-item>
<el-form-item label="虚拟机" prop="vm_id">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-input :model-value="assignForm.vm_id ? `${assignVmName || ''} (ID: ${assignForm.vm_id})` : ''" readonly placeholder="请选择虚拟机" style="flex: 1" />
<el-button type="primary" @click="showAssignVmSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="指定 IP">
<el-input v-model="assignForm.ip" placeholder="留空自动分配" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitAssign">分配</el-button>
</template>
</el-dialog>
<!-- 虚拟机选择器 -->
<VmSelectorPopup v-model="showAssignVmSelector" :service-id="serviceId" :current-id="assignForm.vm_id" @confirm="handleAssignVmSelected" />
<!-- 组网详情弹窗 -->
<el-dialog v-model="detailVisible" title="组网详情" width="800px" destroy-on-close>
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading" style="margin-bottom: 20px">
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="描述">{{ currentDetail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ currentDetail.user_id }}</el-descriptions-item>
<el-descriptions-item label="宿主机">{{ getHostLabel(currentDetail.host_id) }}</el-descriptions-item>
<el-descriptions-item label="网桥">{{ currentDetail.bridge_name }}</el-descriptions-item>
<el-descriptions-item label="网关">{{ currentDetail.gateway }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(currentDetail.created_at) }}</el-descriptions-item>
</el-descriptions>
<div class="networks-section" v-if="detailNetworks.length">
<div class="networks-header">
<h4>组网下的网络</h4>
</div>
<el-table :data="detailNetworks" stripe size="small">
<el-table-column prop="vm_id" label="虚拟机ID" width="100" />
<el-table-column prop="vm_name" label="虚拟机名称" min-width="120" show-overflow-tooltip />
<el-table-column label="虚拟机状态" width="100">
<template #default="{ row }">
<el-tag :type="vmStatusType(row.vm_status)" size="small">{{ row.vm_status || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="网络信息" min-width="200">
<template #default="{ row }">
<template v-if="row.network">
<div>IP: {{ row.network.ip || '-' }}</div>
<div>MAC: {{ row.network.mac || '-' }}</div>
</template>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleRemoveNetwork(row)">移除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else-if="!detailLoading" description="该组网下暂无网络" :image-size="60" />
</el-dialog>
</div>
</template>
<script setup>
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 } from '@element-plus/icons-vue'
import {
getRemoteHostList,
getUserNetworkingList, getUserNetworkingDetail,
createUserNetworking, assignUserNetworking,
deleteUserNetworking, removeUserNetworkingNetwork
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
const submitLoading = ref(false)
const detailLoading = ref(false)
const list = ref([])
const total = ref(0)
const keyword = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 })
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const vmStatusType = (s) => ({ running: 'success', stopped: 'info', suspended: 'warning', error: 'danger' }[s] || 'info')
const getHostLabel = (hid) => {
const h = hostOptions.value.find(x => x.id === hid)
return h ? h.name : (hid || '-')
}
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
}
} catch (e) { console.error('加载宿主机列表失败:', e) }
}
const loadList = async () => {
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
if (keyword.value) params.keyword = keyword.value
const res = await getUserNetworkingList(params)
if (res?.data?.code === 200) {
const d = res.data.data
if (d) {
list.value = d.data || d.list || (Array.isArray(d) ? d : [])
total.value = d.meta?.count ?? d.total ?? list.value.length
} else {
list.value = []
total.value = 0
}
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const handleSearch = () => { queryParams.page = 1; loadList() }
// ---- 创建组网 ----
const createDialogVisible = ref(false)
const createFormRef = ref(null)
const createForm = reactive({ name: '', description: '', user_id: null, host_id: null, bridge_name: '', gateway: '' })
const createRules = {
name: [{ required: true, message: '请输入组网名称', trigger: 'blur' }],
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }],
bridge_name: [{ required: true, message: '请输入网桥名称', trigger: 'blur' }],
gateway: [{ required: true, message: '请输入网关地址', trigger: 'blur' }]
}
const handleAdd = () => {
Object.assign(createForm, { name: '', description: '', user_id: null, host_id: null, bridge_name: '', gateway: '' })
createDialogVisible.value = true
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('name', createForm.name)
if (createForm.description) fd.append('description', createForm.description)
fd.append('user_id', createForm.user_id)
fd.append('host_id', createForm.host_id)
fd.append('bridge_name', createForm.bridge_name)
fd.append('gateway', createForm.gateway)
const res = await createUserNetworking(fd)
if (res?.data?.code === 200) {
ElMessage.success('组网创建成功')
createDialogVisible.value = false
loadList()
} else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { submitLoading.value = false }
})
}
// ---- 分配组网 IP ----
const assignDialogVisible = ref(false)
const assignFormRef = ref(null)
const assignTarget = ref(null)
const assignVmName = ref('')
const showAssignVmSelector = ref(false)
const assignForm = reactive({ vm_id: null, ip: '' })
const assignRules = {
vm_id: [{ required: true, message: '请选择虚拟机', trigger: 'change' }]
}
const handleAssign = (row) => {
assignTarget.value = row
Object.assign(assignForm, { vm_id: null, ip: '' })
assignVmName.value = ''
assignDialogVisible.value = true
}
const handleAssignVmSelected = (vm) => {
assignForm.vm_id = vm.id
assignVmName.value = vm.name || ''
}
const submitAssign = () => {
assignFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('networking_id', assignTarget.value.id)
fd.append('vm_id', assignForm.vm_id)
if (assignForm.ip) fd.append('ip', assignForm.ip)
const res = await assignUserNetworking(fd)
if (res?.data?.code === 200) {
ElMessage.success('IP 分配成功')
assignDialogVisible.value = false
loadList()
} else ElMessage.error(extractApiError(res?.data, '分配失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '分配失败')) } finally { submitLoading.value = false }
})
}
// ---- 组网详情 ----
const detailVisible = ref(false)
const currentDetail = ref(null)
const detailNetworks = ref([])
const handleViewDetail = async (row) => {
currentDetail.value = row
detailNetworks.value = []
detailVisible.value = true
detailLoading.value = true
try {
const res = await getUserNetworkingDetail({ service_id: serviceId.value, networking_id: row.id })
if (res?.data?.code === 200) {
const d = res.data.data
if (d?.data) currentDetail.value = d.data
detailNetworks.value = d?.networks || []
}
} catch { ElMessage.warning('获取详情失败') } finally { detailLoading.value = false }
}
// ---- 删除组网 ----
const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除组网「${row.name}」吗?此操作不可恢复。`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteUserNetworking({ service_id: serviceId.value, networking_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
// ---- 移除组网下的网络 ----
const handleRemoveNetwork = (netItem) => {
ElMessageBox.confirm(`确定要从组网中移除该网络吗?`, '移除确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('networking_id', currentDetail.value.id)
fd.append('network_id', netItem.network?.id || netItem.id)
fd.append('vm_id', netItem.vm_id)
const res = await removeUserNetworkingNetwork(fd)
if (res?.data?.code === 200) {
ElMessage.success('移除成功')
handleViewDetail(currentDetail.value)
} else ElMessage.error(extractApiError(res?.data, '移除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '移除失败')) }
}).catch(() => {})
}
const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(async () => {
if (serviceId.value) {
await loadHostOptions()
loadList()
}
})
</script>
<style scoped>
.networking-manage-container { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
.header-left { display: flex; align-items: center; gap: 16px; }
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
.sub-info { font-size: 13px; color: #909399; }
.header-right { display: flex; gap: 8px; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.networks-section { margin-top: 8px; }
.networks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.networks-header h4 { margin: 0; font-size: 15px; color: #303133; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
+162 -37
View File
@@ -156,7 +156,7 @@
<h3 class="section-title">网络信息</h3>
<el-button size="small" type="primary" @click="handleAddNetwork">添加网络</el-button>
</div>
<el-table v-if="vmNetworks.length" :data="vmNetworks" 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="name" label="名称" min-width="100" />
<el-table-column prop="address" label="IP地址" min-width="140" show-overflow-tooltip />
@@ -174,6 +174,12 @@
</el-table-column>
</el-table>
<el-empty v-else description="暂无网络" :image-size="60" />
<div class="pagination-wrapper" v-if="vmNetworks.length > 0">
<el-pagination v-model:current-page="networkPage" v-model:page-size="networkPageSize"
:page-sizes="[10, 20, 50]" :total="vmNetworks.length" layout="total, sizes, prev, pager, next" small
@size-change="s => { networkPageSize = s; networkPage = 1 }"
@current-change="p => { networkPage = p }" />
</div>
</div>
</el-tab-pane>
@@ -183,7 +189,7 @@
<h3 class="section-title">磁盘卷信息</h3>
<el-button size="small" type="primary" @click="handleAddVolume">添加数据卷</el-button>
</div>
<el-table v-if="vmVolumes.length" :data="vmVolumes" 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="name" label="名称" min-width="100" />
<el-table-column label="大小" width="80">
@@ -213,6 +219,12 @@
</el-table-column>
</el-table>
<el-empty v-else description="暂无磁盘卷" :image-size="60" />
<div class="pagination-wrapper" v-if="vmVolumes.length > 0">
<el-pagination v-model:current-page="volumePage" v-model:page-size="volumePageSize"
:page-sizes="[10, 20, 50]" :total="vmVolumes.length" layout="total, sizes, prev, pager, next" small
@size-change="s => { volumePageSize = s; volumePage = 1 }"
@current-change="p => { volumePage = p }" />
</div>
</div>
</el-tab-pane>
@@ -222,7 +234,7 @@
<h3 class="section-title">安全组管理</h3>
<el-button size="small" type="primary" @click="handleBindSgFromTab">绑定安全组</el-button>
</div>
<el-table v-if="vmSecurityGroups.length" :data="vmSecurityGroups" size="small" stripe>
<el-table v-if="vmSecurityGroups.length" :data="pagedSecurityGroups" size="small" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="note" label="备注" min-width="160" show-overflow-tooltip />
@@ -248,6 +260,12 @@
</el-table-column>
</el-table>
<el-empty v-else description="暂无绑定的安全组" :image-size="60" />
<div class="pagination-wrapper" v-if="vmSecurityGroups.length > 0">
<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
@size-change="s => { securityPageSize = s; securityPage = 1 }"
@current-change="p => { securityPage = p }" />
</div>
</div>
</el-tab-pane>
@@ -255,7 +273,9 @@
<div class="section-block">
<div class="section-header">
<h3 class="section-title">快照管理</h3>
<div style="display: flex; gap: 8px">
<div style="display: flex; align-items: center; gap: 8px">
<el-tag v-if="snapshotQuota" size="small" effect="plain">{{ snapshotQuota.count }} / {{ snapshotQuota.limit }}</el-tag>
<el-button size="small" @click="handleSetSnapshotLimit">设置上限</el-button>
<el-button size="small" type="primary" @click="handleCreateSnapshot">创建快照</el-button>
<el-button size="small" @click="loadSnapshots">刷新</el-button>
</div>
@@ -263,15 +283,22 @@
<el-table :data="snapshotList" v-loading="snapshotLoading" stripe size="small" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)" size="small">{{ snapshotStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务ID" min-width="160">
<template #default="{ row }">
<span style="font-family: Consolas, monospace; font-size: 12px">{{ row.task_id || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ formatTimestamp(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestoreSnapshot(row)">恢复</el-button>
@@ -281,6 +308,12 @@
</el-table-column>
</el-table>
<el-empty v-if="!snapshotList.length && !snapshotLoading" description="暂无快照" :image-size="60" />
<div class="pagination-wrapper" v-if="snapshotTotal > 0">
<el-pagination v-model:current-page="snapshotPage" v-model:page-size="snapshotPageSize"
:page-sizes="[10, 20, 50]" :total="snapshotTotal" layout="total, sizes, prev, pager, next" small
@size-change="s => { snapshotPageSize = s; snapshotPage = 1; loadSnapshots() }"
@current-change="p => { snapshotPage = p; loadSnapshots() }" />
</div>
</div>
</el-tab-pane>
@@ -288,7 +321,9 @@
<div class="section-block">
<div class="section-header">
<h3 class="section-title">备份管理</h3>
<div style="display: flex; gap: 8px">
<div style="display: flex; align-items: center; gap: 8px">
<el-tag v-if="backupQuota" size="small" effect="plain">{{ backupQuota.count }} / {{ backupQuota.limit }}</el-tag>
<el-button size="small" @click="handleSetBackupLimit">设置上限</el-button>
<el-button size="small" type="primary" @click="handleCreateBackup">创建备份</el-button>
<el-button size="small" @click="loadBackups">刷新</el-button>
</div>
@@ -296,15 +331,22 @@
<el-table :data="backupList" v-loading="backupLoading" stripe size="small" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="taskStatusType(row.status)" size="small">{{ snapshotStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务ID" min-width="160">
<template #default="{ row }">
<span style="font-family: Consolas, monospace; font-size: 12px">{{ row.task_id || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ formatTimestamp(row.updated_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestoreBackup(row)">恢复</el-button>
@@ -314,6 +356,12 @@
</el-table-column>
</el-table>
<el-empty v-if="!backupList.length && !backupLoading" description="暂无备份" :image-size="60" />
<div class="pagination-wrapper" v-if="backupTotal > 0">
<el-pagination v-model:current-page="backupPage" v-model:page-size="backupPageSize"
:page-sizes="[10, 20, 50]" :total="backupTotal" layout="total, sizes, prev, pager, next" small
@size-change="s => { backupPageSize = s; backupPage = 1; loadBackups() }"
@current-change="p => { backupPage = p; loadBackups() }" />
</div>
</div>
</el-tab-pane>
@@ -384,9 +432,6 @@
<el-form-item label="快照名称" required>
<el-input v-model="snapshotForm.name" placeholder="请输入快照名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="snapshotForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="snapshotCreateVisible = false">取消</el-button>
@@ -401,9 +446,6 @@
<el-form-item label="备份名称" required>
<el-input v-model="backupForm.name" placeholder="请输入备份名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="backupForm.description" type="textarea" :rows="2" placeholder="可选描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="backupCreateVisible = false">取消</el-button>
@@ -760,8 +802,8 @@ import {
createNetwork, updateNetwork, deleteNetwork, getNetworkList,
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume, getVolumeList,
getVmList,
getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress,
getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress
getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, getSnapshotCount, setSnapshotLimit,
getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress, getBackupCount, setBackupLimit
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import * as echarts from 'echarts'
@@ -784,6 +826,20 @@ const detail = ref(null)
const vmNetworks = ref([])
const vmVolumes = ref([])
const vmImage = ref(null)
const networkPage = ref(1)
const networkPageSize = ref(10)
const pagedNetworks = computed(() => {
const start = (networkPage.value - 1) * networkPageSize.value
return vmNetworks.value.slice(start, start + networkPageSize.value)
})
const volumePage = ref(1)
const volumePageSize = ref(10)
const pagedVolumes = computed(() => {
const start = (volumePage.value - 1) * volumePageSize.value
return vmVolumes.value.slice(start, start + volumePageSize.value)
})
const vmPortGroup = ref(null)
const metricsData = ref(null)
const hostOptions = ref([])
@@ -1275,6 +1331,12 @@ const submitGetVnc = async () => {
// ---- 安全组管理(标签页列表) ----
const vmSecurityGroups = ref([])
const sgListLoading = ref(false)
const securityPage = ref(1)
const securityPageSize = ref(10)
const pagedSecurityGroups = computed(() => {
const start = (securityPage.value - 1) * securityPageSize.value
return vmSecurityGroups.value.slice(start, start + securityPageSize.value)
})
const loadVmSecurityGroups = async () => {
sgListLoading.value = true
@@ -1595,12 +1657,20 @@ const submitTransferVolume = async () => {
// ---- 快照/备份管理 ----
const snapshotList = ref([])
const snapshotLoading = ref(false)
const snapshotQuota = ref(null)
const snapshotPage = ref(1)
const snapshotPageSize = ref(10)
const snapshotTotal = ref(0)
const backupList = ref([])
const backupLoading = ref(false)
const backupQuota = ref(null)
const backupPage = ref(1)
const backupPageSize = ref(10)
const backupTotal = ref(0)
const snapshotCreateVisible = ref(false)
const backupCreateVisible = ref(false)
const snapshotForm = reactive({ name: '', description: '' })
const backupForm = reactive({ name: '', description: '' })
const snapshotForm = reactive({ name: '' })
const backupForm = reactive({ name: '' })
const taskProgressVisible = ref(false)
const taskProgressLoading = ref(false)
const taskProgressData = ref(null)
@@ -1625,29 +1695,85 @@ const taskProgressMeta = computed(() => {
const loadSnapshots = async () => {
snapshotLoading.value = true
try {
const res = await getSnapshotList({ service_id: serviceId.value })
const res = await getSnapshotList({ service_id: serviceId.value, vm_id: vmId.value, page: snapshotPage.value, page_size: snapshotPageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
const all = d.snapshots || d.data || d.list || (Array.isArray(d) ? d : [])
snapshotList.value = all.filter(s => s.vm_id === vmId.value || s.vm_id === String(vmId.value))
} else snapshotList.value = []
} catch { snapshotList.value = [] } finally { snapshotLoading.value = false }
snapshotList.value = d.data || d.list || (Array.isArray(d) ? d : [])
snapshotTotal.value = d.meta?.count ?? d.total ?? snapshotList.value.length
} else { snapshotList.value = []; snapshotTotal.value = 0 }
} catch { snapshotList.value = []; snapshotTotal.value = 0 } finally { snapshotLoading.value = false }
}
const loadSnapshotQuota = async () => {
try {
const res = await getSnapshotCount({ service_id: serviceId.value, vm_id: vmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
snapshotQuota.value = { count: d.count ?? 0, limit: d.limit ?? 0 }
}
} catch { /* ignore */ }
}
const handleSetSnapshotLimit = () => {
ElMessageBox.prompt('请输入快照数量上限', '设置快照上限', {
confirmButtonText: '确定', cancelButtonText: '取消',
inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数',
inputValue: String(snapshotQuota.value?.limit || 10)
}).then(async ({ value }) => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('limit', value)
const res = await setSnapshotLimit(fd)
if (res?.data?.code === 200) { ElMessage.success('快照上限设置成功'); loadSnapshotQuota() }
else ElMessage.error(extractApiError(res?.data, '设置失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置失败')) }
}).catch(() => {})
}
const loadBackups = async () => {
backupLoading.value = true
try {
const res = await getBackupList({ service_id: serviceId.value })
const res = await getBackupList({ service_id: serviceId.value, vm_id: vmId.value, page: backupPage.value, page_size: backupPageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
const all = d.backups || d.data || d.list || (Array.isArray(d) ? d : [])
backupList.value = all.filter(b => b.vm_id === vmId.value || b.vm_id === String(vmId.value))
} else backupList.value = []
} catch { backupList.value = [] } finally { backupLoading.value = false }
backupList.value = d.data || d.list || (Array.isArray(d) ? d : [])
backupTotal.value = d.meta?.count ?? d.total ?? backupList.value.length
} else { backupList.value = []; backupTotal.value = 0 }
} catch { backupList.value = []; backupTotal.value = 0 } finally { backupLoading.value = false }
}
const loadBackupQuota = async () => {
try {
const res = await getBackupCount({ service_id: serviceId.value, vm_id: vmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
backupQuota.value = { count: d.count ?? 0, limit: d.limit ?? 0 }
}
} catch { /* ignore */ }
}
const handleSetBackupLimit = () => {
ElMessageBox.prompt('请输入备份数量上限', '设置备份上限', {
confirmButtonText: '确定', cancelButtonText: '取消',
inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数',
inputValue: String(backupQuota.value?.limit || 10)
}).then(async ({ value }) => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('limit', value)
const res = await setBackupLimit(fd)
if (res?.data?.code === 200) { ElMessage.success('备份上限设置成功'); loadBackupQuota() }
else ElMessage.error(extractApiError(res?.data, '设置失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置失败')) }
}).catch(() => {})
}
const handleCreateSnapshot = () => {
Object.assign(snapshotForm, { name: '', description: '' })
Object.assign(snapshotForm, { name: '' })
snapshotCreateVisible.value = true
}
const submitCreateSnapshot = async () => {
@@ -1658,9 +1784,8 @@ const submitCreateSnapshot = async () => {
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('name', snapshotForm.name)
if (snapshotForm.description) fd.append('description', snapshotForm.description)
const res = await createSnapshot(fd)
if (res?.data?.code === 200) { ElMessage.success('快照创建成功'); snapshotCreateVisible.value = false; loadSnapshots() }
if (res?.data?.code === 200) { ElMessage.success('快照创建成功'); snapshotCreateVisible.value = false; loadSnapshots(); loadSnapshotQuota() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
}
@@ -1689,7 +1814,7 @@ const handleDeleteSnapshot = (row) => {
fd.append('snapshot_id', row.id)
fd.append('vm_id', row.vm_id)
const res = await deleteSnapshot(fd)
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSnapshots() }
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSnapshots(); loadSnapshotQuota() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
@@ -1708,7 +1833,7 @@ const handleSnapshotProgress = async (row) => {
}
const handleCreateBackup = () => {
Object.assign(backupForm, { name: '', description: '' })
Object.assign(backupForm, { name: '' })
backupCreateVisible.value = true
}
const submitCreateBackup = async () => {
@@ -1719,9 +1844,8 @@ const submitCreateBackup = async () => {
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('name', backupForm.name)
if (backupForm.description) fd.append('description', backupForm.description)
const res = await createBackup(fd)
if (res?.data?.code === 200) { ElMessage.success('备份创建成功'); backupCreateVisible.value = false; loadBackups() }
if (res?.data?.code === 200) { ElMessage.success('备份创建成功'); backupCreateVisible.value = false; loadBackups(); loadBackupQuota() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
}
@@ -1750,7 +1874,7 @@ const handleDeleteBackup = (row) => {
fd.append('backup_id', row.id)
fd.append('vm_id', row.vm_id)
const res = await deleteBackup(fd)
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadBackups() }
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadBackups(); loadBackupQuota() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
@@ -1793,8 +1917,8 @@ watch(activeTab, (tab) => {
if (tab === 'network') loadVmNetworks()
if (tab === 'volume') loadVmVolumes()
if (tab === 'security') loadVmSecurityGroups()
if (tab === 'snapshot') loadSnapshots()
if (tab === 'backup') loadBackups()
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
})
onActivated(() => {
isPageActive = true
@@ -1872,4 +1996,5 @@ onMounted(() => { isPageActive = true; initPage() })
.net-speed-value { font-size: 20px; font-weight: 600; color: #1d2129; }
.vnc-result { margin-top: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
</style>
+5 -5
View File
@@ -82,10 +82,10 @@
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > queryParams.count">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
@@ -333,7 +333,7 @@ const total = ref(0)
const keyword = ref('')
const filterStatus = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, count: 10 })
const queryParams = reactive({ page: 1, page_size: 10 })
// 选择器
const showCreateImageSelector = ref(false)
@@ -488,7 +488,7 @@ const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.count }
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
if (keyword.value) params.key = keyword.value
if (filterStatus.value) params.status = filterStatus.value
const res = await getVmList(params)
+1 -1
View File
@@ -61,7 +61,7 @@
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > queryParams.page_size">
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
+5 -5
View File
@@ -59,10 +59,10 @@
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > queryParams.count">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.count"
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.count = s; queryParams.page = 1; loadList() }"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
@@ -216,7 +216,7 @@ const volumeList = ref([])
const total = ref(0)
const filterStatus = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, count: 10 })
const queryParams = reactive({ page: 1, page_size: 10 })
const getHostLabel = (hid) => {
const h = hostOptions.value.find(x => x.id === hid)
@@ -294,7 +294,7 @@ const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.count }
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
if (hostId.value) params.host_id = hostId.value
if (filterStatus.value) params.status = filterStatus.value
const res = await getVolumeList(params)
+348
View File
@@ -0,0 +1,348 @@
# 新增接口对接文档
> 来源:`默认模块.openapi.json` 与 `src/api/admin/kvmService.js` 对比
> 生成时间:2026-03-21
---
## 一、新增接口总览
| # | 模块 | 接口路径 | 方法 | 说明 | 前端状态 |
|---|------|---------|------|------|---------|
| 1 | 快照管理 | `/api/v1/admin/server/host_service/point/snapshot/count` | GET | 获取快照数量与上限 | **新增** |
| 2 | 快照管理 | `/api/v1/admin/server/host_service/point/snapshot/set_limit` | POST | 设置快照数量上限 | **新增** |
| 3 | 备份管理 | `/api/v1/admin/server/host_service/point/backup/count` | GET | 获取备份数量与上限 | **新增** |
| 4 | 备份管理 | `/api/v1/admin/server/host_service/point/backup/set_limit` | POST | 设置备份数量上限 | **新增** |
| 5 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/create` | POST | 创建用户组网 | **新增(全新模块)** |
| 6 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/assign` | POST | 为虚拟机分配组网IP | **新增(全新模块)** |
| 7 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/list` | GET | 获取组网列表 | **新增(全新模块)** |
| 8 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/detail` | GET | 获取组网详情 | **新增(全新模块)** |
| 9 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/delete` | DELETE | 删除组网 | **新增(全新模块)** |
| 10 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/remove_network` | POST | 删除组网下指定网络 | **新增(全新模块)** |
> **注意**:用户组网(Networking)接口的URL前缀为 `/api/v1/admins/service/`admins复数、service单数),与其他接口 `/api/v1/admin/server/` 不同。
---
## 二、接口详细说明
### 2.1 快照管理 - 新增接口
#### 2.1.1 获取快照数量与上限
- **路径**`GET /api/v1/admin/server/host_service/point/snapshot/count`
- **标签**:管理员-快照
- **请求参数**Query):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM服务ID |
| vm_id | integer | 是 | 虚拟机ID |
- **响应**
```json
{
"code": 200,
"data": {
"vm_id": 1,
"count": 3,
"limit": 10
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| vm_id | int64 | 虚拟机ID |
| count | uint32 | 当前快照数量 |
| limit | uint32 | 快照上限 |
- **前端函数名**`getSnapshotCount`
---
#### 2.1.2 设置快照数量上限
- **路径**`POST /api/v1/admin/server/host_service/point/snapshot/set_limit`
- **标签**:管理员-快照
- **Content-Type**`multipart/form-data`
- **请求参数**FormData):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM服务ID |
| vm_id | integer | 是 | 虚拟机ID |
| limit | integer | 是 | 快照数量上限 |
- **响应**
```json
{
"code": 200,
"data": {
"vm_id": 1,
"count": 3,
"limit": 20
}
}
```
- **前端函数名**`setSnapshotLimit`
---
### 2.2 备份管理 - 新增接口
#### 2.2.1 获取备份数量与上限
- **路径**`GET /api/v1/admin/server/host_service/point/backup/count`
- **标签**:管理员-备份
- **请求参数**Query):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM服务ID |
| vm_id | integer | 是 | 虚拟机ID |
- **响应**
```json
{
"code": 200,
"data": {
"vm_id": 1,
"count": 2,
"limit": 5
}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| vm_id | int64 | 虚拟机ID |
| count | uint32 | 当前备份数量 |
| limit | uint32 | 备份上限 |
- **前端函数名**`getBackupCount`
---
#### 2.2.2 设置备份数量上限
- **路径**`POST /api/v1/admin/server/host_service/point/backup/set_limit`
- **标签**:管理员-备份
- **Content-Type**`multipart/form-data`
- **请求参数**FormData):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM服务ID |
| vm_id | integer | 是 | 虚拟机ID |
| limit | integer | 是 | 备份数量上限 |
- **响应**
```json
{
"code": 200,
"data": {
"vm_id": 1,
"count": 2,
"limit": 10
}
}
```
- **前端函数名**`setBackupLimit`
---
### 2.3 用户组网管理(全新模块 KVM-UserNetworking
> **URL 前缀注意**:此模块所有接口使用 `/api/v1/admins/service/` 前缀
#### 2.3.1 获取组网列表
- **路径**`GET /api/v1/admins/service/host_service/point/networking/list`
- **请求参数**Query):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM 服务 ID |
| page | integer | 否 | 页码 |
| count | integer | 否 | 每页数量 |
| host_id | integer | 否 | 按宿主机 ID 筛选 |
| user_id | integer | 否 | 按用户 ID 筛选 |
| keyword | string | 否 | 关键词搜索 |
- **响应**
```json
{
"meta": { "count": 10 },
"data": [
{
"id": 1,
"name": "组网名称",
"description": "描述",
"user_id": 1,
"host_id": 1,
"bridge_name": "br0",
"gateway": "192.168.1.1",
"created_at": "2026-03-21T00:00:00Z",
"updated_at": "2026-03-21T00:00:00Z"
}
]
}
```
- **前端函数名**`getUserNetworkingList`
---
#### 2.3.2 获取组网详情
- **路径**`GET /api/v1/admins/service/host_service/point/networking/detail`
- **请求参数**Query):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM 服务 ID |
| networking_id | integer | 是 | 组网 ID |
- **响应**
```json
{
"data": { "id": 1, "name": "...", "..." },
"networks": [
{
"network": {},
"vm_id": 1,
"vm_name": "vm-1",
"vm_status": "running"
}
]
}
```
- **前端函数名**`getUserNetworkingDetail`
---
#### 2.3.3 创建用户组网
- **路径**`POST /api/v1/admins/service/host_service/point/networking/create`
- **Content-Type**`multipart/form-data`
- **请求参数**FormData):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM 服务 ID |
| name | string | 是 | 组网名称 |
| description | string | 否 | 组网描述 |
| user_id | integer | 是 | 用户 ID |
| host_id | integer | 是 | 宿主机 ID |
| bridge_name | string | 是 | 网桥名称 |
| gateway | string | 是 | 网关地址 |
- **响应**:返回 `UserNetworkingData` 对象
- **前端函数名**`createUserNetworking`
---
#### 2.3.4 为虚拟机分配组网 IP
- **路径**`POST /api/v1/admins/service/host_service/point/networking/assign`
- **Content-Type**`multipart/form-data`
- **请求参数**FormData):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM 服务 ID |
| networking_id | integer | 是 | 组网 ID |
| vm_id | integer | 是 | 虚拟机 ID |
| ip | string | 否 | 指定 IP,不传则自动分配 |
- **响应**:返回 `networking``network``task` 三个对象
- **前端函数名**`assignUserNetworking`
---
#### 2.3.5 删除组网
- **路径**`DELETE /api/v1/admins/service/host_service/point/networking/delete`
- **请求参数**Query):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM 服务 ID |
| networking_id | integer | 是 | 组网 ID |
- **前端函数名**`deleteUserNetworking`
---
#### 2.3.6 删除组网下的指定网络
- **路径**`POST /api/v1/admins/service/host_service/point/networking/remove_network`
- **Content-Type**`multipart/form-data`
- **请求参数**FormData):
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| service_id | integer | 是 | KVM 服务 ID |
| networking_id | integer | 是 | 组网 ID |
| network_id | integer | 是 | 网络 ID |
| vm_id | integer | 是 | 虚拟机 ID(用于从 VM 中移除网络) |
- **前端函数名**`removeUserNetworkingNetwork`
---
## 三、数据模型
### UserNetworkingData
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int64 | 组网ID |
| name | string | 组网名称 |
| description | string | 描述 |
| user_id | int64 | 用户ID |
| host_id | int64 | 宿主机ID |
| bridge_name | string | 网桥名称 |
| gateway | string | 网关地址 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
### NetworkingNetworkItem
| 字段 | 类型 | 说明 |
|------|------|------|
| network | Network | 网络对象 |
| vm_id | int64 | 虚拟机ID |
| vm_name | string | 虚拟机名称 |
| vm_status | string | 虚拟机状态 |
---
## 四、已有接口(已对接,无需修改)
以下接口在 `kvmService.js` 中已存在,无需新增:
- 主控服务管理:list / detail / create / update / delete5个)
- 宿主机组映射管理:list / sync / bind / update / generate_goods / delete6个)
- 远程宿主机组管理:list / detail / tree / optimal_host / create / update / delete7个)
- 宿主机管理:list / detail / metrics / add / update / delete6个)
- 镜像管理:list / detail / host_status / create / update / delete / reload / sync / reload_host / compare_host10个)
- 网络管理:list / detail / create / update / delete5个)
- 数据卷管理:list / detail / create / resize / mount / unmount / transfer / delete8个)
- 虚拟机管理:list / detail / status / metrics / create / update / rebuild / refactor / update_traffic / start / stop / reboot / suspend / resume / rescue / exit_rescue / delete17个)
- 安全组管理:list / detail / create / update / sync / bind / unbind / delete / enable_whitelist / disable_whitelist / create_rule / update_rule / delete_rule / apply / set_shared15个)
- VNC节点管理:list / vm_vnc / add / test / update / delete6个)
- 快照管理:list / progress / create / restore / delete5个)
- 备份管理:list / progress / create / restore / delete5个)
**共计已对接 95 个接口,新增 10 个接口待对接。**
+1124 -7
View File
File diff suppressed because it is too large Load Diff