@@ -8,30 +8,24 @@
< / div >
< / div >
< div class = "header-right" >
< el-select v-model = "selectedServiceId" placeholder="选择主控服务" filterable style="width: 240px" @change="handleServiceCha nge ">
< 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-button @click ="loadAllServices" :loading = "servicesLoadi ng">
< 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 >
< 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 >
<!-- 本地主机组列表 ( 树形折叠 ) -- >
< div class = "main-panel" >
<!-- embedded 模式 : 直接展示单个主控的主机组 -- >
< div v-if = "embedded" class=" main-panel" >
< div class = "panel-header" >
< h4 > 本地主机组列表 < / h4 >
< / div >
< div class = "panel-body" v-loading = "loading " >
< el-table :data = "t reeGroupList" stripe style = "width: 100%" row -key = " _rowKey "
: tree - props = "{ children: '_children', hasChildren: '_hasChildren' }" >
< div class = "panel-body" v-loading = "hostGroupLoadingMap[embeddedServiceId] " >
< el-table :data = "getT reeGroupList(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" >
@@ -60,13 +54,82 @@
< 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 >
< 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 " >
@@ -215,7 +278,7 @@
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 { Plus , Refresh , RefreshRight , Search , ArrowLeft , ArrowRight } from '@element-plus/icons-vue'
import {
getHostGroupList ,
syncHostGroup ,
@@ -237,15 +300,12 @@ 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 embeddedServiceId = computed ( ( ) => injectedServiceId ? . value || 0 )
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 serviceList = ref ( [ ] )
const servicesLoading = ref ( false )
const expandedServiceIds = reactive ( new Set ( ) )
const normalizeService = ( s ) => ( {
id : s . Id ? ? s . id ,
@@ -255,59 +315,29 @@ const normalizeService = (s) => ({
note : s . Note ? ? s . note
} )
const loadServiceOptions = async ( ) => {
const loadAllServices = async ( ) => {
servicesLoading . value = true
try {
const res = await getKvmServiceList ( { page : 1 , count : 10 , key : '' } )
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 )
serviceList . value = raw . map ( normalizeService )
serviceList . value . forEach ( s => {
expandedServiceIds . add ( s . id )
loadHostGroupsForService ( s . id )
} )
}
} catch { /* */ }
}
const handleServiceChange = ( ) => {
hostGroupList . value = [ ]
if ( serviceId . value ) {
loadHostGroups ( )
} catch { /* */ } finally {
servicesLoading . value = false
}
}
const handleRefresh = ( ) => {
if ( ! serviceId . value ) {
ElMessage . warning ( '请先选择主控服务' )
return
}
loadHostGroups ( )
}
/ / = = = = = = = = = = = = = = = = = = = = 每 个 主 控 下 的 主 机 组 数 据 = = = = = = = = = = = = = = = = = = = =
const hostGroupDataMap = reactive ( { } )
const hostGroupLoadingMap = reactive ( { } )
const syncLoadingMap = reactive ( { } )
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
} )
/ / 规 范 化 后 端 P a s c a l C a s e 字 段 为 前 端 c a m e l C a s e
/ / 同 时 保 留 原 始 字 段 以 便 在 需 要 时 直 接 访 问
const normalizeHostGroup = ( item ) => {
if ( ! item ) return item
return {
@@ -325,36 +355,65 @@ const normalizeHostGroup = (item) => {
}
}
/ / = = = = = = = = = = 本 地 主 机 组 列 表 = = = = = = = = = =
const loadHostGroups = async ( ) => {
if ( ! serviceId . value ) return
loading . value = true
const loadHostGroupsForService = async ( svcId ) => {
if ( ! svcId ) return
hostGroupLoadingMap [ svcId ] = true
try {
const res = await getHostGroupList ( { service _id : serviceId . value } )
const res = await getHostGroupList ( { service _id : svcId } )
const body = res ? . data
console . debug ( '[HostGroup] list response body:' , JSON . stringify ( body ) )
if ( body ? . code === 200 && body ? . data ) {
/ / d a t a 可 能 是 直 接 数 组 , 或 { a l l _ c o u n t , d a t a : [ . . . ] } 格 式
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 )
hostGroupDataMap [ svcId ] = items . map ( normalizeHostGroup )
} else {
hostGroupList . value = [ ]
if ( body ? . message ) {
ElMessage . warning ( body . message )
}
hostGroupDataMap [ svcId ] = [ ]
if ( body ? . message ) ElMessage . warning ( body . message )
}
} catch ( error ) {
console . error ( '获取本地主机组列表失败:' , error )
ElMessage . error ( '获取本地主机组列表失败' )
hostGroupDataMap [ svcId ] = [ ]
} finally {
loading . value = false
hostGroupLoadingMap [ svcId ] = false
}
}
/ / = = = = = = = = = = 同 步 = = = = = = = = = =
const handleSync = async ( ) => {
if ( ! serviceId . value ) {
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
}
@@ -363,9 +422,9 @@ const handleSync = async () => {
cancelButtonText : '取消' ,
type : 'info'
} ) . then ( async ( ) => {
syncLoading . value = true
syncLoadingMap [ svcId ] = true
try {
const res = await syncHostGroup ( { service _id : serviceId . value } )
const res = await syncHostGroup ( { service _id : svcId } )
const body = res ? . data
if ( body ? . code === 200 ) {
const synced = body . data
@@ -374,19 +433,20 @@ const handleSync = async () => {
} else {
ElMessage . warning ( body ? . message || '同步返回异常' )
}
loadHostGroups ( )
loadHostGroupsForService ( svcId )
} catch ( error ) {
ElMessage . error ( extractApiError ( error ? . response ? . data , '同步失败' ) )
} finally {
syncLoading . value = false
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 ,
@@ -399,6 +459,7 @@ const editFormRules = {
}
const handleEditGroup = ( row ) => {
editingServiceId . value = row . serviceId
Object . assign ( editForm , {
id : Number ( row . Id ? ? row . id ) ,
name : row . Name ? ? row . name ,
@@ -421,7 +482,7 @@ const submitEdit = () => {
if ( body ? . code === 200 ) {
ElMessage . success ( '修改成功' )
editDialogVisible . value = false
loadHostGroups ( )
if ( editingServiceId . value ) loadHostGroupsForService ( editingServiceId . value )
} else {
ElMessage . error ( extractApiError ( body , '修改失败' ) )
}
@@ -433,10 +494,11 @@ const submitEdit = () => {
} )
}
/ / = = = = = = = = = = 绑 定 = = = = = = = = = =
/ / = = = = = = = = = = = = = = = = = = = = 绑 定 = = = = = = = = = = = = = = = = = = = =
const bindDialogVisible = ref ( false )
const bindSubmitLoading = ref ( false )
const bindFormRef = ref ( null )
const bindingServiceId = ref ( null )
const bindForm = reactive ( {
id : undefined ,
@@ -447,23 +509,19 @@ const bindForm = reactive({
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 = ''
@@ -475,6 +533,7 @@ const clearBindProduct = () => {
}
const handleBind = ( row ) => {
bindingServiceId . value = row . serviceId
Object . assign ( bindForm , {
id : Number ( row . Id ? ? row . id ) ,
_name : row . Name ? ? row . name ,
@@ -498,7 +557,7 @@ const submitBind = async () => {
if ( body ? . code === 200 ) {
ElMessage . success ( '绑定成功' )
bindDialogVisible . value = false
loadHostGroups ( )
if ( bindingServiceId . value ) loadHostGroupsForService ( bindingServiceId . value )
} else {
ElMessage . error ( extractApiError ( body , '绑定失败' ) )
}
@@ -509,10 +568,11 @@ const submitBind = async () => {
}
}
/ / = = = = = = = = = = 生 成 商 品 = = = = = = = = = =
/ / = = = = = = = = = = = = = = = = = = = = 生 成 商 品 = = = = = = = = = = = = = = = = = = = =
const generateDialogVisible = ref ( false )
const generateSubmitLoading = ref ( false )
const generateFormRef = ref ( null )
const generatingServiceId = ref ( null )
const generateForm = reactive ( {
id : undefined ,
@@ -527,10 +587,8 @@ const generateFormRules = {
id : [ { required : true , message : '主机组ID不能为空' , trigger : 'blur' } ]
}
/ / 父 级 商 品 组 选 择 器
const showGenerateGroupSelector = ref ( false )
/ / 标 签 选 择 器
const showGenerateTagSelector = ref ( false )
const tagOptions = ref ( [ ] )
const tagLoading = ref ( false )
@@ -567,10 +625,10 @@ const fetchTagOptions = async () => {
} 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 ,
@@ -603,7 +661,7 @@ const submitGenerate = () => {
if ( body ? . code === 200 ) {
ElMessage . success ( '商品生成成功' )
generateDialogVisible . value = false
loadHostGroups ( )
if ( generatingServiceId . value ) loadHostGroupsForService ( generatingServiceId . value )
} else {
ElMessage . error ( extractApiError ( body , '商品生成失败' ) )
}
@@ -616,8 +674,8 @@ const submitGenerate = () => {
} )
}
/ / = = = = = = = = = = 删 除 本 地 主 机 组 = = = = = = = = = =
const handleDeleteGroup = ( row ) => {
/ / = = = = = = = = = = = = = = = = = = = = 删 除 本 地 主 机 组 = = = = = = = = = = = = = = = = = = = =
const handleDeleteGroup = ( row , svcId ) => {
const rawId = Number ( row . Id ? ? row . id )
if ( ! rawId ) {
ElMessage . error ( '无法获取主机组ID' )
@@ -633,7 +691,7 @@ const handleDeleteGroup = (row) => {
const body = res ? . data
if ( body ? . code === 200 ) {
ElMessage . success ( '删除成功' )
loadHostGroups ( )
if ( svcId ) loadHostGroupsForService ( svcId )
} else {
ElMessage . error ( extractApiError ( body , '删除失败' ) )
}
@@ -643,15 +701,16 @@ const handleDeleteGroup = (row) => {
} ) . catch ( ( ) => { } )
}
/ / = = = = = = = = = = 返 回 = = = = = = = = = =
/ / = = = = = = = = = = = = = = = = = = = = 返 回 = = = = = = = = = = = = = = = = = = = =
const goBack = ( ) => {
router . push ( '/virtualization/kvm-service' )
}
onMounted ( ( ) => {
if ( ! embedded ) loadServiceOptions ( )
if ( serviceId . value ) {
loadHostGroups ( )
if ( ! embedded ) {
loadAllServices ( )
} else if ( embeddedServiceId . value ) {
loadHostGroupsForService ( embeddedServiceId . value )
}
} )
< / script >
@@ -723,8 +782,76 @@ onMounted(() => {
. panel - body {
padding : 16 px ;
min - height : 300 px ;
min - height : 200 px ;
}
/* 主控服务卡片 */
. service - card {
background : # fff ;
border - radius : 8 px ;
box - shadow : 0 1 px 4 px rgba ( 0 , 0 , 0 , 0.08 ) ;
margin - bottom : 12 px ;
overflow : hidden ;
border : 1 px solid # ebeef5 ;
transition : border - color 0.2 s ;
}
. service - card : hover {
border - color : # c0c4cc ;
}
. service - card - header {
display : flex ;
justify - content : space - between ;
align - items : center ;
padding : 14 px 20 px ;
cursor : pointer ;
user - select : none ;
background : # fafbfc ;
transition : background 0.2 s ;
}
. service - card - header : hover {
background : # f0f2f5 ;
}
. service - card - title {
display : flex ;
align - items : center ;
gap : 10 px ;
flex : 1 ;
min - width : 0 ;
}
. expand - icon {
font - size : 14 px ;
color : # 909399 ;
transition : transform 0.3 s ;
flex - shrink : 0 ;
}
. expand - icon . is - expanded {
transform : rotate ( 90 deg ) ;
}
. service - name {
font - size : 15 px ;
font - weight : 600 ;
color : # 303133 ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. service - card - actions {
display : flex ;
gap : 6 px ;
flex - shrink : 0 ;
}
. service - card - body {
border - top : 1 px solid # ebeef5 ;
}
. host - group - table - wrapper {
padding : 16 px ;
min - height : 100 px ;
}
< / style >