fix: 提交修改
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m9s

This commit is contained in:
2026-04-15 16:02:36 +08:00
parent 2f06aa9f5f
commit b3ed406f84
61 changed files with 7476 additions and 7226 deletions
+2 -3
View File
@@ -125,7 +125,7 @@ const vmOptionsLoading = ref(false)
const loadVmOptions = async () => {
vmOptionsLoading.value = true
try {
const res = await getVmList({ service_id: serviceId.value, page: 1, page_size: 10 })
const res = await getVmList({ service_id: serviceId.value, page: 1, count: 10 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : [])
@@ -230,6 +230,5 @@ defineExpose({ loadList })
<style scoped>
.backup-manage { padding: 0; }
.toolbar { display: flex; gap: 8px; margin-top: 12px; margin-bottom: 16px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.toolbar { margin-top: 12px; }
</style>
+477 -196
View File
@@ -17,6 +17,7 @@
<h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2>
</div>
<div class="overview-actions">
<!-- <el-button type="warning" plain @click="openTokenDialog"><el-icon><Key /></el-icon>创建注册令牌</el-button> -->
<el-button type="primary" plain @click="handleEdit">编辑宿主机</el-button>
<el-button type="danger" plain @click="handleDelete">删除</el-button>
</div>
@@ -135,51 +136,69 @@
<el-tab-pane label="监控" name="monitor">
<div class="section-block">
<div class="section-header">
<h3 class="section-title">实时指标</h3>
<h3 class="section-title">监控指标</h3>
<div style="display: flex; align-items: center; gap: 8px;">
<el-tag v-if="pollingActive" type="success" size="small" effect="plain">自动刷新中</el-tag>
<el-button size="small" :icon="Refresh" @click="loadMetrics" :loading="metricsLoading">刷新指标</el-button>
<el-select v-model="historyTimeRange" size="small" style="width: 120px" @change="loadHistoricalMetrics">
<el-option v-for="option in historyTimeOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-button size="small" :icon="Refresh" @click="loadHistoricalMetrics" :loading="historicalMetricsLoading">刷新</el-button>
</div>
</div>
<template v-if="metricsData">
<template v-if="latestMetrics">
<div class="metric-summary-row">
<div class="metric-summary-card">
<div class="metric-summary-label">CPU 使用率</div>
<div class="metric-summary-value">{{ latestMetrics.cpu_usage?.toFixed(1) }}%</div>
<div class="metric-summary-sub">{{ latestMetrics.cpu_count }} </div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">内存使用率</div>
<div class="metric-summary-value">{{ latestMetrics.mem_percent?.toFixed(1) }}%</div>
<div class="metric-summary-sub">{{ formatBytesRaw(latestMetrics.mem_used) }} / {{ formatBytesRaw(latestMetrics.mem_total) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">公网流量</div>
<div class="metric-summary-value">{{ formatNetLabel(latestMetrics.inet_rx) }}</div>
<div class="metric-summary-sub">{{ formatNetLabel(latestMetrics.inet_tx) }}</div>
</div>
<div class="metric-summary-card">
<div class="metric-summary-label">内网流量</div>
<div class="metric-summary-value">{{ formatBytesRaw(latestMetrics.net_rx) }}</div>
<div class="metric-summary-sub">{{ formatBytesRaw(latestMetrics.net_tx) }}</div>
</div>
</div>
</template>
<template v-if="historicalMetricsData">
<el-row :gutter="16">
<el-col :span="12" v-if="metricsData.cpu">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU 使用率 {{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}% ({{ metricsData.cpu.cpu_count ?? '-' }})</span></template>
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU 使用率</span></template>
<div ref="cpuChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12" v-if="metricsData.memory">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存 {{ formatBytesRaw(metricsData.memory.used) }} / {{ formatBytesRaw(metricsData.memory.total) }} ({{ metricsData.memory.percent ?? 0 }}%)</span></template>
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存使用率</span></template>
<div ref="memChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12" v-if="metricsData.disk">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template>
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item">
<div class="disk-path">{{ path }}</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="总计">{{ formatBytesRaw(info.total) }}</el-descriptions-item>
<el-descriptions-item label="已用">{{ formatBytesRaw(info.used) }}</el-descriptions-item>
<el-descriptions-item label="空闲">{{ formatBytesRaw(info.free) }}</el-descriptions-item>
<el-descriptions-item label="使用率">{{ info.percent ?? '-' }}%</el-descriptions-item>
</el-descriptions>
</div>
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 公网流量</span></template>
<div ref="inetChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="12" v-if="metricsData.network || metricsData.internet_speed">
<el-col :span="12">
<el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 内网流量</span></template>
<div ref="netChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
</template>
<el-empty v-else description="加载指标数据中..." />
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
</div>
</el-tab-pane>
@@ -287,45 +306,55 @@
</el-dialog>
<!-- 创建组网弹窗 -->
<el-dialog v-model="nwCreateVisible" title="创建组网" width="480px" destroy-on-close>
<el-dialog v-model="nwCreateVisible" title="创建组网" width="480px" destroy-on-close class="tk-dialog">
<el-form ref="nwCreateFormRef" :model="nwCreateForm" :rules="nwCreateRules" label-width="100px">
<el-form-item label="用户" prop="user_id">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwUserSelector = true">选择</el-button>
<el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button>
</div>
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="nwCreateForm.bridge_name" placeholder="可选" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="nwCreateForm.gateway" placeholder="可选 10.0.0.1" />
</el-form-item>
<div class="tk-section">
<div class="tk-section-title">组网信息</div>
<el-form-item label="用户" prop="user_id">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwUserSelector = true">选择</el-button>
<el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button>
</div>
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="nwCreateForm.bridge_name" placeholder="可选" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="nwCreateForm.gateway" placeholder="可选 10.0.0.1" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="nwCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button>
<div class="tk-dialog-footer">
<el-button @click="nwCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button>
</div>
</template>
</el-dialog>
<!-- 分配IP弹窗 -->
<el-dialog v-model="nwAssignVisible" title="为虚拟机分配组网IP" width="480px" destroy-on-close>
<el-dialog v-model="nwAssignVisible" title="为虚拟机分配组网IP" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px">
<el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item>
<el-form-item label="虚拟机" required>
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwVmSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="指定IP">
<el-input v-model="nwAssignIp" placeholder="留空自动分配" />
</el-form-item>
<div class="tk-section">
<div class="tk-section-title">分配信息</div>
<el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item>
<el-form-item label="虚拟机" required>
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showNwVmSelector = true">选择</el-button>
</div>
</el-form-item>
<el-form-item label="指定IP">
<el-input v-model="nwAssignIp" placeholder="留空自动分配" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="nwAssignVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button>
<div class="tk-dialog-footer">
<el-button @click="nwAssignVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button>
</div>
</template>
</el-dialog>
@@ -334,61 +363,163 @@
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="890px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
<el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item>
<el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item>
<el-form-item label="认证Token"><el-input v-model="formData.token" show-password /></el-form-item>
<el-divider content-position="left">SSH 配置</el-divider>
<el-form-item label="SSH 端口"><el-input-number v-model="formData.port" :min="0" :max="65535" style="width: 100%" /></el-form-item>
<el-form-item label="SSH 用户名"><el-input v-model="formData.user" /></el-form-item>
<el-form-item label="SSH 密码"><el-input v-model="formData.password" show-password /></el-form-item>
<el-form-item label="私钥"><el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容" /></el-form-item>
<el-divider content-position="left">资源限制</el-divider>
<el-form-item label="最大CPU"><el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" /></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-dialog v-model="editDialogVisible" title="编辑宿主机" width="890px" destroy-on-close class="tk-dialog">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
<el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item>
<el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item>
<el-form-item label="认证Token"><el-input v-model="formData.token" 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="formData.port" :min="0" :max="65535" controls-position="right" style="width: 100%" /></el-form-item>
<el-form-item label="用户名"><el-input v-model="formData.user" /></el-form-item>
<el-form-item label="密码"><el-input v-model="formData.password" show-password /></el-form-item>
<el-form-item label="私钥"><el-input v-model="formData.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="formData.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-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 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-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="下行带宽(Mbps)"><el-input-number v-model="formData.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="formData.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
</el-row>
<el-form-item label="宿主机组">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showGroupSelector = true">选择</el-button>
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0">清除</el-button>
<el-form-item label="下行带宽"><el-input-number v-model="formData.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="formData.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span></el-form-item>
</div>
</el-form-item>
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">其他配置</div>
<el-form-item label="宿主机组">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showGroupSelector = true">选择</el-button>
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0">清除</el-button>
</div>
</el-form-item>
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
<div class="tk-dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</template>
</el-dialog>
<HostGroupSelectorPopup v-model="showGroupSelector" :service-id="serviceId" :current-id="formData.host_group_id" @confirm="g => formData.host_group_id = g.id" />
<!-- 创建注册令牌弹窗 -->
<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">
<div style="display: flex; gap: 8px; width: 100%">
<el-input :model-value="tokenForm.host_group_id ? `宿主机组 #${tokenForm.host_group_id}` : ''" placeholder="请选择宿主机组" disabled style="flex: 1" />
<el-button type="primary" @click="showTokenGroupSelector = true">选择</el-button>
<el-button v-if="tokenForm.host_group_id" @click="tokenForm.host_group_id = 0">清除</el-button>
</div>
</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">令牌有效期</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>
<!-- 令牌用宿主机组选择器 -->
<HostGroupSelectorPopup v-model="showTokenGroupSelector" :service-id="serviceId" :current-id="tokenForm.host_group_id" @confirm="handleTokenGroupSelected" />
</div>
</template>
@@ -396,13 +527,15 @@
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Box, Connection, Search, Plus } from '@element-plus/icons-vue'
import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue'
import {
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost,
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
assignUserNetworking, removeUserNetworkingNetwork
assignUserNetworking, removeUserNetworkingNetwork,
createHostToken, getMetricsHistory
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
import ImageManage from '@/views/virtualization/ImageManage.vue'
import NetworkManage from '@/views/virtualization/NetworkManage.vue'
@@ -442,14 +575,16 @@ watch(activeTab, (tab) => {
nextTick(() => { tabRefMap[tab]?.value?.loadList?.() })
}
}
if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() }
else stopPolling()
if (tab === 'monitor' && detail.value) {
if (!historicalMetricsData.value) {
loadHistoricalMetrics()
}
}
if (tab === 'networking') loadNetworkingList()
})
const loading = ref(false)
const submitLoading = ref(false)
const metricsLoading = ref(false)
const detail = ref(null)
provide('embedded', true)
@@ -480,7 +615,8 @@ const fallbackCopy = (text) => {
} catch { ElMessage.error('复制失败') }
document.body.removeChild(ta)
}
const metricsData = ref(null)
const historicalMetricsData = ref(null)
const historicalMetricsLoading = ref(false)
const editDialogVisible = ref(false)
const showGroupSelector = ref(false)
const formRef = ref(null)
@@ -561,65 +697,77 @@ const loadDetail = async () => {
const cpuChartRef = ref(null)
const memChartRef = ref(null)
const netChartRef = ref(null)
const inetChartRef = ref(null)
let cpuChart = null
let memChart = null
let netChart = null
const MAX_HISTORY = 60
const metricsHistory = reactive({
times: [],
cpu: [],
memPercent: [],
netRx: [],
netTx: []
})
const pollingActive = ref(false)
let pollTimer = null
let inetChart = null
let isPageActive = false
const loadMetrics = async () => {
if (!serviceId.value || !hostId.value || !isPageActive) return
metricsLoading.value = true
const latestMetrics = computed(() => {
const arr = historicalMetricsData.value
if (!Array.isArray(arr) || !arr.length) return null
return arr[arr.length - 1]
})
// 历史指标时间范围
const historyTimeRange = ref('1m') // 1m, 5m, 1h, 1d
const historyTimeOptions = [
{ label: '最近1分钟', value: '1m' },
{ label: '最近5分钟', value: '5m' },
{ label: '最近1小时', value: '1h' },
{ label: '最近1天', value: '1d' },
]
// 加载历史指标数据
const loadHistoricalMetrics = async () => {
if (!serviceId.value || !hostId.value) return
historicalMetricsLoading.value = true
try {
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value })
// 计算时间范围
const now = new Date()
let startTime = new Date()
switch (historyTimeRange.value) {
case '1m':
startTime.setMinutes(now.getMinutes() - 1)
break
case '5m':
startTime.setMinutes(now.getMinutes() - 5)
break
case '1h':
startTime.setHours(now.getHours() - 1)
break
case '1d':
startTime.setDate(now.getDate() - 1)
break
}
const params = {
service_id: serviceId.value,
host_id: hostId.value,
start: startTime.toISOString(),
end_time: now.toISOString(),
interval: { '1m': '1m', '5m': '5m', '1h': '1h', '1d': '1d' }[historyTimeRange.value] || '5m'
}
const res = await getMetricsHistory(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
metricsData.value = body.data.data ?? body.data
pushHistory(metricsData.value)
historicalMetricsData.value = Array.isArray(body.data) ? body.data : (body.data.data || [])
await nextTick()
renderCharts()
renderHistoricalCharts()
} else {
ElMessage.error(extractApiError(body, '加载历史指标失败'))
}
} catch { /* silent for polling */ } finally { metricsLoading.value = false }
}
const pushHistory = (d) => {
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
metricsHistory.times.push(now)
metricsHistory.cpu.push(d.cpu?.cpu_usage_percent ?? 0)
metricsHistory.memPercent.push(d.memory?.percent ?? 0)
metricsHistory.netRx.push(d.internet_speed?.rx_bytes ?? 0)
metricsHistory.netTx.push(d.internet_speed?.tx_bytes ?? 0)
if (metricsHistory.times.length > MAX_HISTORY) {
metricsHistory.times.shift()
metricsHistory.cpu.shift()
metricsHistory.memPercent.shift()
metricsHistory.netRx.shift()
metricsHistory.netTx.shift()
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '加载历史指标失败'))
} finally {
historicalMetricsLoading.value = false
}
}
const makeLineOption = (title, seriesData, color, yFormatter) => ({
tooltip: { trigger: 'axis', formatter: (params) => {
const p = params[0]
return `${p.axisValue}<br/>${p.marker} ${p.seriesName}: ${yFormatter ? yFormatter(p.value) : p.value}`
}},
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: metricsHistory.times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: yFormatter || (v => v) } },
series: Array.isArray(seriesData)
? seriesData.map(s => ({ name: s.name, type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2 }, data: s.data, itemStyle: { color: s.color } }))
: [{ name: title, type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data: seriesData }]
})
const formatNetLabel = (v) => {
if (!v) return '0 B/s'
@@ -629,33 +777,78 @@ const formatNetLabel = (v) => {
return v + ' B/s'
}
const renderCharts = () => {
const times = [...metricsHistory.times]
const cpuData = [...metricsHistory.cpu]
const memData = [...metricsHistory.memPercent]
const rxData = [...metricsHistory.netRx]
const txData = [...metricsHistory.netTx]
// 渲染历史指标图表
const renderHistoricalCharts = () => {
const metrics = historicalMetricsData.value
if (!Array.isArray(metrics) || !metrics.length) return
const range = historyTimeRange.value
const showDate = range === '7d' || range === '24h'
const symbolType = range === '7d' ? 'circle' : 'none'
const labelRotate = showDate ? 45 : 0
const times = metrics.map(m => {
const date = new Date(m.bucket)
if (range === '7d') return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
})
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
const memData = metrics.map(m => m.mem_percent ?? 0)
const inetRxData = metrics.map(m => m.inet_rx ?? 0)
const inetTxData = metrics.map(m => m.inet_tx ?? 0)
const netRxRate = []
const netTxRate = []
for (let i = 0; i < metrics.length; i++) {
if (i === 0) { netRxRate.push(0); netTxRate.push(0); continue }
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
if (dt > 0) {
netRxRate.push(Math.max(0, ((metrics[i].net_rx ?? 0) - (metrics[i - 1].net_rx ?? 0)) / dt))
netTxRate.push(Math.max(0, ((metrics[i].net_tx ?? 0) - (metrics[i - 1].net_tx ?? 0)) / dt))
} else {
netRxRate.push(0); netTxRate.push(0)
}
}
const baseGrid = { top: 10, right: 16, bottom: 24, left: 50 }
const makeXAxis = () => ({ type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10, rotate: labelRotate } })
const makeSeries = (name, data, color) => ({ name, type: 'line', smooth: true, symbol: symbolType, areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data })
if (cpuChartRef.value) {
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
cpuChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} CPU: ${p[0].value.toFixed(1)}%` },
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
series: [{ name: 'CPU', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' }, data: cpuData }]
series: [makeSeries('CPU', cpuData, '#409eff')]
}, true)
}
if (memChartRef.value) {
if (!memChart) memChart = echarts.init(memChartRef.value)
memChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
series: [{ name: '内存', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#67c23a' }, itemStyle: { color: '#67c23a' }, data: memData }]
series: [makeSeries('内存', memData, '#67c23a')]
}, true)
}
if (inetChartRef.value) {
if (!inetChart) inetChart = echarts.init(inetChartRef.value)
inetChart.setOption({
tooltip: { trigger: 'axis', formatter: (params) => {
let s = params[0].axisValue
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
return s
}},
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } },
series: [makeSeries('接收', inetRxData, '#409eff'), makeSeries('发送', inetTxData, '#e6a23c')]
}, true)
}
if (netChartRef.value) {
if (!netChart) netChart = echarts.init(netChartRef.value)
netChart.setOption({
@@ -664,33 +857,18 @@ const renderCharts = () => {
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
return s
}},
grid: { top: 10, right: 16, bottom: 24, left: 50 },
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
grid: baseGrid, xAxis: makeXAxis(),
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } },
series: [
{ name: '接收', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' }, data: rxData },
{ name: '发送', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#e6a23c' }, itemStyle: { color: '#e6a23c' }, data: txData }
]
series: [makeSeries('接收', netRxRate, '#409eff'), makeSeries('发送', netTxRate, '#e6a23c')]
}, true)
}
}
const startPolling = () => {
if (!serviceId.value || !hostId.value || !isPageActive) return
stopPolling()
pollingActive.value = true
pollTimer = setInterval(() => { loadMetrics() }, 3000)
}
const stopPolling = () => {
pollingActive.value = false
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
const disposeCharts = () => {
cpuChart?.dispose(); cpuChart = null
memChart?.dispose(); memChart = null
netChart?.dispose(); netChart = null
inetChart?.dispose(); inetChart = null
}
const handleEdit = () => {
@@ -736,6 +914,115 @@ const handleDelete = () => {
}).catch(() => {})
}
// ========== 创建注册令牌 ==========
const tokenDialogVisible = ref(false)
const tokenSubmitLoading = ref(false)
const tokenResultVisible = ref(false)
const showTokenGroupSelector = 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
})
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 = () => {
const d = detail.value
Object.assign(tokenForm, {
name: '', host_group_id: d?.host_group_id || 0,
max_cpu: d?.max_cpu || 4,
max_memory: d?.max_memory || 4194304,
max_disk: d?.max_disk || 100,
rx_bandwidth: d?.rx_bandwidth || 100,
tx_bandwidth: d?.tx_bandwidth || 100,
description: '', expire_hours: 24
})
tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB'
tokenDialogVisible.value = true
}
const handleTokenGroupSelected = (group) => {
tokenForm.host_group_id = group.id
}
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)
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 = () => {
tagsViewStore.delVisitedView(route)
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
@@ -916,26 +1203,20 @@ const initPage = () => {
showToken.value = false
showPassword.value = false
showPrivateKey.value = false
metricsData.value = null
metricsHistory.times.length = 0
metricsHistory.cpu.length = 0
metricsHistory.memPercent.length = 0
metricsHistory.netRx.length = 0
metricsHistory.netTx.length = 0
historicalMetricsData.value = null
disposeCharts()
loadDetail()
if (activeTab.value === 'monitor') loadMetrics().then(() => startPolling())
if (activeTab.value === 'monitor') loadHistoricalMetrics()
}
watch(hostId, () => { if (isPageActive) initPage() })
onActivated(() => {
isPageActive = true
if (loadedHostId !== hostId.value) initPage()
else if (activeTab.value === 'monitor') startPolling()
})
onMounted(() => { isPageActive = true; initPage() })
onDeactivated(() => { isPageActive = false; stopPolling() })
onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
onDeactivated(() => { isPageActive = false })
onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
</script>
<style scoped>
@@ -981,16 +1262,16 @@ onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
.secret-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.secret-cell code { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mono-text { font-family: 'Consolas', 'Monaco', monospace; }
.text-muted { color: #c0c4cc; }
.metrics-card { margin-bottom: 0; }
.metrics-title { font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
.chart-container { width: 100%; height: 220px; }
.disk-item { margin-bottom: 8px; }
.disk-path { font-weight: 500; color: #409eff; font-size: 13px; margin-bottom: 4px; font-family: 'Consolas', monospace; }
.unit-input-row { display: flex; gap: 6px; width: 100%; }
.wide-number { flex: 1; min-width: 140px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.metric-summary-row { display: flex; gap: 16px; margin-bottom: 16px; }
.metric-summary-card { flex: 1; min-width: 0; background: #f7f8fa; border-radius: 6px; padding: 14px 16px; border: 1px solid #e8e8e8; display: flex; flex-direction: column; }
.metric-summary-label { font-size: 12px; color: #86909c; margin-bottom: 8px; }
.metric-summary-value { font-size: 22px; font-weight: 600; color: #1d2129; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>
@@ -727,27 +727,4 @@ onMounted(() => {
}
.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>
+341 -124
View File
@@ -9,6 +9,9 @@
</div>
</div>
<div class="header-right">
<el-button type="warning" @click="openTokenDialog">
<el-icon><Key /></el-icon>创建注册令牌
</el-button>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增宿主机
</el-button>
@@ -18,6 +21,7 @@
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<el-button type="warning" @click="openTokenDialog"><el-icon><Key /></el-icon>创建注册令牌</el-button>
<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>
@@ -95,85 +99,85 @@
</div>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="宿主机名称" />
</el-form-item>
<el-form-item label="服务地址" prop="base_url">
<el-input v-model="formData.base_url" placeholder="宿主机服务 URL" />
</el-form-item>
<el-form-item label="IP 地址" prop="ip">
<el-input v-model="formData.ip" placeholder="宿主机 IP" />
</el-form-item>
<el-form-item label="认证Token">
<el-input v-model="formData.token" placeholder="宿主机服务 Token(可选)" show-password />
</el-form-item>
<el-divider content-position="left">SSH 配置</el-divider>
<el-form-item label="SSH 端口">
<el-input-number v-model="formData.port" :min="0" :max="65535" placeholder="22" style="width: 100%" />
</el-form-item>
<el-form-item label="SSH 用户名">
<el-input v-model="formData.user" placeholder="默认 tunneluser" />
</el-form-item>
<el-form-item label="SSH 密码">
<el-input v-model="formData.password" placeholder="SSH 密码(可选)" show-password />
</el-form-item>
<el-form-item label="SSH 私钥">
<el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
</el-form-item>
<el-divider content-position="left">资源限制</el-divider>
<el-form-item label="最大CPU(核)">
<el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" />
</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-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close class="tk-dialog">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="宿主机名称" />
</el-form-item>
<el-form-item label="服务地址" prop="base_url">
<el-input v-model="formData.base_url" placeholder="宿主机服务 URL" />
</el-form-item>
<el-form-item label="IP 地址" prop="ip">
<el-input v-model="formData.ip" placeholder="宿主机 IP" />
</el-form-item>
<el-form-item label="认证Token">
<el-input v-model="formData.token" placeholder="宿主机服务 Token(可选)" show-password />
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">SSH 配置</div>
<el-form-item label="端口">
<div class="tk-inline-unit">
<el-input-number v-model="formData.port" :min="0" :max="65535" placeholder="22" controls-position="right" />
</div>
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="formData.user" placeholder="默认 tunneluser" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="formData.password" placeholder="SSH 密码(可选)" show-password />
</el-form-item>
<el-form-item label="私钥">
<el-input v-model="formData.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="formData.max_cpu" :min="0" controls-position="right" /><span class="tk-res-unit"></span>
</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 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-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
<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-col>
<el-col :span="12">
<el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
<el-form-item label="下行带宽">
<el-input-number v-model="formData.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="formData.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="宿主机组">
<div class="bind-selector-row">
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}${formData._groupName ? ' - ' + formData._groupName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0; formData._groupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="介绍">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="宿主机介绍(可选)" />
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">其他配置</div>
<el-form-item label="宿主机组">
<div class="bind-selector-row">
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}${formData._groupName ? ' - ' + formData._groupName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0; formData._groupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="介绍">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="宿主机介绍(可选)" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
<div class="tk-dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</template>
</el-dialog>
@@ -220,53 +224,144 @@
</template>
</el-dialog>
<!-- 创建注册令牌弹窗 -->
<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">
<div class="bind-selector-row">
<el-input
:model-value="tokenForm.host_group_id ? `宿主机组 #${tokenForm.host_group_id}${tokenForm._groupName ? ' - ' + tokenForm._groupName : ''}` : ''"
placeholder="请选择宿主机组" disabled style="flex: 1" />
<el-button type="primary" @click="showTokenGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="tokenForm.host_group_id" @click="tokenForm.host_group_id = 0; tokenForm._groupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</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">令牌有效期</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>
<!-- 令牌用宿主机组选择器 -->
<HostGroupSelectorPopup
v-model="showTokenGroupSelector"
:service-id="serviceId"
:current-id="tokenForm.host_group_id"
@confirm="handleTokenGroupSelected"
/>
<!-- 指标弹窗 -->
<el-dialog v-model="metricsVisible" title="宿主机指标" width="700px" destroy-on-close>
<div v-loading="metricsLoading">
<template v-if="metricsData">
<div class="metrics-time">数据时间{{ formatBucket(metricsData.bucket) }}</div>
<!-- CPU -->
<el-card shadow="never" class="metrics-card" v-if="metricsData.cpu">
<el-card shadow="never" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="使用率">{{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="使用率">{{ (metricsData.cpu_usage ?? 0).toFixed(1) }}%</el-descriptions-item>
<el-descriptions-item label="核心数">{{ metricsData.cpu_count ?? '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 内存 -->
<el-card shadow="never" class="metrics-card" v-if="metricsData.memory">
<el-card shadow="never" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item>
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item>
<el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.memory.free) }}</el-descriptions-item>
<el-descriptions-item label="使用率">{{ metricsData.memory.percent ?? '-' }}%</el-descriptions-item>
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.mem_total) }}</el-descriptions-item>
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.mem_used) }}</el-descriptions-item>
<el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.mem_free) }}</el-descriptions-item>
<el-descriptions-item label="使用率">{{ (metricsData.mem_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 磁盘 -->
<el-card shadow="never" class="metrics-card" v-if="metricsData.disk">
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template>
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item">
<div class="disk-path">{{ path }}</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="总计">{{ formatBytesRaw(info.total) }}</el-descriptions-item>
<el-descriptions-item label="已用">{{ formatBytesRaw(info.used) }}</el-descriptions-item>
<el-descriptions-item label="空闲">{{ formatBytesRaw(info.free) }}</el-descriptions-item>
<el-descriptions-item label="使用率">{{ info.percent ?? '-' }}%</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
<!-- 网络 -->
<el-card shadow="never" class="metrics-card" v-if="metricsData.network || metricsData.internet_speed">
<el-card shadow="never" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
<el-descriptions :column="2" border size="small">
<template v-if="metricsData.network">
<el-descriptions-item label="接收">{{ formatBytesRaw(metricsData.network.rx_bytes) }}</el-descriptions-item>
<el-descriptions-item label="发送">{{ formatBytesRaw(metricsData.network.tx_bytes) }}</el-descriptions-item>
</template>
<template v-if="metricsData.internet_speed">
<el-descriptions-item label="实时接收速率">{{ formatBytesRaw(metricsData.internet_speed.rx_bytes) }}/s</el-descriptions-item>
<el-descriptions-item label="实时发送速率">{{ formatBytesRaw(metricsData.internet_speed.tx_bytes) }}/s</el-descriptions-item>
</template>
<el-descriptions-item label="公网接收速率">{{ formatNetSpeed(metricsData.inet_rx) }}</el-descriptions-item>
<el-descriptions-item label="公网发送速率">{{ formatNetSpeed(metricsData.inet_tx) }}</el-descriptions-item>
<el-descriptions-item label="内网接收(累积)">{{ formatBytesRaw(metricsData.net_rx) }}</el-descriptions-item>
<el-descriptions-item label="内网发送(累积)">{{ formatBytesRaw(metricsData.net_tx) }}</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
@@ -283,13 +378,14 @@
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, Monitor, Coin, Box, Connection } from '@element-plus/icons-vue'
import { Plus, Refresh, Search, ArrowLeft, Monitor, Coin, Connection, Key, CopyDocument } from '@element-plus/icons-vue'
import {
getRemoteHostList, getRemoteHostDetail, getRemoteHostMetrics,
getRemoteHostList, getRemoteHostDetail,
addRemoteHost, updateRemoteHost, deleteRemoteHost,
getHostGroupList
getHostGroupList, createHostToken, getMetricsHistory
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
const route = useRoute()
@@ -385,6 +481,21 @@ const formatBytesRaw = (val) => {
return val + ' B'
}
const formatBucket = (bucket) => {
if (!bucket) return '-'
const d = new Date(bucket)
return isNaN(d.getTime()) ? String(bucket) : d.toLocaleString('zh-CN')
}
const formatNetSpeed = (v) => {
if (!v && v !== 0) return '0 B/s'
v = Number(v)
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
return v.toFixed(0) + ' B/s'
}
/** 格式化后端 {seconds, nanos} 时间戳 */
const formatTimestamp = (ts) => {
if (!ts) return '-'
@@ -551,10 +662,20 @@ const handleMetrics = async (row) => {
metricsLoading.value = true
metricsData.value = null
try {
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: row.id })
const now = new Date()
const start = new Date(now.getTime() - 60 * 60 * 1000)
const res = await getMetricsHistory({
service_id: serviceId.value,
host_id: row.id,
start: start.toISOString(),
end_time: now.toISOString(),
interval: '1m'
})
const body = res?.data
if (body?.code === 200 && body?.data) {
metricsData.value = body.data.data ?? body.data
const arr = Array.isArray(body.data) ? body.data : (body.data.data || [])
metricsData.value = arr.length ? arr[arr.length - 1] : null
if (!metricsData.value) ElMessage.warning('暂无指标数据')
} else {
ElMessage.warning('暂无指标数据')
}
@@ -584,6 +705,118 @@ const handleDelete = (row) => {
}).catch(() => {})
}
// ========== 创建注册令牌 ==========
const tokenDialogVisible = ref(false)
const tokenSubmitLoading = ref(false)
const tokenResultVisible = ref(false)
const showTokenGroupSelector = 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,
_groupName: ''
})
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, _groupName: ''
})
tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB'
tokenDialogVisible.value = true
}
const handleTokenGroupSelected = (group) => {
tokenForm.host_group_id = group.id
tokenForm._groupName = group.name || ''
}
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)
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) {
const data = body.data
tokenResultInfo.name = tokenForm.name
tokenResultInfo.expire_hours = tokenForm.expire_hours
tokenResultInfo.token = data.token || data.Token || JSON.stringify(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 textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
ElMessage.success('令牌信息已复制到剪贴板')
}
}
const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(() => {
@@ -592,27 +825,11 @@ onMounted(() => {
</script>
<style scoped>
.unit-input-row { display: flex; gap: 6px; width: 100%; }
.wide-number { flex: 1; min-width: 140px; }
.host-manage-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; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; }
.host-addr { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
.host-url { font-size: 12px; color: #909399; margin-top: 2px; }
.resource-info { display: flex; flex-wrap: wrap; gap: 4px; }
.text-muted { color: #c0c4cc; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.bind-selector-row { display: flex; align-items: center; width: 100%; }
.metrics-time { font-size: 12px; color: #86909c; margin-bottom: 12px; }
.metrics-card { margin-bottom: 12px; }
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
.disk-item { margin-bottom: 8px; }
.disk-path { font-weight: 500; color: #409eff; font-size: 13px; margin-bottom: 4px; font-family: 'Consolas', monospace; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
+293 -100
View File
@@ -13,6 +13,7 @@
<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>
@@ -86,25 +87,126 @@
</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-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">令牌有效期</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>
<el-button @click="groupDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitGroupForm">确定</el-button>
<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>
@@ -166,84 +268,82 @@
</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-form-item label="SSH 私钥">
<el-input v-model="hostForm.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
</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-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-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 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-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 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-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 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-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-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">其他配置</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>
<el-button @click="hostDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitHostForm">确定</el-button>
<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>
@@ -253,15 +353,17 @@
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 { 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
addRemoteHost, updateRemoteHost, deleteRemoteHost,
createHostToken
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env'
const route = useRoute()
const router = useRouter()
@@ -609,6 +711,106 @@ const handleDeleteHost = (row) => {
}).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
})
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
})
tokenMemUnit.value = 'GB'
tokenDiskUnit.value = 'GB'
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)
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() })
@@ -616,11 +818,6 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
<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;
@@ -640,10 +837,6 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
.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>
+68 -66
View File
@@ -59,7 +59,7 @@
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="同步状态" width="100">
<el-table-column v-if="isEmbeddedHost" label="同步状态" width="100">
<template #default="{ row }">
<el-tag :type="syncStatusType(row.sync_status)" size="small">{{ syncStatusLabel(row.sync_status) }}</el-tag>
</template>
@@ -87,42 +87,45 @@
</div>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建镜像' : '编辑镜像'" width="560px" destroy-on-close>
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建镜像' : '编辑镜像'" width="560px" destroy-on-close class="tk-dialog">
<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="path">
<el-input v-model="formData.path" placeholder="URL 或服务器文件路径" />
</el-form-item>
<el-form-item label="系统类型" prop="os_type">
<el-select v-model="formData.os_type" style="width: 100%">
<el-option label="Linux" value="linux" />
<el-option label="Windows" value="windows" />
</el-select>
</el-form-item>
<el-form-item label="镜像类型" prop="type">
<el-select v-model="formData.type" style="width: 100%">
<el-option label="系统镜像" value="system" />
<el-option label="数据镜像" value="data" />
</el-select>
</el-form-item>
<el-form-item label="介绍">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍可选" />
</el-form-item>
<template v-if="dialogType === 'edit'">
<el-form-item label="状态">
<el-select v-model="formData.status" style="width: 100%">
<el-option label="等待中" value="pending" />
<el-option label="下载中" value="downloading" />
<el-option label="就绪" value="ready" />
<el-option label="错误" value="error" />
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="镜像名称" />
</el-form-item>
<el-form-item label="路径" prop="path">
<el-input v-model="formData.path" placeholder="URL 或服务器文件路径" />
</el-form-item>
<el-form-item label="系统类型" prop="os_type">
<el-select v-model="formData.os_type" style="width: 100%">
<el-option label="Linux" value="linux" />
<el-option label="Windows" value="windows" />
</el-select>
</el-form-item>
<el-form-item label="大小">
<el-input-number v-model="formData.size" :min="0" style="width: 100%" />
<el-form-item label="镜像类型" prop="type">
<el-select v-model="formData.type" style="width: 100%">
<el-option label="系统镜像" value="system" />
<el-option label="数据镜像" value="data" />
</el-select>
</el-form-item>
</template>
<el-form-item label="介绍">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍可选" />
</el-form-item>
<template v-if="dialogType === 'edit'">
<el-form-item label="状态">
<el-select v-model="formData.status" style="width: 100%">
<el-option label="等待中" value="pending" />
<el-option label="下载中" value="downloading" />
<el-option label="就绪" value="ready" />
<el-option label="错误" value="error" />
</el-select>
</el-form-item>
<el-form-item label="大小">
<el-input-number v-model="formData.size" :min="0" style="width: 100%" />
</el-form-item>
</template>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -177,37 +180,47 @@
</el-dialog>
<!-- 同步到宿主机弹窗 -->
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="440px" destroy-on-close>
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px">
<el-form-item label="目标宿主机" required>
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%" v-loading="hostOptionsLoading">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<div class="tk-section">
<div class="tk-section-title">同步配置</div>
<el-form-item label="目标宿主机" required>
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%" v-loading="hostOptionsLoading">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="syncDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="syncLoading" @click="submitSyncToHost">确定同步</el-button>
<div class="tk-dialog-footer">
<el-button @click="syncDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="syncLoading" @click="submitSyncToHost">确定同步</el-button>
</div>
</template>
</el-dialog>
<!-- 重下载到宿主机弹窗 -->
<el-dialog v-model="reloadDialogVisible" title="重新下载镜像到宿主机" width="440px" destroy-on-close>
<el-dialog v-model="reloadDialogVisible" title="重新下载镜像到宿主机" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px">
<el-form-item label="镜像">
<el-input :model-value="reloadTarget?.name" disabled />
</el-form-item>
<el-form-item label="目标宿主机" required>
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%" v-loading="hostOptionsLoading">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<div class="tk-section">
<div class="tk-section-title">重下载配置</div>
<el-form-item label="镜像">
<el-input :model-value="reloadTarget?.name" disabled />
</el-form-item>
<el-form-item label="目标宿主机" required>
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%" v-loading="hostOptionsLoading">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="reloadDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="reloadLoading" @click="submitReloadOnHost">确定重下载</el-button>
<div class="tk-dialog-footer">
<el-button @click="reloadDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="reloadLoading" @click="submitReloadOnHost">确定重下载</el-button>
</div>
</template>
</el-dialog>
</div>
@@ -345,7 +358,7 @@ const loadList = async () => {
if (hostId) {
res = await getImageCompareHost({ service_id: serviceId.value, host_id: hostId })
} else {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.page_size }
if (keyword.value) params.keyword = keyword.value
if (filterOsType.value) params.os_type = filterOsType.value
if (filterType.value) params.type = filterType.value
@@ -611,16 +624,5 @@ defineExpose({ loadList })
<style scoped>
.image-manage-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; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
.host-status-section { margin-top: 8px; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
-21
View File
@@ -379,25 +379,4 @@ onMounted(() => {
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>
+90 -85
View File
@@ -71,48 +71,55 @@
</div>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="网络名称" />
</el-form-item>
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="formData.host_id" placeholder="选择宿主机" filterable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="网络类型" prop="type">
<el-select v-model="formData.type" style="width: 100%">
<el-option label="(Bridge/外网)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select>
</el-form-item>
<el-form-item label="IP 地址(CIDR)" prop="address">
<el-input v-model="formData.address" placeholder="例如 192.168.1.0/24" />
</el-form-item>
<el-form-item label="网关地址" prop="gateway">
<el-input v-model="formData.gateway" placeholder="例如 192.168.1.1" />
</el-form-item>
<el-form-item label="DNS 服务器">
<el-input v-model="formData.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" />
</el-form-item>
<el-divider content-position="left">高级配置(可选)</el-divider>
<el-form-item label="MAC 地址">
<el-input v-model="formData.mac_address" placeholder="不填则随机" />
</el-form-item>
<el-form-item label="虚拟网桥名">
<el-input v-model="formData.bridge_name" placeholder="不填使用默认" />
</el-form-item>
<el-form-item label="逻辑网桥名">
<el-input v-model="formData.ls_bridge_name" placeholder="不填使用默认" />
</el-form-item>
<el-form-item label="逻辑端口名">
<el-input v-model="formData.ls_name" placeholder="不填使用默认" />
</el-form-item>
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close class="tk-dialog">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="网络名称" />
</el-form-item>
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="formData.host_id" placeholder="选择宿主机" filterable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="络类型" prop="type">
<el-select v-model="formData.type" style="width: 100%">
<el-option label="网桥(Bridge/外网)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select>
</el-form-item>
<el-form-item label="IP(CIDR)" prop="address">
<el-input v-model="formData.address" placeholder="例如 192.168.1.0/24" />
</el-form-item>
<el-form-item label="网关地址" prop="gateway">
<el-input v-model="formData.gateway" placeholder="例如 192.168.1.1" />
</el-form-item>
<el-form-item label="DNS 服务器">
<el-input v-model="formData.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" />
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">高级配置</div>
<el-form-item label="MAC 地址">
<el-input v-model="formData.mac_address" placeholder="不填则随机" />
</el-form-item>
<el-form-item label="虚拟网桥名">
<el-input v-model="formData.bridge_name" placeholder="不填使用默认" />
</el-form-item>
<el-form-item label="逻辑网桥名">
<el-input v-model="formData.ls_bridge_name" placeholder="不填使用默认" />
</el-form-item>
<el-form-item label="逻辑端口名">
<el-input v-model="formData.ls_name" placeholder="不填使用默认" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
<div class="tk-dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</template>
</el-dialog>
@@ -140,42 +147,50 @@
</el-dialog>
<!-- 批量创建弹窗 -->
<el-dialog v-model="batchDialogVisible" title="批量创建网络" width="560px" destroy-on-close>
<el-alert type="info" :closable="false" style="margin-bottom: 16px">通过指定 IP 范围(start_ip ~ end_ip)批量创建网络条目</el-alert>
<el-form ref="batchFormRef" :model="batchForm" :rules="batchFormRules" label-width="120px">
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="batchForm.host_id" placeholder="选择宿主机" filterable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="起始IP" prop="start_ip">
<el-input v-model="batchForm.start_ip" placeholder=" 192.168.1.10" />
</el-form-item>
<el-form-item label="结束IP" prop="end_ip">
<el-input v-model="batchForm.end_ip" placeholder=" 192.168.1.50" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="batchForm.gateway" placeholder="可选 192.168.1.1" />
</el-form-item>
<el-form-item label="子网掩码">
<el-input v-model="batchForm.mask" placeholder="可选 24" />
</el-form-item>
<el-form-item label="DNS">
<el-input v-model="batchForm.nameservers" placeholder="可选 114.114.114.114,8.8.8.8" />
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="batchForm.bridge_name" placeholder="可选" />
</el-form-item>
<el-form-item label="网络类型">
<el-select v-model="batchForm.type" style="width: 100%">
<el-option label="网桥(Bridge)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select>
</el-form-item>
<el-dialog v-model="batchDialogVisible" title="批量创建网络" width="560px" destroy-on-close class="tk-dialog">
<el-form ref="batchFormRef" :model="batchForm" :rules="batchFormRules" label-width="100px">
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px">通过指定 IP 范围(start_ip ~ end_ip)批量创建网络条目</el-alert>
<div class="tk-section">
<div class="tk-section-title">IP 范围</div>
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="batchForm.host_id" placeholder="选择宿主机" filterable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="起始IP" prop="start_ip">
<el-input v-model="batchForm.start_ip" placeholder=" 192.168.1.10" />
</el-form-item>
<el-form-item label="结束IP" prop="end_ip">
<el-input v-model="batchForm.end_ip" placeholder=" 192.168.1.50" />
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">网络配置</div>
<el-form-item label="网关">
<el-input v-model="batchForm.gateway" placeholder="可选 192.168.1.1" />
</el-form-item>
<el-form-item label="子网掩码">
<el-input v-model="batchForm.mask" placeholder="可选 24" />
</el-form-item>
<el-form-item label="DNS">
<el-input v-model="batchForm.nameservers" placeholder="可选 114.114.114.114,8.8.8.8" />
</el-form-item>
<el-form-item label="网桥名称">
<el-input v-model="batchForm.bridge_name" placeholder="可选" />
</el-form-item>
<el-form-item label="网络类型">
<el-select v-model="batchForm.type" style="width: 100%">
<el-option label="网桥(Bridge)" value="bridge" />
<el-option label="内网(NAT)" value="nat" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleBatchSubmit">确定创建</el-button>
<div class="tk-dialog-footer">
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleBatchSubmit">确定创建</el-button>
</div>
</template>
</el-dialog>
</div>
@@ -256,7 +271,7 @@ const loadList = async () => {
if (!hid) { ElMessage.warning('请先选择宿主机'); return }
loading.value = true
try {
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, page_size: queryParams.page_size }
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, count: queryParams.page_size }
if (keyword.value) params.key = keyword.value
if (filterType.value) params.type = filterType.value
if (filterIpVersion.value) params.ip_version = filterIpVersion.value
@@ -426,14 +441,4 @@ defineExpose({ loadList })
<style scoped>
.network-manage-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; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
@@ -335,13 +335,4 @@ onMounted(() => { if (serviceId.value) loadList() })
<style scoped>
.remote-hg-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; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
@@ -533,17 +533,7 @@ onMounted(async () => {
<style scoped>
.sg-manage-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; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.rules-section { margin-top: 8px; }
.rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.rules-header h4 { margin: 0; font-size: 15px; color: #303133; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
+2 -3
View File
@@ -125,7 +125,7 @@ const vmOptionsLoading = ref(false)
const loadVmOptions = async () => {
vmOptionsLoading.value = true
try {
const res = await getVmList({ service_id: serviceId.value, page: 1, page_size: 10 })
const res = await getVmList({ service_id: serviceId.value, page: 1, count: 10 })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : [])
@@ -230,6 +230,5 @@ defineExpose({ loadList })
<style scoped>
.snapshot-manage { padding: 0; }
.toolbar { display: flex; gap: 8px; margin-top: 12px; margin-bottom: 16px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.toolbar { margin-top: 12px; }
</style>
@@ -430,18 +430,7 @@ onMounted(async () => {
<style scoped>
.networking-manage-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; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.networks-section { margin-top: 8px; }
.networks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.networks-header h4 { margin: 0; font-size: 15px; color: #303133; }
.mono-text { font-family: 'Cascadia Code', 'Consolas', 'Monaco', monospace; font-size: 12px; color: #303133; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
File diff suppressed because it is too large Load Diff
+172 -144
View File
@@ -49,9 +49,10 @@
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<el-table-column label="状态" width="140">
<template #default="{ row }">
<el-tag :type="vmStatusType(row.status)" size="small">{{ vmStatusLabel(row.status) }}</el-tag>
<el-tag v-if="row.data_migrate_status && !['completed','failed','aborted','cancelled'].includes(row.data_migrate_status)" type="warning" size="small" effect="dark" style="margin-left:4px">迁移中</el-tag>
</template>
</el-table-column>
<!-- <el-table-column label="宿主机" width="140">
@@ -90,130 +91,142 @@
</div>
<!-- 创建弹窗 -->
<el-dialog v-model="createDialogVisible" title="创建虚拟机" width="800px" destroy-on-close>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px">
<el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item>
<el-form-item label="镜像" prop="image_id">
<div class="bind-selector-row">
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showCreateImageSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="用户" prop="user_id">
<div class="bind-selector-row">
<el-input :model-value="createForm.user_id ? `${createForm._userName || ''} (ID: ${createForm.user_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showUserSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.user_id" @click="createForm.user_id = 0; createForm._userName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-divider content-position="left">宿主机配置(二选一)</el-divider>
<template v-if="isEmbeddedHost">
<el-form-item label="宿主机">
<el-input :model-value="embeddedHostLabel" disabled />
</el-form-item>
</template>
<template v-else>
<el-form-item label="分配方式">
<el-radio-group v-model="hostMode">
<el-radio value="host">指定宿主机</el-radio>
<el-radio value="group">指定宿主机组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="宿主机" v-if="hostMode === 'host'">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="宿主机组" v-if="hostMode === 'group'">
<el-dialog v-model="createDialogVisible" title="创建虚拟机" width="800px" destroy-on-close class="tk-dialog">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
<div class="tk-section">
<div class="tk-section-title">基本信息</div>
<el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item>
<el-form-item label="镜像" prop="image_id">
<div class="bind-selector-row">
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showCreateImageSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="用户" prop="user_id">
<div class="bind-selector-row">
<el-input :model-value="createForm.user_id ? `${createForm._userName || ''} (ID: ${createForm.user_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showUserSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.user_id" @click="createForm.user_id = 0; createForm._userName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
</template>
<el-divider content-position="left">资源配置</el-divider>
<div class="resource-row">
<div class="resource-item">
<span class="resource-label">* 内存</span>
<el-select v-model="memoryUnit" class="resource-unit-select">
<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="resource-input" />
</div>
<div class="resource-item">
<span class="resource-label">* 系统盘</span>
<el-select v-model="diskUnit" class="resource-unit-select">
<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="resource-input" />
</div>
</div>
<div class="resource-row">
<div class="resource-item">
<span class="resource-label">* CPU(核)</span>
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" class="resource-input" />
</div>
<div class="resource-item">
<span class="resource-label">下行带宽(Mbps)</span>
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" class="resource-input" />
</div>
<div class="resource-item">
<span class="resource-label">上行带宽(Mbps)</span>
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" class="resource-input" />
</div>
</div>
<el-divider content-position="left">网络配置(二选一)</el-divider>
<el-form-item label="IP分配方式">
<el-radio-group v-model="ipMode">
<el-radio value="num">按IP数量分配</el-radio>
<el-radio value="ids">选择网络IP</el-radio>
</el-radio-group>
</el-form-item>
<el-row :gutter="16" v-if="ipMode === 'num'">
<el-col :span="12">
<div class="tk-section">
<div class="tk-section-title">宿主机配置</div>
<template v-if="isEmbeddedHost">
<el-form-item label="宿主机">
<el-input :model-value="embeddedHostLabel" disabled />
</el-form-item>
</template>
<template v-else>
<el-form-item label="分配方式">
<el-radio-group v-model="hostMode">
<el-radio value="host">指定宿主机</el-radio>
<el-radio value="group">指定宿主机组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="宿主机" v-if="hostMode === 'host'">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
<el-form-item label="宿主机组" v-if="hostMode === 'group'">
<div class="bind-selector-row">
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
</template>
</div>
<div class="tk-section">
<div class="tk-section-title">资源配置</div>
<div class="tk-resource-grid">
<el-form-item label="CPU" required>
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span>
</el-form-item>
<el-form-item label="内存" required>
<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="系统盘" required>
<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="createForm.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="createForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item>
</div>
<el-form-item label="额外数据卷">
<div style="display:flex;align-items:center;gap:6px">
<el-input-number v-model="createForm.data_volume_size" :min="0" controls-position="right" style="width:200px" />
<span class="tk-res-unit">GB</span>
<span style="font-size:12px;color:#909399;margin-left:8px">0 表示不创建</span>
</div>
</el-form-item>
</div>
<div class="tk-section">
<div class="tk-section-title">网络配置</div>
<el-form-item label="IP分配方式">
<el-radio-group v-model="ipMode">
<el-radio value="num">按IP数量分配</el-radio>
<el-radio value="ids">选择网络IP</el-radio>
</el-radio-group>
</el-form-item>
<div class="tk-resource-grid" v-if="ipMode === 'num'">
<el-form-item label="IPv4数量">
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" style="width: 100%" />
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="IPv6数量">
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" style="width: 100%" />
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
<el-select v-model="createForm.network_ids" multiple filterable placeholder="选择可用网络IP" style="width: 100%">
<el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''}`" :value="n.id" />
</el-select>
<div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络(仅显示未使用的网络)</div>
</el-form-item>
</div>
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
<el-select v-model="createForm.network_ids" multiple filterable placeholder="选择可用网络IP" style="width: 100%">
<el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''}`" :value="n.id" />
</el-select>
<div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络(仅显示未使用的网络)</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
<div class="tk-dialog-footer">
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
</div>
</template>
</el-dialog>
<!-- 重装弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="重装虚拟机" width="440px" destroy-on-close>
<el-alert title="重装会使用新镜像重置虚拟机原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<el-dialog v-model="rebuildDialogVisible" title="重装虚拟机" width="480px" destroy-on-close class="tk-dialog">
<el-form label-width="100px">
<el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item>
<el-form-item label="新镜像" required>
<div class="bind-selector-row">
<el-input :model-value="rebuildImageId ? `镜像 #${rebuildImageId}${rebuildImageName ? ' - ' + rebuildImageName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showRebuildImageSelector = true" style="margin-left: 8px">选择</el-button>
</div>
</el-form-item>
<div class="tk-section">
<div class="tk-section-title">重装信息</div>
<el-alert title="重装会使用新镜像重置虚拟机原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item>
<el-form-item label="新镜像" required>
<div class="bind-selector-row">
<el-input :model-value="rebuildImageId ? `镜像 #${rebuildImageId}${rebuildImageName ? ' - ' + rebuildImageName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showRebuildImageSelector = true" style="margin-left: 8px">选择</el-button>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重装</el-button>
<div class="tk-dialog-footer">
<el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重装</el-button>
</div>
</template>
</el-dialog>
@@ -294,19 +307,18 @@
<!-- 指标 -->
<template v-if="vmMetricsData">
<h4 style="margin: 16px 0 8px">实时指标</h4>
<h4 style="margin: 16px 0 8px">最新指标 <span style="font-size:12px; font-weight:400; color:#86909c">{{ formatBucket(vmMetricsData.bucket) }}</span></h4>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="虚拟机">{{ vmMetricsData.vm_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="CPU使用率">
<span :style="{ color: vmMetricsData.cpu_usage_percent > 90 ? '#F56C6C' : vmMetricsData.cpu_usage_percent > 60 ? '#E6A23C' : '#67C23A' }">
{{ (vmMetricsData.cpu_usage_percent ?? 0).toFixed(1) }}%
<span :style="{ color: (vmMetricsData.cpu_usage ?? 0) > 90 ? '#F56C6C' : (vmMetricsData.cpu_usage ?? 0) > 60 ? '#E6A23C' : '#67C23A' }">
{{ (vmMetricsData.cpu_usage ?? 0).toFixed(1) }}%
</span>
</el-descriptions-item>
<template v-if="vmMetricsData.internet_speed && Object.keys(vmMetricsData.internet_speed).length">
<el-descriptions-item label="网络速率" :span="2">
<div v-for="(val, key) in vmMetricsData.internet_speed" :key="key">{{ key }}: {{ val }}</div>
</el-descriptions-item>
</template>
<el-descriptions-item label="内存">{{ formatMemKB(vmMetricsData.mem_used) }} / {{ formatMemKB(vmMetricsData.mem_total) }}</el-descriptions-item>
<el-descriptions-item label="磁盘读取">{{ formatBytesRaw(vmMetricsData.disk_read) }}</el-descriptions-item>
<el-descriptions-item label="磁盘写入">{{ formatBytesRaw(vmMetricsData.disk_write) }}</el-descriptions-item>
<el-descriptions-item label="网络接收">{{ formatNetSpeed(vmMetricsData.net_rx) }}</el-descriptions-item>
<el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</el-descriptions-item>
</el-descriptions>
</template>
</div>
@@ -350,9 +362,9 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue'
import {
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getVmMetrics,
getRemoteHostList, getVmList, getVmDetail, getVmStatus,
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList, getMetricsHistory
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
@@ -427,7 +439,7 @@ const diskDisplay = computed({
const loadNetworkOptions = async (hid) => {
if (!hid) return
try {
const params = { service_id: serviceId.value, host_id: hid, used: false, page: 1, page_size: 10 }
const params = { service_id: serviceId.value, host_id: hid, used: false, page: 1, count: 10 }
if (injectedHostId?.value) params.type = 'bridge'
const res = await getNetworkList(params)
const body = res?.data
@@ -476,7 +488,7 @@ const vmMetricsData = ref(null)
const createForm = reactive({
name: '', host_id: null, image_id: 0, vcpu: 0, memory: 0,
system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0,
system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, data_volume_size: 0,
host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [],
_imageName: '', _groupName: '', _userName: ''
})
@@ -501,7 +513,7 @@ const vmStatusLabel = (s) => ({
reboot: '重启中', poweroff: '已关机', unknown: '未知'
}[s] || s || '-')
const formatMemory = (kb) => {
const formatMemKB = (kb) => {
if (!kb) return '-'
kb = Number(kb)
if (kb >= 1073741824) return (kb / 1073741824).toFixed(1) + ' TB'
@@ -509,6 +521,7 @@ const formatMemory = (kb) => {
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
return kb + ' KB'
}
const formatMemory = formatMemKB
const formatTimestamp = (ts) => {
if (!ts) return '-'
@@ -532,6 +545,21 @@ const formatBytesRaw = (val) => {
return val + ' B'
}
const formatNetSpeed = (v) => {
if (!v && v !== 0) return '0 B/s'
v = Number(v)
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
return v.toFixed(0) + ' B/s'
}
const formatBucket = (bucket) => {
if (!bucket) return '-'
const d = new Date(bucket)
return isNaN(d.getTime()) ? String(bucket) : d.toLocaleString('zh-CN')
}
// 选择器回调
const handleCreateImageSelected = (img) => { createForm.image_id = img.id; createForm._imageName = img.name }
const handleRebuildImageSelected = (img) => { rebuildImageId.value = img.id; rebuildImageName.value = img.name }
@@ -542,7 +570,7 @@ const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.page_size }
if (hostId.value) params.host_id = hostId.value
if (keyword.value) params.key = keyword.value
if (filterStatus.value) params.status = filterStatus.value
@@ -562,7 +590,8 @@ const handleAdd = () => {
Object.assign(createForm, {
name: '', host_id: injectedHostId?.value || null, image_id: 0,
vcpu: 0, memory: 0, system_size: 0,
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [],
rx_bandwidth: 0, tx_bandwidth: 0, data_volume_size: 0,
host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [],
_imageName: '', _groupName: '', _userName: ''
})
memoryUnit.value = 'GB'
@@ -570,6 +599,7 @@ const handleAdd = () => {
hostMode.value = 'host'
ipMode.value = 'num'
networkOptions.value = []
loadHostOptions()
createDialogVisible.value = true
if (injectedHostId?.value) {
loadNetworkOptions(injectedHostId.value)
@@ -596,6 +626,7 @@ const submitCreate = () => {
if (createForm.name) fd.append('name', createForm.name)
if (createForm.rx_bandwidth) fd.append('rx_bandwidth', createForm.rx_bandwidth)
if (createForm.tx_bandwidth) fd.append('tx_bandwidth', createForm.tx_bandwidth)
if (createForm.data_volume_size > 0) fd.append('data_volume_size', createForm.data_volume_size)
if (hostMode.value === 'host') fd.append('host_id', createForm.host_id)
else fd.append('host_group_id', createForm.host_group_id)
if (ipMode.value === 'num') {
@@ -737,9 +768,24 @@ const fetchVmStatus = async (vm) => {
const fetchVmMetrics = async (vm) => {
try {
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: vm.name })
if (res?.data?.code === 200) vmMetricsData.value = res.data.data?.data ?? res.data.data
else ElMessage.warning('暂无指标数据')
const now = new Date()
const start = new Date(now.getTime() - 60 * 60 * 1000)
const res = await getMetricsHistory({
service_id: serviceId.value,
host_id: vm.host_id || hostId.value,
vm_name: vm.name,
start: start.toISOString(),
end_time: now.toISOString(),
interval: '1m'
})
const body = res?.data
if (body?.code === 200 && body?.data) {
const arr = Array.isArray(body.data) ? body.data : (body.data.data || [])
vmMetricsData.value = arr.length ? arr[arr.length - 1] : null
if (!vmMetricsData.value) ElMessage.warning('暂无指标数据')
} else {
ElMessage.warning('暂无指标数据')
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取指标失败')) }
}
@@ -773,23 +819,5 @@ defineExpose({ loadList })
<style scoped>
.vm-manage-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; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.vm-config { display: flex; gap: 4px; flex-wrap: wrap; }
.text-muted { color: #c0c4cc; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.detail-actions { margin-top: 16px; display: flex; gap: 8px; }
.bind-selector-row { display: flex; align-items: center; width: 100%; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
.resource-row { display: flex; gap: 20px; margin-bottom: 18px; }
.resource-item { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; }
.resource-label { white-space: nowrap; font-size: 14px; color: #606266; flex-shrink: 0; }
.resource-unit-select { width: 72px; flex-shrink: 0; }
.resource-input { flex: 1; min-width: 0; }
</style>
@@ -0,0 +1,272 @@
<template>
<div class="vnc-command-manage">
<div class="toolbar">
<el-button type="primary" :icon="Plus" @click="openCreateGroup">新建分组</el-button>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<div v-loading="loading" class="group-list">
<el-empty v-if="!groups.length && !loading" description="暂无指令分组" />
<el-card v-for="group in groups" :key="group.id" shadow="never" class="group-card">
<!-- 分组头部 -->
<div class="group-header">
<div class="group-title">
<div v-if="group.icon" class="group-icon-img">
<img :src="group.icon" :alt="group.name" />
</div>
<span v-else class="group-icon">{{ group.defaultIcon }}</span>
<span class="group-name">{{ group.name }}</span>
<el-tag size="small" type="info" style="margin-left:8px">{{ (group.items || []).length }} 条指令</el-tag>
</div>
<div class="group-actions">
<el-button link type="primary" size="small" @click="openCreateItem(group)">添加指令</el-button>
<el-button link type="primary" size="small" @click="openEditGroup(group)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDeleteGroup(group)">删除</el-button>
</div>
</div>
<!-- 指令列表 -->
<el-table v-if="group.items && group.items.length" :data="group.items" size="small" stripe style="margin-top:8px">
<el-table-column prop="label" label="指令名称" min-width="140" />
<el-table-column prop="cmd" label="指令内容" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<code class="cmd-text">{{ row.cmd }}</code>
</template>
</el-table-column>
<el-table-column label="变量" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="parseVars(row.vars).length" size="small" type="warning">{{ parseVars(row.vars).length }} </el-tag>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="70" align="center" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEditItem(row, group)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDeleteItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else :image-size="40" description="暂无指令" style="padding:12px 0" />
</el-card>
</div>
<!-- 新建/编辑分组弹窗 -->
<el-dialog v-model="groupDialogVisible" :title="groupForm.id ? '编辑分组' : '新建分组'" width="440px" destroy-on-close>
<el-form :model="groupForm" label-width="90px">
<el-form-item label="分组名称" required>
<el-input v-model="groupForm.name" placeholder="请输入分组名称" />
</el-form-item>
<el-form-item label="文本图标">
<el-input v-model="groupForm.defaultIcon" placeholder="如 📚,无文件图标时显示" />
</el-form-item>
<el-form-item label="图标文件">
<div class="icon-file-row">
<div v-if="groupForm._iconUrl" class="icon-preview">
<img :src="groupForm._iconUrl" alt="图标预览" />
</div>
<el-button size="small" @click="showIconSelector = true">
{{ groupForm.iconFileId ? '更换图标' : '选择图标文件' }}
</el-button>
<el-button v-if="groupForm.iconFileId" size="small" type="danger" plain
@click="groupForm.iconFileId = ''; groupForm._iconUrl = ''">清除</el-button>
<span v-if="groupForm._iconName" style="font-size:12px;color:#909399;margin-left:6px">{{ groupForm._iconName }}</span>
</div>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="groupForm.sort" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="groupDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitGroup">确定</el-button>
</template>
</el-dialog>
<!-- 图标文件选择器 -->
<ImageSelector v-model="showIconSelector" @confirm="onIconSelected" />
<!-- 新建/编辑指令项弹窗 -->
<el-dialog v-model="itemDialogVisible" :title="itemForm.id ? '编辑指令' : '添加指令'" width="560px" destroy-on-close>
<el-form :model="itemForm" label-width="90px">
<el-form-item label="指令名称" required>
<el-input v-model="itemForm.label" placeholder="如:重启 Nginx" />
</el-form-item>
<el-form-item label="指令内容" required>
<el-input v-model="itemForm.cmd" type="textarea" :rows="3"
placeholder="支持 %var% 变量占位符,如:cd %path% && ls" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="itemForm.sort" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
<!-- 变量配置 -->
<el-form-item label="变量列表">
<div class="vars-editor">
<div v-for="(v, i) in varsList" :key="i" class="var-row">
<el-input v-model="v.k" placeholder="变量名(如 path)" style="width:140px" />
<el-input v-model="v.p" placeholder="提示文字(如 目录路径)" style="flex:1;margin:0 8px" />
<el-button :icon="Delete" circle size="small" type="danger" plain @click="varsList.splice(i, 1)" />
</div>
<el-button size="small" :icon="Plus" @click="varsList.push({ k: '', p: '' })" style="margin-top:6px">添加变量</el-button>
<div style="font-size:12px;color:#909399;margin-top:4px">变量在指令中以 %变量名% 形式使用</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="itemDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitItem">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Delete } from '@element-plus/icons-vue'
import ImageSelector from '@/components/admin/ImageSelector.vue'
import {
getVncCommandGroupList, createVncCommandGroup, updateVncCommandGroup, deleteVncCommandGroup,
createVncCommandItem, updateVncCommandItem, deleteVncCommandItem
} from '@/api/admin/vncCommand'
import { extractApiError } from '@/utils/kvmErrorUtil'
const loading = ref(false)
const submitLoading = ref(false)
const groups = ref([])
const loadList = async () => {
loading.value = true
try {
const res = await getVncCommandGroupList()
if (res?.data?.code === 200) {
const d = res.data.data
groups.value = Array.isArray(d) ? d : (d?.data || d?.list || [])
} else { groups.value = [] }
} catch { groups.value = [] } finally { loading.value = false }
}
const parseVars = (vars) => {
if (!vars) return []
try { const v = typeof vars === 'string' ? JSON.parse(vars) : vars; return Array.isArray(v) ? v : [] } catch { return [] }
}
// ---- 分组 ----
const groupDialogVisible = ref(false)
const showIconSelector = ref(false)
const groupForm = reactive({ id: 0, name: '', defaultIcon: '', iconFileId: '', sort: 0 })
const openCreateGroup = () => {
Object.assign(groupForm, { id: 0, name: '', defaultIcon: '', iconFileId: '', sort: 0 })
groupDialogVisible.value = true
}
const openEditGroup = (g) => {
Object.assign(groupForm, {
id: g.id,
name: g.name,
defaultIcon: g.defaultIcon || '',
iconFileId: g.iconFileId || '',
sort: g.sort || 0
})
// 设置预览数据
if (g.icon) {
groupForm._iconUrl = g.icon
}
groupDialogVisible.value = true
}
const submitGroup = async () => {
if (!groupForm.name) { ElMessage.warning('请填写分组名称'); return }
submitLoading.value = true
try {
const res = groupForm.id
? await updateVncCommandGroup({ id: groupForm.id, name: groupForm.name, defaultIcon: groupForm.defaultIcon, icon_file_id: groupForm.iconFileId, sort: groupForm.sort })
: await createVncCommandGroup({ name: groupForm.name, defaultIcon: groupForm.defaultIcon, icon_file_id: groupForm.iconFileId, sort: groupForm.sort })
if (res?.data?.code === 200) { ElMessage.success(groupForm.id ? '修改成功' : '创建成功'); groupDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
}
const handleDeleteGroup = (g) => {
ElMessageBox.confirm(`确定删除分组「${g.name}」?将同时删除该分组下所有指令!`, '删除确认', { type: 'error' })
.then(async () => {
try {
const res = await deleteVncCommandGroup({ id: g.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
// ---- 指令项 ----
const itemDialogVisible = ref(false)
const itemForm = reactive({ id: 0, group_id: 0, label: '', cmd: '', sort: 0 })
const varsList = ref([])
const openCreateItem = (group) => {
Object.assign(itemForm, { id: 0, group_id: group.id, label: '', cmd: '', sort: 0 })
varsList.value = []
itemDialogVisible.value = true
}
const openEditItem = (item, group) => {
Object.assign(itemForm, { id: item.id, group_id: group.id, label: item.label, cmd: item.cmd, sort: item.sort || 0 })
varsList.value = parseVars(item.vars).map(v => ({ k: v.k, p: v.p }))
itemDialogVisible.value = true
}
const submitItem = async () => {
if (!itemForm.label) { ElMessage.warning('请填写指令名称'); return }
if (!itemForm.cmd) { ElMessage.warning('请填写指令内容'); return }
submitLoading.value = true
try {
const validVars = varsList.value.filter(v => v.k)
const varsJson = validVars.length ? JSON.stringify(validVars) : ''
const res = itemForm.id
? await updateVncCommandItem({ id: itemForm.id, label: itemForm.label, cmd: itemForm.cmd, sort: itemForm.sort, vars: varsJson })
: await createVncCommandItem({ group_id: itemForm.group_id, label: itemForm.label, cmd: itemForm.cmd, sort: itemForm.sort, vars: varsJson })
if (res?.data?.code === 200) { ElMessage.success(itemForm.id ? '修改成功' : '创建成功'); itemDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
}
const handleDeleteItem = (item) => {
ElMessageBox.confirm(`确定删除指令「${item.label}」?`, '删除确认', { type: 'warning' })
.then(async () => {
try {
const res = await deleteVncCommandItem({ id: item.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
// 图标选择处理
const onIconSelected = (selectedFile) => {
if (selectedFile && selectedFile.id) {
groupForm.iconFileId = selectedFile.id
// 添加预览URL和文件名用于显示
groupForm._iconUrl = selectedFile.url
groupForm._iconName = selectedFile.realName
}
showIconSelector.value = false
}
loadList()
</script>
<style scoped>
.vnc-command-manage { padding: 20px; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
.group-list { display: flex; flex-direction: column; gap: 16px; }
.group-card { border-radius: 8px; }
.group-header { display: flex; justify-content: space-between; align-items: center; }
.group-title { display: flex; align-items: center; gap: 6px; }
.group-icon { font-size: 18px; }
.group-icon-img { width: 24px; height: 24px; border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.group-icon-img img { width: 100%; height: 100%; object-fit: cover; }
.group-name { font-size: 15px; font-weight: 600; color: #303133; }
.group-actions { display: flex; gap: 4px; }
.cmd-text { font-family: monospace; font-size: 12px; color: #409eff; background: #f0f7ff; padding: 2px 6px; border-radius: 4px; }
.vars-editor { width: 100%; }
.var-row { display: flex; align-items: center; margin-bottom: 8px; }
.icon-file-row { display: flex; align-items: center; gap: 8px; }
.icon-preview { width: 32px; height: 32px; border-radius: 4px; overflow: hidden; border: 1px solid #dcdfe6; }
.icon-preview img { width: 100%; height: 100%; object-fit: cover; }
</style>
@@ -357,21 +357,10 @@ onMounted(async () => {
<style scoped>
.vnc-node-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; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.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; }
.test-result { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 4px; margin-top: 12px; font-size: 14px; }
.test-result.success { background: #f0f9eb; color: #67c23a; }
.test-result.error { background: #fef0f0; color: #f56c6c; }
.vnc-result { margin-top: 12px; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>
+13 -8
View File
@@ -56,8 +56,11 @@
<el-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="当前大小">{{ detail?.size || 0 }} GB</el-form-item>
<el-form-item label="新大小(GB)">
<el-input-number v-model="newSize" :min="1" controls-position="right" style="width: 100%" />
<el-form-item label="新大小">
<div class="unit-input-row">
<el-input-number v-model="resizeForm.size" :min="1" controls-position="right" style="flex:1" />
<el-select v-model="resizeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -107,7 +110,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
@@ -165,16 +168,18 @@ const loadDetail = async () => {
//
const resizeDialogVisible = ref(false)
const newSize = ref(1)
const resizeForm = reactive({ size: 1, _sizeUnit: 'GB' })
const handleResize = () => {
if (!detail.value) return
newSize.value = detail.value.size || 10
resizeForm.size = detail.value.size || 10
resizeForm._sizeUnit = 'GB'
resizeDialogVisible.value = true
}
const submitResize = async () => {
actionLoading.value = true
try {
const res = await resizeVolume({ service_id: serviceId.value, volume_id: volumeId.value, size: newSize.value })
const sizeGb = resizeForm._sizeUnit === 'TB' ? resizeForm.size * 1024 : resizeForm.size
const res = await resizeVolume({ service_id: serviceId.value, volume_id: volumeId.value, size: sizeGb })
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '调整失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { actionLoading.value = false }
@@ -285,7 +290,7 @@ onMounted(() => { isPageActive = true; initPage() })
.main-content { padding: 20px; }
.info-card { margin-bottom: 20px; }
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
.bind-selector-row { display: flex; align-items: center; width: 100%; }
.action-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-select { width: 90px; flex-shrink: 0; }
</style>
+26 -21
View File
@@ -77,7 +77,12 @@
<el-dialog v-model="createDialogVisible" title="创建数据卷" width="560px" destroy-on-close>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px">
<el-form-item label="名称" prop="name"><el-input v-model="createForm.name" placeholder="数据卷名称" /></el-form-item>
<el-form-item label="大小(GB)" prop="size"><el-input-number v-model="createForm.size" :min="1" controls-position="right" style="width: 100%" /></el-form-item>
<el-form-item label="大小" prop="size">
<div class="unit-input-row">
<el-input-number v-model="createForm.size" :min="1" controls-position="right" style="flex:1" />
<el-select v-model="createForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</el-form-item>
<el-form-item label="宿主机" prop="host_id">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
@@ -110,8 +115,11 @@
<el-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="当前大小">{{ resizeTarget?.size || 0 }} GB</el-form-item>
<el-form-item label="新大小(GB)">
<el-input-number v-model="newSize" :min="1" controls-position="right" style="width: 100%" />
<el-form-item label="新大小">
<div class="unit-input-row">
<el-input-number v-model="resizeForm.size" :min="1" controls-position="right" style="flex:1" />
<el-select v-model="resizeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
</div>
</el-form-item>
</el-form>
<template #footer>
@@ -236,7 +244,7 @@ const createFormRef = ref(null)
const resizeDialogVisible = ref(false)
const mountDialogVisible = ref(false)
const resizeTarget = ref(null)
const newSize = ref(1)
const resizeForm = reactive({ size: 1, _sizeUnit: 'GB' })
const mountTarget = ref(null)
const mountVmId = ref(0)
const mountVmName = ref('')
@@ -262,7 +270,7 @@ const showVmSelector = ref(false)
const showMountVmSelector = ref(false)
const createForm = reactive({
name: '', size: 10, host_id: 0, is_system: false,
name: '', size: 10, _sizeUnit: 'GB', host_id: 0, is_system: false,
image_id: 0, vm_id: 0, target_device: '',
_imageName: '', _vmName: ''
})
@@ -301,7 +309,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
Object.assign(createForm, {
name: '', size: 10, host_id: hostId.value || '',
name: '', size: 10, _sizeUnit: 'GB', host_id: hostId.value || '',
is_system: false, image_id: '', vm_id: '', target_device: '',
_imageName: '', _vmName: ''
})
@@ -313,9 +321,10 @@ const submitCreate = () => {
if (!valid) return
submitLoading.value = true
try {
const sizeGb = createForm._sizeUnit === 'TB' ? createForm.size * 1024 : createForm.size
const payload = {
service_id: serviceId.value,
name: createForm.name, size: createForm.size,
name: createForm.name, size: sizeGb,
host_id: createForm.host_id, is_system: createForm.is_system
}
if (createForm.image_id) payload.image_id = createForm.image_id
@@ -328,12 +337,18 @@ const submitCreate = () => {
})
}
const handleResize = (row) => { resizeTarget.value = row; newSize.value = row.size || 10; resizeDialogVisible.value = true }
const handleResize = (row) => {
resizeTarget.value = row
resizeForm.size = row.size || 10
resizeForm._sizeUnit = 'GB'
resizeDialogVisible.value = true
}
const submitResize = async () => {
submitLoading.value = true
try {
const res = await resizeVolume({ service_id: serviceId.value, volume_id: resizeTarget.value.id, size: newSize.value })
const sizeGb = resizeForm._sizeUnit === 'TB' ? resizeForm.size * 1024 : resizeForm.size
const res = await resizeVolume({ service_id: serviceId.value, volume_id: resizeTarget.value.id, size: sizeGb })
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '调整失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { submitLoading.value = false }
@@ -434,16 +449,6 @@ defineExpose({ loadList })
<style scoped>
.volume-manage-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; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.bind-selector-row { display: flex; align-items: center; width: 100%; }
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-select { width: 90px; flex-shrink: 0; }
</style>