feat: 添加用户虚拟机商品管理
This commit is contained in:
@@ -0,0 +1,948 @@
|
||||
<template>
|
||||
<div class="uvm-detail">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button link @click="goBack"><el-icon><ArrowLeft /></el-icon>返回列表</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">用户虚拟机详情</span>
|
||||
</div>
|
||||
<el-button :icon="Refresh" plain @click="loadDetail" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<!-- 概览卡片 -->
|
||||
<el-card shadow="never" class="overview-card" v-if="userGoods">
|
||||
<div class="overview-header">
|
||||
<div class="overview-left">
|
||||
<el-icon :size="44" color="#409eff"><Monitor /></el-icon>
|
||||
<div class="overview-info">
|
||||
<div class="name-row">
|
||||
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2>
|
||||
<el-tag v-if="vm?.status" :type="vmStatusType(vm.status)" size="small" style="margin-left:8px">{{ vmStatusLabel(vm.status) }}</el-tag>
|
||||
<el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span>用户商品ID: <b>{{ userGoods.id }}</b></span>
|
||||
<el-divider direction="vertical" />
|
||||
<span>虚拟机ID: <b>{{ userGoods.itemId || '-' }}</b></span>
|
||||
<el-divider direction="vertical" />
|
||||
<span>用户ID: <b>{{ userGoods.userId }}</b></span>
|
||||
<el-divider direction="vertical" />
|
||||
<span>到期: <b>{{ formatExpireTime(userGoods.expireTime) }}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<el-button size="small" type="primary" @click="handleVnc">VNC</el-button>
|
||||
<el-button size="small" type="success" @click="handlePower('start')" :disabled="vm?.status === 'running'">启动</el-button>
|
||||
<el-button size="small" type="warning" @click="handlePower('reboot')">重启</el-button>
|
||||
<el-button size="small" type="danger" @click="handlePower('stop')" :disabled="vm?.status === 'stopped' || vm?.status === 'stop'">关机</el-button>
|
||||
<el-dropdown trigger="click" @command="handleMoreCmd">
|
||||
<el-button size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
|
||||
<el-dropdown-item command="resume">恢复</el-dropdown-item>
|
||||
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
|
||||
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
|
||||
<el-dropdown-item divided command="rebuild">重装系统</el-dropdown-item>
|
||||
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
|
||||
<el-dropdown-item command="updateTraffic">修改带宽</el-dropdown-item>
|
||||
<el-dropdown-item divided command="migrate">迁移</el-dropdown-item>
|
||||
<el-dropdown-item command="transfer">转移用户</el-dropdown-item>
|
||||
<el-dropdown-item divided command="editGoods">编辑商品信息</el-dropdown-item>
|
||||
<el-dropdown-item divided command="delete" style="color:#f56c6c">删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider style="margin:12px 0 8px" />
|
||||
<!-- VM 配置信息 -->
|
||||
<el-descriptions :column="4" border size="small" v-if="vm">
|
||||
<el-descriptions-item label="vCPU">{{ vm.vcpu || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="内存">{{ formatMemory(vm.memory) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下行带宽">{{ vm.rx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="上行带宽">{{ vm.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
|
||||
<el-descriptions-item label="SSH端口">{{ vm.ssh_port || 22 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="流量上限">{{ formatTraffic(vm.traffic_max) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP">{{ vm.ips || '-' }}</el-descriptions-item>
|
||||
<!-- <el-descriptions-item label="快照/备份上限">{{ vm.snapshot_num || 0 }} / {{ vm.backup_num || 0 }}</el-descriptions-item> -->
|
||||
<el-descriptions-item label="续费价格">¥{{ (userGoods.renewPrice / 100 ).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="基础价格">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品">{{ userGoods.good?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ userGoods.note || '-' }}</el-descriptions-item>
|
||||
<!-- <el-descriptions-item label="UUID" :span="2"><span style="font-family:monospace;font-size:12px">{{ vm.uuid || '-' }}</span></el-descriptions-item> -->
|
||||
<el-descriptions-item label="入站安全组" v-if="inPortGroup">
|
||||
<el-tag size="small" type="success">{{ inPortGroup.name }} (ID:{{ inPortGroup.id }})</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="镜像" v-if="vmImage">{{ vmImage.name }} ({{ vmImage.os_type }})</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-descriptions :column="3" border size="small" v-else>
|
||||
<el-descriptions-item label="商品">{{ userGoods.good?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="续费价格">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="基础价格">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ userGoods.note || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<el-card shadow="never" class="tabs-card" v-if="userGoods">
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
|
||||
<!-- 数据卷 -->
|
||||
<el-tab-pane label="数据卷" name="volume">
|
||||
<div class="tab-toolbar">
|
||||
<el-button size="small" type="primary" @click="handleCreateVolume">创建数据卷</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadVolumes">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="volumes" v-loading="volumeLoading" stripe size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="大小" width="80"><template #default="{ row }">{{ row.size }} GB</template></el-table-column>
|
||||
<el-table-column label="类型" width="80"><template #default="{ row }"><el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="80"><template #default="{ row }"><el-tag :type="row.status === 'ready' ? 'success' : 'info'" size="small">{{ row.status || '-' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="挂载" width="80"><template #default="{ row }"><el-tag :type="row.is_mount ? 'success' : 'info'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip><template #default="{ row }"><span style="font-family:monospace;font-size:12px">{{ row.path || '-' }}</span></template></el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleResizeVolume(row)">扩容</el-button>
|
||||
<el-button link type="success" size="small" @click="handleMountVolume(row)" v-if="!row.is_mount">挂载</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleUnmountVolume(row)" v-if="row.is_mount">卸载</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteVolume(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!volumes.length && !volumeLoading" :image-size="60" description="暂无数据卷" />
|
||||
<div class="pagination-wrapper" v-if="volumeTotal > 0">
|
||||
<el-pagination v-model:current-page="volumePage" v-model:page-size="volumePageSize" :page-sizes="[10,20]" :total="volumeTotal" layout="total,sizes,prev,pager,next" small
|
||||
@size-change="s => { volumePageSize = s; volumePage = 1; loadVolumes() }" @current-change="p => { volumePage = p; loadVolumes() }" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 快照 -->
|
||||
<el-tab-pane label="快照" name="snapshot">
|
||||
<div class="tab-toolbar">
|
||||
<el-tag v-if="snapshotQuota" size="small" effect="plain">{{ snapshotQuota.count || 0}} / {{ snapshotQuota.limit }}</el-tag>
|
||||
<el-button size="small" @click="handleSetSnapshotLimit">设置上限</el-button>
|
||||
<el-button size="small" type="primary" @click="handleCreateSnapshot">创建快照</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadSnapshots">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="snapshots" v-loading="snapshotLoading" stripe size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="taskStatusType(row.status)" size="small">{{ row.status || '-' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleRestoreSnapshot(row)">恢复</el-button>
|
||||
<el-button link type="info" size="small" @click="handleSnapshotProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteSnapshot(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!snapshots.length && !snapshotLoading" :image-size="60" description="暂无快照" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 备份 -->
|
||||
<el-tab-pane label="备份" name="backup">
|
||||
<div class="tab-toolbar">
|
||||
<el-tag v-if="backupQuota" size="small" effect="plain">{{ backupQuota.count || 0 }} / {{ backupQuota.limit }}</el-tag>
|
||||
<el-button size="small" @click="handleSetBackupLimit">设置上限</el-button>
|
||||
<el-button size="small" type="primary" @click="handleCreateBackup">创建备份</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadBackups">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="backups" v-loading="backupLoading" stripe size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="taskStatusType(row.status)" size="small">{{ row.status || '-' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleRestoreBackup(row)">恢复</el-button>
|
||||
<el-button link type="info" size="small" @click="handleBackupProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteBackup(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!backups.length && !backupLoading" :image-size="60" description="暂无备份" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 安全组 -->
|
||||
<el-tab-pane label="安全组" name="security">
|
||||
<div class="tab-toolbar">
|
||||
<el-button size="small" type="primary" @click="showSgBindSelector = true">绑定安全组</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
|
||||
</div>
|
||||
<!-- 只展示详情接口返回的入站安全组 -->
|
||||
<el-table :data="inPortGroupList" stripe size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column label="方向" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.direction === 'in' ? 'success' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : '出站' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="白名单" width="80">
|
||||
<template #default="{ row }"><el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="锁定" width="70">
|
||||
<template #default="{ row }"><el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '是' : '否' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="warning" size="small" @click="handleApplySg(row)">应用</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleUnbindSg(row)">解绑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!inPortGroupList.length" :image-size="60" description="暂无绑定的安全组" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 网络 -->
|
||||
<el-tab-pane label="网络" name="network">
|
||||
<div class="tab-toolbar">
|
||||
<el-button size="small" :icon="Refresh" @click="loadNetworks">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="networks" v-loading="networkLoading" stripe size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="address" label="地址(CIDR)" min-width="150" />
|
||||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||||
<el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="80"><template #default="{ row }"><el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!networks.length && !networkLoading" :image-size="60" description="暂无网络" />
|
||||
<div class="pagination-wrapper" v-if="networkTotal > 0">
|
||||
<el-pagination v-model:current-page="networkPage" v-model:page-size="networkPageSize" :page-sizes="[10,20]" :total="networkTotal" layout="total,sizes,prev,pager,next" small
|
||||
@size-change="s => { networkPageSize = s; networkPage = 1; loadNetworks() }" @current-change="p => { networkPage = p; loadNetworks() }" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 组网 -->
|
||||
<el-tab-pane label="组网" name="networking">
|
||||
<div class="tab-toolbar">
|
||||
<el-button size="small" type="primary" @click="handleCreateNetworking">创建组网</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadNetworkings">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="networkings" v-loading="networkingLoading" stripe size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" />
|
||||
<el-table-column prop="bridge_name" label="网桥" min-width="100" />
|
||||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="success" size="small" @click="handleAssignNetworking(row)">分配IP</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteNetworking(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!networkings.length && !networkingLoading" :image-size="60" description="暂无组网" />
|
||||
<div class="pagination-wrapper" v-if="networkingTotal > 0">
|
||||
<el-pagination v-model:current-page="networkingPage" v-model:page-size="networkingPageSize" :page-sizes="[10,20]" :total="networkingTotal" layout="total,sizes,prev,pager,next" small
|
||||
@size-change="s => { networkingPageSize = s; networkingPage = 1; loadNetworkings() }" @current-change="p => { networkingPage = p; loadNetworkings() }" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<!-- VNC -->
|
||||
<el-dialog v-model="vncVisible" title="VNC连接" width="480px" destroy-on-close>
|
||||
<div v-loading="vncLoading">
|
||||
<el-descriptions :column="1" border v-if="vncResult">
|
||||
<el-descriptions-item label="VNC地址"><el-link type="primary" :href="vncResult.url" target="_blank">{{ vncResult.url }}</el-link></el-descriptions-item>
|
||||
<el-descriptions-item label="过期时间">{{ formatTime(vncResult.expire_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-empty v-else-if="!vncLoading" description="获取失败" :image-size="60" />
|
||||
</div>
|
||||
<template #footer><el-button @click="vncVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 电源操作 -->
|
||||
<el-dialog v-model="powerVisible" :title="`${powerLabels[powerAction]}虚拟机`" width="380px" destroy-on-close>
|
||||
<div style="padding:8px 0">确定要{{ powerLabels[powerAction] }}吗?</div>
|
||||
<template #footer>
|
||||
<el-button @click="powerVisible = false">取消</el-button>
|
||||
<el-button :type="powerAction === 'stop' ? 'danger' : 'primary'" :loading="actionLoading" @click="submitPower">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建数据卷 -->
|
||||
<el-dialog v-model="volCreateVisible" title="创建数据卷" width="440px" destroy-on-close>
|
||||
<el-form :model="volCreateForm" label-width="100px">
|
||||
<el-form-item label="名称" required><el-input v-model="volCreateForm.name" /></el-form-item>
|
||||
<el-form-item label="大小(GB)"><el-input-number v-model="volCreateForm.size" :min="1" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="目标设备名"><el-input v-model="volCreateForm.target_device" placeholder="不填自动生成" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="volCreateVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitCreateVolume">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 扩容数据卷 -->
|
||||
<el-dialog v-model="volResizeVisible" title="扩容数据卷" width="400px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="当前大小">{{ volResizeTarget?.size || 0 }} GB</el-form-item>
|
||||
<el-form-item label="新大小(GB)"><el-input-number v-model="volNewSize" :min="volResizeTarget?.size || 1" controls-position="right" style="width:100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="volResizeVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitResizeVolume">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建快照 -->
|
||||
<el-dialog v-model="snapshotCreateVisible" title="创建快照" width="400px" destroy-on-close>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="快照名称" required><el-input v-model="snapshotForm.name" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="snapshotForm.description" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="snapshotCreateVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitCreateSnapshot">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建备份 -->
|
||||
<el-dialog v-model="backupCreateVisible" title="创建备份" width="400px" destroy-on-close>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="备份名称" required><el-input v-model="backupForm.name" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="backupForm.description" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="backupCreateVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitCreateBackup">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建安全组 -->
|
||||
<el-dialog v-model="sgCreateVisible" title="创建安全组" width="440px" destroy-on-close>
|
||||
<el-form :model="sgCreateForm" label-width="90px">
|
||||
<el-form-item label="名称" required><el-input v-model="sgCreateForm.name" /></el-form-item>
|
||||
<el-form-item label="方向">
|
||||
<el-select v-model="sgCreateForm.direction" style="width:100%">
|
||||
<el-option label="入站 (in)" value="in" /><el-option label="出站 (out)" value="out" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="锁定"><el-switch v-model="sgCreateForm.lock" /></el-form-item>
|
||||
<el-form-item label="白名单"><el-switch v-model="sgCreateForm.drop_all" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="sgCreateVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitCreateSg">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建组网 -->
|
||||
<el-dialog v-model="networkingCreateVisible" title="创建组网" width="440px" destroy-on-close>
|
||||
<el-form :model="networkingCreateForm" label-width="90px">
|
||||
<el-form-item label="名称" required><el-input v-model="networkingCreateForm.name" /></el-form-item>
|
||||
<el-form-item label="网桥名称" required><el-input v-model="networkingCreateForm.bridge_name" /></el-form-item>
|
||||
<el-form-item label="网关"><el-input v-model="networkingCreateForm.gateway" placeholder="可选" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="networkingCreateForm.description" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="networkingCreateVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitCreateNetworking">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 分配IP -->
|
||||
<el-dialog v-model="assignVisible" title="分配组网IP" width="400px" destroy-on-close>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="组网">{{ assignTarget?.name }}</el-form-item>
|
||||
<el-form-item label="指定IP"><el-input v-model="assignIp" placeholder="留空自动分配" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitAssign">分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重装系统 -->
|
||||
<el-dialog v-model="rebuildVisible" title="重装系统" width="440px" destroy-on-close>
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:12px">重装会清除当前系统数据!</el-alert>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="镜像ID" required><el-input-number v-model="rebuildImageId" :min="1" controls-position="right" style="width:100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rebuildVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="actionLoading" @click="submitRebuild">确定重装</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改带宽 -->
|
||||
<el-dialog v-model="trafficVisible" title="修改带宽" width="440px" destroy-on-close>
|
||||
<el-form :model="trafficForm" label-width="130px">
|
||||
<el-form-item label="下行带宽(Mbps)"><el-input-number v-model="trafficForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="上行带宽(Mbps)"><el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="流量上限(MB)"><el-input-number v-model="trafficForm.traffic_max" :min="0" controls-position="right" style="width:100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="trafficVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitUpdateTraffic">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 转移用户 -->
|
||||
<el-dialog v-model="transferVisible" title="转移虚拟机" width="440px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="目标用户">
|
||||
<div class="selector-row">
|
||||
<el-input :model-value="transferForm._userName || (transferForm.target_user_id ? `用户 #${transferForm.target_user_id}` : '')" readonly placeholder="请选择目标用户" style="flex:1" />
|
||||
<el-button type="primary" @click="showTransferUserSelector = true" style="margin-left:8px">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitTransfer">确定转移</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<UserSelector v-model:visible="showTransferUserSelector" @select="u => { transferForm.target_user_id = u.user_id; transferForm._userName = u.user_name }" />
|
||||
<!-- 绑定安全组选择器 -->
|
||||
<UserVmSecurityGroupSelector v-model="showSgBindSelector" :user-goods-id="userGoodsId"
|
||||
@confirm="sg => handleBindSg(sg)" />
|
||||
|
||||
<!-- 编辑商品信息弹窗 -->
|
||||
<el-dialog v-model="editGoodsVisible" title="编辑商品信息" width="480px" destroy-on-close>
|
||||
<el-form :model="editGoodsForm" label-width="110px">
|
||||
<el-form-item label="备注"><el-input v-model="editGoodsForm.note" /></el-form-item>
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="editGoodsForm.renew_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)">
|
||||
<el-input-number v-model="editGoodsForm.base_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="editGoodsForm.expire_time" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editGoodsVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="actionLoading" @click="submitEditGoods">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 任务进度弹窗 -->
|
||||
<el-dialog v-model="progressVisible" :title="progressTitle" width="480px" destroy-on-close>
|
||||
<div v-loading="progressLoading">
|
||||
<el-descriptions :column="1" border size="small" v-if="progressData">
|
||||
<el-descriptions-item label="任务ID">
|
||||
<span style="font-family:monospace;font-size:12px">{{ progressData.task_id || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="taskStatusType(progressData.status)" size="small">{{ progressData.status || '-' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="元数据" v-if="progressData.meta && progressData.meta.length > 2">
|
||||
<span style="word-break:break-all;font-size:12px">{{ progressData.meta }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-empty v-else-if="!progressLoading" description="暂无进度信息" :image-size="60" />
|
||||
</div>
|
||||
<template #footer><el-button @click="progressVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, ArrowDown, Monitor } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getUserVmDetail, getUserVmVnc,
|
||||
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
|
||||
transferUserVm, updateUserVmTraffic,
|
||||
getUserVmVolumeList, createUserVmVolume, resizeUserVmVolume, mountUserVmVolume, unmountUserVmVolume, deleteUserVmVolume,
|
||||
getUserVmSnapshotList, getUserVmSnapshotCount, createUserVmSnapshot, restoreUserVmSnapshot, deleteUserVmSnapshot, getUserVmSnapshotProgress, setUserVmSnapshotLimit,
|
||||
getUserVmBackupList, getUserVmBackupCount, createUserVmBackup, restoreUserVmBackup, deleteUserVmBackup, getUserVmBackupProgress, setUserVmBackupLimit,
|
||||
getUserVmPostGroupList, createUserVmPostGroup, bindUserVmPostGroup, unbindUserVmPostGroup, applyUserVmPostGroup, deleteUserVmPostGroup, enableUserVmPostGroupWhitelist, disableUserVmPostGroupWhitelist,
|
||||
getUserVmNetworkList, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, deleteUserVmNetworking
|
||||
} from '@/api/admin/userVm'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import UserSelector from '@/components/UserSelector/index.vue'
|
||||
import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userGoodsId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const activeTab = ref('volume')
|
||||
|
||||
// 详情数据
|
||||
const userGoods = ref(null)
|
||||
const vm = ref(null)
|
||||
const vmNetworks = ref([])
|
||||
const vmVolumes = ref([])
|
||||
const vmImage = ref(null)
|
||||
const inPortGroup = ref(null)
|
||||
|
||||
// 入站安全组列表(来自详情接口)
|
||||
const inPortGroupList = computed(() => inPortGroup.value ? [inPortGroup.value] : [])
|
||||
const showSgBindSelector = ref(false)
|
||||
|
||||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
const formatExpireTime = (t) => { if (!t) return '-'; const d = dayjs(t); return d.year() < 2000 ? '永久' : d.format('YYYY-MM-DD HH:mm') }
|
||||
const formatMemory = (kb) => { if (!kb) return '-'; const n = Number(kb); if (n >= 1048576) return (n / 1048576).toFixed(1) + ' GB'; if (n >= 1024) return (n / 1024).toFixed(0) + ' MB'; return n + ' KB' }
|
||||
const formatTraffic = (kb) => { if (!kb) return '-'; const n = Number(kb); if (n >= 1048576) return (n / 1048576).toFixed(1) + ' GB'; if (n >= 1024) return (n / 1024).toFixed(0) + ' MB'; return n + ' KB' }
|
||||
const vmStatusType = (s) => ({ running: 'success', stopped: 'danger', stop: 'danger', shutoff: 'danger', paused: 'warning', error: 'danger' }[s] || 'info')
|
||||
const vmStatusLabel = (s) => ({ running: '运行中', stopped: '已停止', stop: '已停止', shutoff: '已关闭', paused: '已暂停', error: '错误' }[s] || s || '-')
|
||||
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
|
||||
|
||||
const goBack = () => router.push('/user-goods/vm-list')
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!userGoodsId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUserVmDetail({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
userGoods.value = d.user_goods
|
||||
const vmData = d.vm
|
||||
if (vmData) {
|
||||
vm.value = vmData.data
|
||||
vmNetworks.value = vmData.networks || []
|
||||
vmVolumes.value = vmData.volumes || []
|
||||
vmImage.value = vmData.image || null
|
||||
inPortGroup.value = vmData.in_port_group || null
|
||||
}
|
||||
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleTabChange = (tab) => {
|
||||
if (tab === 'volume') loadVolumes()
|
||||
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
|
||||
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
|
||||
if (tab === 'security') loadSecurityGroups()
|
||||
if (tab === 'network') loadNetworks()
|
||||
if (tab === 'networking') loadNetworkings()
|
||||
}
|
||||
|
||||
// ---- VNC ----
|
||||
const vncVisible = ref(false)
|
||||
const vncLoading = ref(false)
|
||||
const vncResult = ref(null)
|
||||
const handleVnc = async () => {
|
||||
vncVisible.value = true; vncLoading.value = true; vncResult.value = null
|
||||
try {
|
||||
const res = await getUserVmVnc({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200) vncResult.value = res.data.data
|
||||
else ElMessage.error(extractApiError(res?.data, '获取VNC失败'))
|
||||
} catch { /* */ } finally { vncLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 电源 ----
|
||||
const powerVisible = ref(false)
|
||||
const powerAction = ref('')
|
||||
const powerLabels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复', rescue: '救援', exitRescue: '退出救援' }
|
||||
const powerApis = { start: startUserVm, stop: stopUserVm, reboot: rebootUserVm, suspend: suspendUserVm, resume: resumeUserVm, rescue: rescueUserVm, exitRescue: exitRescueUserVm }
|
||||
|
||||
const handlePower = (action) => { powerAction.value = action; powerVisible.value = true }
|
||||
const submitPower = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await powerApis[powerAction.value]({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${powerLabels[powerAction.value]}成功`); powerVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const handleMoreCmd = (cmd) => {
|
||||
if (powerLabels[cmd]) { handlePower(cmd); return }
|
||||
if (cmd === 'rebuild') { rebuildImageId.value = 0; rebuildVisible.value = true }
|
||||
if (cmd === 'updateTraffic') { Object.assign(trafficForm, { rx_bandwidth: vm.value?.rx_bandwidth || 0, tx_bandwidth: vm.value?.tx_bandwidth || 0, traffic_max: 0 }); trafficVisible.value = true }
|
||||
if (cmd === 'transfer') { Object.assign(transferForm, { target_user_id: 0, _userName: '' }); transferVisible.value = true }
|
||||
if (cmd === 'updateVm') router.push({ path: '/user-goods/vm-list' })
|
||||
if (cmd === 'migrate') ElMessage.info('迁移请在列表页操作')
|
||||
if (cmd === 'editGoods') openEditGoods()
|
||||
if (cmd === 'delete') {
|
||||
ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => {
|
||||
try {
|
||||
const res = await deleteUserVm({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||
} catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 数据卷 ----
|
||||
const volumes = ref([])
|
||||
const volumeLoading = ref(false)
|
||||
const volumePage = ref(1)
|
||||
const volumePageSize = ref(10)
|
||||
const volumeTotal = ref(0)
|
||||
const volCreateVisible = ref(false)
|
||||
const volResizeVisible = ref(false)
|
||||
const volResizeTarget = ref(null)
|
||||
const volNewSize = ref(1)
|
||||
const volCreateForm = reactive({ name: '', size: 10, target_device: '' })
|
||||
|
||||
const loadVolumes = async () => {
|
||||
volumeLoading.value = true
|
||||
try {
|
||||
const res = await getUserVmVolumeList({ user_goods_id: userGoodsId.value, page: volumePage.value, count: volumePageSize.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data; volumes.value = d.data || (Array.isArray(d) ? d : [])
|
||||
volumeTotal.value = d.all_count ?? d.total ?? volumes.value.length
|
||||
}
|
||||
} catch { /* */ } finally { volumeLoading.value = false }
|
||||
}
|
||||
const handleCreateVolume = () => { Object.assign(volCreateForm, { name: '', size: 10, target_device: '' }); volCreateVisible.value = true }
|
||||
const submitCreateVolume = async () => {
|
||||
if (!volCreateForm.name) { ElMessage.warning('请输入名称'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await createUserVmVolume({ user_goods_id: userGoodsId.value, ...volCreateForm })
|
||||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); volCreateVisible.value = false; loadVolumes() }
|
||||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
const handleResizeVolume = (row) => { volResizeTarget.value = row; volNewSize.value = row.size || 1; volResizeVisible.value = true }
|
||||
const submitResizeVolume = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await resizeUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: volResizeTarget.value.id, size: volNewSize.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('扩容成功'); volResizeVisible.value = false; loadVolumes() }
|
||||
else ElMessage.error(extractApiError(res?.data, '扩容失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '扩容失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
const handleMountVolume = (row) => {
|
||||
ElMessageBox.confirm('确定挂载该数据卷吗?', '挂载', { type: 'info' }).then(async () => {
|
||||
try { const res = await mountUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('挂载成功'); loadVolumes() } else ElMessage.error(extractApiError(res?.data, '挂载失败')) } catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
const handleUnmountVolume = (row) => {
|
||||
ElMessageBox.confirm('确定卸载该数据卷吗?', '卸载', { type: 'warning' }).then(async () => {
|
||||
try { const res = await unmountUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadVolumes() } else ElMessage.error(extractApiError(res?.data, '卸载失败')) } catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
const handleDeleteVolume = (row) => {
|
||||
ElMessageBox.confirm('确定删除该数据卷吗?', '删除', { type: 'error' }).then(async () => {
|
||||
try { const res = await deleteUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadVolumes() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 任务进度 ----
|
||||
const progressVisible = ref(false)
|
||||
const progressLoading = ref(false)
|
||||
const progressTitle = ref('')
|
||||
const progressData = ref(null)
|
||||
|
||||
// ---- 快照 ----
|
||||
const snapshots = ref([])
|
||||
const snapshotLoading = ref(false)
|
||||
const snapshotQuota = ref(null)
|
||||
const snapshotCreateVisible = ref(false)
|
||||
const snapshotForm = reactive({ name: '', description: '' })
|
||||
|
||||
const loadSnapshots = async () => {
|
||||
snapshotLoading.value = true
|
||||
try {
|
||||
const res = await getUserVmSnapshotList({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) { const d = res.data.data; snapshots.value = d.data || (Array.isArray(d) ? d : []) }
|
||||
} catch { /* */ } finally { snapshotLoading.value = false }
|
||||
}
|
||||
const loadSnapshotQuota = async () => {
|
||||
try { const res = await getUserVmSnapshotCount({ user_goods_id: userGoodsId.value }); if (res?.data?.code === 200) snapshotQuota.value = res.data.data } catch { /* */ }
|
||||
}
|
||||
const handleCreateSnapshot = () => { Object.assign(snapshotForm, { name: '', description: '' }); snapshotCreateVisible.value = true }
|
||||
const submitCreateSnapshot = async () => {
|
||||
if (!snapshotForm.name) { ElMessage.warning('请输入名称'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await createUserVmSnapshot({ user_goods_id: userGoodsId.value, ...snapshotForm })
|
||||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); snapshotCreateVisible.value = false; loadSnapshots(); loadSnapshotQuota() }
|
||||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
const handleRestoreSnapshot = (row) => {
|
||||
ElMessageBox.confirm(`确定恢复快照「${row.name}」吗?`, '恢复', { type: 'warning' }).then(async () => {
|
||||
try { const res = await restoreUserVmSnapshot({ user_goods_id: userGoodsId.value, snapshot_id: row.id }); if (res?.data?.code === 200) ElMessage.success('恢复操作已提交'); else ElMessage.error(extractApiError(res?.data, '恢复失败')) } catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
const handleDeleteSnapshot = (row) => {
|
||||
ElMessageBox.confirm(`确定删除快照「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
|
||||
try { const res = await deleteUserVmSnapshot({ user_goods_id: userGoodsId.value, snapshot_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSnapshots(); loadSnapshotQuota() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
const handleSnapshotProgress = async (row) => {
|
||||
progressTitle.value = '快照任务进度'
|
||||
progressData.value = null
|
||||
progressVisible.value = true
|
||||
progressLoading.value = true
|
||||
try {
|
||||
const res = await getUserVmSnapshotProgress({ user_goods_id: userGoodsId.value, task_id: String(row.task_id || row.id) })
|
||||
if (res?.data?.code === 200) progressData.value = res.data.data?.data ?? res.data.data
|
||||
else ElMessage.warning('暂无进度信息')
|
||||
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
|
||||
}
|
||||
const handleSetSnapshotLimit = () => { ElMessageBox.prompt('请输入快照上限', '设置快照上限', { inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数', inputValue: String(snapshotQuota.value?.limit || 10) })
|
||||
.then(async ({ value }) => { try { const res = await setUserVmSnapshotLimit({ user_goods_id: userGoodsId.value, limit: value }); if (res?.data?.code === 200) { ElMessage.success('设置成功'); loadSnapshotQuota() } else ElMessage.error(extractApiError(res?.data, '设置失败')) } catch { /* */ } }).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 备份 ----
|
||||
const backups = ref([])
|
||||
const backupLoading = ref(false)
|
||||
const backupQuota = ref(null)
|
||||
const backupCreateVisible = ref(false)
|
||||
const backupForm = reactive({ name: '', description: '' })
|
||||
|
||||
const loadBackups = async () => {
|
||||
backupLoading.value = true
|
||||
try {
|
||||
const res = await getUserVmBackupList({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) { const d = res.data.data; backups.value = d.data || (Array.isArray(d) ? d : []) }
|
||||
} catch { /* */ } finally { backupLoading.value = false }
|
||||
}
|
||||
const loadBackupQuota = async () => {
|
||||
try { const res = await getUserVmBackupCount({ user_goods_id: userGoodsId.value }); if (res?.data?.code === 200) backupQuota.value = res.data.data } catch { /* */ }
|
||||
}
|
||||
const handleCreateBackup = () => { Object.assign(backupForm, { name: '', description: '' }); backupCreateVisible.value = true }
|
||||
const submitCreateBackup = async () => {
|
||||
if (!backupForm.name) { ElMessage.warning('请输入名称'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await createUserVmBackup({ user_goods_id: userGoodsId.value, ...backupForm })
|
||||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); backupCreateVisible.value = false; loadBackups(); loadBackupQuota() }
|
||||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
const handleRestoreBackup = (row) => {
|
||||
ElMessageBox.confirm(`确定恢复备份「${row.name}」吗?`, '恢复', { type: 'warning' }).then(async () => {
|
||||
try { const res = await restoreUserVmBackup({ user_goods_id: userGoodsId.value, backup_id: row.id }); if (res?.data?.code === 200) ElMessage.success('恢复操作已提交'); else ElMessage.error(extractApiError(res?.data, '恢复失败')) } catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
const handleDeleteBackup = (row) => {
|
||||
ElMessageBox.confirm(`确定删除备份「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
|
||||
try { const res = await deleteUserVmBackup({ user_goods_id: userGoodsId.value, backup_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadBackups(); loadBackupQuota() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
const handleBackupProgress = async (row) => {
|
||||
progressTitle.value = '备份任务进度'
|
||||
progressData.value = null
|
||||
progressVisible.value = true
|
||||
progressLoading.value = true
|
||||
try {
|
||||
const res = await getUserVmBackupProgress({ user_goods_id: userGoodsId.value, task_id: String(row.task_id || row.id) })
|
||||
if (res?.data?.code === 200) progressData.value = res.data.data?.data ?? res.data.data
|
||||
else ElMessage.warning('暂无进度信息')
|
||||
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
|
||||
}
|
||||
const handleSetBackupLimit = () => {
|
||||
ElMessageBox.prompt('请输入备份上限', '设置备份上限', { inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数', inputValue: String(backupQuota.value?.limit || 10) })
|
||||
.then(async ({ value }) => { try { const res = await setUserVmBackupLimit({ user_goods_id: userGoodsId.value, limit: value }); if (res?.data?.code === 200) { ElMessage.success('设置成功'); loadBackupQuota() } else ElMessage.error(extractApiError(res?.data, '设置失败')) } catch { /* */ } }).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 安全组 ----
|
||||
const sgs = ref([])
|
||||
const sgLoading = ref(false)
|
||||
const sgPage = ref(1)
|
||||
const sgPageSize = ref(10)
|
||||
const sgTotal = ref(0)
|
||||
const sgCreateVisible = ref(false)
|
||||
const sgCreateForm = reactive({ name: '', direction: 'in', lock: false, drop_all: false })
|
||||
|
||||
const loadSecurityGroups = async () => {
|
||||
sgLoading.value = true
|
||||
try {
|
||||
const res = await getUserVmPostGroupList({ user_goods_id: userGoodsId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data; sgs.value = d.groups || d.data || (Array.isArray(d) ? d : [])
|
||||
sgTotal.value = d.total ?? sgs.value.length
|
||||
}
|
||||
} catch { /* */ } finally { sgLoading.value = false }
|
||||
}
|
||||
const handleCreateSg = () => { Object.assign(sgCreateForm, { name: '', direction: 'in', lock: false, drop_all: false }); sgCreateVisible.value = true }
|
||||
const submitCreateSg = async () => {
|
||||
if (!sgCreateForm.name) { ElMessage.warning('请输入名称'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await createUserVmPostGroup({ user_goods_id: userGoodsId.value, ...sgCreateForm })
|
||||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); sgCreateVisible.value = false; loadSecurityGroups() }
|
||||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
const handleBindSg = async (row) => { try { const res = await bindUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadDetail() } else ElMessage.error(extractApiError(res?.data, '绑定失败')) } catch { /* */ } }
|
||||
const handleUnbindSg = async (row) => { try { const res = await unbindUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('解绑成功'); loadDetail() } else ElMessage.error(extractApiError(res?.data, '解绑失败')) } catch { /* */ } }
|
||||
const handleApplySg = async (row) => { try { const res = await applyUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) ElMessage.success('应用成功'); else ElMessage.error(extractApiError(res?.data, '应用失败')) } catch { /* */ } }
|
||||
const handleSgWhitelist = async (row) => {
|
||||
const api = row.drop_all ? disableUserVmPostGroupWhitelist : enableUserVmPostGroupWhitelist
|
||||
try { const res = await api({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('操作成功'); loadSecurityGroups() } else ElMessage.error(extractApiError(res?.data, '操作失败')) } catch { /* */ }
|
||||
}
|
||||
const handleDeleteSg = (row) => {
|
||||
ElMessageBox.confirm(`确定删除安全组「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
|
||||
try { const res = await deleteUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSecurityGroups() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 网络 ----
|
||||
const networks = ref([])
|
||||
const networkLoading = ref(false)
|
||||
const networkPage = ref(1)
|
||||
const networkPageSize = ref(10)
|
||||
const networkTotal = ref(0)
|
||||
|
||||
const loadNetworks = async () => {
|
||||
networkLoading.value = true
|
||||
try {
|
||||
const res = await getUserVmNetworkList({ user_goods_id: userGoodsId.value, page: networkPage.value, count: networkPageSize.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data; networks.value = d.data || (Array.isArray(d) ? d : [])
|
||||
networkTotal.value = d.all_count ?? d.total ?? networks.value.length
|
||||
}
|
||||
} catch { /* */ } finally { networkLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 组网 ----
|
||||
const networkings = ref([])
|
||||
const networkingLoading = ref(false)
|
||||
const networkingPage = ref(1)
|
||||
const networkingPageSize = ref(10)
|
||||
const networkingTotal = ref(0)
|
||||
const networkingCreateVisible = ref(false)
|
||||
const networkingCreateForm = reactive({ name: '', bridge_name: '', gateway: '', description: '' })
|
||||
const assignVisible = ref(false)
|
||||
const assignTarget = ref(null)
|
||||
const assignIp = ref('')
|
||||
|
||||
const loadNetworkings = async () => {
|
||||
networkingLoading.value = true
|
||||
try {
|
||||
const res = await getUserVmNetworkingList({ user_goods_id: userGoodsId.value, page: networkingPage.value, count: networkingPageSize.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data; networkings.value = d.data || (Array.isArray(d) ? d : [])
|
||||
networkingTotal.value = d.all_count ?? d.total ?? networkings.value.length
|
||||
}
|
||||
} catch { /* */ } finally { networkingLoading.value = false }
|
||||
}
|
||||
const handleCreateNetworking = () => { Object.assign(networkingCreateForm, { name: '', bridge_name: '', gateway: '', description: '' }); networkingCreateVisible.value = true }
|
||||
const submitCreateNetworking = async () => {
|
||||
if (!networkingCreateForm.name || !networkingCreateForm.bridge_name) { ElMessage.warning('请填写名称和网桥名称'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await createUserVmNetworking({ user_goods_id: userGoodsId.value, ...networkingCreateForm })
|
||||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); networkingCreateVisible.value = false; loadNetworkings() }
|
||||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
const handleAssignNetworking = (row) => { assignTarget.value = row; assignIp.value = ''; assignVisible.value = true }
|
||||
const submitAssign = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const payload = { user_goods_id: userGoodsId.value, networking_id: assignTarget.value.id }
|
||||
if (assignIp.value.trim()) payload.ip = assignIp.value.trim()
|
||||
const res = await assignUserVmNetworking(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('分配成功'); assignVisible.value = false; loadNetworkings() }
|
||||
else ElMessage.error(extractApiError(res?.data, '分配失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '分配失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
const handleDeleteNetworking = (row) => {
|
||||
ElMessageBox.confirm(`确定删除组网「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
|
||||
try { const res = await deleteUserVmNetworking({ user_goods_id: userGoodsId.value, networking_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadNetworkings() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 重装 ----
|
||||
const rebuildVisible = ref(false)
|
||||
const rebuildImageId = ref(0)
|
||||
const submitRebuild = async () => {
|
||||
if (!rebuildImageId.value) { ElMessage.warning('请填写镜像ID'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await rebuildUserVm({ user_goods_id: userGoodsId.value, image_id: rebuildImageId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('重装成功'); rebuildVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '重装失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重装失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 修改带宽 ----
|
||||
const trafficVisible = ref(false)
|
||||
const trafficForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, traffic_max: 0 })
|
||||
const submitUpdateTraffic = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await updateUserVmTraffic({ user_goods_id: userGoodsId.value, ...trafficForm })
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); trafficVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 转移 ----
|
||||
const transferVisible = ref(false)
|
||||
const showTransferUserSelector = ref(false)
|
||||
const transferForm = reactive({ target_user_id: 0, _userName: '' })
|
||||
const submitTransfer = async () => {
|
||||
if (!transferForm.target_user_id) { ElMessage.warning('请选择目标用户'); return }
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await transferUserVm({ user_goods_id: userGoodsId.value, target_user_id: transferForm.target_user_id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('转移成功'); transferVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '转移失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '转移失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 编辑商品信息 ----
|
||||
const editGoodsVisible = ref(false)
|
||||
const editGoodsForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '' })
|
||||
|
||||
const openEditGoods = () => {
|
||||
Object.assign(editGoodsForm, {
|
||||
note: userGoods.value?.note || '',
|
||||
renew_price: (userGoods.value?.renewPrice || 0) / 100,
|
||||
base_price: (userGoods.value?.basePrice || 0) / 100,
|
||||
expire_time: (() => {
|
||||
const t = userGoods.value?.expireTime
|
||||
if (!t) return ''
|
||||
const d = dayjs(t)
|
||||
return d.year() < 2000 ? '' : d.format('YYYY-MM-DD HH:mm:ss')
|
||||
})()
|
||||
})
|
||||
editGoodsVisible.value = true
|
||||
}
|
||||
|
||||
const submitEditGoods = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const { updateUserGoods } = await import('@/api/admin/userVm')
|
||||
const payload = { id: userGoodsId.value }
|
||||
if (editGoodsForm.note !== undefined) payload.note = editGoodsForm.note
|
||||
if (editGoodsForm.renew_price) payload.renew_price = Math.round(editGoodsForm.renew_price * 100)
|
||||
if (editGoodsForm.base_price) payload.base_price = Math.round(editGoodsForm.base_price * 100)
|
||||
if (editGoodsForm.expire_time) payload.expire_time = editGoodsForm.expire_time
|
||||
const res = await updateUserGoods(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('保存成功'); editGoodsVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '保存失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDetail()
|
||||
loadVolumes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.uvm-detail { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.main-content { padding: 16px 20px; }
|
||||
.overview-card { margin-bottom: 16px; }
|
||||
.overview-header { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.overview-left { display: flex; align-items: center; gap: 16px; }
|
||||
.overview-info { display: flex; flex-direction: column; gap: 8px; }
|
||||
.name-row { display: flex; align-items: center; }
|
||||
.vm-name { margin: 0; font-size: 20px; font-weight: 600; color: #303133; }
|
||||
.meta-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #606266; flex-wrap: wrap; }
|
||||
.overview-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tabs-card { }
|
||||
.tab-toolbar { display: flex; gap: 8px; align-items: center; margin: 12px 0; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.selector-row { display: flex; align-items: center; width: 100%; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user