feat: 添加用户虚拟机商品管理
Build and Deploy Vue3 / build (push) Successful in 1m40s
Build and Deploy Vue3 / deploy (push) Successful in 1m8s

This commit is contained in:
2026-03-31 15:13:04 +08:00
parent 71d3605f4f
commit c07e09c151
28 changed files with 7143 additions and 621 deletions
+948
View File
@@ -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>