From 765f925482a56e73d6932ce154bf61b4c91cbe0c Mon Sep 17 00:00:00 2001 From: shiran Date: Mon, 25 May 2026 18:31:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=B8=BB=E6=9C=BA=E7=BB=84?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E5=B1=95=E7=A4=BA=E5=85=A8=E9=83=A8=E4=B8=BB?= =?UTF-8?q?=E6=8E=A7=E5=B9=B6=E5=B1=95=E5=BC=80=E5=8A=A0=E8=BD=BD=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主机组映射页改为卡片列表展示所有主控服务,展开后按需请求主机组;套餐管理增加必填参数未配置提醒。 Co-authored-by: Cursor --- .../product/components/ProductPlanManager.vue | 35 ++ src/views/virtualization/HostGroupMapping.vue | 347 ++++++++++++------ 2 files changed, 272 insertions(+), 110 deletions(-) diff --git a/src/views/product/components/ProductPlanManager.vue b/src/views/product/components/ProductPlanManager.vue index f0dbeeb..b07d3ac 100644 --- a/src/views/product/components/ProductPlanManager.vue +++ b/src/views/product/components/ProductPlanManager.vue @@ -162,6 +162,17 @@ +
+
+ + 以下必填参数尚未配置,请将其加入「参数配置」或「额外参数」中: +
+
+ + *{{ spec.name }} + +
+
@@ -385,6 +396,10 @@ const selectedExtraArgIds = ref([]) 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 missingMustSpecs = computed(() => { + const allSelectedIds = [...selectedArgIds.value, ...selectedExtraArgIds.value] + return planSpecList.value.filter(s => s.must && !allSelectedIds.includes(s.id)) +}) const getSpecDisplayMin = (spec) => { if (!hasUnit(spec)) return spec.min ?? 0 @@ -1184,6 +1199,26 @@ watch(() => props.visible, (val) => { .plan-card-actions .el-button + .el-button { 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::-webkit-scrollbar { width: 6px; } .plan-form-content::-webkit-scrollbar-track { background: transparent; } diff --git a/src/views/virtualization/HostGroupMapping.vue b/src/views/virtualization/HostGroupMapping.vue index e6e6941..69fa776 100644 --- a/src/views/virtualization/HostGroupMapping.vue +++ b/src/views/virtualization/HostGroupMapping.vue @@ -8,30 +8,24 @@
- - - - - 从远程同步 - - + 刷新
- 从远程同步 - 刷新 + 从远程同步 + 刷新
- -
+ +

本地主机组列表

-
- +
+ @@ -60,13 +54,82 @@ 编辑 绑定 生成商品 - 删除 + 删除
+ +
+ +
+
+
+ + {{ service.name }} + ID: {{ service.id }} + {{ service.host }}{{ service.port ? ':' + service.port : '' }} +
+
+ + 同步 + + + 刷新 + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ @@ -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 -}) - -// 规范化后端 PascalCase 字段为前端 camelCase -// 同时保留原始字段以便在需要时直接访问 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) { - // 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) + 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) } }) @@ -723,8 +782,76 @@ onMounted(() => { .panel-body { 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; +}