feat(admin): 主机组映射展示全部主控并展开加载主机组
主机组映射页改为卡片列表展示所有主控服务,展开后按需请求主机组;套餐管理增加必填参数未配置提醒。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -162,6 +162,17 @@
|
|||||||
<el-form-item label="说明" prop="note">
|
<el-form-item label="说明" prop="note">
|
||||||
<el-input v-model="planForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" />
|
<el-input v-model="planForm.note" type="textarea" :rows="2" placeholder="请输入套餐说明" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<div v-if="missingMustSpecs.length > 0" class="must-params-alert">
|
||||||
|
<div class="must-params-alert-header">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
<span>以下必填参数尚未配置,请将其加入「参数配置」或「额外参数」中:</span>
|
||||||
|
</div>
|
||||||
|
<div class="must-params-alert-tags">
|
||||||
|
<el-tag v-for="spec in missingMustSpecs" :key="spec.id" type="danger" size="small" effect="plain">
|
||||||
|
<span class="must-star">*</span>{{ spec.name }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<el-form-item label="参数配置" prop="args">
|
<el-form-item label="参数配置" prop="args">
|
||||||
<div class="args-config-container">
|
<div class="args-config-container">
|
||||||
<div class="args-select-row">
|
<div class="args-select-row">
|
||||||
@@ -385,6 +396,10 @@ const selectedExtraArgIds = ref([])
|
|||||||
|
|
||||||
const selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id)))
|
const selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id)))
|
||||||
const extraSpecList = computed(() => planSpecList.value.filter(spec => !selectedArgIds.value.includes(spec.id)))
|
const extraSpecList = computed(() => planSpecList.value.filter(spec => !selectedArgIds.value.includes(spec.id)))
|
||||||
|
const missingMustSpecs = computed(() => {
|
||||||
|
const allSelectedIds = [...selectedArgIds.value, ...selectedExtraArgIds.value]
|
||||||
|
return planSpecList.value.filter(s => s.must && !allSelectedIds.includes(s.id))
|
||||||
|
})
|
||||||
|
|
||||||
const getSpecDisplayMin = (spec) => {
|
const getSpecDisplayMin = (spec) => {
|
||||||
if (!hasUnit(spec)) return spec.min ?? 0
|
if (!hasUnit(spec)) return spec.min ?? 0
|
||||||
@@ -1184,6 +1199,26 @@ watch(() => props.visible, (val) => {
|
|||||||
.plan-card-actions .el-button + .el-button {
|
.plan-card-actions .el-button + .el-button {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
.must-params-alert {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: linear-gradient(135deg, #fff5f5 0%, #fff1f0 100%);
|
||||||
|
border: 1px solid #fcdcdc;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid #f56c6c;
|
||||||
|
}
|
||||||
|
.must-params-alert-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #f56c6c;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.must-params-alert-header .el-icon { font-size: 16px; flex-shrink: 0; }
|
||||||
|
.must-params-alert-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.must-params-alert-tags .must-star { color: #f56c6c; font-weight: 700; margin-right: 2px; }
|
||||||
.plan-form-content { max-height: 60vh; overflow-y: auto; padding-right: 8px; margin-right: -8px; }
|
.plan-form-content { max-height: 60vh; overflow-y: auto; padding-right: 8px; margin-right: -8px; }
|
||||||
.plan-form-content::-webkit-scrollbar { width: 6px; }
|
.plan-form-content::-webkit-scrollbar { width: 6px; }
|
||||||
.plan-form-content::-webkit-scrollbar-track { background: transparent; }
|
.plan-form-content::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|||||||
@@ -8,30 +8,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<el-select v-model="selectedServiceId" placeholder="选择主控服务" filterable style="width: 240px" @change="handleServiceChange">
|
<el-button @click="loadAllServices" :loading="servicesLoading">
|
||||||
<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-icon><Refresh /></el-icon>刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="embedded-toolbar" v-if="embedded">
|
<div class="embedded-toolbar" v-if="embedded">
|
||||||
<el-button type="primary" @click="handleSync" :loading="syncLoading"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
|
<el-button type="primary" @click="handleSync(embeddedServiceId)" :loading="syncLoadingMap[embeddedServiceId]"><el-icon><RefreshRight /></el-icon>从远程同步</el-button>
|
||||||
<el-button @click="loadHostGroups"><el-icon><Refresh /></el-icon>刷新</el-button>
|
<el-button @click="loadHostGroupsForService(embeddedServiceId)"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 本地主机组列表(树形折叠) -->
|
<!-- embedded 模式:直接展示单个主控的主机组 -->
|
||||||
<div class="main-panel">
|
<div v-if="embedded" class="main-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h4>本地主机组列表</h4>
|
<h4>本地主机组列表</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body" v-loading="loading">
|
<div class="panel-body" v-loading="hostGroupLoadingMap[embeddedServiceId]">
|
||||||
<el-table :data="treeGroupList" stripe style="width: 100%" row-key="_rowKey"
|
<el-table :data="getTreeGroupList(embeddedServiceId)" stripe style="width: 100%" row-key="_rowKey"
|
||||||
:tree-props="{ children: '_children', hasChildren: '_hasChildren' }">
|
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="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
<el-table-column label="远程ID" width="80">
|
<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="handleEditGroup(row)">编辑</el-button>
|
||||||
<el-button link type="primary" size="small" @click.stop="handleBind(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="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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</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-dialog v-model="editDialogVisible" title="编辑本地主机组" width="480px" destroy-on-close>
|
||||||
<el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="90px">
|
<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 { ref, reactive, computed, inject, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 {
|
import {
|
||||||
getHostGroupList,
|
getHostGroupList,
|
||||||
syncHostGroup,
|
syncHostGroup,
|
||||||
@@ -237,15 +300,12 @@ const embedded = inject('embedded', false)
|
|||||||
const injectedServiceId = inject('serviceId', null)
|
const injectedServiceId = inject('serviceId', null)
|
||||||
const injectedServiceName = inject('serviceName', null)
|
const injectedServiceName = inject('serviceName', null)
|
||||||
|
|
||||||
const selectedServiceId = ref(parseInt(route.query.service_id) || null)
|
const embeddedServiceId = computed(() => injectedServiceId?.value || 0)
|
||||||
const serviceOptions = ref([])
|
|
||||||
|
|
||||||
const serviceId = computed(() => injectedServiceId?.value || selectedServiceId.value || 0)
|
// ==================== 主控服务列表 ====================
|
||||||
const serviceName = computed(() => {
|
const serviceList = ref([])
|
||||||
if (injectedServiceName?.value) return injectedServiceName.value
|
const servicesLoading = ref(false)
|
||||||
const s = serviceOptions.value.find(x => x.id === selectedServiceId.value)
|
const expandedServiceIds = reactive(new Set())
|
||||||
return s?.name || route.query.service_name || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const normalizeService = (s) => ({
|
const normalizeService = (s) => ({
|
||||||
id: s.Id ?? s.id,
|
id: s.Id ?? s.id,
|
||||||
@@ -255,59 +315,29 @@ const normalizeService = (s) => ({
|
|||||||
note: s.Note ?? s.note
|
note: s.Note ?? s.note
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadServiceOptions = async () => {
|
const loadAllServices = async () => {
|
||||||
|
servicesLoading.value = true
|
||||||
try {
|
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) {
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
const inner = res.data.data
|
const inner = res.data.data
|
||||||
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
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 { /* */ }
|
} catch { /* */ } finally {
|
||||||
}
|
servicesLoading.value = false
|
||||||
|
|
||||||
const handleServiceChange = () => {
|
|
||||||
hostGroupList.value = []
|
|
||||||
if (serviceId.value) {
|
|
||||||
loadHostGroups()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefresh = () => {
|
// ==================== 每个主控下的主机组数据 ====================
|
||||||
if (!serviceId.value) {
|
const hostGroupDataMap = reactive({})
|
||||||
ElMessage.warning('请先选择主控服务')
|
const hostGroupLoadingMap = reactive({})
|
||||||
return
|
const syncLoadingMap = reactive({})
|
||||||
}
|
|
||||||
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) => {
|
const normalizeHostGroup = (item) => {
|
||||||
if (!item) return item
|
if (!item) return item
|
||||||
return {
|
return {
|
||||||
@@ -325,36 +355,65 @@ const normalizeHostGroup = (item) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 本地主机组列表 ==========
|
const loadHostGroupsForService = async (svcId) => {
|
||||||
const loadHostGroups = async () => {
|
if (!svcId) return
|
||||||
if (!serviceId.value) return
|
hostGroupLoadingMap[svcId] = true
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const res = await getHostGroupList({ service_id: serviceId.value })
|
const res = await getHostGroupList({ service_id: svcId })
|
||||||
const body = res?.data
|
const body = res?.data
|
||||||
console.debug('[HostGroup] list response body:', JSON.stringify(body))
|
|
||||||
if (body?.code === 200 && body?.data) {
|
if (body?.code === 200 && body?.data) {
|
||||||
// data 可能是直接数组,或 { all_count, data: [...] } 格式
|
|
||||||
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
|
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
|
||||||
hostGroupList.value = items.map(normalizeHostGroup)
|
hostGroupDataMap[svcId] = items.map(normalizeHostGroup)
|
||||||
console.debug('[HostGroup] normalized list:', hostGroupList.value)
|
|
||||||
} else {
|
} else {
|
||||||
hostGroupList.value = []
|
hostGroupDataMap[svcId] = []
|
||||||
if (body?.message) {
|
if (body?.message) ElMessage.warning(body.message)
|
||||||
ElMessage.warning(body.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取本地主机组列表失败:', error)
|
console.error('获取本地主机组列表失败:', error)
|
||||||
ElMessage.error('获取本地主机组列表失败')
|
ElMessage.error('获取本地主机组列表失败')
|
||||||
|
hostGroupDataMap[svcId] = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
hostGroupLoadingMap[svcId] = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 同步 ==========
|
const buildTree = (items) => {
|
||||||
const handleSync = async () => {
|
if (!items || !items.length) return []
|
||||||
if (!serviceId.value) {
|
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')
|
ElMessage.warning('缺少主控服务ID')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -363,9 +422,9 @@ const handleSync = async () => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'info'
|
type: 'info'
|
||||||
}).then(async () => {
|
}).then(async () => {
|
||||||
syncLoading.value = true
|
syncLoadingMap[svcId] = true
|
||||||
try {
|
try {
|
||||||
const res = await syncHostGroup({ service_id: serviceId.value })
|
const res = await syncHostGroup({ service_id: svcId })
|
||||||
const body = res?.data
|
const body = res?.data
|
||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
const synced = body.data
|
const synced = body.data
|
||||||
@@ -374,19 +433,20 @@ const handleSync = async () => {
|
|||||||
} else {
|
} else {
|
||||||
ElMessage.warning(body?.message || '同步返回异常')
|
ElMessage.warning(body?.message || '同步返回异常')
|
||||||
}
|
}
|
||||||
loadHostGroups()
|
loadHostGroupsForService(svcId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
|
ElMessage.error(extractApiError(error?.response?.data, '同步失败'))
|
||||||
} finally {
|
} finally {
|
||||||
syncLoading.value = false
|
syncLoadingMap[svcId] = false
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 编辑 ==========
|
// ==================== 编辑 ====================
|
||||||
const editDialogVisible = ref(false)
|
const editDialogVisible = ref(false)
|
||||||
const editSubmitLoading = ref(false)
|
const editSubmitLoading = ref(false)
|
||||||
const editFormRef = ref(null)
|
const editFormRef = ref(null)
|
||||||
|
const editingServiceId = ref(null)
|
||||||
|
|
||||||
const editForm = reactive({
|
const editForm = reactive({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -399,6 +459,7 @@ const editFormRules = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditGroup = (row) => {
|
const handleEditGroup = (row) => {
|
||||||
|
editingServiceId.value = row.serviceId
|
||||||
Object.assign(editForm, {
|
Object.assign(editForm, {
|
||||||
id: Number(row.Id ?? row.id),
|
id: Number(row.Id ?? row.id),
|
||||||
name: row.Name ?? row.name,
|
name: row.Name ?? row.name,
|
||||||
@@ -421,7 +482,7 @@ const submitEdit = () => {
|
|||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
ElMessage.success('修改成功')
|
ElMessage.success('修改成功')
|
||||||
editDialogVisible.value = false
|
editDialogVisible.value = false
|
||||||
loadHostGroups()
|
if (editingServiceId.value) loadHostGroupsForService(editingServiceId.value)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(extractApiError(body, '修改失败'))
|
ElMessage.error(extractApiError(body, '修改失败'))
|
||||||
}
|
}
|
||||||
@@ -433,10 +494,11 @@ const submitEdit = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 绑定 ==========
|
// ==================== 绑定 ====================
|
||||||
const bindDialogVisible = ref(false)
|
const bindDialogVisible = ref(false)
|
||||||
const bindSubmitLoading = ref(false)
|
const bindSubmitLoading = ref(false)
|
||||||
const bindFormRef = ref(null)
|
const bindFormRef = ref(null)
|
||||||
|
const bindingServiceId = ref(null)
|
||||||
|
|
||||||
const bindForm = reactive({
|
const bindForm = reactive({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -447,23 +509,19 @@ const bindForm = reactive({
|
|||||||
good_id: 0
|
good_id: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 选择器弹窗控制
|
|
||||||
const showGroupSelector = ref(false)
|
const showGroupSelector = ref(false)
|
||||||
const showProductSelector = ref(false)
|
const showProductSelector = ref(false)
|
||||||
|
|
||||||
// 商品组选中回调
|
|
||||||
const handleGroupSelected = (group) => {
|
const handleGroupSelected = (group) => {
|
||||||
bindForm.good_group_id = group.id
|
bindForm.good_group_id = group.id
|
||||||
bindForm._groupName = group.name || ''
|
bindForm._groupName = group.name || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 商品选中回调
|
|
||||||
const handleProductSelected = (product) => {
|
const handleProductSelected = (product) => {
|
||||||
bindForm.good_id = product.id
|
bindForm.good_id = product.id
|
||||||
bindForm._goodName = product.name || ''
|
bindForm._goodName = product.name || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除绑定
|
|
||||||
const clearBindGroup = () => {
|
const clearBindGroup = () => {
|
||||||
bindForm.good_group_id = 0
|
bindForm.good_group_id = 0
|
||||||
bindForm._groupName = ''
|
bindForm._groupName = ''
|
||||||
@@ -475,6 +533,7 @@ const clearBindProduct = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleBind = (row) => {
|
const handleBind = (row) => {
|
||||||
|
bindingServiceId.value = row.serviceId
|
||||||
Object.assign(bindForm, {
|
Object.assign(bindForm, {
|
||||||
id: Number(row.Id ?? row.id),
|
id: Number(row.Id ?? row.id),
|
||||||
_name: row.Name ?? row.name,
|
_name: row.Name ?? row.name,
|
||||||
@@ -498,7 +557,7 @@ const submitBind = async () => {
|
|||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
ElMessage.success('绑定成功')
|
ElMessage.success('绑定成功')
|
||||||
bindDialogVisible.value = false
|
bindDialogVisible.value = false
|
||||||
loadHostGroups()
|
if (bindingServiceId.value) loadHostGroupsForService(bindingServiceId.value)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(extractApiError(body, '绑定失败'))
|
ElMessage.error(extractApiError(body, '绑定失败'))
|
||||||
}
|
}
|
||||||
@@ -509,10 +568,11 @@ const submitBind = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 生成商品 ==========
|
// ==================== 生成商品 ====================
|
||||||
const generateDialogVisible = ref(false)
|
const generateDialogVisible = ref(false)
|
||||||
const generateSubmitLoading = ref(false)
|
const generateSubmitLoading = ref(false)
|
||||||
const generateFormRef = ref(null)
|
const generateFormRef = ref(null)
|
||||||
|
const generatingServiceId = ref(null)
|
||||||
|
|
||||||
const generateForm = reactive({
|
const generateForm = reactive({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -527,10 +587,8 @@ const generateFormRules = {
|
|||||||
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
|
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 父级商品组选择器
|
|
||||||
const showGenerateGroupSelector = ref(false)
|
const showGenerateGroupSelector = ref(false)
|
||||||
|
|
||||||
// 标签选择器
|
|
||||||
const showGenerateTagSelector = ref(false)
|
const showGenerateTagSelector = ref(false)
|
||||||
const tagOptions = ref([])
|
const tagOptions = ref([])
|
||||||
const tagLoading = ref(false)
|
const tagLoading = ref(false)
|
||||||
@@ -567,10 +625,10 @@ const fetchTagOptions = async () => {
|
|||||||
} catch { /* */ } finally { tagLoading.value = false }
|
} catch { /* */ } finally { tagLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听标签选择器打开时加载数据
|
|
||||||
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
|
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
|
||||||
|
|
||||||
const handleGenerateGoods = (row) => {
|
const handleGenerateGoods = (row) => {
|
||||||
|
generatingServiceId.value = row.serviceId
|
||||||
Object.assign(generateForm, {
|
Object.assign(generateForm, {
|
||||||
id: Number(row.Id ?? row.id),
|
id: Number(row.Id ?? row.id),
|
||||||
parent_group_id: 0,
|
parent_group_id: 0,
|
||||||
@@ -603,7 +661,7 @@ const submitGenerate = () => {
|
|||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
ElMessage.success('商品生成成功')
|
ElMessage.success('商品生成成功')
|
||||||
generateDialogVisible.value = false
|
generateDialogVisible.value = false
|
||||||
loadHostGroups()
|
if (generatingServiceId.value) loadHostGroupsForService(generatingServiceId.value)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(extractApiError(body, '商品生成失败'))
|
ElMessage.error(extractApiError(body, '商品生成失败'))
|
||||||
}
|
}
|
||||||
@@ -616,8 +674,8 @@ const submitGenerate = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 删除本地主机组 ==========
|
// ==================== 删除本地主机组 ====================
|
||||||
const handleDeleteGroup = (row) => {
|
const handleDeleteGroup = (row, svcId) => {
|
||||||
const rawId = Number(row.Id ?? row.id)
|
const rawId = Number(row.Id ?? row.id)
|
||||||
if (!rawId) {
|
if (!rawId) {
|
||||||
ElMessage.error('无法获取主机组ID')
|
ElMessage.error('无法获取主机组ID')
|
||||||
@@ -633,7 +691,7 @@ const handleDeleteGroup = (row) => {
|
|||||||
const body = res?.data
|
const body = res?.data
|
||||||
if (body?.code === 200) {
|
if (body?.code === 200) {
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
loadHostGroups()
|
if (svcId) loadHostGroupsForService(svcId)
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(extractApiError(body, '删除失败'))
|
ElMessage.error(extractApiError(body, '删除失败'))
|
||||||
}
|
}
|
||||||
@@ -643,15 +701,16 @@ const handleDeleteGroup = (row) => {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 返回 ==========
|
// ==================== 返回 ====================
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
router.push('/virtualization/kvm-service')
|
router.push('/virtualization/kvm-service')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!embedded) loadServiceOptions()
|
if (!embedded) {
|
||||||
if (serviceId.value) {
|
loadAllServices()
|
||||||
loadHostGroups()
|
} else if (embeddedServiceId.value) {
|
||||||
|
loadHostGroupsForService(embeddedServiceId.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -723,8 +782,76 @@ onMounted(() => {
|
|||||||
|
|
||||||
.panel-body {
|
.panel-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
min-height: 300px;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user