Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/HostGroupMapping.vue
T
shiran 765f925482
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 30s
feat(admin): 主机组映射展示全部主控并展开加载主机组
主机组映射页改为卡片列表展示所有主控服务,展开后按需请求主机组;套餐管理增加必填参数未配置提醒。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 18:31:56 +08:00

858 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="host-group-mapping-container">
<!-- 顶部信息 -->
<div class="page-header" v-if="!embedded">
<div class="header-left">
<div class="header-info">
<h3>宿主机组映射管理</h3>
</div>
</div>
<div class="header-right">
<el-button @click="loadAllServices" :loading="servicesLoading">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleSync(embeddedServiceId)" :loading="syncLoadingMap[embeddedServiceId]"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
<el-button @click="loadHostGroupsForService(embeddedServiceId)"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<!-- embedded 模式直接展示单个主控的主机组 -->
<div v-if="embedded" class="main-panel">
<div class="panel-header">
<h4>本地主机组列表</h4>
</div>
<div class="panel-body" v-loading="hostGroupLoadingMap[embeddedServiceId]">
<el-table :data="getTreeGroupList(embeddedServiceId)" stripe style="width: 100%" row-key="_rowKey"
default-expand-all :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-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, embeddedServiceId)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- embedded 模式展示所有主控服务每个可折叠展开 -->
<div v-if="!embedded" v-loading="servicesLoading">
<el-empty v-if="!servicesLoading && serviceList.length === 0" description="暂无主控服务" :image-size="100" />
<div v-for="service in serviceList" :key="service.id" class="service-card">
<div class="service-card-header" @click="toggleService(service)">
<div class="service-card-title">
<el-icon class="expand-icon" :class="{ 'is-expanded': expandedServiceIds.has(service.id) }"><ArrowRight /></el-icon>
<span class="service-name">{{ service.name }}</span>
<el-tag size="small" type="info">ID: {{ service.id }}</el-tag>
<el-tag v-if="service.host" size="small">{{ service.host }}{{ service.port ? ':' + service.port : '' }}</el-tag>
</div>
<div class="service-card-actions" @click.stop>
<el-button type="primary" size="small" @click="handleSync(service.id)" :loading="syncLoadingMap[service.id]">
<el-icon><RefreshRight /></el-icon>同步
</el-button>
<el-button size="small" @click="loadHostGroupsForService(service.id)">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<el-collapse-transition>
<div v-show="expandedServiceIds.has(service.id)" class="service-card-body">
<div v-loading="hostGroupLoadingMap[service.id]" class="host-group-table-wrapper">
<el-table
v-if="hostGroupDataMap[service.id]?.length > 0"
:data="getTreeGroupList(service.id)"
stripe style="width: 100%" row-key="_rowKey"
default-expand-all
: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-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, service.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else-if="!hostGroupLoadingMap[service.id]" description="暂无主机组数据,请先从远程同步" :image-size="60" />
</div>
</div>
</el-collapse-transition>
</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" :disabled="!!bindForm.good_id">选择</el-button>
<el-button v-if="bindForm.good_group_id" @click="clearBindGroup" style="margin-left: 4px">清除</el-button>
</div>
<div v-if="bindForm.good_id" style="font-size: 12px; color: #e6a23c; margin-top: 4px">已绑定商品请先清除商品后再绑定商品组</div>
</el-form-item>
<el-form-item label="绑定商品">
<div class="bind-selector-row">
<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" :disabled="!!bindForm.good_group_id">选择</el-button>
<el-button v-if="bindForm.good_id" @click="clearBindProduct" style="margin-left: 4px">清除</el-button>
</div>
<div v-if="bindForm.good_group_id" style="font-size: 12px; color: #e6a23c; margin-top: 4px">已绑定商品组请先清除商品组后再绑定商品</div>
</el-form-item>
<el-alert type="info" :closable="false" style="margin-bottom: 12px;">
<template #title>
可选择绑定商品组或商品选择其中一个即可点击"清除"按钮可取消对应绑定
</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="showGenerateGroupSelector"
:current-group-id="generateForm.parent_group_id"
@confirm="g => { generateForm.parent_group_id = g.id; generateForm._parentGroupName = g.name }"
/>
<!-- 生成商品 - 标签选择器 -->
<el-dialog v-model="showGenerateTagSelector" title="选择标签" width="560px" append-to-body destroy-on-close>
<div style="margin-bottom: 12px; display: flex; gap: 8px">
<el-input v-model="tagKeyword" placeholder="搜索标签名称" clearable style="width: 220px" @input="filterTags">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button :icon="Refresh" @click="() => { tagOptions.value = []; fetchTagOptions() }" :loading="tagLoading">刷新</el-button>
</div>
<el-table :data="filteredTagOptions" v-loading="tagLoading" highlight-current-row
@current-change="row => selectedTagRow = row" :height="300" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
</el-table>
<el-empty v-if="!filteredTagOptions.length && !tagLoading" description="暂无标签" :image-size="60" />
<template #footer>
<el-button @click="showGenerateTagSelector = false">取消</el-button>
<el-button type="primary" :disabled="!selectedTagRow" @click="confirmTagSelect">确定选择</el-button>
</template>
</el-dialog>
<!-- 绑定弹窗用商品组选择器 -->
<ProductGroupSelector
v-model="showGroupSelector"
:current-group-id="bindForm.good_group_id"
@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 :model-value="generateForm.id" disabled style="width: 100%" />
</el-form-item>
<el-form-item label="父级GoodGroup">
<div class="bind-selector-row">
<el-input
:model-value="generateForm.parent_group_id ? `商品组 #${generateForm.parent_group_id}${generateForm._parentGroupName ? ' - ' + generateForm._parentGroupName : ''}` : '不挂载父级'"
disabled
style="flex: 1"
/>
<el-button type="primary" @click="showGenerateGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="generateForm.parent_group_id" @click="generateForm.parent_group_id = 0; generateForm._parentGroupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="标签">
<div class="bind-selector-row">
<el-input
:model-value="generateForm.tag_id ? `标签 #${generateForm.tag_id}${generateForm._tagName ? ' - ' + generateForm._tagName : ''}` : '不设置标签'"
disabled
style="flex: 1"
/>
<el-button type="primary" @click="showGenerateTagSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="generateForm.tag_id" @click="generateForm.tag_id = 0; generateForm._tagName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="Table标识" prop="table">
<el-input v-model="generateForm.table" placeholder=" kvm_service" />
</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, inject, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, RefreshRight, Search, ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import {
getHostGroupList,
syncHostGroup,
bindHostGroup,
updateHostGroup,
generateGoodsByHostGroup,
deleteHostGroup,
getKvmServiceList
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { getProductGroupTagList } from '@/api/admin/product'
import ProductGroupSelector from '@/components/admin/ProductGroupSelector.vue'
import ProductSelector from '@/components/admin/ProductSelector.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const embeddedServiceId = computed(() => injectedServiceId?.value || 0)
// ==================== 主控服务列表 ====================
const serviceList = ref([])
const servicesLoading = ref(false)
const expandedServiceIds = reactive(new Set())
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 loadAllServices = async () => {
servicesLoading.value = true
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 : [])
serviceList.value = raw.map(normalizeService)
serviceList.value.forEach(s => {
expandedServiceIds.add(s.id)
loadHostGroupsForService(s.id)
})
}
} catch { /* */ } finally {
servicesLoading.value = false
}
}
// ==================== 每个主控下的主机组数据 ====================
const hostGroupDataMap = reactive({})
const hostGroupLoadingMap = reactive({})
const syncLoadingMap = reactive({})
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.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.createdAt ?? item.created_at,
UpdatedAt: item.UpdatedAt ?? item.updatedAt ?? item.updated_at,
}
}
const loadHostGroupsForService = async (svcId) => {
if (!svcId) return
hostGroupLoadingMap[svcId] = true
try {
const res = await getHostGroupList({ service_id: svcId })
const body = res?.data
if (body?.code === 200 && body?.data) {
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
hostGroupDataMap[svcId] = items.map(normalizeHostGroup)
} else {
hostGroupDataMap[svcId] = []
if (body?.message) ElMessage.warning(body.message)
}
} catch (error) {
console.error('获取本地主机组列表失败:', error)
ElMessage.error('获取本地主机组列表失败')
hostGroupDataMap[svcId] = []
} finally {
hostGroupLoadingMap[svcId] = false
}
}
const buildTree = (items) => {
if (!items || !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
}
const getTreeGroupList = (svcId) => {
return buildTree(hostGroupDataMap[svcId] || [])
}
const toggleService = (service) => {
if (expandedServiceIds.has(service.id)) {
expandedServiceIds.delete(service.id)
} else {
expandedServiceIds.add(service.id)
if (!hostGroupDataMap[service.id]) {
loadHostGroupsForService(service.id)
}
}
}
// ==================== 同步 ====================
const handleSync = async (svcId) => {
if (!svcId) {
ElMessage.warning('缺少主控服务ID')
return
}
ElMessageBox.confirm('确定要从远程同步主机组数据到本地吗?', '同步确认', {
confirmButtonText: '确定同步',
cancelButtonText: '取消',
type: 'info'
}).then(async () => {
syncLoadingMap[svcId] = true
try {
const res = await syncHostGroup({ service_id: svcId })
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 || '同步返回异常')
}
loadHostGroupsForService(svcId)
} catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
} finally {
syncLoadingMap[svcId] = false
}
}).catch(() => {})
}
// ==================== 编辑 ====================
const editDialogVisible = ref(false)
const editSubmitLoading = ref(false)
const editFormRef = ref(null)
const editingServiceId = ref(null)
const editForm = reactive({
id: undefined,
name: '',
note: ''
})
const editFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
}
const handleEditGroup = (row) => {
editingServiceId.value = row.serviceId
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
if (editingServiceId.value) loadHostGroupsForService(editingServiceId.value)
} else {
ElMessage.error(extractApiError(body, '修改失败'))
}
} catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '修改失败'))
} finally {
editSubmitLoading.value = false
}
})
}
// ==================== 绑定 ====================
const bindDialogVisible = ref(false)
const bindSubmitLoading = ref(false)
const bindFormRef = ref(null)
const bindingServiceId = 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) => {
bindingServiceId.value = row.serviceId
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
if (bindingServiceId.value) loadHostGroupsForService(bindingServiceId.value)
} else {
ElMessage.error(extractApiError(body, '绑定失败'))
}
} catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '绑定失败'))
} finally {
bindSubmitLoading.value = false
}
}
// ==================== 生成商品 ====================
const generateDialogVisible = ref(false)
const generateSubmitLoading = ref(false)
const generateFormRef = ref(null)
const generatingServiceId = ref(null)
const generateForm = reactive({
id: undefined,
parent_group_id: 0,
_parentGroupName: '',
tag_id: 0,
_tagName: '',
table: 'kvm_service'
})
const generateFormRules = {
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
}
const showGenerateGroupSelector = ref(false)
const showGenerateTagSelector = ref(false)
const tagOptions = ref([])
const tagLoading = ref(false)
const tagKeyword = ref('')
const selectedTagRow = ref(null)
const filteredTagOptions = computed(() =>
tagKeyword.value
? tagOptions.value.filter(t => t.name?.includes(tagKeyword.value))
: tagOptions.value
)
const filterTags = () => { /* computed 自动响应 */ }
const confirmTagSelect = () => {
if (!selectedTagRow.value) return
generateForm.tag_id = selectedTagRow.value.id
generateForm._tagName = selectedTagRow.value.name
showGenerateTagSelector.value = false
selectedTagRow.value = null
tagKeyword.value = ''
}
const loadTagOptions = async () => {
if (tagOptions.value.length) return
await fetchTagOptions()
}
const fetchTagOptions = async () => {
tagLoading.value = true
try {
const res = await getProductGroupTagList()
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
tagOptions.value = Array.isArray(inner) ? inner : (inner.data || inner.list || [])
}
} catch { /* */ } finally { tagLoading.value = false }
}
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
const handleGenerateGoods = (row) => {
generatingServiceId.value = row.serviceId
Object.assign(generateForm, {
id: Number(row.Id ?? row.id),
parent_group_id: 0,
_parentGroupName: '',
tag_id: 0,
_tagName: '',
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) payload.parent_group_id = generateForm.parent_group_id
if (generateForm.tag_id) payload.tag_id = generateForm.tag_id
if (generateForm.table) payload.table = generateForm.table
const res = await generateGoodsByHostGroup(payload)
const body = res?.data
if (body?.code === 200) {
ElMessage.success('商品生成成功')
generateDialogVisible.value = false
if (generatingServiceId.value) loadHostGroupsForService(generatingServiceId.value)
} else {
ElMessage.error(extractApiError(body, '商品生成失败'))
}
} catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '生成失败'))
} finally {
generateSubmitLoading.value = false
}
}).catch(() => {})
})
}
// ==================== 删除本地主机组 ====================
const handleDeleteGroup = (row, svcId) => {
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('删除成功')
if (svcId) loadHostGroupsForService(svcId)
} else {
ElMessage.error(extractApiError(body, '删除失败'))
}
} catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '删除失败'))
}
}).catch(() => {})
}
// ==================== 返回 ====================
const goBack = () => {
router.push('/virtualization/kvm-service')
}
onMounted(() => {
if (!embedded) {
loadAllServices()
} else if (embeddedServiceId.value) {
loadHostGroupsForService(embeddedServiceId.value)
}
})
</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;
}
.embedded-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.main-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: 200px;
}
/* 主控服务卡片 */
.service-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
margin-bottom: 12px;
overflow: hidden;
border: 1px solid #ebeef5;
transition: border-color 0.2s;
}
.service-card:hover {
border-color: #c0c4cc;
}
.service-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
cursor: pointer;
user-select: none;
background: #fafbfc;
transition: background 0.2s;
}
.service-card-header:hover {
background: #f0f2f5;
}
.service-card-title {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.expand-icon {
font-size: 14px;
color: #909399;
transition: transform 0.3s;
flex-shrink: 0;
}
.expand-icon.is-expanded {
transform: rotate(90deg);
}
.service-name {
font-size: 15px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-card-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.service-card-body {
border-top: 1px solid #ebeef5;
}
.host-group-table-wrapper {
padding: 16px;
min-height: 100px;
}
</style>