feat: 对接宿主机组映射管理
Build and Deploy Vue3 / build (push) Successful in 1m20s
Build and Deploy Vue3 / deploy (push) Successful in 1m0s

This commit is contained in:
2026-03-13 17:33:02 +08:00
parent d650bfeb61
commit 25975c8b29
7 changed files with 8487 additions and 0 deletions
@@ -0,0 +1,707 @@
<template>
<div class="host-group-mapping-container">
<!-- 顶部信息 -->
<div class="page-header">
<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-icon><RefreshRight /></el-icon>从远程同步
</el-button>
<el-button @click="loadHostGroups">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</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>
<!-- 右侧远程主机组树 -->
<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>
</template>
</el-tree>
<el-empty v-else description="暂无远程主机组数据,请先同步" />
</div>
</div>
</div>
<!-- 编辑本地主机组弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑本地主机组" width="480px" destroy-on-close>
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="90px">
<el-form-item label="名称" prop="name">
<el-input v-model="editForm.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input v-model="editForm.note" type="textarea" :rows="3" placeholder="备注(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitLoading" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<!-- 绑定弹窗 -->
<el-dialog v-model="bindDialogVisible" title="绑定商品组/商品" width="520px" destroy-on-close>
<el-form ref="bindFormRef" :model="bindForm" label-width="100px">
<el-form-item label="主机组">
<el-input :model-value="bindForm._name" disabled />
</el-form-item>
<el-form-item label="绑定商品组">
<div class="bind-selector-row">
<el-input
:model-value="bindForm.good_group_id ? `商品组 #${bindForm.good_group_id}${bindForm._groupName ? ' - ' + bindForm._groupName : ''}` : '未绑定'"
disabled
style="flex: 1"
/>
<el-button type="primary" @click="showGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="bindForm.good_group_id" @click="clearBindGroup" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="绑定商品">
<div class="bind-selector-row">
<el-input
:model-value="bindForm.good_id ? `商品 #${bindForm.good_id}${bindForm._goodName ? ' - ' + bindForm._goodName : ''}` : '未绑定'"
disabled
style="flex: 1"
/>
<el-button type="primary" @click="showProductSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="bindForm.good_id" @click="clearBindProduct" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-alert type="info" :closable="false" style="margin-bottom: 12px;">
<template #title>
可选择绑定商品组或商品选择其中一个即可点击"清除"按钮可取消对应绑定
</template>
</el-alert>
</el-form>
<template #footer>
<el-button @click="bindDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="bindSubmitLoading" @click="submitBind">绑定</el-button>
</template>
</el-dialog>
<!-- 商品组选择器 -->
<ProductGroupSelector
v-model="showGroupSelector"
:current-group-id="bindForm.good_group_id"
@confirm="handleGroupSelected"
/>
<!-- 商品选择器 -->
<ProductSelector
v-model="showProductSelector"
:current-product-id="bindForm.good_id"
@confirm="handleProductSelected"
/>
<!-- 生成商品弹窗 -->
<el-dialog v-model="generateDialogVisible" title="根据主机组自动生成商品" width="520px" destroy-on-close>
<el-alert type="warning" :closable="false" style="margin-bottom: 16px;">
<template #title>
此操作将根据所选主机组树自动生成 GoodGroup商品分组Goods商品 Args参数请谨慎操作
</template>
</el-alert>
<el-form ref="generateFormRef" :model="generateForm" :rules="generateFormRules" label-width="120px">
<el-form-item label="起始主机组ID" prop="id">
<el-input-number v-model="generateForm.id" :min="1" disabled style="width: 100%" />
</el-form-item>
<el-form-item label="父级GoodGroup">
<el-input-number v-model="generateForm.parent_group_id" :min="0" placeholder="挂载到已有父级(可选)" style="width: 100%" />
</el-form-item>
<el-form-item label="标签ID">
<el-input-number v-model="generateForm.tag_id" :min="0" placeholder="根节点标签ID(可选)" style="width: 100%" />
</el-form-item>
<el-form-item label="Table标识" prop="table">
<el-input v-model="generateForm.table" placeholder="如 kvm_service" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="generateDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="generateSubmitLoading" @click="submitGenerate">确定生成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, RefreshRight, Search, ArrowLeft } from '@element-plus/icons-vue'
import {
getHostGroupList,
syncHostGroup,
bindHostGroup,
updateHostGroup,
generateGoodsByHostGroup,
deleteHostGroup,
getRemoteHostGroupTree
} from '@/api/admin/kvmService'
import ProductGroupSelector from '@/components/admin/ProductGroupSelector.vue'
import ProductSelector from '@/components/admin/ProductSelector.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
const serviceName = computed(() => route.query.service_name || '')
const loading = ref(false)
const syncLoading = ref(false)
const remoteTreeLoading = ref(false)
const hostGroupList = ref([])
const remoteTreeData = ref([])
const selectedGroup = ref(null)
// 规范化后端 PascalCase 字段为前端 camelCase
// 同时保留原始字段以便在需要时直接访问
const normalizeHostGroup = (item) => {
if (!item) return item
return {
...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,
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,
}
}
// ========== 本地主机组列表 ==========
const loadHostGroups = async () => {
if (!serviceId.value) return
loading.value = true
try {
const res = await getHostGroupList({ service_id: serviceId.value })
const body = res?.data
console.debug('[HostGroup] list response body:', JSON.stringify(body))
if (body?.code === 200 && body?.data) {
// data 可能是直接数组,或 { all_count, data: [...] } 格式
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
hostGroupList.value = items.map(normalizeHostGroup)
console.debug('[HostGroup] normalized list:', hostGroupList.value)
} else {
hostGroupList.value = []
if (body?.message) {
ElMessage.warning(body.message)
}
}
} catch (error) {
console.error('获取本地主机组列表失败:', error)
ElMessage.error('获取本地主机组列表失败')
} finally {
loading.value = false
}
}
// ========== 远程主机组树 ==========
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) {
ElMessage.warning('缺少主控服务ID')
return
}
ElMessageBox.confirm('确定要从远程同步主机组数据到本地吗?', '同步确认', {
confirmButtonText: '确定同步',
cancelButtonText: '取消',
type: 'info'
}).then(async () => {
syncLoading.value = true
try {
const res = await syncHostGroup({ service_id: serviceId.value })
const body = res?.data
if (body?.code === 200) {
const synced = body.data
const count = Array.isArray(synced) ? synced.length : 0
ElMessage.success(`同步完成,共同步 ${count} 个主机组`)
} else {
ElMessage.warning(body?.message || '同步返回异常')
}
loadHostGroups()
loadRemoteTree()
} catch (error) {
ElMessage.error('同步失败: ' + (error?.response?.data?.message || error.message))
} 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)
const editFormRef = ref(null)
const editForm = reactive({
id: undefined,
name: '',
note: ''
})
const editFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
}
const handleEditGroup = (row) => {
Object.assign(editForm, {
id: Number(row.Id ?? row.id),
name: row.Name ?? row.name,
note: row.Note ?? row.note ?? ''
})
editDialogVisible.value = true
}
const submitEdit = () => {
editFormRef.value?.validate(async (valid) => {
if (!valid) return
editSubmitLoading.value = true
try {
const res = await updateHostGroup({
id: editForm.id,
name: editForm.name,
note: editForm.note
})
const body = res?.data
if (body?.code === 200) {
ElMessage.success('修改成功')
editDialogVisible.value = false
loadHostGroups()
} else {
ElMessage.error(body?.message || '修改失败')
}
} catch (error) {
ElMessage.error('修改失败: ' + (error?.response?.data?.message || error.message))
} finally {
editSubmitLoading.value = false
}
})
}
// ========== 绑定 ==========
const bindDialogVisible = ref(false)
const bindSubmitLoading = ref(false)
const bindFormRef = ref(null)
const bindForm = reactive({
id: undefined,
_name: '',
_groupName: '',
_goodName: '',
good_group_id: 0,
good_id: 0
})
// 选择器弹窗控制
const showGroupSelector = ref(false)
const showProductSelector = ref(false)
// 商品组选中回调
const handleGroupSelected = (group) => {
bindForm.good_group_id = group.id
bindForm._groupName = group.name || ''
}
// 商品选中回调
const handleProductSelected = (product) => {
bindForm.good_id = product.id
bindForm._goodName = product.name || ''
}
// 清除绑定
const clearBindGroup = () => {
bindForm.good_group_id = 0
bindForm._groupName = ''
}
const clearBindProduct = () => {
bindForm.good_id = 0
bindForm._goodName = ''
}
const handleBind = (row) => {
Object.assign(bindForm, {
id: Number(row.Id ?? row.id),
_name: row.Name ?? row.name,
_groupName: '',
_goodName: '',
good_group_id: row.goodGroupId ?? row.GoodGroupId ?? 0,
good_id: row.goodId ?? row.GoodId ?? 0
})
bindDialogVisible.value = true
}
const submitBind = async () => {
bindSubmitLoading.value = true
try {
const res = await bindHostGroup({
id: bindForm.id,
good_group_id: bindForm.good_group_id,
good_id: bindForm.good_id
})
const body = res?.data
if (body?.code === 200) {
ElMessage.success('绑定成功')
bindDialogVisible.value = false
loadHostGroups()
} else {
ElMessage.error(body?.message || '绑定失败')
}
} catch (error) {
ElMessage.error('绑定失败: ' + (error?.response?.data?.message || error.message))
} finally {
bindSubmitLoading.value = false
}
}
// ========== 生成商品 ==========
const generateDialogVisible = ref(false)
const generateSubmitLoading = ref(false)
const generateFormRef = ref(null)
const generateForm = reactive({
id: undefined,
parent_group_id: 0,
tag_id: 0,
table: 'kvm_service'
})
const generateFormRules = {
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
}
const handleGenerateGoods = (row) => {
Object.assign(generateForm, {
id: Number(row.Id ?? row.id),
parent_group_id: 0,
tag_id: 0,
table: 'kvm_service'
})
generateDialogVisible.value = true
}
const submitGenerate = () => {
generateFormRef.value?.validate(async (valid) => {
if (!valid) return
ElMessageBox.confirm('此操作会自动生成商品分组和商品,确定继续吗?', '确认生成', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
generateSubmitLoading.value = true
try {
const payload = { id: generateForm.id }
if (generateForm.parent_group_id > 0) payload.parent_group_id = generateForm.parent_group_id
if (generateForm.tag_id > 0) payload.tag_id = generateForm.tag_id
if (generateForm.table) payload.table = generateForm.table
const res = await generateGoodsByHostGroup(payload)
const body = res?.data
if (body?.code === 200) {
ElMessage.success('商品生成成功')
generateDialogVisible.value = false
loadHostGroups()
} else {
ElMessage.error(body?.message || '商品生成失败')
}
} catch (error) {
ElMessage.error('生成失败: ' + (error?.response?.data?.message || error.message))
} finally {
generateSubmitLoading.value = false
}
}).catch(() => {})
})
}
// ========== 删除本地主机组 ==========
const handleDeleteGroup = (row) => {
const rawId = Number(row.Id ?? row.id)
if (!rawId) {
ElMessage.error('无法获取主机组ID')
return
}
ElMessageBox.confirm(`确定要删除本地主机组「${row.Name ?? row.name}」吗?删除后不可恢复。`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteHostGroup({ id: rawId })
const body = res?.data
if (body?.code === 200) {
ElMessage.success('删除成功')
loadHostGroups()
} else {
ElMessage.error(body?.message || '删除失败')
}
} catch (error) {
ElMessage.error('删除失败: ' + (error?.response?.data?.message || error.message))
}
}).catch(() => {})
}
// ========== 返回 ==========
const goBack = () => {
router.push('/virtualization/kvm-service')
}
onMounted(() => {
if (serviceId.value) {
loadHostGroups()
loadRemoteTree()
}
})
</script>
<style scoped>
.host-group-mapping-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;
}
/* 布局 */
.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 {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #ebeef5;
background: #fafafa;
}
.panel-header h4 {
margin: 0;
font-size: 15px;
color: #303133;
}
.panel-body {
padding: 16px;
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;
font-size: 12px;
}
/* 绑定选择器行 */
.bind-selector-row {
display: flex;
align-items: center;
width: 100%;
}
:deep(.el-table) {
--el-table-border-color: #ebeef5;
--el-table-header-bg-color: #fafafa;
--el-table-row-hover-bg-color: #f5f7fa;
}
:deep(.el-table th) {
font-weight: 600;
color: #303133;
font-size: 13px;
}
</style>