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
+517
View File
@@ -0,0 +1,517 @@
<template>
<div class="user-vm-list">
<div class="toolbar">
<div class="toolbar-left">
<el-button type="primary" :icon="Plus" @click="handleCreate">新建虚拟机</el-button>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<div class="toolbar-right">
<el-input v-model="query.key" placeholder="搜索商品名称" clearable style="width:200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="query.bound" placeholder="绑定状态" clearable style="width:120px" @change="handleSearch">
<el-option label="已绑定" :value="true" />
<el-option label="未绑定" :value="false" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
</div>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column label="用户商品ID" width="100">
<template #default="{ row }">{{ row.id }}</template>
</el-table-column>
<el-table-column label="虚拟机ID" width="90">
<template #default="{ row }">{{ row.itemId || row.item_id || '-' }}</template>
</el-table-column>
<el-table-column label="用户" min-width="130">
<template #default="{ row }">
<span>{{ row.user?.UserName || '-' }}</span>
<span style="color:#909399;font-size:12px"> ({{ row.userId || row.user_id }})</span>
</template>
</el-table-column>
<el-table-column label="商品" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.good?.name || '-' }}</span>
<el-tag v-if="row.tag" size="small" type="info" style="margin-left:4px">{{ row.tag }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定状态" width="90">
<template #default="{ row }">
<el-tag :type="row.itemId || row.item_id ? 'success' : 'info'" size="small">
{{ row.itemId || row.item_id ? '已绑定' : '未绑定' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" width="160">
<template #default="{ row }">{{ formatExpireTime(row.expireTime || row.expire_time) }}</template>
</el-table-column>
<el-table-column label="续费价" width="90">
<template #default="{ row }">
<span v-if="row.renewPrice">¥{{ (row.renewPrice).toFixed(2) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="基础价" width="90">
<template #default="{ row }">
<span v-if="row.basePrice">¥{{ (row.basePrice ).toFixed(2) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="goDetail(row)">详情</el-button>
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="query.page" v-model:page-size="query.count"
:page-sizes="[10,20,50]" :total="total" layout="total,sizes,prev,pager,next"
@size-change="s => { query.count = s; query.page = 1; loadList() }"
@current-change="p => { query.page = p; loadList() }" />
</div>
<!-- 新建虚拟机弹窗 -->
<el-dialog v-model="createVisible" title="新建用户虚拟机" width="940px" destroy-on-close class="scrollable-dialog">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px" v-loading="createLoading">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="商品" prop="good_id">
<div class="selector-row">
<el-input :model-value="createForm._goodName || (createForm.good_id ? `商品 #${createForm.good_id}` : '')" readonly placeholder="请选择商品" style="flex:1" />
<el-button type="primary" @click="showProductSelector = true" style="margin-left:8px">选择</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="用户" prop="user_id">
<div class="selector-row">
<el-input :model-value="createForm._userName || (createForm.user_id ? `用户 #${createForm.user_id}` : '')" readonly placeholder="请选择用户" style="flex:1" />
<el-button type="primary" @click="showUserSelector = true" style="margin-left:8px">选择</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="虚拟机名称" prop="name">
<el-input v-model="createForm.name" placeholder="虚拟机名称" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="内存(MB)" prop="memory">
<el-input-number v-model="createForm._memoryMB" :min="0" controls-position="right" style="width:100%" placeholder="MB" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="vCPU" prop="vcpu">
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="系统盘(GB)" prop="system_size">
<el-input-number v-model="createForm.system_size" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="镜像ID" prop="image_id">
<div style="display:flex;flex-direction:column;gap:6px;width:100%">
<div class="selector-row">
<el-input :model-value="createForm._serviceName || (createForm._serviceId ? `主控 #${createForm._serviceId}` : '')"
readonly placeholder="1. 选择主控服务" style="flex:1" />
<el-button type="primary" @click="showServiceSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm._serviceId" @click="createForm._serviceId = 0; createForm._serviceName = ''; createForm.image_id = 0; createForm._imageName = ''" style="margin-left:4px">清除</el-button>
</div>
<div class="selector-row">
<el-input :model-value="createForm._imageName || (createForm.image_id ? `镜像 #${createForm.image_id}` : '')"
readonly placeholder="2. 选择镜像" style="flex:1" />
<el-button type="primary" @click="showImageSelector = true" :disabled="!createForm._serviceId" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left:4px">清除</el-button>
</div>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="下行带宽(Mbps)" width="220px">
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上行带宽(Mbps)" width="220px">
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="IPv4数量" label-width="90px">
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="IPv6数量" label-width="90px">
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="快照上限">
<el-input-number v-model="createForm.snapshot_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="备份上限">
<el-input-number v-model="createForm.backup_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="续费价格()">
<el-input-number v-model="createForm._renewPriceYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="基础价格()">
<el-input-number v-model="createForm._basePriceYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="订单">
<div class="selector-row">
<el-input :model-value="createForm._orderName || (createForm.order_id ? `订单 #${createForm.order_id}` : '')" readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="showOrderSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.order_id" @click="createForm.order_id = 0; createForm._orderName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="到期时间">
<el-date-picker v-model="createForm.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-col>
</el-row>
<el-form-item label="备注">
<el-input v-model="createForm.note" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">确定创建</el-button>
</template>
</el-dialog>
<!-- 编辑虚拟机弹窗(对接 /user_vm/update -->
<el-dialog v-model="editVisible" title="编辑虚拟机配置" width="560px" destroy-on-close class="scrollable-dialog">
<el-form :model="editForm" label-width="130px" v-loading="editLoading">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="editForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="editForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Root密码">
<el-input v-model="editForm.root_password" placeholder="留空则不修改" show-password />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="SSH端口">
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="快照上限">
<el-input-number v-model="editForm.snapshot_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="备份上限">
<el-input-number v-model="editForm.backup_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="安全组">
<div class="selector-row">
<el-input :model-value="editForm._sgName || (editForm.port_group_id ? `安全组 #${editForm.port_group_id}` : '')"
readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="showSgSelector = true" :disabled="!editForm.id" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm.port_group_id" @click="editForm.port_group_id = 0; editForm._sgName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="公网网络">
<div class="selector-row">
<el-input :model-value="editForm._networkName || (editForm.internet_network_id ? `网络 #${editForm.internet_network_id}` : '')"
readonly placeholder="可选仅网桥类型" style="flex:1" />
<el-button type="primary" @click="showNetworkSelector = true" :disabled="!editForm.id" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm.internet_network_id" @click="editForm.internet_network_id = 0; editForm._networkName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<!-- 商品选择器 -->
<ProductSelector v-model="showProductSelector" @confirm="p => { createForm.good_id = p.id; createForm._goodName = p.name }" />
<!-- 用户选择器 -->
<UserSelector v-model:visible="showUserSelector" @select="u => { createForm.user_id = u.user_id; createForm._userName = u.user_name }" />
<!-- 订单选择器 -->
<OrderSelector v-model="showOrderSelector" @confirm="o => { createForm.order_id = o.id; createForm._orderName = o.name }" />
<!-- 主控服务选择器(镜像用) -->
<KvmServiceSelector v-model="showServiceSelector" @confirm="s => { createForm._serviceId = s.id; createForm._serviceName = s.name; createForm.image_id = 0; createForm._imageName = '' }" />
<!-- 镜像选择器 -->
<ImageSelectorPopup v-model="showImageSelector" :service-id="createForm._serviceId || 0" @confirm="img => { createForm.image_id = img.id; createForm._imageName = img.name }" />
<!-- 编辑用安全组选择器 -->
<UserVmSecurityGroupSelector v-model="showSgSelector" :user-goods-id="editForm.id"
@confirm="sg => { editForm.port_group_id = sg.id; editForm._sgName = sg.name }" />
<!-- 编辑用公网网络选择器 -->
<UserVmNetworkSelector v-model="showNetworkSelector" :user-goods-id="editForm.id"
@confirm="net => { editForm.internet_network_id = net.id; editForm._networkName = net.name }" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { getUserVmList, getUserVmDetail, createUserVm, updateUserVm, deleteUserVm } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { formatToApiTime } from '@/utils/tool'
import ProductSelector from '@/components/admin/ProductSelector.vue'
import UserSelector from '@/components/UserSelector/index.vue'
import OrderSelector from '@/components/admin/OrderSelector.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue'
import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue'
import dayjs from 'dayjs'
const router = useRouter()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({ page: 1, count: 10, key: '', bound: null })
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const formatExpireTime = (t) => {
if (!t) return '-'
const d = dayjs(t)
if (d.year() < 2000) return '永久'
return d.format('YYYY-MM-DD HH:mm')
}
const loadList = async () => {
loading.value = true
try {
const params = { page: query.page, count: query.count }
if (query.key) params.key = query.key
if (query.bound !== null && query.bound !== undefined && query.bound !== '') params.bound = query.bound
const res = await getUserVmList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.data || (Array.isArray(d) ? d : [])
total.value = d.all_count ?? d.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const handleSearch = () => { query.page = 1; loadList() }
const goDetail = (row) => {
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
}
// ---- 新建 ----
const createVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref(null)
const showProductSelector = ref(false)
const showUserSelector = ref(false)
const showOrderSelector = ref(false)
const showServiceSelector = ref(false)
const showImageSelector = ref(false)
const createForm = reactive({
good_id: 0, _goodName: '', user_id: 0, _userName: '',
order_id: 0, _orderName: '',
name: '',
_memoryMB: 0,
vcpu: 0, system_size: 0,
rx_bandwidth: 0, tx_bandwidth: 0,
_serviceId: 0, _serviceName: '', image_id: 0, _imageName: '',
ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0,
_renewPriceYuan: 0, _basePriceYuan: 0,
note: '', expire_time: ''
})
const createRules = {
good_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择商品')), trigger: 'change' }],
user_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择用户')), trigger: 'change' }],
vcpu: [{ required: true, message: '请填写vCPU', trigger: 'blur' }],
system_size: [{ required: true, message: '请填写系统盘大小', trigger: 'blur' }],
image_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请填写镜像ID')), trigger: 'blur' }]
}
const handleCreate = () => {
Object.assign(createForm, { good_id: 0, _goodName: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', name: '', _memoryMB: 0, vcpu: 0, system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, _serviceId: 0, _serviceName: '', image_id: 0, _imageName: '', ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0, _renewPriceYuan: 0, _basePriceYuan: 0, note: '', expire_time: '' })
createVisible.value = true
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
createLoading.value = true
try {
const payload = {
good_id: createForm.good_id,
user_id: createForm.user_id,
name: createForm.name,
memory: Math.round((createForm._memoryMB || 0) * 1024), // MB → KB
vcpu: createForm.vcpu,
system_size: createForm.system_size,
rx_bandwidth: createForm.rx_bandwidth,
tx_bandwidth: createForm.tx_bandwidth,
image_id: createForm.image_id,
ipv4_num: createForm.ipv4_num,
ipv6_num: createForm.ipv6_num,
snapshot_num: createForm.snapshot_num,
backup_num: createForm.backup_num,
renew_price: Math.round(createForm._renewPriceYuan || 0 ),
base_price: Math.round(createForm._basePriceYuan || 0 ),
note: createForm.note
}
if (createForm.order_id) payload.order_id = createForm.order_id
if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time)
const res = await createUserVm(payload)
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { createLoading.value = false }
})
}
// ---- 编辑 ----
const editVisible = ref(false)
const editLoading = ref(false)
const showSgSelector = ref(false)
const showNetworkSelector = ref(false)
const editForm = reactive({
id: 0,
rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '',
ssh_port: 22,
port_group_id: 0, _sgName: '',
snapshot_num: 0, backup_num: 0,
internet_network_id: 0, _networkName: ''
})
const handleEdit = async (row) => {
// 先重置
Object.assign(editForm, {
id: row.id,
rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '',
ssh_port: 22,
port_group_id: 0, _sgName: '',
snapshot_num: 0, backup_num: 0,
internet_network_id: 0, _networkName: ''
})
editVisible.value = true
editLoading.value = true
try {
const res = await getUserVmDetail({ user_goods_id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
const vm = d.vm?.data ?? d.vm
if (vm) {
editForm.rx_bandwidth = vm.rx_bandwidth || 0
editForm.tx_bandwidth = vm.tx_bandwidth || 0
editForm.ssh_port = vm.ssh_port || 22
editForm.snapshot_num = vm.snapshot_num || 0
editForm.backup_num = vm.backup_num || 0
}
// 回填入站安全组
const inSg = d.vm?.in_port_group
if (inSg) {
editForm.port_group_id = inSg.id
editForm._sgName = inSg.name
}
// 回填公网网络(取第一个 bridge 类型)
const bridgeNet = (d.vm?.networks || []).find(n => n.type === 'bridge')
if (bridgeNet) {
editForm.internet_network_id = bridgeNet.id
editForm._networkName = bridgeNet.name || bridgeNet.address
}
}
} catch { /* 回填失败不影响编辑 */ } finally { editLoading.value = false }
}
const submitEdit = async () => {
editLoading.value = true
try {
const payload = { user_goods_id: editForm.id }
if (editForm.rx_bandwidth) payload.rx_bandwidth = editForm.rx_bandwidth
if (editForm.tx_bandwidth) payload.tx_bandwidth = editForm.tx_bandwidth
if (editForm.root_password) payload.root_password = editForm.root_password
if (editForm.ssh_port && editForm.ssh_port !== 22) payload.ssh_port = editForm.ssh_port
if (editForm.port_group_id) payload.port_group_id = editForm.port_group_id
if (editForm.snapshot_num) payload.snapshot_num = editForm.snapshot_num
if (editForm.backup_num) payload.backup_num = editForm.backup_num
if (editForm.internet_network_id) payload.internet_network_id = editForm.internet_network_id
const res = await updateUserVm(payload)
if (res?.data?.code === 200) { ElMessage.success('保存成功'); editVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '保存失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { editLoading.value = false }
}
// ---- 删除 ----
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除该用户虚拟机吗?此操作会同时删除远程VM和用户商品记录!`, '删除确认', { type: 'error' })
.then(async () => {
try {
const res = await deleteUserVm({ user_goods_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
onMounted(loadList)
</script>
<style scoped>
.user-vm-list { padding: 20px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
.toolbar-left, .toolbar-right { display: flex; gap: 8px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.selector-row { display: flex; align-items: center; width: 100%; }
:global(.scrollable-dialog .el-dialog__body) {
max-height: 65vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
:global(.scrollable-dialog .el-dialog__body::-webkit-scrollbar) {
display: none;
}
</style>