Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/HostTreeManage.vue
T
lin cf19956b88
Build and Deploy Vue3 / build (push) Successful in 1m22s
Build and Deploy Vue3 / deploy (push) Successful in 1m2s
feat: 对接虚拟化平台管理
2026-03-19 18:13:24 +08:00

645 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="host-tree-container">
<div class="page-header" v-if="!embedded">
<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>
<div class="toolbar">
<el-button type="primary" @click="handleAddGroup"><el-icon><FolderAdd /></el-icon>新建宿主机组</el-button>
<el-button type="success" @click="handleAddHost"><el-icon><Plus /></el-icon>新增宿主机</el-button>
<el-button @click="loadTreeData"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<!-- 树形表格 -->
<el-table :data="treeDisplayData" v-loading="loading" row-key="_rowKey" style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }">
<el-table-column label="名称" min-width="280">
<template #default="{ row }">
<div class="tree-name-cell" :style="{ paddingLeft: (row._depth * 24) + 'px' }">
<span v-if="row._isGroup" class="expand-icon" @click="toggleExpand(row)">
<el-icon v-if="row._loading"><Loading /></el-icon>
<el-icon v-else :class="{ 'is-expanded': row._expanded }"><ArrowRight /></el-icon>
</span>
<span v-else class="expand-placeholder"></span>
<el-tag v-if="row._isGroup && row._isUngrouped" type="info" size="small">未分组</el-tag>
<el-tag v-else-if="row._isGroup" type="warning" size="small">宿主机组</el-tag>
<el-tag v-else type="primary" size="small">宿主机</el-tag>
<span class="row-name">{{ row.name || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="ID" width="70">
<template #default="{ row }">{{ row._isUngrouped ? '-' : row.id }}</template>
</el-table-column>
<el-table-column label="信息" min-width="260">
<template #default="{ row }">
<template v-if="row._isGroup">
<span class="text-muted">{{ row.note || '-' }}</span>
</template>
<template v-else>
<div class="host-addr">{{ row.ip || '-' }}</div>
<div class="host-url" v-if="row.base_url">{{ row.base_url }}</div>
</template>
</template>
</el-table-column>
<el-table-column label="资源/父级" min-width="220">
<template #default="{ row }">
<template v-if="row._isGroup">
<span v-if="row.parent_id" class="text-muted">父级: {{ getGroupName(row.parent_id) }}</span>
<span v-else class="text-muted">顶级分组</span>
</template>
<template v-else>
<div class="resource-info">
<el-tag size="small" type="info" v-if="row.max_cpu">CPU: {{ row.max_cpu }}</el-tag>
<el-tag size="small" type="info" v-if="row.max_memory">内存: {{ formatMemKBDisplay(row.max_memory) }}</el-tag>
<el-tag size="small" type="info" v-if="row.max_disk">磁盘: {{ formatDiskGB(row.max_disk) }}</el-tag>
</div>
</template>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag v-if="row._isHost" :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<template v-if="row._isGroup && !row._isUngrouped">
<el-button link type="primary" size="small" @click="handleViewGroupDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleEditGroup(row)">编辑</el-button>
<el-button link type="success" size="small" @click="handleAddHostToGroup(row)">新增主机</el-button>
<el-button link type="primary" size="small" @click="handleOptimalHost(row)">最优主机</el-button>
<el-button link type="danger" size="small" @click="handleDeleteGroup(row)">删除</el-button>
</template>
<template v-else-if="row._isHost">
<el-button link type="primary" size="small" @click="handleGoHostDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleEditHost(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDeleteHost(row)">删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 新建/编辑宿主机组弹窗 -->
<el-dialog v-model="groupDialogVisible" :title="groupDialogType === 'add' ? '新建宿主机组' : '编辑宿主机组'" width="480px" destroy-on-close>
<el-form ref="groupFormRef" :model="groupForm" :rules="groupFormRules" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="groupForm.name" placeholder="宿主机组名称" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="groupForm.note" type="textarea" :rows="3" placeholder="备注(可选)" />
</el-form-item>
<el-form-item label="父级组">
<el-select v-model="groupForm.parent_id" placeholder="选择父级" style="width: 100%" clearable @clear="groupForm.parent_id = 0">
<el-option :value="0" label="无(顶级分组)" />
<el-option v-for="g in parentGroupOptions" :key="g.id" :value="g.id" :label="`${g.name} (ID: ${g.id})`" :disabled="g.id === groupForm.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="groupDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitGroupForm">确定</el-button>
</template>
</el-dialog>
<!-- 宿主机组详情弹窗 -->
<el-dialog v-model="groupDetailVisible" title="宿主机组详情" width="560px" destroy-on-close>
<div v-loading="groupDetailLoading">
<el-descriptions title="基本信息" :column="2" border v-if="groupDetailData">
<el-descriptions-item label="ID">{{ groupDetailData.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ groupDetailData.name }}</el-descriptions-item>
<el-descriptions-item label="父级">{{ getGroupName(groupDetailData.parent_id) }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ groupDetailData.note || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(groupDetailData.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(groupDetailData.updated_at) }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="父级宿主机组" :column="2" border v-if="groupParentData" style="margin-top: 20px;">
<el-descriptions-item label="ID">{{ groupParentData.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ groupParentData.name }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer><el-button @click="groupDetailVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 最优主机弹窗 -->
<el-dialog v-model="optimalVisible" title="最优主机配置" width="700px" destroy-on-close>
<div v-loading="optimalLoading">
<template v-if="optimalData">
<el-descriptions :column="2" border>
<el-descriptions-item label="主机ID">{{ optimalData.host_id }}</el-descriptions-item>
<el-descriptions-item label="主机名称">{{ optimalData.host_name }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin: 16px 0 8px; color: #303133;">资源字段配置</h4>
<el-table :data="optimalData.fields || []" border stripe size="small">
<el-table-column prop="name" label="名称" width="110" />
<el-table-column prop="key" label="Key" width="120">
<template #default="{ row }"><code>{{ row.key }}</code></template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80" align="center">
<template #default="{ row }"><el-tag size="small" :type="row.type === 'select' ? 'warning' : ''">{{ row.type }}</el-tag></template>
</el-table-column>
<el-table-column label="范围" min-width="200">
<template #default="{ row }">
<template v-if="row.type === 'number'">
<span>{{ formatOptimalRange(row) }}</span>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="必填" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.must" type="danger" size="small"></el-tag>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
</el-table>
</template>
<el-empty v-else :description="optimalError || '暂无数据'" />
</div>
<template #footer><el-button @click="optimalVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 新建/编辑宿主机弹窗 -->
<el-dialog v-model="hostDialogVisible" :title="hostDialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close>
<el-form ref="hostFormRef" :model="hostForm" :rules="hostFormRules" label-width="120px">
<el-form-item label="名称" prop="name">
<el-input v-model="hostForm.name" placeholder="宿主机名称" />
</el-form-item>
<el-form-item label="服务地址" prop="base_url">
<el-input v-model="hostForm.base_url" placeholder="宿主机服务 URL" />
</el-form-item>
<el-form-item label="IP 地址" prop="ip">
<el-input v-model="hostForm.ip" placeholder="宿主机 IP" />
</el-form-item>
<el-form-item label="认证Token">
<el-input v-model="hostForm.token" placeholder="可选" show-password />
</el-form-item>
<el-divider content-position="left">SSH 配置</el-divider>
<el-form-item label="SSH 端口">
<el-input-number v-model="hostForm.port" :min="0" :max="65535" style="width: 100%" />
</el-form-item>
<el-form-item label="SSH 用户名">
<el-input v-model="hostForm.user" placeholder="默认 tunneluser" />
</el-form-item>
<el-form-item label="SSH 密码">
<el-input v-model="hostForm.password" placeholder="可选" show-password />
</el-form-item>
<el-divider content-position="left">资源限制</el-divider>
<el-form-item label="最大CPU(核)">
<el-input-number v-model="hostForm.max_cpu" :min="0" controls-position="right" style="width: 240px" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="最大内存">
<div class="unit-input-row">
<el-select v-model="memoryUnit" style="width: 70px; flex-shrink: 0;" size="default">
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="wide-number" />
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大磁盘">
<div class="unit-input-row">
<el-select v-model="diskUnit" style="width: 70px; flex-shrink: 0;" size="default">
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
</el-select>
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" class="wide-number" />
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="hostForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="hostForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="宿主机组">
<el-select v-model="hostForm.host_group_id" placeholder="选择宿主机组" clearable filterable style="width: 100%">
<el-option :value="0" label="不选择" />
<el-option v-for="g in allGroups" :key="g.id" :value="g.id" :label="`${g.name} (ID: ${g.id})`" />
</el-select>
</el-form-item>
<el-form-item label="介绍">
<el-input v-model="hostForm.description" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="hostDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitHostForm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, ArrowLeft, ArrowRight, Loading, FolderAdd } from '@element-plus/icons-vue'
import {
getRemoteHostGroupList, getRemoteHostGroupTree, getRemoteHostGroupDetail,
createRemoteHostGroup, updateRemoteHostGroup, deleteRemoteHostGroup,
getOptimalHostInfo,
getRemoteHostList, getRemoteHostDetail,
addRemoteHost, updateRemoteHost, deleteRemoteHost
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
const route = useRoute()
const router = useRouter()
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
const submitLoading = ref(false)
const allGroups = ref([])
const allHosts = ref([])
const expandedGroupIds = ref(new Set())
const formatMemKBDisplay = (val) => {
if (!val) return '-'; val = Number(val)
if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' TB'
if (val >= 1048576) return (val / 1048576).toFixed(1) + ' GB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' MB'
return val + ' KB'
}
const formatDiskGB = (val) => {
if (!val) return '-'; val = Number(val)
if (val >= 1024) return (val / 1024).toFixed(1) + ' TB'
return val.toFixed(1) + ' GB'
}
const formatMemKB = (val) => {
if (!val) return '-'; val = Number(val)
if (val >= 1073741824) return (val / 1073741824).toFixed(1) + ' TB'
if (val >= 1048576) return (val / 1048576).toFixed(1) + ' GB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' MB'
return val + ' KB'
}
const formatDiskMB = (val) => {
if (!val) return '-'; val = Number(val)
if (val >= 1048576) return (val / 1048576).toFixed(1) + ' TB'
if (val >= 1024) return (val / 1024).toFixed(1) + ' GB'
return val.toFixed(1) + ' MB'
}
const formatOptimalRange = (field) => {
if (!field || field.type !== 'number') return '-'
const parts = []
const fmt = (v) => {
if (field.key === 'memory') return formatMemKB(v)
if (field.key === 'system_size') return formatDiskMB(v)
return v
}
if (field.min != null) parts.push('最小: ' + fmt(field.min))
if (field.max != null) parts.push('最大: ' + fmt(field.max))
return parts.join(' / ') || '-'
}
const memoryUnit = ref('GB')
const diskUnit = ref('GB')
const memoryUnitOptions = [
{ label: 'KB', factor: 1 },
{ label: 'MB', factor: 1024 },
{ label: 'GB', factor: 1048576 },
{ label: 'TB', factor: 1073741824 }
]
const diskUnitOptions = [
{ label: 'GB', factor: 1 },
{ label: 'TB', factor: 1024 }
]
const getMemFactor = () => memoryUnitOptions.find(u => u.label === memoryUnit.value)?.factor || 1048576
const getDiskFactor = () => diskUnitOptions.find(u => u.label === diskUnit.value)?.factor || 1
const memoryDisplay = computed({
get: () => hostForm.max_memory ? +(hostForm.max_memory / getMemFactor()).toFixed(2) : 0,
set: (v) => { hostForm.max_memory = Math.round((v || 0) * getMemFactor()) }
})
const diskDisplay = computed({
get: () => hostForm.max_disk ? +(hostForm.max_disk / getDiskFactor()).toFixed(2) : 0,
set: (v) => { hostForm.max_disk = Math.round((v || 0) * getDiskFactor()) }
})
const formatTimestamp = (ts) => {
if (!ts) return '-'
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
return '-'
}
const getGroupName = (gid) => {
if (!gid) return '-'
const g = allGroups.value.find(x => x.id === gid)
return g ? `${g.name} (ID: ${gid})` : `ID: ${gid}`
}
const parentGroupOptions = computed(() => allGroups.value.filter(g => g.id !== groupForm.id))
const UNGROUPED_ID = '__ungrouped__'
const treeDisplayData = computed(() => {
const rows = []
const groupIds = new Set(allGroups.value.map(g => g.id))
const topGroups = allGroups.value.filter(g => !g.parent_id || !groupIds.has(g.parent_id))
const childGroupsOf = (pid) => allGroups.value.filter(g => g.parent_id === pid)
const hostsOf = (gid) => allHosts.value.filter(h => h.host_group_id === gid)
const ungroupedHosts = allHosts.value.filter(h => !h.host_group_id || !groupIds.has(h.host_group_id))
const addGroup = (group, depth) => {
const isExpanded = expandedGroupIds.value.has(group.id)
rows.push({ ...group, _rowKey: `group-${group.id}`, _isGroup: true, _isHost: false, _expanded: isExpanded, _depth: depth })
if (isExpanded) {
childGroupsOf(group.id).forEach(c => addGroup(c, depth + 1))
hostsOf(group.id).forEach(h => {
rows.push({ ...h, _rowKey: `host-${h.id}`, _isGroup: false, _isHost: true, _depth: depth + 1 })
})
}
}
topGroups.forEach(g => addGroup(g, 0))
if (ungroupedHosts.length) {
const isExpanded = expandedGroupIds.value.has(UNGROUPED_ID)
rows.push({ id: UNGROUPED_ID, name: '未分组宿主机', note: `${ungroupedHosts.length}`, _rowKey: 'group-ungrouped', _isGroup: true, _isHost: false, _expanded: isExpanded, _depth: 0, _isUngrouped: true })
if (isExpanded) {
ungroupedHosts.forEach(h => {
rows.push({ ...h, _rowKey: `host-${h.id}`, _isGroup: false, _isHost: true, _depth: 1 })
})
}
}
return rows
})
const toggleExpand = (row) => {
if (expandedGroupIds.value.has(row.id)) {
expandedGroupIds.value.delete(row.id)
} else {
expandedGroupIds.value.add(row.id)
}
}
const flattenTree = (nodes, parentId = 0) => {
const result = []
if (!Array.isArray(nodes)) return result
for (const node of nodes) {
const { children, ...group } = node
if (parentId) group.parent_id = parentId
result.push(group)
if (children && children.length) {
result.push(...flattenTree(children, group.id))
}
}
return result
}
const loadTreeData = async () => {
if (!serviceId.value) return
loading.value = true
try {
const [groupRes, hostRes] = await Promise.all([
getRemoteHostGroupTree({ service_id: serviceId.value }),
getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 500 })
])
if (groupRes?.data?.code === 200 && groupRes?.data?.data) {
const inner = groupRes.data.data
const tree = inner.tree || inner.host_groups || inner.groups || inner.data || (Array.isArray(inner) ? inner : [])
allGroups.value = flattenTree(tree)
}
if (hostRes?.data?.code === 200 && hostRes?.data?.data) {
const inner = hostRes.data.data
allHosts.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '加载数据失败'))
} finally { loading.value = false }
}
// ---- 宿主机组 CRUD ----
const groupDialogVisible = ref(false)
const groupDialogType = ref('add')
const groupFormRef = ref(null)
const groupForm = reactive({ id: undefined, name: '', note: '', parent_id: 0 })
const groupFormRules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }] }
const groupDetailVisible = ref(false)
const groupDetailLoading = ref(false)
const groupDetailData = ref(null)
const groupParentData = ref(null)
const optimalVisible = ref(false)
const optimalLoading = ref(false)
const optimalData = ref(null)
const optimalError = ref('')
const handleAddGroup = () => {
groupDialogType.value = 'add'
Object.assign(groupForm, { id: undefined, name: '', note: '', parent_id: 0 })
groupDialogVisible.value = true
}
const handleEditGroup = (row) => {
groupDialogType.value = 'edit'
Object.assign(groupForm, { id: row.id, name: row.name, note: row.note || '', parent_id: row.parent_id || 0 })
groupDialogVisible.value = true
}
const submitGroupForm = () => {
groupFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const payload = { service_id: serviceId.value, name: groupForm.name, note: groupForm.note, parent_id: groupForm.parent_id || 0 }
let res
if (groupDialogType.value === 'add') {
res = await createRemoteHostGroup(payload)
} else {
payload.id = groupForm.id
res = await updateRemoteHostGroup(payload)
}
if (res?.data?.code === 200) { ElMessage.success(groupDialogType.value === 'add' ? '创建成功' : '修改成功'); groupDialogVisible.value = false; loadTreeData() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
})
}
const handleDeleteGroup = (row) => {
ElMessageBox.confirm(`确定要删除宿主机组「${row.name}」吗?`, '删除确认', { type: 'warning' }).then(async () => {
try {
const res = await deleteRemoteHostGroup({ service_id: serviceId.value, id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadTreeData() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const handleViewGroupDetail = async (row) => {
groupDetailVisible.value = true
groupDetailLoading.value = true
groupDetailData.value = null
groupParentData.value = null
try {
const res = await getRemoteHostGroupDetail({ service_id: serviceId.value, id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
groupDetailData.value = d.host_group || d
groupParentData.value = d.parent_host_group || null
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取详情失败')) }
finally { groupDetailLoading.value = false }
}
const handleOptimalHost = async (row) => {
optimalVisible.value = true
optimalLoading.value = true
optimalData.value = null
optimalError.value = ''
try {
const res = await getOptimalHostInfo({ service_id: serviceId.value, host_group_id: row.id })
if (res?.data?.code === 200 && res?.data?.data) optimalData.value = res.data.data
else optimalError.value = extractApiError(res?.data, '暂无数据')
} catch (e) { optimalError.value = extractApiError(e?.response?.data, '获取失败') }
finally { optimalLoading.value = false }
}
// ---- 宿主机 CRUD ----
const hostDialogVisible = ref(false)
const hostDialogType = ref('add')
const hostFormRef = ref(null)
const hostForm = reactive({
id: undefined, name: '', base_url: '', ip: '', token: '',
port: 22, user: '', password: '',
max_cpu: 0, max_memory: 0, max_disk: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: ''
})
const hostFormRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
base_url: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
ip: [{ required: true, message: '请输入IP地址', trigger: 'blur' }]
}
const handleAddHost = () => {
hostDialogType.value = 'add'
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '' })
hostDialogVisible.value = true
}
const handleAddHostToGroup = (group) => {
hostDialogType.value = 'add'
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '' })
hostDialogVisible.value = true
}
const handleEditHost = (row) => {
hostDialogType.value = 'edit'
Object.assign(hostForm, {
id: row.id, name: row.name, base_url: row.base_url || '', ip: row.ip || '', token: row.token || '',
port: row.port || 22, user: row.user || '', password: row.password || '',
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
host_group_id: row.host_group_id || 0, description: row.description || ''
})
getRemoteHostDetail({ service_id: serviceId.value, id: row.id }).then(res => {
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data.host ?? res.data.data.data ?? res.data.data
if (d.password) hostForm.password = d.password
if (d.token) hostForm.token = d.token
}
}).catch(() => {})
hostDialogVisible.value = true
}
const submitHostForm = () => {
hostFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const payload = { ...hostForm, service_id: serviceId.value }
delete payload.id
if (!payload.token) delete payload.token
if (!payload.password) delete payload.password
if (!payload.host_group_id) delete payload.host_group_id
if (!payload.description) delete payload.description
let res
if (hostDialogType.value === 'add') {
res = await addRemoteHost(payload)
} else {
payload.id = hostForm.id
res = await updateRemoteHost(payload)
}
if (res?.data?.code === 200) { ElMessage.success(hostDialogType.value === 'add' ? '新增成功' : '修改成功'); hostDialogVisible.value = false; loadTreeData() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
})
}
const handleGoHostDetail = (row) => {
router.push({ path: '/virtualization/host-detail', query: { service_id: serviceId.value, id: row.id, service_name: serviceName.value } })
}
const handleDeleteHost = (row) => {
ElMessageBox.confirm(`确定要删除宿主机「${row.name}」吗?`, '删除确认', { type: 'warning' }).then(async () => {
try {
const res = await deleteRemoteHost({ service_id: serviceId.value, id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadTreeData() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(() => { if (serviceId.value) loadTreeData() })
</script>
<style scoped>
.host-tree-container { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.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; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
.tree-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.expand-icon {
cursor: pointer;
display: inline-flex;
align-items: center;
width: 20px;
justify-content: center;
transition: transform 0.2s;
}
.expand-icon .el-icon { font-size: 14px; color: #606266; transition: transform 0.2s; }
.expand-icon .is-expanded { transform: rotate(90deg); }
.expand-placeholder { width: 20px; display: inline-block; }
.row-name { font-weight: 500; color: #303133; }
.unit-input-row { display: flex; gap: 6px; width: 100%; }
.wide-number { flex: 1; min-width: 140px; }
.host-addr { color: #409eff; font-size: 13px; }
.host-url { color: #909399; font-size: 12px; }
.resource-info { display: flex; gap: 4px; flex-wrap: wrap; }
.text-muted { color: #c0c4cc; font-size: 12px; }
</style>