feat: 对接虚拟化平台管理
Build and Deploy Vue3 / build (push) Successful in 1m22s
Build and Deploy Vue3 / deploy (push) Successful in 1m2s

This commit is contained in:
2026-03-19 18:13:24 +08:00
parent cd16ec17ae
commit cf19956b88
24 changed files with 5000 additions and 807 deletions
+120 -180
View File
@@ -3,17 +3,18 @@
<!-- 顶部信息 -->
<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="handleSync" :loading="syncLoading">
<el-select v-model="selectedServiceId" placeholder="选择主控服务" filterable style="width: 240px" @change="handleServiceChange">
<el-option v-for="s in serviceOptions" :key="s.id" :label="`${s.name} (ID: ${s.id})`" :value="s.id" />
</el-select>
<el-button type="primary" @click="handleSync" :loading="syncLoading" :disabled="!serviceId">
<el-icon><RefreshRight /></el-icon>从远程同步
</el-button>
<el-button @click="loadHostGroups">
<el-button @click="handleRefresh">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
@@ -23,88 +24,46 @@
<el-button @click="loadHostGroups"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<!-- 布局左侧本地主机组列表 / 右侧详情&操作 -->
<div class="content-layout">
<!-- 左侧本地主机组列表 -->
<div class="left-panel">
<div class="panel-header">
<h4>本地主机组列表</h4>
</div>
<div class="panel-body" v-loading="loading">
<el-table :data="hostGroupList" stripe style="width: 100%" @row-click="handleRowClick"
:row-class-name="getRowClassName" highlight-current-row>
<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="远程ID" width="80">
<template #default="{ row }">
{{ row.remoteId || '-' }}
</template>
</el-table-column>
<el-table-column label="父级远程ID" width="100">
<template #default="{ row }">
{{ row.parentRemoteId || '-' }}
</template>
</el-table-column>
<el-table-column label="绑定商品组" min-width="120">
<template #default="{ row }">
<el-tag v-if="row.goodGroupId" type="success" size="small">
商品组#{{ row.goodGroupId }}
</el-tag>
<span v-else class="text-muted">未绑定</span>
</template>
</el-table-column>
<el-table-column label="绑定商品" min-width="100">
<template #default="{ row }">
<el-tag v-if="row.goodId" type="warning" size="small">
商品#{{ row.goodId }}
</el-tag>
<span v-else class="text-muted">未绑定</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.note || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 本地主机组列表树形折叠 -->
<div class="main-panel">
<div class="panel-header">
<h4>本地主机组列表</h4>
</div>
<!-- 右侧远程主机组树 -->
<div class="right-panel">
<div class="panel-header">
<h4>远程主机组树</h4>
<el-button size="small" @click="loadRemoteTree" :loading="remoteTreeLoading">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<div class="panel-body" v-loading="remoteTreeLoading">
<el-tree
v-if="remoteTreeData.length > 0"
:data="remoteTreeData"
:props="{ label: 'name', children: 'children' }"
node-key="id"
default-expand-all
:expand-on-click-node="false"
>
<template #default="{ node, data }">
<div class="tree-node-content">
<span class="tree-node-name">{{ data.name }}</span>
<span class="tree-node-id">ID: {{ data.id }}</span>
<span v-if="data.note" class="tree-node-note">{{ data.note }}</span>
</div>
<div class="panel-body" v-loading="loading">
<el-table :data="treeGroupList" stripe style="width: 100%" row-key="_rowKey"
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }">
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="id" label="ID" width="70" />
<el-table-column label="远程ID" width="80">
<template #default="{ row }">{{ row.remoteId || '-' }}</template>
</el-table-column>
<el-table-column label="父级远程ID" width="100">
<template #default="{ row }">{{ row.parentRemoteId || '-' }}</template>
</el-table-column>
<el-table-column label="绑定商品组" min-width="120">
<template #default="{ row }">
<el-tag v-if="row.goodGroupId" type="success" size="small">商品组#{{ row.goodGroupId }}</el-tag>
<span v-else class="text-muted">未绑定</span>
</template>
</el-tree>
<el-empty v-else description="暂无远程主机组数据,请先同步" />
</div>
</el-table-column>
<el-table-column label="绑定商品" min-width="100">
<template #default="{ row }">
<el-tag v-if="row.goodId" type="warning" size="small">商品#{{ row.goodId }}</el-tag>
<span v-else class="text-muted">未绑定</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="handleEditGroup(row)">编辑</el-button>
<el-button link type="primary" size="small" @click.stop="handleBind(row)">绑定</el-button>
<el-button link type="success" size="small" @click.stop="handleGenerateGoods(row)">生成商品</el-button>
<el-button link type="danger" size="small" @click.stop="handleDeleteGroup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
@@ -219,8 +178,9 @@ import {
updateHostGroup,
generateGoodsByHostGroup,
deleteHostGroup,
getRemoteHostGroupTree
getKvmServiceList
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import ProductGroupSelector from '@/components/admin/ProductGroupSelector.vue'
import ProductSelector from '@/components/admin/ProductSelector.vue'
import dayjs from 'dayjs'
@@ -230,16 +190,76 @@ 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 selectedServiceId = ref(parseInt(route.query.service_id) || null)
const serviceOptions = ref([])
const serviceId = computed(() => injectedServiceId?.value || selectedServiceId.value || 0)
const serviceName = computed(() => {
if (injectedServiceName?.value) return injectedServiceName.value
const s = serviceOptions.value.find(x => x.id === selectedServiceId.value)
return s?.name || route.query.service_name || ''
})
const normalizeService = (s) => ({
id: s.Id ?? s.id,
name: s.Name ?? s.name,
host: s.Host ?? s.host,
port: s.Port ?? s.port,
note: s.Note ?? s.note
})
const loadServiceOptions = async () => {
try {
const res = await getKvmServiceList({ page: 1, count: 100, key: '' })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
serviceOptions.value = raw.map(normalizeService)
}
} catch { /* */ }
}
const handleServiceChange = () => {
hostGroupList.value = []
if (serviceId.value) {
loadHostGroups()
}
}
const handleRefresh = () => {
if (!serviceId.value) {
ElMessage.warning('请先选择主控服务')
return
}
loadHostGroups()
}
const loading = ref(false)
const syncLoading = ref(false)
const remoteTreeLoading = ref(false)
const hostGroupList = ref([])
const remoteTreeData = ref([])
const selectedGroup = ref(null)
const treeGroupList = computed(() => {
const items = hostGroupList.value
if (!items.length) return []
const map = new Map()
items.forEach(item => {
map.set(item.remoteId, { ...item, _rowKey: `g-${item.id}`, _children: [], _hasChildren: false })
})
const roots = []
map.forEach(item => {
if (item.parentRemoteId && map.has(item.parentRemoteId)) {
const parent = map.get(item.parentRemoteId)
parent._children.push(item)
parent._hasChildren = true
} else {
roots.push(item)
}
})
return roots
})
// 规范化后端 PascalCase 字段为前端 camelCase
// 同时保留原始字段以便在需要时直接访问
const normalizeHostGroup = (item) => {
@@ -286,27 +306,6 @@ const loadHostGroups = async () => {
}
}
// ========== 远程主机组树 ==========
const loadRemoteTree = async () => {
if (!serviceId.value) return
remoteTreeLoading.value = true
try {
const res = await getRemoteHostGroupTree({ service_id: serviceId.value })
const body = res?.data
const data = body?.data || body
if (Array.isArray(data)) {
remoteTreeData.value = data
} else {
remoteTreeData.value = []
}
} catch (error) {
console.error('获取远程主机组树失败:', error)
ElMessage.error('获取远程主机组树失败')
} finally {
remoteTreeLoading.value = false
}
}
// ========== 同步 ==========
const handleSync = async () => {
if (!serviceId.value) {
@@ -330,24 +329,14 @@ const handleSync = async () => {
ElMessage.warning(body?.message || '同步返回异常')
}
loadHostGroups()
loadRemoteTree()
} catch (error) {
ElMessage.error('同步失败: ' + (error?.response?.data?.message || error.message))
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
} finally {
syncLoading.value = false
}
}).catch(() => {})
}
// ========== 行点击选中 ==========
const handleRowClick = (row) => {
selectedGroup.value = row
}
const getRowClassName = ({ row }) => {
return selectedGroup.value?.id === row.id ? 'selected-row' : ''
}
// ========== 编辑 ==========
const editDialogVisible = ref(false)
const editSubmitLoading = ref(false)
@@ -388,10 +377,10 @@ const submitEdit = () => {
editDialogVisible.value = false
loadHostGroups()
} else {
ElMessage.error(body?.message || '修改失败')
ElMessage.error(extractApiError(body, '修改失败'))
}
} catch (error) {
ElMessage.error('修改失败: ' + (error?.response?.data?.message || error.message))
ElMessage.error(extractApiError(error?.response?.data, '修改失败'))
} finally {
editSubmitLoading.value = false
}
@@ -465,10 +454,10 @@ const submitBind = async () => {
bindDialogVisible.value = false
loadHostGroups()
} else {
ElMessage.error(body?.message || '绑定失败')
ElMessage.error(extractApiError(body, '绑定失败'))
}
} catch (error) {
ElMessage.error('绑定失败: ' + (error?.response?.data?.message || error.message))
ElMessage.error(extractApiError(error?.response?.data, '绑定失败'))
} finally {
bindSubmitLoading.value = false
}
@@ -523,10 +512,10 @@ const submitGenerate = () => {
generateDialogVisible.value = false
loadHostGroups()
} else {
ElMessage.error(body?.message || '商品生成失败')
ElMessage.error(extractApiError(body, '商品生成失败'))
}
} catch (error) {
ElMessage.error('生成失败: ' + (error?.response?.data?.message || error.message))
ElMessage.error(extractApiError(error?.response?.data, '生成失败'))
} finally {
generateSubmitLoading.value = false
}
@@ -553,10 +542,10 @@ const handleDeleteGroup = (row) => {
ElMessage.success('删除成功')
loadHostGroups()
} else {
ElMessage.error(body?.message || '删除失败')
ElMessage.error(extractApiError(body, '删除失败'))
}
} catch (error) {
ElMessage.error('删除失败: ' + (error?.response?.data?.message || error.message))
ElMessage.error(extractApiError(error?.response?.data, '删除失败'))
}
}).catch(() => {})
}
@@ -567,9 +556,9 @@ const goBack = () => {
}
onMounted(() => {
if (!embedded) loadServiceOptions()
if (serviceId.value) {
loadHostGroups()
loadRemoteTree()
}
})
</script>
@@ -617,21 +606,7 @@ onMounted(() => {
margin-bottom: 16px;
}
/* 布局 */
.content-layout {
display: grid;
grid-template-columns: 1fr 360px;
gap: 20px;
}
@media (max-width: 1200px) {
.content-layout {
grid-template-columns: 1fr;
}
}
.left-panel,
.right-panel {
.main-panel {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
@@ -658,41 +633,6 @@ onMounted(() => {
min-height: 300px;
}
/* 表格行选中高亮 */
:deep(.el-table .selected-row) {
background-color: #ecf5ff !important;
}
/* 远程树节点 */
.tree-node-content {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
padding: 2px 0;
}
.tree-node-name {
font-weight: 500;
color: #303133;
}
.tree-node-id {
font-size: 11px;
color: #909399;
background: #f0f2f5;
padding: 1px 6px;
border-radius: 3px;
}
.tree-node-note {
font-size: 11px;
color: #b0b3b8;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted {
color: #c0c4cc;