98678859cb
Co-authored-by: Cursor <cursoragent@cursor.com>
950 lines
44 KiB
Vue
950 lines
44 KiB
Vue
<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 type="warning" @click="openTokenDialog"><el-icon><Key /></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' : 'info'" 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="tokenDialogVisible" title="创建宿主机注册令牌" width="700px" destroy-on-close class="token-dialog">
|
||
<el-form ref="tokenFormRef" :model="tokenForm" :rules="tokenRules" label-width="120px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">基本信息</div>
|
||
<el-form-item label="宿主机名称" prop="name">
|
||
<el-input v-model="tokenForm.name" placeholder="为该宿主机命名" />
|
||
</el-form-item>
|
||
<el-form-item label="所属宿主机组" prop="host_group_id">
|
||
<el-select v-model="tokenForm.host_group_id" placeholder="请选择宿主机组" filterable style="width: 100%">
|
||
<el-option :value="0" label="请选择" disabled />
|
||
<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="tokenForm.description" type="textarea" :rows="2" placeholder="宿主机描述(可选)" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">资源配额</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="CPU" prop="max_cpu" class="tk-res-item">
|
||
<el-input-number v-model="tokenForm.max_cpu" :min="1" controls-position="right" /><span class="tk-res-unit">核</span>
|
||
</el-form-item>
|
||
<el-form-item label="内存" prop="max_memory" class="tk-res-item">
|
||
<el-input-number v-model="tokenMemDisplay" :min="0" controls-position="right" />
|
||
<el-select v-model="tokenMemUnit" class="tk-unit-select">
|
||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="磁盘" prop="max_disk" class="tk-res-item">
|
||
<el-input-number v-model="tokenDiskDisplay" :min="0" controls-position="right" />
|
||
<el-select v-model="tokenDiskUnit" class="tk-unit-select">
|
||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="下行带宽" class="tk-res-item">
|
||
<el-input-number v-model="tokenForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
<el-form-item label="上行带宽" class="tk-res-item">
|
||
<el-input-number v-model="tokenForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title clickable" @click="showTokenDiskIo = !showTokenDiskIo">
|
||
硬盘 IO 限制
|
||
<el-icon class="section-arrow" :class="{ expanded: showTokenDiskIo }"><ArrowRight /></el-icon>
|
||
<span class="section-hint">可选,不展开则使用默认值</span>
|
||
</div>
|
||
<div v-show="showTokenDiskIo">
|
||
<div class="io-sub-title">
|
||
带宽限制
|
||
<el-select v-model="tokenIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
|
||
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label" class="tk-res-item">
|
||
<el-input-number :model-value="+(tokenForm[f.key] / getTokenIoBwFactor()).toFixed(2)" @update:model-value="v => tokenForm[f.key] = Math.round((v || 0) * getTokenIoBwFactor())" :min="0" controls-position="right" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="io-sub-title">IOPS 限制</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label" class="tk-res-item">
|
||
<el-input-number v-model="tokenForm[f.key]" :min="0" controls-position="right" />
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">令牌有效期</div>
|
||
<el-form-item label="有效期" prop="expire_hours">
|
||
<el-input-number v-model="tokenForm.expire_hours" :min="1" :max="8760" controls-position="right" style="width: 100%" />
|
||
<div class="form-hint">单位:小时。默认 24 小时,最大 8760 小时(365天)</div>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="tokenDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="tokenSubmitLoading" @click="handleTokenSubmit">
|
||
<el-icon><Key /></el-icon>创建令牌
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 令牌结果弹窗 -->
|
||
<el-dialog v-model="tokenResultVisible" title="注册令牌已生成" width="560px" :close-on-click-modal="false" class="token-result-dialog">
|
||
<div class="tk-result-wrapper">
|
||
<div class="tk-result-header">
|
||
<el-icon class="tk-result-icon"><Key /></el-icon>
|
||
<div>
|
||
<div class="tk-result-name">{{ tokenResultInfo.name }}</div>
|
||
<div class="tk-result-meta">有效期 {{ tokenResultInfo.expire_hours }} 小时</div>
|
||
</div>
|
||
</div>
|
||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">
|
||
<template #title>请立即复制并保存此令牌,关闭后将无法再次查看</template>
|
||
</el-alert>
|
||
<div class="tk-token-block">
|
||
<div class="tk-token-label">后端地址</div>
|
||
<div class="tk-token-value">{{ baseUrl }}</div>
|
||
</div>
|
||
<div class="tk-token-block">
|
||
<div class="tk-token-label">service_id(主控服务ID)</div>
|
||
<div class="tk-token-value">{{ tokenResultInfo.service_id }}</div>
|
||
</div>
|
||
<div class="tk-token-block">
|
||
<div class="tk-token-label">注册令牌</div>
|
||
<div class="tk-token-value">{{ tokenResultInfo.token }}</div>
|
||
</div>
|
||
<el-button type="primary" class="tk-copy-btn" @click="copyToken">
|
||
<el-icon><CopyDocument /></el-icon>复制令牌到剪贴板
|
||
</el-button>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="tokenResultVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 新建/编辑宿主机组弹窗 -->
|
||
<el-dialog v-model="groupDialogVisible" :title="groupDialogType === 'add' ? '新建宿主机组' : '编辑宿主机组'" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form ref="groupFormRef" :model="groupForm" :rules="groupFormRules" label-width="80px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">基本信息</div>
|
||
<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>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="groupDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="submitLoading" @click="submitGroupForm">确定</el-button>
|
||
</div>
|
||
</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 class="tk-dialog">
|
||
<el-form ref="hostFormRef" :model="hostForm" :rules="hostFormRules" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">基本信息</div>
|
||
<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>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">SSH 配置</div>
|
||
<el-form-item label="端口">
|
||
<el-input-number v-model="hostForm.port" :min="0" :max="65535" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
<el-form-item label="用户名">
|
||
<el-input v-model="hostForm.user" placeholder="默认 tunneluser" />
|
||
</el-form-item>
|
||
<el-form-item label="密码">
|
||
<el-input v-model="hostForm.password" placeholder="可选" show-password />
|
||
</el-form-item>
|
||
<el-form-item label="私钥">
|
||
<el-input v-model="hostForm.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">资源限制</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="CPU">
|
||
<el-input-number v-model="hostForm.max_cpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span>
|
||
</el-form-item>
|
||
<el-form-item label="内存">
|
||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
|
||
<el-select v-model="memoryUnit" class="tk-unit-select">
|
||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="磁盘">
|
||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
|
||
<el-select v-model="diskUnit" class="tk-unit-select">
|
||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="下行带宽">
|
||
<el-input-number v-model="hostForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
<el-form-item label="上行带宽">
|
||
<el-input-number v-model="hostForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title clickable" @click="showHostDiskIo = !showHostDiskIo">
|
||
硬盘 IO 限制
|
||
<el-icon class="section-arrow" :class="{ expanded: showHostDiskIo }"><ArrowRight /></el-icon>
|
||
<span class="section-hint">可选,不展开则使用默认值</span>
|
||
</div>
|
||
<div v-show="showHostDiskIo">
|
||
<div class="io-sub-title">
|
||
带宽限制
|
||
<el-select v-model="hostIoBwUnit" class="tk-unit-select" style="width: 90px; margin-left: 8px">
|
||
<el-option v-for="u in ioBwUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||
</el-select>
|
||
</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item v-for="f in diskIoBwFields" :key="f.key" :label="f.label">
|
||
<el-input-number :model-value="+(hostForm[f.key] / getHostIoBwFactor()).toFixed(2)" @update:model-value="v => hostForm[f.key] = Math.round((v || 0) * getHostIoBwFactor())" :min="0" controls-position="right" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="io-sub-title">IOPS 限制</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item v-for="f in diskIoIopsFields" :key="f.key" :label="f.label">
|
||
<el-input-number v-model="hostForm[f.key]" :min="0" controls-position="right" />
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">其他配置</div>
|
||
<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>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="hostDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="submitLoading" @click="submitHostForm">确定</el-button>
|
||
</div>
|
||
</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, Key, CopyDocument } from '@element-plus/icons-vue'
|
||
import {
|
||
getRemoteHostGroupList, getRemoteHostGroupTree, getRemoteHostGroupDetail,
|
||
createRemoteHostGroup, updateRemoteHostGroup, deleteRemoteHostGroup,
|
||
getOptimalHostInfo,
|
||
getRemoteHostList, getRemoteHostDetail,
|
||
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
||
createHostToken
|
||
} from '@/api/admin/kvmService'
|
||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||
import { baseUrl } from '@/config/env'
|
||
|
||
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: 10 })
|
||
])
|
||
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 diskIoDefaults = {
|
||
read_bytes_sec: 314572800, write_bytes_sec: 314572800,
|
||
read_iops_sec: 1000, write_iops_sec: 1000,
|
||
read_bytes_sec_max: 314572800, write_bytes_sec_max: 314572800,
|
||
read_iops_sec_max: 1000, write_iops_sec_max: 1000
|
||
}
|
||
const diskIoBwFields = [
|
||
{ key: 'read_bytes_sec', label: '读取' },
|
||
{ key: 'write_bytes_sec', label: '写入' },
|
||
{ key: 'read_bytes_sec_max', label: '突发读取' },
|
||
{ key: 'write_bytes_sec_max', label: '突发写入' }
|
||
]
|
||
const diskIoIopsFields = [
|
||
{ key: 'read_iops_sec', label: '读取' },
|
||
{ key: 'write_iops_sec', label: '写入' },
|
||
{ key: 'read_iops_sec_max', label: '突发读取' },
|
||
{ key: 'write_iops_sec_max', label: '突发写入' }
|
||
]
|
||
const diskIoFields = [
|
||
...diskIoBwFields.map(f => ({ ...f, isBandwidth: true })),
|
||
...diskIoIopsFields.map(f => ({ ...f, isBandwidth: false }))
|
||
]
|
||
const ioBwUnitOptions = [
|
||
{ label: 'B/s', factor: 1 },
|
||
{ label: 'KB/s', factor: 1024 },
|
||
{ label: 'MB/s', factor: 1048576 },
|
||
{ label: 'GB/s', factor: 1073741824 }
|
||
]
|
||
const hostIoBwUnit = ref('MB/s')
|
||
const tokenIoBwUnit = ref('MB/s')
|
||
const getHostIoBwFactor = () => ioBwUnitOptions.find(u => u.label === hostIoBwUnit.value)?.factor || 1048576
|
||
const getTokenIoBwFactor = () => ioBwUnitOptions.find(u => u.label === tokenIoBwUnit.value)?.factor || 1048576
|
||
|
||
const showHostDiskIo = ref(false)
|
||
const showTokenDiskIo = ref(false)
|
||
|
||
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: '', private_key: '',
|
||
max_cpu: 0, max_memory: 0, max_disk: 0,
|
||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
|
||
...diskIoDefaults
|
||
})
|
||
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: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', ...diskIoDefaults })
|
||
showHostDiskIo.value = false
|
||
hostIoBwUnit.value = 'MB/s'
|
||
hostDialogVisible.value = true
|
||
}
|
||
|
||
const handleAddHostToGroup = (group) => {
|
||
hostDialogType.value = 'add'
|
||
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '', ...diskIoDefaults })
|
||
showHostDiskIo.value = false
|
||
hostIoBwUnit.value = 'MB/s'
|
||
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 || '', private_key: row.private_key || '',
|
||
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 || '',
|
||
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
|
||
})
|
||
showHostDiskIo.value = diskIoFields.some(f => row[f.key] && row[f.key] !== diskIoDefaults[f.key])
|
||
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
|
||
if (d.private_key) hostForm.private_key = d.private_key
|
||
diskIoFields.forEach(f => { if (d[f.key] !== undefined) hostForm[f.key] = d[f.key] })
|
||
}
|
||
}).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.private_key) delete payload.private_key
|
||
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 tokenDialogVisible = ref(false)
|
||
const tokenSubmitLoading = ref(false)
|
||
const tokenResultVisible = ref(false)
|
||
const tokenFormRef = ref(null)
|
||
const tokenMemUnit = ref('GB')
|
||
const tokenDiskUnit = ref('GB')
|
||
|
||
const tokenForm = reactive({
|
||
name: '', host_group_id: 0, max_cpu: 4,
|
||
max_memory: 4194304, max_disk: 100,
|
||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||
description: '', expire_hours: 24,
|
||
...diskIoDefaults
|
||
})
|
||
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||
const tokenRules = {
|
||
name: [{ required: true, message: '请输入宿主机名称', trigger: 'blur' }],
|
||
host_group_id: [{ required: true, type: 'number', min: 1, message: '请选择宿主机组', trigger: 'change' }],
|
||
max_cpu: [{ required: true, type: 'number', min: 1, message: '请设置最大CPU核数', trigger: 'change' }],
|
||
max_memory: [{ required: true, type: 'number', min: 1, message: '请设置最大内存', trigger: 'change' }],
|
||
max_disk: [{ required: true, type: 'number', min: 1, message: '请设置最大磁盘', trigger: 'change' }],
|
||
expire_hours: [{ required: true, type: 'number', min: 1, message: '请设置有效期', trigger: 'change' }]
|
||
}
|
||
|
||
const getTokenMemFactor = () => memoryUnitOptions.find(u => u.label === tokenMemUnit.value)?.factor || 1048576
|
||
const getTokenDiskFactor = () => diskUnitOptions.find(u => u.label === tokenDiskUnit.value)?.factor || 1
|
||
const tokenMemDisplay = computed({
|
||
get: () => tokenForm.max_memory ? +(tokenForm.max_memory / getTokenMemFactor()).toFixed(2) : 0,
|
||
set: (v) => { tokenForm.max_memory = Math.round((v || 0) * getTokenMemFactor()) }
|
||
})
|
||
const tokenDiskDisplay = computed({
|
||
get: () => tokenForm.max_disk ? +(tokenForm.max_disk / getTokenDiskFactor()).toFixed(2) : 0,
|
||
set: (v) => { tokenForm.max_disk = Math.round((v || 0) * getTokenDiskFactor()) }
|
||
})
|
||
|
||
const openTokenDialog = () => {
|
||
Object.assign(tokenForm, {
|
||
name: '', host_group_id: 0, max_cpu: 4,
|
||
max_memory: 4194304, max_disk: 100,
|
||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||
description: '', expire_hours: 24,
|
||
...diskIoDefaults
|
||
})
|
||
tokenMemUnit.value = 'GB'
|
||
tokenDiskUnit.value = 'GB'
|
||
showTokenDiskIo.value = false
|
||
tokenIoBwUnit.value = 'MB/s'
|
||
tokenDialogVisible.value = true
|
||
}
|
||
|
||
const handleTokenSubmit = () => {
|
||
tokenFormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
tokenSubmitLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('name', tokenForm.name)
|
||
fd.append('host_group_id', tokenForm.host_group_id)
|
||
fd.append('max_cpu', tokenForm.max_cpu)
|
||
fd.append('max_memory', tokenForm.max_memory)
|
||
fd.append('max_disk', tokenForm.max_disk)
|
||
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
||
fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
|
||
diskIoFields.forEach(f => { if (tokenForm[f.key] !== undefined) fd.append(f.key, tokenForm[f.key]) })
|
||
fd.append('description', tokenForm.description || '')
|
||
fd.append('expire_hours', tokenForm.expire_hours)
|
||
const res = await createHostToken(fd)
|
||
const body = res?.data
|
||
if (body?.code === 200 && body?.data) {
|
||
tokenResultInfo.name = tokenForm.name
|
||
tokenResultInfo.expire_hours = tokenForm.expire_hours
|
||
tokenResultInfo.token = body.data.token || body.data.Token || JSON.stringify(body.data)
|
||
tokenResultInfo.service_id = serviceId.value
|
||
tokenDialogVisible.value = false
|
||
tokenResultVisible.value = true
|
||
ElMessage.success('注册令牌创建成功')
|
||
} else {
|
||
ElMessage.error(extractApiError(body, '创建令牌失败'))
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(extractApiError(e?.response?.data, '创建令牌失败'))
|
||
} finally {
|
||
tokenSubmitLoading.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
const copyToken = async () => {
|
||
const text = `后端地址:${baseUrl}\nservice_id:${tokenResultInfo.service_id}\n注册令牌:${tokenResultInfo.token}`
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
ElMessage.success('令牌信息已复制到剪贴板')
|
||
} catch {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
document.body.appendChild(ta)
|
||
ta.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(ta)
|
||
ElMessage.success('令牌信息已复制到剪贴板')
|
||
}
|
||
}
|
||
|
||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||
|
||
onMounted(() => { if (serviceId.value) loadTreeData() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.host-tree-container { padding: 0; }
|
||
|
||
.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; }
|
||
|
||
.host-addr { color: #409eff; font-size: 13px; }
|
||
.host-url { color: #909399; font-size: 12px; }
|
||
.tk-section-title.clickable { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 6px; }
|
||
.tk-section-title.clickable:hover { color: #409eff; }
|
||
.section-arrow { transition: transform 0.2s; font-size: 14px; }
|
||
.section-arrow.expanded { transform: rotate(90deg); }
|
||
.section-hint { font-size: 12px; color: #909399; font-weight: 400; }
|
||
.io-sub-title { font-size: 13px; font-weight: 500; color: #606266; margin: 12px 0 8px; display: flex; align-items: center; }
|
||
</style>
|