feat: 对接宿主机组映射管理
This commit is contained in:
@@ -0,0 +1,141 @@
|
|||||||
|
import { http2 } from '@/utils/request.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务管理 API
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取 KVM 主控服务列表 */
|
||||||
|
export const getKvmServiceList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 KVM 主控服务详情 */
|
||||||
|
export const getKvmServiceDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 KVM 主控服务 */
|
||||||
|
export const createKvmService = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改 KVM 主控服务 */
|
||||||
|
export const updateKvmService = (id, data) => {
|
||||||
|
return http2.post(`/api/v1/admin/server/host_service/update?id=${id}`, data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 KVM 主控服务 */
|
||||||
|
export const deleteKvmService = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 宿主机组映射管理 API
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取本地主机组列表 */
|
||||||
|
export const getHostGroupList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/host_group/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从远程同步主机组到本地 */
|
||||||
|
export const syncHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/host_group/sync', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定主机组到商品组或商品 */
|
||||||
|
export const bindHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/host_group/bind', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改本地主机组信息 */
|
||||||
|
export const updateHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/host_group/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据主机组树自动生成 GoodGroup/Goods/Args */
|
||||||
|
export const generateGoodsByHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/host_group/generate_goods', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除本地主机组 */
|
||||||
|
export const deleteHostGroup = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/host_group/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - 远程宿主机组管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取远程主机组列表 */
|
||||||
|
export const getRemoteHostGroupList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host_group/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取远程主机组详情 */
|
||||||
|
export const getRemoteHostGroupDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host_group/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取远程主机组树形结构 */
|
||||||
|
export const getRemoteHostGroupTree = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host_group/tree', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取主机组最优主机配置信息 */
|
||||||
|
export const getOptimalHostInfo = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host_group/optimal_host', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建远程主机组 */
|
||||||
|
export const createRemoteHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/host_group/create', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改远程主机组 */
|
||||||
|
export const updateRemoteHostGroup = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/host_group/update', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除远程主机组 */
|
||||||
|
export const deleteRemoteHostGroup = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/host_group/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 主控服务接口 - 远程宿主机管理
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取宿主机列表 */
|
||||||
|
export const getRemoteHostList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取宿主机详情 */
|
||||||
|
export const getRemoteHostDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/host/detail', { params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="选择商品组"
|
||||||
|
width="800px"
|
||||||
|
append-to-body
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="group-selector">
|
||||||
|
<!-- 搜索筛选区域 -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<el-form :inline="true" class="search-form">
|
||||||
|
<el-form-item>
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索商品组名称"
|
||||||
|
clearable
|
||||||
|
style="width: 220px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch" :icon="Search">搜索</el-button>
|
||||||
|
<el-button @click="handleReset" :icon="Refresh">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 商品组列表表格 -->
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="groupList"
|
||||||
|
highlight-current-row
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
style="width: 100%"
|
||||||
|
:height="350"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
|
<el-table-column prop="name" label="商品组名称" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="父级ID" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.parent_id || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="标签" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.tag" size="small" type="info">{{ row.tag }}</el-tag>
|
||||||
|
<span v-else class="text-muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.disable ? 'danger' : 'success'" size="small">
|
||||||
|
{{ row.disable ? '禁用' : '启用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-container" v-if="total > 0">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="searchParams.page"
|
||||||
|
v-model:page-size="searchParams.count"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="groupList.length === 0 && !loading" description="暂无商品组数据" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleConfirm"
|
||||||
|
:disabled="!selectedGroup"
|
||||||
|
>
|
||||||
|
确定选择
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { getProductGroupList } from '@/api/admin/product'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 当前已选中的商品组ID(用于回显)
|
||||||
|
currentGroupId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const groupList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const selectedGroup = ref(null)
|
||||||
|
const keyword = ref('')
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive({
|
||||||
|
page: 1,
|
||||||
|
count: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 modelValue 变化
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal
|
||||||
|
if (newVal) {
|
||||||
|
selectedGroup.value = null
|
||||||
|
keyword.value = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 visible 变化
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取商品组列表
|
||||||
|
const fetchGroupList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
groupList.value = []
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: searchParams.page,
|
||||||
|
count: searchParams.count
|
||||||
|
}
|
||||||
|
if (keyword.value.trim()) {
|
||||||
|
params.keyword = keyword.value.trim()
|
||||||
|
}
|
||||||
|
const res = await getProductGroupList(params)
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const inner = body.data
|
||||||
|
const items = Array.isArray(inner) ? inner : (inner.data || inner.list || [])
|
||||||
|
// 过滤掉已删除的
|
||||||
|
groupList.value = items.filter(item => !item.delete)
|
||||||
|
total.value = inner.all_count ?? inner.total ?? groupList.value.length
|
||||||
|
|
||||||
|
// 如果有当前选中的商品组ID,自动选中
|
||||||
|
if (props.currentGroupId) {
|
||||||
|
const current = groupList.value.find(
|
||||||
|
g => g.id === Number(props.currentGroupId)
|
||||||
|
)
|
||||||
|
if (current) {
|
||||||
|
selectedGroup.value = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(body?.message || '获取商品组列表失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取商品组列表失败:', error)
|
||||||
|
ElMessage.error('获取商品组列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
keyword.value = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页处理
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
searchParams.count = size
|
||||||
|
searchParams.page = 1
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
searchParams.page = page
|
||||||
|
fetchGroupList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择商品组
|
||||||
|
const handleCurrentChange = (row) => {
|
||||||
|
selectedGroup.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格行样式
|
||||||
|
const tableRowClassName = ({ row }) => {
|
||||||
|
if (selectedGroup.value && row.id === selectedGroup.value.id) {
|
||||||
|
return 'selected-row'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false
|
||||||
|
selectedGroup.value = null
|
||||||
|
groupList.value = []
|
||||||
|
keyword.value = ''
|
||||||
|
searchParams.page = 1
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectedGroup.value) {
|
||||||
|
emit('confirm', selectedGroup.value)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
ElMessage.warning('请选择一个商品组')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-selector {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
:deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row:hover) {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.selected-row td) {
|
||||||
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__body tr.current-row > td) {
|
||||||
|
background-color: var(--el-color-primary-light-8) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -135,6 +135,17 @@ export const menus = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/virtualization',
|
||||||
|
title: '虚拟化平台管理',
|
||||||
|
icon: 'Platform',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/virtualization/kvm-service',
|
||||||
|
title: '主控服务管理'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/system',
|
path: '/system',
|
||||||
title: '系统管理',
|
title: '系统管理',
|
||||||
|
|||||||
@@ -389,6 +389,35 @@ const routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
// 虚拟化平台管理路由
|
||||||
|
{
|
||||||
|
path: 'virtualization',
|
||||||
|
name: 'Virtualization',
|
||||||
|
meta: {
|
||||||
|
title: '虚拟化平台管理',
|
||||||
|
icon: 'Platform'
|
||||||
|
},
|
||||||
|
redirect: '/virtualization/kvm-service',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'kvm-service',
|
||||||
|
name: 'KvmService',
|
||||||
|
component: () => import('../views/virtualization/KvmService.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '主控服务管理'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'host-group-mapping',
|
||||||
|
name: 'HostGroupMapping',
|
||||||
|
component: () => import('../views/virtualization/HostGroupMapping.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '宿主机组映射管理',
|
||||||
|
activeMenu: '/virtualization/kvm-service'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
// 站点审计路由
|
// 站点审计路由
|
||||||
{
|
{
|
||||||
path: 'audit',
|
path: 'audit',
|
||||||
|
|||||||
@@ -0,0 +1,707 @@
|
|||||||
|
<template>
|
||||||
|
<div class="host-group-mapping-container">
|
||||||
|
<!-- 顶部信息 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||||
|
<div class="header-info">
|
||||||
|
<h3>宿主机组映射管理</h3>
|
||||||
|
<span class="sub-info" v-if="serviceName">所属主控服务:{{ serviceName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 布局:左侧本地主机组列表 / 右侧详情&操作 -->
|
||||||
|
<div class="content-layout">
|
||||||
|
<!-- 左侧:本地主机组列表 -->
|
||||||
|
<div class="left-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h4>本地主机组列表</h4>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body" v-loading="loading">
|
||||||
|
<el-table :data="hostGroupList" stripe style="width: 100%" @row-click="handleRowClick"
|
||||||
|
:row-class-name="getRowClassName" highlight-current-row>
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 右侧:远程主机组树 -->
|
||||||
|
<div class="right-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h4>远程主机组树</h4>
|
||||||
|
<el-button size="small" @click="loadRemoteTree" :loading="remoteTreeLoading">
|
||||||
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body" v-loading="remoteTreeLoading">
|
||||||
|
<el-tree
|
||||||
|
v-if="remoteTreeData.length > 0"
|
||||||
|
:data="remoteTreeData"
|
||||||
|
:props="{ label: 'name', children: 'children' }"
|
||||||
|
node-key="id"
|
||||||
|
default-expand-all
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
>
|
||||||
|
<template #default="{ node, data }">
|
||||||
|
<div class="tree-node-content">
|
||||||
|
<span class="tree-node-name">{{ data.name }}</span>
|
||||||
|
<span class="tree-node-id">ID: {{ data.id }}</span>
|
||||||
|
<span v-if="data.note" class="tree-node-note">{{ data.note }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
<el-empty v-else description="暂无远程主机组数据,请先同步" />
|
||||||
|
</div>
|
||||||
|
</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">选择</el-button>
|
||||||
|
<el-button v-if="bindForm.good_group_id" @click="clearBindGroup" style="margin-left: 4px">清除</el-button>
|
||||||
|
</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">选择</el-button>
|
||||||
|
<el-button v-if="bindForm.good_id" @click="clearBindProduct" style="margin-left: 4px">清除</el-button>
|
||||||
|
</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="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-number v-model="generateForm.id" :min="1" disabled style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="父级GoodGroup">
|
||||||
|
<el-input-number v-model="generateForm.parent_group_id" :min="0" placeholder="挂载到已有父级(可选)" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签ID">
|
||||||
|
<el-input-number v-model="generateForm.tag_id" :min="0" placeholder="根节点标签ID(可选)" style="width: 100%" />
|
||||||
|
</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, onMounted } 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,
|
||||||
|
getRemoteHostGroupTree
|
||||||
|
} from '@/api/admin/kvmService'
|
||||||
|
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 serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||||||
|
const serviceName = computed(() => route.query.service_name || '')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const syncLoading = ref(false)
|
||||||
|
const remoteTreeLoading = ref(false)
|
||||||
|
const hostGroupList = ref([])
|
||||||
|
const remoteTreeData = ref([])
|
||||||
|
const selectedGroup = ref(null)
|
||||||
|
|
||||||
|
// 规范化后端 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.remoteId ?? item.remote_id,
|
||||||
|
parentRemoteId: item.ServiceParentHostGroupId ?? item.parentRemoteId,
|
||||||
|
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.created_at,
|
||||||
|
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 loadRemoteTree = async () => {
|
||||||
|
if (!serviceId.value) return
|
||||||
|
remoteTreeLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getRemoteHostGroupTree({ service_id: serviceId.value })
|
||||||
|
const body = res?.data
|
||||||
|
const data = body?.data || body
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
remoteTreeData.value = data
|
||||||
|
} else {
|
||||||
|
remoteTreeData.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取远程主机组树失败:', error)
|
||||||
|
ElMessage.error('获取远程主机组树失败')
|
||||||
|
} finally {
|
||||||
|
remoteTreeLoading.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()
|
||||||
|
loadRemoteTree()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('同步失败: ' + (error?.response?.data?.message || error.message))
|
||||||
|
} finally {
|
||||||
|
syncLoading.value = false
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 行点击选中 ==========
|
||||||
|
const handleRowClick = (row) => {
|
||||||
|
selectedGroup.value = row
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowClassName = ({ row }) => {
|
||||||
|
return selectedGroup.value?.id === row.id ? 'selected-row' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 编辑 ==========
|
||||||
|
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(body?.message || '修改失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('修改失败: ' + (error?.response?.data?.message || error.message))
|
||||||
|
} 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(body?.message || '绑定失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('绑定失败: ' + (error?.response?.data?.message || error.message))
|
||||||
|
} 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,
|
||||||
|
tag_id: 0,
|
||||||
|
table: 'kvm_service'
|
||||||
|
})
|
||||||
|
|
||||||
|
const generateFormRules = {
|
||||||
|
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerateGoods = (row) => {
|
||||||
|
Object.assign(generateForm, {
|
||||||
|
id: Number(row.Id ?? row.id),
|
||||||
|
parent_group_id: 0,
|
||||||
|
tag_id: 0,
|
||||||
|
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 > 0) payload.parent_group_id = generateForm.parent_group_id
|
||||||
|
if (generateForm.tag_id > 0) 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(body?.message || '商品生成失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('生成失败: ' + (error?.response?.data?.message || error.message))
|
||||||
|
} 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(body?.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('删除失败: ' + (error?.response?.data?.message || error.message))
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 返回 ==========
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/virtualization/kvm-service')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (serviceId.value) {
|
||||||
|
loadHostGroups()
|
||||||
|
loadRemoteTree()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 布局 */
|
||||||
|
.content-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 360px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.content-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel,
|
||||||
|
.right-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格行选中高亮 */
|
||||||
|
:deep(.el-table .selected-row) {
|
||||||
|
background-color: #ecf5ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 远程树节点 */
|
||||||
|
.tree-node-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #909399;
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #b0b3b8;
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 绑定选择器行 */
|
||||||
|
.bind-selector-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
--el-table-border-color: #ebeef5;
|
||||||
|
--el-table-header-bg-color: #fafafa;
|
||||||
|
--el-table-row-hover-bg-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<template>
|
||||||
|
<div class="kvm-service-container">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<el-button type="primary" @click="handleAdd">
|
||||||
|
<el-icon><Plus /></el-icon>新建主控服务
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="loadList">
|
||||||
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKey"
|
||||||
|
placeholder="搜索服务名称/地址"
|
||||||
|
style="width: 260px"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 服务列表 -->
|
||||||
|
<el-table :data="serviceList" v-loading="loading" stripe style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="Name" label="服务名称" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="服务地址" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="host-addr">{{ row.Host }}:{{ row.Port }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="认证Token" min-width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.Token" class="token-mask">{{ maskToken(row.Token) }}</span>
|
||||||
|
<span v-else class="text-muted">未设置</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="Note" label="备注" min-width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.Note || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.CreatedAt || row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="240" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
|
||||||
|
<el-button link type="primary" @click="goHostGroupMapping(row)">主机组</el-button>
|
||||||
|
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-wrapper" v-if="total > queryParams.count">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="queryParams.page"
|
||||||
|
v-model:page-size="queryParams.count"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新建/编辑弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogType === 'add' ? '新建主控服务' : '编辑主控服务'"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||||
|
<el-form-item label="服务名称" prop="name">
|
||||||
|
<el-input v-model="formData.name" placeholder="请输入服务名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务地址" prop="host">
|
||||||
|
<el-input v-model="formData.host" placeholder="请输入服务地址,如 192.168.1.100 或域名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="服务端口" prop="port">
|
||||||
|
<el-input v-model="formData.port" placeholder="请输入服务端口,如 8080" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="认证Token" prop="token">
|
||||||
|
<el-input v-model="formData.token" placeholder="请输入认证Token(可选)" show-password />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="note">
|
||||||
|
<el-input v-model="formData.note" type="textarea" :rows="3" placeholder="备注说明(可选)" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 详情弹窗 -->
|
||||||
|
<el-dialog v-model="detailDialogVisible" title="主控服务详情" width="580px" destroy-on-close>
|
||||||
|
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading">
|
||||||
|
<el-descriptions-item label="ID">{{ currentDetail.Id ?? currentDetail.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务名称">{{ currentDetail.Name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务地址">{{ currentDetail.Host }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务端口">{{ currentDetail.Port }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="认证Token" :span="2">
|
||||||
|
<el-input v-if="currentDetail.Token" :model-value="currentDetail.Token" readonly show-password style="max-width: 300px" />
|
||||||
|
<span v-else class="text-muted">未设置</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注" :span="2">{{ currentDetail.Note || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间" :span="2">{{ formatTime(currentDetail.CreatedAt) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
getKvmServiceList,
|
||||||
|
getKvmServiceDetail,
|
||||||
|
createKvmService,
|
||||||
|
updateKvmService,
|
||||||
|
deleteKvmService
|
||||||
|
} from '@/api/admin/kvmService'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const serviceList = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const searchKey = ref('')
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
page: 1,
|
||||||
|
count: 10,
|
||||||
|
key: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹窗控制
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const dialogType = ref('add')
|
||||||
|
const formRef = ref(null)
|
||||||
|
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const currentDetail = ref(null)
|
||||||
|
|
||||||
|
const formData = reactive({
|
||||||
|
id: undefined,
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
port: '',
|
||||||
|
token: '',
|
||||||
|
note: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
||||||
|
host: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
|
||||||
|
port: [{ required: true, message: '请输入服务端口', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化后端 PascalCase 字段为前端 camelCase
|
||||||
|
// 同时保留原始字段以便在需要时直接访问
|
||||||
|
const normalizeService = (item) => {
|
||||||
|
if (!item) return item
|
||||||
|
return {
|
||||||
|
...item, // 保留原始字段(如 Id、Name 等)
|
||||||
|
id: item.Id ?? item.id,
|
||||||
|
name: item.Name ?? item.name,
|
||||||
|
host: item.Host ?? item.host,
|
||||||
|
port: item.Port ?? item.port,
|
||||||
|
token: item.Token ?? item.token,
|
||||||
|
note: item.Note ?? item.note,
|
||||||
|
CreatedAt: item.CreatedAt ?? item.created_at,
|
||||||
|
UpdatedAt: item.UpdatedAt ?? item.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载列表
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getKvmServiceList(queryParams)
|
||||||
|
// http2 返回完整 axios 响应,res.data 为 JSON body
|
||||||
|
const body = res?.data
|
||||||
|
console.debug('[KvmService] list response body:', JSON.stringify(body))
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
const inner = body.data // { all_count, data: [...] } 或直接是数组
|
||||||
|
const items = Array.isArray(inner) ? inner : (inner.data || inner.list || [])
|
||||||
|
serviceList.value = items.map(normalizeService)
|
||||||
|
total.value = inner.all_count ?? inner.total ?? items.length
|
||||||
|
console.debug('[KvmService] normalized list:', serviceList.value)
|
||||||
|
} else {
|
||||||
|
serviceList.value = []
|
||||||
|
total.value = 0
|
||||||
|
if (body?.message) {
|
||||||
|
ElMessage.warning(body.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取主控服务列表失败:', error)
|
||||||
|
ElMessage.error('获取主控服务列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
queryParams.page = 1
|
||||||
|
queryParams.key = searchKey.value
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
queryParams.count = size
|
||||||
|
queryParams.page = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page) => {
|
||||||
|
queryParams.page = page
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遮掩Token
|
||||||
|
const maskToken = (token) => {
|
||||||
|
if (!token) return ''
|
||||||
|
if (token.length <= 8) return '****'
|
||||||
|
return token.substring(0, 4) + '****' + token.substring(token.length - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (t) => {
|
||||||
|
if (!t) return '-'
|
||||||
|
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
const resetForm = () => {
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: undefined,
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
port: '',
|
||||||
|
token: '',
|
||||||
|
note: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialogType.value = 'add'
|
||||||
|
resetForm()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const handleEdit = (row) => {
|
||||||
|
dialogType.value = 'edit'
|
||||||
|
Object.assign(formData, {
|
||||||
|
id: Number(row.Id ?? row.id),
|
||||||
|
name: row.Name ?? row.name,
|
||||||
|
host: row.Host ?? row.host,
|
||||||
|
port: row.Port ?? row.port,
|
||||||
|
token: row.Token ?? row.token ?? '',
|
||||||
|
note: row.Note ?? row.note ?? ''
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = () => {
|
||||||
|
formRef.value?.validate(async (valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitLoading.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: formData.name,
|
||||||
|
host: formData.host,
|
||||||
|
port: formData.port,
|
||||||
|
token: formData.token,
|
||||||
|
note: formData.note
|
||||||
|
}
|
||||||
|
let res
|
||||||
|
if (dialogType.value === 'add') {
|
||||||
|
res = await createKvmService(payload)
|
||||||
|
} else {
|
||||||
|
const editId = Number(formData.id)
|
||||||
|
if (!editId) {
|
||||||
|
ElMessage.error('无法获取服务ID')
|
||||||
|
submitLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res = await updateKvmService(editId, payload)
|
||||||
|
}
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200) {
|
||||||
|
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '更新成功')
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(body?.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('操作失败:', error)
|
||||||
|
ElMessage.error('操作失败: ' + (error?.response?.data?.message || error.message))
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = (row) => {
|
||||||
|
const rawId = row.Id ?? row.id
|
||||||
|
if (!rawId) {
|
||||||
|
ElMessage.error('无法获取服务ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessageBox.confirm(`确定要删除主控服务「${row.Name}」吗?删除后不可恢复。`, '删除确认', {
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteKvmService({ id: Number(rawId) })
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 200) {
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadList()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(body?.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('删除失败: ' + (error?.response?.data?.message || error.message))
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleViewDetail = async (row) => {
|
||||||
|
// 优先使用原始 Id(PascalCase),回退到规范化后的 id
|
||||||
|
const rawId = row.Id ?? row.id
|
||||||
|
console.debug('[KvmService] handleViewDetail rawId:', rawId, 'row:', row)
|
||||||
|
if (rawId === undefined || rawId === null || rawId === '') {
|
||||||
|
ElMessage.error('无法获取服务ID,请刷新列表后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
currentDetail.value = null
|
||||||
|
try {
|
||||||
|
const res = await getKvmServiceDetail({ id: Number(rawId) })
|
||||||
|
const body = res?.data
|
||||||
|
console.debug('[KvmService] detail response body:', JSON.stringify(body))
|
||||||
|
if (body?.code === 200 && body?.data) {
|
||||||
|
currentDetail.value = normalizeService(body.data)
|
||||||
|
} else {
|
||||||
|
// 接口返回非200,显示错误但仍展示列表行数据
|
||||||
|
ElMessage.error(body?.message || '获取详情失败')
|
||||||
|
currentDetail.value = normalizeService(row)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取详情失败:', error)
|
||||||
|
const errMsg = error?.response?.data?.message || error?.message || '未知错误'
|
||||||
|
ElMessage.error('获取详情失败: ' + errMsg)
|
||||||
|
currentDetail.value = normalizeService(row)
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到宿主机组映射管理
|
||||||
|
const goHostGroupMapping = (row) => {
|
||||||
|
const id = Number(row.Id ?? row.id)
|
||||||
|
const name = row.Name ?? row.name
|
||||||
|
router.push({
|
||||||
|
path: '/virtualization/host-group-mapping',
|
||||||
|
query: { service_id: id, service_name: name }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kvm-service-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host-addr {
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
color: #409eff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-mask {
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
--el-table-border-color: #ebeef5;
|
||||||
|
--el-table-header-bg-color: #fafafa;
|
||||||
|
--el-table-row-hover-bg-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+6826
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user