Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/HostGroupMapping.vue
T
lin b3ed406f84
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m9s
fix: 提交修改
2026-04-15 16:02:36 +08:00

731 lines
25 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-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="handleRefresh">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<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 class="main-panel">
<div class="panel-header">
<h4>本地主机组列表</h4>
</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-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>
<!-- 编辑本地主机组弹窗 -->
<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 } 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 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: 10, 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 hostGroupList = 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) => {
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 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 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()
} catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
} finally {
syncLoading.value = false
}
}).catch(() => {})
}
// ========== 编辑 ==========
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(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 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(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 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) => {
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
loadHostGroups()
} else {
ElMessage.error(extractApiError(body, '商品生成失败'))
}
} catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '生成失败'))
} 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(extractApiError(body, '删除失败'))
}
} catch (error) {
ElMessage.error(extractApiError(error?.response?.data, '删除失败'))
}
}).catch(() => {})
}
// ========== 返回 ==========
const goBack = () => {
router.push('/virtualization/kvm-service')
}
onMounted(() => {
if (!embedded) loadServiceOptions()
if (serviceId.value) {
loadHostGroups()
}
})
</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: 300px;
}
</style>