Files
ApiServer-Web-admin_dashboa…/src/views/user-vm/UserVmList.vue
T
lin c7245cec67
Build and Deploy Vue3 / build (push) Successful in 1m34s
Build and Deploy Vue3 / deploy (push) Successful in 1m10s
fix: 用户商品模块
2026-04-16 15:43:08 +08:00

1814 lines
87 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="user-vm-list">
<el-card class="main-container" shadow="never">
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" class="search-form">
<el-form-item label="用户ID">
<el-input :model-value="filterUserName || (query.user_id ? `${query.user_id}` : '')"
readonly placeholder="筛选用户" clearable style="width:140px;cursor:pointer"
@click="showFilterUserSelector = true"
@clear="query.user_id = ''; filterUserName = ''; handleSearch()" />
</el-form-item>
<el-form-item label="商品ID">
<el-input :model-value="filterGoodName || (query.good_id ? `${query.good_id}` : '')"
readonly placeholder="筛选商品" clearable style="width:140px;cursor:pointer"
@click="showFilterProductSelector = true"
@clear="query.good_id = ''; filterGoodName = ''; handleSearch()" />
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="query.key" placeholder="搜索关键词" clearable style="width:180px"
@keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="绑定状态">
<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-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.bound = null; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleCreate()">
<el-icon><Plus /></el-icon>新建用户虚拟机
</el-button>
<el-button type="success" @click="loadList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
</div>
</div>
<div class="table-section">
<el-alert v-if="listError" :title="listError" type="warning" :closable="false" show-icon style="margin:12px 20px 0" />
<el-table :data="list" v-loading="loading" stripe style="width:100%"
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600, fontSize: '13px' }">
<el-table-column label="用户商品ID" width="110">
<template #default="{ row }">{{ row.id }}</template>
</el-table-column>
<el-table-column label="商品名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
{{ row.good?.name ? `${row.good.name}(ID:${row.good.id})` : '-' }}
</template>
</el-table-column>
<el-table-column label="用户" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.user?.UserName || '-' }} <span style="color:#909399;font-size:12px">(ID:{{ row.userId || row.user_id || '-' }})</span></template>
</el-table-column>
<el-table-column label="绑定状态" width="90">
<template #default="{ row }">
<el-tag :type="row.itemId ? 'success' : 'info'" size="small">
{{ row.itemId ? '已绑定' : '未绑定' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="续费价格" width="100">
<template #default="{ row }">
<span v-if="row.renewPrice">¥{{ (row.renewPrice / 100).toFixed(2) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="基础价格" width="100">
<template #default="{ row }">
<span v-if="row.basePrice">¥{{ (row.basePrice / 100).toFixed(2) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="到期时间" width="150">
<template #default="{ row }">{{ formatExpireTime(row.expireTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.itemId && row.itemId !== 0" link type="primary" size="small" @click="goDetail(row)">详情</el-button>
<el-button v-if="row.itemId && row.itemId !== 0" link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="!row.itemId || row.itemId === 0" link type="success" size="small" @click="handleBindVm(row)">绑定虚拟机</el-button>
<el-dropdown trigger="click" @command="cmd => handleMoreCmd(cmd, row)" style="margin-left:8px">
<el-button link type="primary" size="small" style="transform: translateY(4px);">更多<el-icon style="margin-left:2px;"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="remind">提醒记录</el-dropdown-item>
<el-dropdown-item command="send">发送提醒</el-dropdown-item>
<el-dropdown-item command="delete" divided style="color:#F56C6C">删除商品</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<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, jumper"
@size-change="s => { query.count = s; query.page = 1; loadList() }"
@current-change="p => { query.page = p; loadList() }"
background
class="pagination"
/>
</div>
</el-card>
<!-- 新建虚拟机 / 绑定虚拟机弹窗 -->
<el-dialog v-model="createVisible" title="创建虚拟机/绑定已有虚拟机" width="900px" destroy-on-close class="scrollable-dialog" @opened="onCreateDialogOpened">
<el-tabs v-model="createDialogTab" style="margin-top:-10px">
<el-tab-pane label="创建用户虚拟机" name="create">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px" v-loading="createLoading">
<!-- 行1: 商品 + 用户 -->
<el-row :gutter="20">
<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="请选择商品" />
<el-button type="primary" @click="showProductSelector = true">选择</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="请选择用户" />
<el-button type="primary" @click="showUserSelector = true">选择</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行2: 虚拟机名称 -->
<el-form-item label="虚拟机名称" prop="name">
<el-input v-model="createForm.name" placeholder="虚拟机名称" />
</el-form-item>
<!-- 行3: 内存 + vCPU -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="内存" prop="memory">
<div class="unit-input-row">
<el-input-number v-model="createForm._memoryValue" :min="0" controls-position="right" style="flex:1" />
<el-select v-model="createForm._memoryUnit" class="unit-select">
<el-option label="MB" value="MB" /><el-option label="GB" value="GB" />
</el-select>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="vCPU" prop="vcpu">
<div class="unit-input-row">
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" style="flex:1" />
<span class="unit-text">核</span>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行4: 系统盘 + 主控服务 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="系统盘" prop="system_size">
<div class="unit-input-row">
<el-input-number v-model="createForm.system_size" :min="0" controls-position="right" style="flex:1" />
<el-select v-model="createForm._systemSizeUnit" class="unit-select">
<el-option label="GB" value="GB" /><el-option label="TB" value="TB" />
</el-select>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主控服务">
<div class="selector-row">
<el-input :model-value="createForm._serviceName || (createForm._serviceId ? `主控 #${createForm._serviceId}` : '')" readonly placeholder="选择主控服务" />
<el-button type="primary" @click="showServiceSelector = true">选择</el-button>
<el-button v-if="createForm._serviceId" @click="createForm._serviceId = 0; createForm._serviceName = ''; createForm.image_id = 0; createForm._imageName = ''">清除</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行5: 下行带宽 + 镜像 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="下行带宽">
<div class="unit-input-row">
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" style="flex:1" />
<el-select v-model="createForm._rxUnit" class="unit-select">
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
</el-select>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="镜像" prop="image_id">
<div class="selector-row">
<el-input :model-value="createForm._imageName || (createForm.image_id ? `镜像 #${createForm.image_id}` : '')" readonly placeholder="请先选择商品" />
<el-button type="primary" @click="showImageSelector = true" :disabled="!createForm.good_id">选择</el-button>
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''">清除</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行6: 上行带宽 + 额外数据卷 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="上行带宽">
<div class="unit-input-row">
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" style="flex:1" />
<el-select v-model="createForm._txUnit" class="unit-select">
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
</el-select>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数据卷">
<div class="unit-input-row">
<el-input-number v-model="createForm.data_volume_size" :min="0" controls-position="right" style="flex:1" />
<span class="unit-text">GB</span>
</div>
<div style="font-size:12px;color:#909399;margin-top:2px">额外数据卷大小,0 表示不创建</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行7: IPv4 + IPv6 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="IPv4数量">
<div class="unit-input-row">
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" style="flex:1" />
<span class="unit-text">个</span>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="IPv6数量">
<div class="unit-input-row">
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" style="flex:1" />
<span class="unit-text">个</span>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="快照上限">
<div class="unit-input-row">
<el-input-number v-model="createForm.snapshot_num" :min="0" controls-position="right" style="flex:1" />
<span class="unit-text">个</span>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="备份上限">
<div class="unit-input-row">
<el-input-number v-model="createForm.backup_num" :min="0" controls-position="right" style="flex:1" />
<span class="unit-text">个</span>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行9: 续费价格 + 基础价格 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="续费价格">
<div class="unit-input-row">
<el-input-number v-model="createForm._renewPriceYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text">元</span>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="基础价格">
<div class="unit-input-row">
<el-input-number v-model="createForm._basePriceYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text">元</span>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行9: 基础价格 + 订单 -->
<!-- 行10: 订单 + 到期时间 -->
<el-row :gutter="20">
<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="可选" />
<el-button type="primary" @click="showOrderSelector = true">选择</el-button>
<el-button v-if="createForm.order_id" @click="createForm.order_id = 0; createForm._orderName = ''">清除</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>
<!-- 行11: 宿主机 + 宿主机组 -->
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="指定宿主机">
<div class="selector-row">
<el-input :model-value="createForm._hostName || (createForm.host_id ? `宿主机 #${createForm.host_id}` : '')" readonly placeholder="可选不选则自动分配" />
<el-button type="primary" @click="showHostSelector = true" :disabled="!createForm._serviceId">选择</el-button>
<el-button v-if="createForm.host_id" @click="createForm.host_id = 0; createForm._hostName = ''">清除</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="指定宿主机组">
<div class="selector-row">
<el-input :model-value="createForm._hostGroupName || (createForm.host_group_id ? `主机组 #${createForm.host_group_id}` : '')" readonly placeholder="可选不选则用商品绑定的" />
<el-button type="primary" @click="showHostGroupSelector = true" :disabled="!createForm._serviceId">选择</el-button>
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = 0; createForm._hostGroupName = ''">清除</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="网络">
<div class="selector-row">
<el-input :model-value="createForm._networkNames || ''" readonly placeholder="可选可多选" />
<el-button type="primary" @click="showNetworkSelector = true" :disabled="!createForm._serviceId || !createForm.host_id">选择</el-button>
<el-button v-if="createForm.network_ids.length" @click="createForm.network_ids = []; createForm._networkNames = ''">清除</el-button>
</div>
<div v-if="!createForm.host_id && createForm._serviceId" style="font-size:12px;color:#c0c4cc;margin-top:2px">请先选择宿主机</div>
</el-form-item>
</el-col>
</el-row>
<!-- 行12: 备注 -->
<el-form-item label="备注">
<el-input v-model="createForm.note" type="textarea" :rows="2" />
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="绑定虚拟机" name="bind">
<el-form ref="bindGoodsFormRef" :model="bindGoodsForm" :rules="bindGoodsFormRules" label-width="110px">
<el-form-item label="商品" prop="good_id">
<div class="selector-row">
<el-input :model-value="bindGoodsForm._goodName || (bindGoodsForm.good_id ? `商品 #${bindGoodsForm.good_id}` : '')"
readonly placeholder="请选择商品" style="flex:1" />
<el-button type="primary" @click="showBindGoodsProductSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="bindGoodsForm.good_id" @click="bindGoodsForm.good_id = 0; bindGoodsForm._goodName = ''; bindGoodsForm._goodTag = ''; bindGoodsForm.item_id = 0; bindGoodsForm._itemName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="用户" prop="user_id">
<div class="selector-row">
<el-input :model-value="bindGoodsForm._userName || (bindGoodsForm.user_id ? `用户 #${bindGoodsForm.user_id}` : '')"
readonly placeholder="请选择用户" style="flex:1" />
<el-button type="primary" @click="showBindGoodsUserSel = true" style="margin-left:8px">选择</el-button>
<el-button v-if="bindGoodsForm.user_id" @click="bindGoodsForm.user_id = 0; bindGoodsForm._userName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="订单">
<div class="selector-row">
<el-input :model-value="bindGoodsForm._orderName || (bindGoodsForm.order_id ? `订单 #${bindGoodsForm.order_id}` : '')"
readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="showBindGoodsOrderSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="bindGoodsForm.order_id" @click="bindGoodsForm.order_id = 0; bindGoodsForm._orderName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="套餐">
<div class="selector-row">
<el-input :model-value="bindGoodsForm._planName || (bindGoodsForm.good_plan_id ? `套餐 #${bindGoodsForm.good_plan_id}` : '')"
readonly placeholder="可选选择后自动填入参数" style="flex:1" />
<el-button type="primary" @click="showBindGoodsPlanSelector = true" :disabled="!bindGoodsForm.good_id" style="margin-left:8px">选择</el-button>
<el-button v-if="bindGoodsForm.good_plan_id" @click="bindGoodsForm.good_plan_id = 0; bindGoodsForm._planName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="归属项">
<div class="selector-row">
<el-input :model-value="bindGoodsForm._itemName || (bindGoodsForm.item_id ? `#${bindGoodsForm.item_id}` : '')"
readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="handleBindGoodsItemSelect" :disabled="!bindGoodsForm.good_id" style="margin-left:8px">
{{ bindGoodsForm._goodTag === '云服务器' ? '选择虚拟机' : '使用商品ID' }}
</el-button>
<el-button v-if="bindGoodsForm.item_id" @click="bindGoodsForm.item_id = 0; bindGoodsForm._itemName = ''" style="margin-left:4px">清除</el-button>
</div>
<div v-if="!bindGoodsForm.good_id" class="form-hint disabled">请先选择商品</div>
<div v-else-if="bindGoodsForm._goodTag === '云服务器'" class="form-hint">云服务器商品,点击选择用户虚拟机作为归属项</div>
<div v-else class="form-hint">普通商品,点击将商品ID赋值为归属项</div>
</el-form-item>
<el-form-item label="续费价格">
<div class="unit-input-row">
<el-input-number v-model="bindGoodsForm._renewYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text">元</span>
</div>
</el-form-item>
<el-form-item label="基础价格">
<div class="unit-input-row">
<el-input-number v-model="bindGoodsForm._baseYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text">元</span>
</div>
</el-form-item>
<el-form-item label="到期时间">
<el-date-picker v-model="bindGoodsForm.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-item label="备注">
<el-input v-model="bindGoodsForm.note" type="textarea" :rows="2" />
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button v-if="createDialogTab === 'create'" type="primary" :loading="createLoading" @click="submitCreate">确定创建</el-button>
<el-button v-if="createDialogTab === 'bind'" type="primary" :loading="bindGoodsFormLoading" @click="submitBindGoodsForm">确定</el-button>
</template>
</el-dialog>
<!-- 编辑用户商品弹窗 -->
<el-dialog v-model="editVisible" title="编辑用户商品" width="600px" destroy-on-close class="scrollable-dialog">
<el-form ref="editFormRef" :model="editForm" :rules="editRules" label-width="110px">
<el-form-item label="归属项">
<div class="selector-row">
<el-input :model-value="editForm._itemName || (editForm.item_id ? `虚拟机 #${editForm.item_id}` : '')" readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="handleEditItemSelect" :disabled="!editForm._goodId" style="margin-left:8px">{{ editForm._goodTag === '云服务器' ? '选择用户虚拟机' : '使用商品ID' }}</el-button>
<el-button v-if="editForm.item_id" @click="editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
</div>
<div v-if="editForm._goodTag === '云服务器'" class="form-hint">云服务器商品,点击选择用户虚拟机作为归属项</div>
<div v-else class="form-hint">普通商品,点击分配商品ID作为归属项</div>
</el-form-item>
<el-form-item label="续费价格">
<div class="unit-input-row">
<el-input-number v-model="editForm._renewYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text">元</span>
</div>
</el-form-item>
<el-form-item label="基础价格">
<div class="unit-input-row">
<el-input-number v-model="editForm._baseYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text">元</span>
</div>
</el-form-item>
<el-form-item label="到期时间">
<el-date-picker v-model="editForm.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-item label="备注">
<el-input v-model="editForm.note" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存</el-button>
</div>
</template>
</el-dialog>
<!-- 用户虚拟机选择弹窗 -->
<el-dialog v-model="showVmListDialog" title="选择用户虚拟机" width="800px" append-to-body destroy-on-close>
<div style="margin-bottom:12px">
<el-form :inline="true" size="default">
<el-form-item label="关键词">
<el-input v-model="vmListQuery.key" placeholder="搜索" clearable style="width:180px"
@keyup.enter="loadVmListForItem" @clear="loadVmListForItem" />
</el-form-item>
<el-form-item label="用户ID">
<div class="selector-row">
<el-input :model-value="vmListQuery._userName || (vmListQuery.user_id ? `用户 #${vmListQuery.user_id}` : '')"
readonly placeholder="按用户筛选" style="flex:1" @click="showVmUserSelector = true" />
<el-button v-if="vmListQuery.user_id" @click="vmListQuery.user_id = ''; vmListQuery._userName = ''; loadVmListForItem()" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="vmListQuery.status" placeholder="筛选状态" clearable style="width:120px" @change="loadVmListForItem">
<el-option label="运行中" value="running" />
<el-option label="已停止" value="stopped" />
<el-option label="未知" value="unknown" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadVmListForItem">搜索</el-button>
<el-button @click="loadVmListForItem" :icon="Refresh">刷新</el-button>
<el-button @click="resetVmListFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table :data="vmListForItem" v-loading="vmListLoading" highlight-current-row
@current-change="r => vmListSelected = r" :height="350" style="width:100%"
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="虚拟机名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.name || '-' }}</template>
</el-table-column>
<el-table-column label="配置" min-width="120">
<template #default="{ row }">
<div v-if="row.vcpu && row.memory">
{{ row.vcpu }}core / {{ formatMemory(row.memory) }}
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定状态" width="90">
<template #default="{ row }">
<el-tag :type="row.bound ? 'success' : 'info'" size="small">
{{ row.bound ? '已绑定' : '未绑定' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="IP地址" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.ips || '-' }}</template>
</el-table-column>
<el-table-column label="用户ID" width="80">
<template #default="{ row }">{{ row.user_id || '-' }}</template>
</el-table-column>
<template #empty>
<el-empty description="暂无虚拟机数据" :image-size="80" />
</template>
</el-table>
<div style="display:flex;justify-content:flex-end;margin-top:12px">
<el-pagination v-model:current-page="vmListQuery.page" v-model:page-size="vmListQuery.count"
:total="vmListTotal" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next" background
@size-change="s => { vmListQuery.count = s; vmListQuery.page = 1; loadVmListForItem() }"
@current-change="p => { vmListQuery.page = p; loadVmListForItem() }" />
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="showVmListDialog = false">取消</el-button>
<el-button type="primary" :disabled="!vmListSelected" @click="confirmVmForItem">确定选择</el-button>
</div>
</template>
</el-dialog>
<!-- 订单参数配置弹窗 -->
<el-dialog v-model="showArgsDialog" title="配置订单参数" width="600px" append-to-body destroy-on-close class="scrollable-dialog">
<div v-if="argsSpecLoading" v-loading="true" style="min-height:120px"></div>
<el-empty v-else-if="argsSpecList.length === 0" description="该商品暂无参数配置" :image-size="80" />
<div v-else>
<div v-for="spec in argsSpecList" :key="spec.id" class="args-spec-item">
<div class="args-spec-label">
{{ spec.name }}
<el-tag v-if="spec.must" size="small" type="danger" style="margin-left:6px">必填</el-tag>
</div>
<template v-if="spec.type === 'select' && spec.attrs && spec.attrs.length > 0">
<el-radio-group v-model="argsValues[spec.id]" size="small" @change="buildArgsJson">
<el-radio-button v-for="attr in spec.attrs" :key="attr.id" :value="attr.id">{{ attr.name }}</el-radio-button>
</el-radio-group>
</template>
<template v-else-if="spec.type === 'number'">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<el-input-number
v-model="argsDisplayValues[spec.id]"
:min="hasUnit(spec) ? fromBaseUnit(spec.min ?? 0, argsDisplayUnits[spec.id], getArgKey(spec)) : (spec.min ?? 0)"
:max="hasUnit(spec) ? fromBaseUnit(spec.max ?? 0, argsDisplayUnits[spec.id], getArgKey(spec)) : (spec.max ?? 0)"
:step="hasUnit(spec) ? (fromBaseUnit(spec.step ?? 1, argsDisplayUnits[spec.id], getArgKey(spec)) || 1) : (spec.step ?? 1)"
:step-strictly="true"
@change="onArgsNumberChange(spec)"
style="width:200px"
/>
<el-select v-if="hasUnit(spec)" :model-value="argsDisplayUnits[spec.id]" size="default" style="width:90px" @change="(newUnit) => onArgsUnitChange(spec, newUnit)">
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
</el-select>
<span class="form-hint" style="margin-top:0">范围: {{ spec.min ?? 0 }} ~ {{ spec.max ?? 0 }}
<template v-if="hasUnit(spec)"> {{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }}</span>
</div>
</template>
<template v-else>
<el-input v-model="argsValues[spec.id]" placeholder="请输入值" style="width:200px" @input="buildArgsJson" />
</template>
</div>
<div v-if="editForm.order_args" style="margin-top:16px">
<div class="form-hint" style="margin-bottom:6px;margin-top:0">生成的参数 JSON</div>
<el-input v-model="editForm.order_args" type="textarea" :rows="4" readonly style="font-family:monospace;font-size:12px" />
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="showArgsDialog = false">取消</el-button>
<el-button type="primary" @click="showArgsDialog = false">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 到期提醒记录弹窗 -->
<el-dialog v-model="remindVisible" title="到期提醒记录" width="700px" destroy-on-close append-to-body>
<div style="margin-bottom:12px;display:flex;justify-content:space-between;align-items:center">
<span style="font-size:13px;color:#909399">用户商品 ID: {{ remindGoodsId }}</span>
<el-button type="primary" size="small" :icon="Refresh" @click="loadRemindList">刷新</el-button>
</div>
<el-table :data="remindList" v-loading="remindLoading" stripe size="small" :max-height="400">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="用户" width="100">
<template #default="{ row }">{{ row.user?.UserName || row.user?.user_name || `#${row.userId ?? row.user_id ?? '-'}` }}</template>
</el-table-column>
<el-table-column label="提醒类型" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.type === 'manual' ? 'warning' : 'info'">{{ row.type === 'manual' ? '手动' : '自动' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="发送方式" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.method === 'sms' ? 'success' : row.method === 'email' ? 'primary' : 'info'">{{ row.method === 'sms' ? '短信' : row.method === 'email' ? '邮件' : (row.method || '-') }}</el-tag>
</template>
</el-table-column>
<el-table-column label="发送状态" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.success ? 'success' : 'danger'">{{ row.success ? '成功' : '失败' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="发送时间" min-width="160">
<template #default="{ row }">{{ formatRemindTime(row) }}</template>
</el-table-column>
<el-table-column label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
</el-table>
<div style="display:flex;justify-content:flex-end;margin-top:12px" v-if="remindTotal > remindQuery.count">
<el-pagination v-model:current-page="remindQuery.page" :page-size="remindQuery.count" :total="remindTotal" layout="total, prev, pager, next" small background @current-change="loadRemindList" />
</div>
</el-dialog>
<!-- 选择器组件 -->
<ProductSelector v-model="showProductSelector" default-tag="云服务器" @confirm="handleProductSelect" />
<ProductSelector v-model="showProductSelectorEdit" default-tag="云服务器" @confirm="handleProductSelectEdit" />
<UserSelector v-model:visible="showUserSelector" @select="u => { createForm.user_id = u.user_id; createForm._userName = u.user_name }" />
<UserSelector v-model:visible="showUserSelectorEdit" @select="u => { editForm.user_id = u.user_id; editForm._userName = u.user_name }" />
<UserSelector v-model:visible="showVmUserSelector" @select="handleVmUserSelect" />
<OrderSelector v-model="showOrderSelector" @confirm="o => { createForm.order_id = o.id; createForm._orderName = o.name }" />
<OrderSelector v-model="showOrderSelectorEdit" @confirm="handleOrderSelectEdit" />
<PlanSelector v-model="showPlanSelector" :good-id="createForm.good_id || 0" @confirm="handlePlanSelectedForCreate" />
<PlanSelector v-model="showPlanSelectorEdit" :good-id="editForm.good_id || 0" @confirm="handlePlanSelectEdit" />
<KvmServiceSelector v-model="showServiceSelector" @confirm="s => { createForm._serviceId = s.id; createForm._serviceName = s.name; createForm.image_id = 0; createForm._imageName = ''; createForm.host_id = 0; createForm._hostName = ''; createForm.host_group_id = 0; createForm._hostGroupName = ''; createForm.network_ids = []; createForm._networkNames = '' }" />
<ImageSelectorPopup v-model="showImageSelector" :good-id="createForm.good_id || 0" :use-user-vm-api="true" @confirm="img => { createForm.image_id = img.id; createForm._imageName = img.name }" />
<!-- 宿主机选择器 -->
<HostSelectorPopup v-model="showHostSelector" :service-id="createForm._serviceId || 0" @confirm="h => { createForm.host_id = h.id; createForm._hostName = h.name || h.ip; createForm.network_ids = []; createForm._networkNames = '' }" />
<!-- 宿主机组选择器 -->
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="createForm._serviceId || 0" @confirm="g => { createForm.host_group_id = g.id; createForm._hostGroupName = g.name }" />
<!-- 网络选择器(多选用弹窗内表格) -->
<NetworkSelectorPopup v-model="showNetworkSelector" :service-id="createForm._serviceId || 0" :host-id="createForm.host_id || 0" filter-used="false" @confirm="n => addNetwork(n)" />
<UserVmSecurityGroupSelector v-model="showSgSelector" :user-goods-id="editForm.id" @confirm="sg => { editForm.port_group_id = sg.id; editForm._sgName = sg.name }" />
<UserVmNetworkSelector v-model="showEditNetworkSelector" :user-goods-id="editForm.id" @confirm="net => { editForm.internet_network_id = net.id; editForm._networkName = net.name }" />
<!-- 绑定虚拟机弹窗 -->
<el-dialog v-model="bindVmDialogVisible" title="绑定虚拟机" width="900px" destroy-on-close append-to-body>
<div v-if="bindTargetRow" style="margin-bottom:8px;color:#909399;font-size:13px">
绑定目标用户商品: <b>{{ bindTargetRow.good?.name || '-' }}</b>ID: {{ bindTargetRow.id }}),用户: {{ bindTargetRow.user?.UserName || '-' }}
</div>
<!-- 全局模式:商品选择 + 筛选 -->
<template v-if="!bindTargetRow">
<div style="margin-bottom:12px">
<el-form :inline="true" size="default">
<el-form-item label="商品">
<div class="selector-row" style="width:260px">
<el-input
:model-value="bindSelectedProduct ? `${bindSelectedProduct.name}ID: ${bindSelectedProduct.id}` : ''"
readonly placeholder="请先选择商品" style="flex:1;cursor:pointer"
@click="showBindProductSelector = true" />
<el-button v-if="bindSelectedProduct" link @click.stop="bindSelectedProduct = null; bindVmList = []; bindVmTotal = 0">清除</el-button>
</div>
</el-form-item>
<el-form-item label="关键词">
<el-input v-model="bindVmQuery.key" placeholder="搜索" clearable style="width:140px"
:disabled="!bindSelectedProduct"
@keyup.enter="loadBindVmList" @clear="loadBindVmList" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="bindVmQuery.status" placeholder="全部" clearable style="width:110px"
:disabled="!bindSelectedProduct" @change="loadBindVmList">
<el-option label="运行中" value="running" />
<el-option label="已停止" value="stopped" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="!bindSelectedProduct" @click="loadBindVmList">搜索</el-button>
<el-button :disabled="!bindSelectedProduct" @click="bindVmQuery.key = ''; bindVmQuery.status = ''; loadBindVmList()">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-alert v-if="!bindSelectedProduct" title="请先选择商品选择后将自动加载该商品下的虚拟机列表" type="info" :closable="false" show-icon style="margin-bottom:12px" />
</template>
<!-- 行内模式:只有筛选 -->
<div v-else style="margin-bottom:12px">
<el-form :inline="true" size="default">
<el-form-item label="关键词">
<el-input v-model="bindVmQuery.key" placeholder="搜索" clearable style="width:160px"
@keyup.enter="loadBindVmList" @clear="loadBindVmList" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="bindVmQuery.status" placeholder="全部" clearable style="width:120px" @change="loadBindVmList">
<el-option label="运行中" value="running" />
<el-option label="已停止" value="stopped" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadBindVmList">搜索</el-button>
<el-button @click="bindVmQuery.key = ''; bindVmQuery.status = ''; loadBindVmList()">重置</el-button>
</el-form-item>
</el-form>
</div>
<el-table v-if="bindTargetRow || bindSelectedProduct" :data="bindVmList" v-loading="bindVmLoading" stripe :height="380" style="width:100%"
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="虚拟机名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.name || '-' }}</template>
</el-table-column>
<el-table-column label="配置" min-width="120">
<template #default="{ row }">
<span v-if="row.vcpu && row.memory">{{ row.vcpu }}核 / {{ formatMemory(row.memory) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="vmStatusType(row.status)" size="small">{{ vmStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定状态" width="90">
<template #default="{ row }">
<el-tag :type="row.bound ? 'success' : 'info'" size="small">{{ row.bound ? '已绑定' : '未绑定' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="IP地址" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.ips || '-' }}</template>
</el-table-column>
<el-table-column label="用户" width="80">
<template #default="{ row }">{{ row.user_id || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button v-if="!row.bound" link type="primary" size="small"
:loading="bindSubmitLoading && bindSubmitVmId === row.id"
@click="onBindVmClick(row)">绑定</el-button>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无虚拟机数据" :image-size="80" />
</template>
</el-table>
<div v-if="bindTargetRow || bindSelectedProduct" style="display:flex;justify-content:flex-end;margin-top:12px">
<el-pagination v-model:current-page="bindVmQuery.page" v-model:page-size="bindVmQuery.count"
:total="bindVmTotal" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next" background
@size-change="s => { bindVmQuery.count = s; bindVmQuery.page = 1; loadBindVmList() }"
@current-change="p => { bindVmQuery.page = p; loadBindVmList() }" />
</div>
<template #footer>
<el-button @click="bindVmDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 选择用户商品弹窗(全局绑定时使用) -->
<el-dialog v-model="bindGoodsDialogVisible" title="选择要绑定的用户商品" width="750px" destroy-on-close append-to-body>
<div style="margin-bottom:8px;color:#909399;font-size:13px">
待绑定虚拟机: <b>{{ bindSelectedVm?.name || '-' }}</b>ID: {{ bindSelectedVm?.id }}
</div>
<div style="margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px">
<el-form :inline="true" size="default" style="margin:0">
<el-form-item label="用户" style="margin-bottom:0">
<div class="selector-row" style="width:200px">
<el-input
:model-value="bindGoodsQuery._userName || (bindGoodsQuery.user_id ? `用户 #${bindGoodsQuery.user_id}` : '')"
readonly placeholder="点击选择用户" style="flex:1;cursor:pointer"
@click="showBindGoodsUserSelector = true" />
<el-button v-if="bindGoodsQuery.user_id" link @click.stop="bindGoodsQuery.user_id = ''; bindGoodsQuery._userName = ''; loadBindGoodsList()">清除</el-button>
</div>
</el-form-item>
<el-form-item style="margin-bottom:0">
<el-button type="primary" @click="loadBindGoodsList">搜索</el-button>
</el-form-item>
</el-form>
<el-button type="primary" @click="handleCreateFromBind">新建用户商品</el-button>
</div>
<el-table :data="bindGoodsList" v-loading="bindGoodsLoading" highlight-current-row
@current-change="r => bindGoodsSelected = r" :height="300" style="width:100%"
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column label="商品名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.good?.name || '-' }}</template>
</el-table-column>
<el-table-column label="用户" min-width="120">
<template #default="{ row }">{{ row.user?.UserName || '-' }} ({{ row.userId || row.user_id || '-' }})</template>
</el-table-column>
<el-table-column label="绑定状态" width="90">
<template #default="{ row }">
<el-tag :type="row.itemId ? 'success' : 'info'" size="small">{{ row.itemId ? '已绑定' : '未绑定' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" width="150">
<template #default="{ row }">{{ formatExpireTime(row.expireTime) }}</template>
</el-table-column>
<template #empty>
<el-empty description="暂无用户商品数据" :image-size="80" />
</template>
</el-table>
<div style="display:flex;justify-content:flex-end;margin-top:12px">
<el-pagination v-model:current-page="bindGoodsQuery.page" v-model:page-size="bindGoodsQuery.count"
:total="bindGoodsTotal" :page-sizes="[10,20,50]" layout="total,sizes,prev,pager,next" background
@size-change="s => { bindGoodsQuery.count = s; bindGoodsQuery.page = 1; loadBindGoodsList() }"
@current-change="p => { bindGoodsQuery.page = p; loadBindGoodsList() }" />
</div>
<template #footer>
<el-button @click="bindGoodsDialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="!bindGoodsSelected" :loading="bindSubmitLoading" @click="confirmGlobalBind">确定绑定</el-button>
</template>
</el-dialog>
<UserSelector v-model:visible="showBindGoodsUserSelector" @select="handleBindGoodsUserSelect" />
<!-- 绑定专用商品选择器 -->
<ProductSelector v-model="showBindProductSelector" default-tag="云服务器" @confirm="handleBindProductSelected" />
<!-- 新建用户商品弹窗(绑定流程中使用) -->
<el-dialog v-model="bindCreateGoodsVisible" title="新建用户商品" width="520px" destroy-on-close append-to-body>
<el-form ref="bindCreateGoodsFormRef" :model="bindCreateGoodsForm" :rules="bindCreateGoodsRules" label-width="100px">
<el-form-item label="商品" prop="good_id">
<div class="selector-row">
<el-input :model-value="bindCreateGoodsForm._goodName || (bindCreateGoodsForm.good_id ? `商品 #${bindCreateGoodsForm.good_id}` : '')"
readonly placeholder="请选择商品" style="flex:1" />
<el-button type="primary" @click="showBindCreateProductSelector = true" style="margin-left:8px">选择</el-button>
</div>
</el-form-item>
<el-form-item label="用户" prop="user_id">
<div class="selector-row">
<el-input :model-value="bindCreateGoodsForm._userName || (bindCreateGoodsForm.user_id ? `用户 #${bindCreateGoodsForm.user_id}` : '')"
readonly placeholder="请选择用户" style="flex:1" />
<el-button type="primary" @click="showBindCreateUserSelector = true" style="margin-left:8px">选择</el-button>
</div>
</el-form-item>
<el-form-item label="续费价格">
<div class="unit-input-row">
<el-input-number v-model="bindCreateGoodsForm._renewYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text">元</span>
</div>
</el-form-item>
<el-form-item label="基础价格">
<div class="unit-input-row">
<el-input-number v-model="bindCreateGoodsForm._baseYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
<span class="unit-text">元</span>
</div>
</el-form-item>
<el-form-item label="到期时间">
<el-date-picker v-model="bindCreateGoodsForm.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-item label="备注">
<el-input v-model="bindCreateGoodsForm.note" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindCreateGoodsVisible = false">取消</el-button>
<el-button type="primary" :loading="bindCreateGoodsLoading" @click="submitBindCreateGoods">确定</el-button>
</template>
</el-dialog>
<ProductSelector v-model="showBindCreateProductSelector" default-tag="云服务器" @confirm="p => { bindCreateGoodsForm.good_id = p.id; bindCreateGoodsForm._goodName = p.name }" />
<UserSelector v-model:visible="showBindCreateUserSelector" @select="u => { bindCreateGoodsForm.user_id = u.user_id; bindCreateGoodsForm._userName = u.user_name }" />
<!-- 绑定tab的选择器 -->
<ProductSelector v-model="showBindGoodsProductSelector" default-tag="云服务器" @confirm="p => { bindGoodsForm.good_id = p.id; bindGoodsForm._goodName = p.name; bindGoodsForm._goodTag = p.tag || '云服务器' }" />
<UserSelector v-model:visible="showBindGoodsUserSel" @select="u => { bindGoodsForm.user_id = u.user_id; bindGoodsForm._userName = u.user_name }" />
<OrderSelector v-model="showBindGoodsOrderSelector" @confirm="o => { bindGoodsForm.order_id = o.id; bindGoodsForm._orderName = o.name }" />
<PlanSelector v-model="showBindGoodsPlanSelector" :good-id="bindGoodsForm.good_id || 0" @confirm="p => { bindGoodsForm.good_plan_id = p.id; bindGoodsForm._planName = p.name }" />
<!-- 筛选区选择器 -->
<UserSelector v-model:visible="showFilterUserSelector" @select="u => { query.user_id = u.user_id; filterUserName = u.user_name; handleSearch() }" />
<ProductSelector v-model="showFilterProductSelector" default-tag="云服务器" @confirm="p => { query.good_id = p.id; filterGoodName = p.name; handleSearch() }" />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowDown } from '@element-plus/icons-vue'
import { getUserVmList, getUserVmDetail, createUserVm, updateUserVm, deleteUserVm, getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, bindUserVm, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
import { hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit, toBaseUnit, fromBaseUnit } from '@/utils/dynamicUnit'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { formatToApiTime, vmStatusLabel, vmStatusType } 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 PlanSelector from '@/components/admin/PlanSelector.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
import HostSelectorPopup from '@/components/admin/HostSelectorPopup.vue'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.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, bound: null, user_id: '', good_id: '', key: '' })
const filterUserName = ref('')
const filterGoodName = ref('')
const showFilterUserSelector = ref(false)
const showFilterProductSelector = ref(false)
const listError = ref('')
const formatMemory = (kb) => {
if (!kb) return '-'
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
return kb + ' KB'
}
const formatExpireTime = (t) => {
if (!t) return '-'
const d = dayjs(t)
if (d.year() < 2000) return '永久'
return d.format('YYYY-MM-DD HH:mm')
}
const loadGoods = async () => {
// 不再需要加载商品列表,直接加载用户商品
loadList()
}
const loadList = async () => {
loading.value = true
listError.value = ''
try {
const params = { page: query.page, count: query.count, tag: '云服务器' }
if (query.bound !== null && query.bound !== undefined && query.bound !== '') params.bound = query.bound
if (query.user_id) params.user_id = query.user_id
if (query.good_id) params.good_id = query.good_id
if (query.key) params.key = query.key
const res = await getUserGoodsList(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.meta?.count ?? d.all_count ?? d.total ?? list.value.length
} else {
list.value = []
total.value = 0
if (res?.data?.message) {
listError.value = res.data.message
}
}
} catch (e) {
list.value = []
total.value = 0
const errData = e?.response?.data
listError.value = extractApiError(errData, '获取列表失败')
} finally { loading.value = false }
}
const handleSearch = () => { query.page = 1; loadList() }
const goDetail = (row) => {
if (!row.id) return
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
}
// ---- 新建 ----
const createVisible = ref(false)
const createLoading = ref(false)
const createDialogTab = ref('create')
let pendingTab = 'create'
const onCreateDialogOpened = () => { createDialogTab.value = pendingTab }
// 绑定虚拟机 tab(新增用户商品)
const bindGoodsFormRef = ref(null)
const bindGoodsFormLoading = ref(false)
const showBindGoodsProductSelector = ref(false)
const showBindGoodsUserSel = ref(false)
const showBindGoodsOrderSelector = ref(false)
const showBindGoodsPlanSelector = ref(false)
const bindGoodsForm = reactive({
good_id: 0, _goodName: '', _goodTag: '', user_id: 0, _userName: '',
order_id: 0, _orderName: '', good_plan_id: 0, _planName: '',
item_id: 0, _itemName: '', order_args: '',
_renewYuan: 0, _baseYuan: 0, expire_time: '', note: ''
})
const bindGoodsFormRules = {
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' }]
}
const resetBindGoodsForm = () => {
Object.assign(bindGoodsForm, {
good_id: 0, _goodName: '', _goodTag: '', user_id: 0, _userName: '',
order_id: 0, _orderName: '', good_plan_id: 0, _planName: '',
item_id: 0, _itemName: '', order_args: '',
_renewYuan: 0, _baseYuan: 0, expire_time: '', note: ''
})
}
const handleBindGoodsItemSelect = () => {
if (bindGoodsForm._goodTag === '云服务器') {
vmItemTarget.value = 'bind'
showVmListDialog.value = true
loadVmListForItem()
} else {
bindGoodsForm.item_id = bindGoodsForm.good_id
bindGoodsForm._itemName = `商品 #${bindGoodsForm.good_id}`
ElMessage.success('已将商品ID赋值为归属项')
}
}
const submitBindGoodsForm = async () => {
try { await bindGoodsFormRef.value?.validate() } catch { return }
bindGoodsFormLoading.value = true
try {
const payload = {
good_id: bindGoodsForm.good_id,
user_id: bindGoodsForm.user_id,
item_id: bindGoodsForm.item_id || 0,
renew_price: Math.round((bindGoodsForm._renewYuan || 0) * 100),
base_price: Math.round((bindGoodsForm._baseYuan || 0) * 100),
note: bindGoodsForm.note || ''
}
if (bindGoodsForm.order_id) payload.order_id = bindGoodsForm.order_id
if (bindGoodsForm.good_plan_id) payload.good_plan_id = bindGoodsForm.good_plan_id
if (bindGoodsForm.order_args) payload.order_args = bindGoodsForm.order_args
if (bindGoodsForm.expire_time) payload.expire_time = formatToApiTime(bindGoodsForm.expire_time)
const res = await createUserGoods(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 { bindGoodsFormLoading.value = false }
}
const createFormRef = ref(null)
const showProductSelector = ref(false)
const showUserSelector = ref(false)
const showOrderSelector = ref(false)
const showPlanSelector = ref(false)
const handlePlanSelectedForCreate = (plan) => {
createForm.good_plan_id = plan.id
createForm._planName = plan.name || `套餐 #${plan.id}`
}
const showServiceSelector = ref(false)
const showImageSelector = ref(false)
const showHostSelector = ref(false)
const showHostGroupSelector = ref(false)
const showNetworkSelector = ref(false)
// 编辑相关变量
const editFormRef = ref(null)
const showProductSelectorEdit = ref(false)
const showUserSelectorEdit = ref(false)
const showOrderSelectorEdit = ref(false)
const showPlanSelectorEdit = ref(false)
// 订单参数配置
const argsSpecList = ref([])
const argsSpecLoading = ref(false)
const argsValues = reactive({})
const argsDisplayValues = reactive({})
const argsDisplayUnits = reactive({})
const showArgsDialog = ref(false)
// ---- item_id 选择 ----
const vmItemTarget = ref('create')
const showVmListDialog = ref(false)
const vmListForItem = ref([])
const vmListLoading = ref(false)
const vmListSelected = ref(null)
const vmListTotal = ref(0)
const vmListQuery = reactive({ page: 1, count: 10, key: '', user_id: '', status: '', _userName: '' })
const showVmUserSelector = ref(false)
const createForm = reactive({
good_id: 0, _goodName: '', user_id: 0, _userName: '',
order_id: 0, _orderName: '', name: '',
_memoryValue: 0, _memoryUnit: 'MB', vcpu: 0,
system_size: 0, _systemSizeUnit: 'GB',
rx_bandwidth: 0, _rxUnit: 'Mbps',
tx_bandwidth: 0, _txUnit: 'Mbps',
_serviceId: 0, _serviceName: '', image_id: 0, _imageName: '',
host_id: 0, _hostName: '', host_group_id: 0, _hostGroupName: '',
network_ids: [], _networkNames: '',
ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0,
_renewPriceYuan: 0, _basePriceYuan: 0,
data_volume_size: 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' }],
memory: [{ required: true, validator: (r, v, cb) => createForm._memoryValue > 0 ? cb() : cb(new Error('请填写内存')), trigger: 'blur' }],
vcpu: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请填写vCPU')), trigger: 'blur' }],
system_size: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请填写系统盘大小')), trigger: 'blur' }],
image_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择镜像')), trigger: 'blur' }]
}
const editRules = {
// 简化的验证规则,只验证必要字段
}
const handleCreate = (tab = 'create') => {
Object.assign(createForm, {
good_id: 0, _goodName: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', name: '',
_memoryValue: 0, _memoryUnit: 'MB', vcpu: 0,
system_size: 0, _systemSizeUnit: 'GB',
rx_bandwidth: 0, _rxUnit: 'Mbps', tx_bandwidth: 0, _txUnit: 'Mbps',
_serviceId: 0, _serviceName: '', image_id: 0, _imageName: '',
host_id: 0, _hostName: '', host_group_id: 0, _hostGroupName: '',
network_ids: [], _networkNames: '',
ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0,
_renewPriceYuan: 0, _basePriceYuan: 0, data_volume_size: 0, note: '', expire_time: ''
})
resetBindGoodsForm()
pendingTab = tab
createVisible.value = true
}
// 处理编辑弹窗中的选择虚拟机功能
const handleSelectVmInEdit = async () => {
if (!editForm._currentGoodId) {
ElMessage.warning('当前编辑项没有商品ID')
return
}
try {
// 调用用户虚拟机列表API,传递当前编辑项的商品ID
const res = await getUserVmList({
page: 1,
count: 10,
good_id: editForm._currentGoodId // 传递当前编辑项的商品ID
})
if (res.data.code === 200) {
console.log('用户虚拟机列表:', res.data.data)
ElMessage.success(`已获取商品ID ${editForm._currentGoodId} 的虚拟机数据`)
}
} catch (error) {
console.error('获取用户虚拟机列表失败:', error)
ElMessage.error(`获取商品ID ${editForm._currentGoodId} 的虚拟机数据失败`)
}
}
// 处理商品选择
const handleProductSelect = async (product) => {
// 设置商品信息
createForm.good_id = product.id
createForm._goodName = product.name
createForm.image_id = 0
createForm._imageName = ''
// 调用用户虚拟机列表API
try {
const res = await getUserVmList({
page: 1,
count: 10,
good_id: product.id // 传递当前选择的商品ID
})
if (res.data.code === 200) {
console.log('用户虚拟机列表:', res.data.data)
ElMessage.success(`已获取商品 ${product.name} 的虚拟机数据`)
}
} catch (error) {
console.error('获取用户虚拟机列表失败:', error)
ElMessage.error(`获取商品 ${product.name} 的虚拟机数据失败`)
}
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
createLoading.value = true
try {
const memKB = createForm._memoryUnit === 'GB'
? (createForm._memoryValue || 0) * 1024 * 1024
: (createForm._memoryValue || 0) * 1024
const sysSize = createForm._systemSizeUnit === 'TB'
? (createForm.system_size || 0) * 1024
: (createForm.system_size || 0)
const rxBw = createForm._rxUnit === 'Gbps'
? (createForm.rx_bandwidth || 0) * 1000
: (createForm.rx_bandwidth || 0)
const txBw = createForm._txUnit === 'Gbps'
? (createForm.tx_bandwidth || 0) * 1000
: (createForm.tx_bandwidth || 0)
const payload = {
good_id: createForm.good_id,
user_id: createForm.user_id,
name: createForm.name,
memory: Math.round(memKB),
vcpu: createForm.vcpu,
system_size: Math.round(sysSize),
rx_bandwidth: Math.round(rxBw),
tx_bandwidth: Math.round(txBw),
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) * 100),
base_price: Math.round((createForm._basePriceYuan || 0) * 100),
note: createForm.note
}
if (createForm.data_volume_size > 0) payload.data_volume_size = createForm.data_volume_size
if (createForm.order_id) payload.order_id = createForm.order_id
if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time)
if (createForm.host_id) payload.host_id = createForm.host_id
if (createForm.host_group_id) payload.host_group_id = createForm.host_group_id
if (createForm.network_ids.length) payload.network_ids = createForm.network_ids
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 addNetwork = (n) => {
if (!createForm.network_ids.includes(n.id)) {
createForm.network_ids.push(n.id)
const names = createForm._networkNames ? createForm._networkNames + ', ' + (n.name || n.address || `#${n.id}`) : (n.name || n.address || `#${n.id}`)
createForm._networkNames = names
}
}
// ---- 编辑 ----
const editVisible = ref(false)
const editLoading = ref(false)
const showSgSelector = ref(false)
const showEditNetworkSelector = ref(false)
const editForm = reactive({
id: 0,
note: '',
_renewYuan: 0,
_baseYuan: 0,
expire_time: '',
item_id: 0,
_itemName: '',
_goodId: 0,
_goodTag: ''
})
const handleEdit = async (row) => {
if (!row.itemId) { ElMessage.warning('该用户商品未绑定虚拟机,无法编辑'); return }
Object.assign(editForm, {
id: row.id,
note: row.note || '',
_renewYuan: ((row.renewPrice || row.renew_price || 0) / 100),
_baseYuan: ((row.basePrice || row.base_price || 0) / 100),
expire_time: row.expireTime || row.expire_time || '',
item_id: row.itemId || row.item_id || 0,
_itemName: row.itemName || row._itemName || '',
_goodId: row.goodId || row.good_id || 0,
_goodTag: row.good?.tag || row._goodTag || ''
})
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
editForm.root_password = vm.root_password || ''
}
const inSg = d.vm?.in_port_group
if (inSg) { editForm.port_group_id = inSg.id; editForm._sgName = inSg.name }
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 = {
id: editForm.id,
note: editForm.note,
renew_price: Math.round((editForm._renewYuan || 0) * 100),
base_price: Math.round((editForm._baseYuan || 0) * 100)
}
if (editForm.expire_time) payload.expire_time = formatToApiTime(editForm.expire_time)
if (editForm.item_id) payload.item_id = editForm.item_id
const res = await updateUserGoods(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 handleProductSelectEdit = (p) => {
editForm.good_id = p.id
editForm._goodName = p.name
editForm._goodTag = p.tag || ''
editForm.item_id = 0
editForm._itemName = ''
clearArgsConfig()
}
const handleUserSelectEdit = (u) => {
editForm.user_id = u.user_id
editForm._userName = u.user_name
}
const handleOrderSelectEdit = (o) => {
editForm.order_id = o.id
editForm._orderName = o.name
}
const handlePlanSelectEdit = async (plan) => {
editForm.good_plan_id = plan.id
editForm._planName = plan.name || `套餐 #${plan.id}`
if (plan.args) {
editForm.order_args = typeof plan.args === 'string' ? plan.args : JSON.stringify(plan.args)
}
if (plan.id && editForm.good_id) {
try {
const res = await getProductPlanDetail({ good_id: String(editForm.good_id), plan_id: String(plan.id) })
if (res?.data?.code === 200 && res.data.data?.args) {
editForm.order_args = typeof res.data.data.args === 'string' ? res.data.data.args : JSON.stringify(res.data.data.args)
}
} catch { /* keep what we have */ }
}
}
const handleItemSelect = () => {
if (editForm._goodTag === '云服务器') {
vmItemTarget.value = 'edit'
showVmListDialog.value = true
loadVmListForItem()
} else {
editForm.item_id = editForm.good_id
editForm._itemName = `商品 #${editForm.good_id}`
ElMessage.success('已将商品ID赋值为归属项')
}
}
const handleEditItemSelect = () => {
if (editForm._goodTag === '云服务器') {
vmItemTarget.value = 'edit'
showVmListDialog.value = true
loadVmListForItem()
} else {
editForm.item_id = editForm._goodId
editForm._itemName = `商品 #${editForm._goodId}`
ElMessage.success('已将商品ID赋值为归属项')
}
}
const argsCount = computed(() => {
try { const arr = JSON.parse(editForm.order_args || '[]'); return Array.isArray(arr) ? arr.length : 0 } catch { return 0 }
})
const openArgsDialog = () => {
if (!editForm.good_id) return
showArgsDialog.value = true
if (argsSpecList.value.length === 0) loadArgsSpec(editForm.good_id)
}
const clearArgsConfig = () => {
argsSpecList.value = []
for (const k in argsValues) delete argsValues[k]
editForm.order_args = ''
}
const loadArgsSpec = async (goodId) => {
if (!goodId) return
argsSpecLoading.value = true
try {
const res = await getProductParameterList({ good_id: goodId })
if (res?.data?.code === 200) {
argsSpecList.value = res.data.data || []
for (const spec of argsSpecList.value) {
if (spec.type === 'number' && argsValues[spec.id] === undefined) {
argsValues[spec.id] = spec.min ?? 0
if (hasUnit(spec)) {
argsDisplayUnits[spec.id] = getParamDefaultUnit(spec)
argsDisplayValues[spec.id] = fromBaseUnit(spec.min ?? 0, argsDisplayUnits[spec.id], getArgKey(spec))
} else {
argsDisplayValues[spec.id] = spec.min ?? 0
}
}
}
}
} catch { argsSpecList.value = [] } finally { argsSpecLoading.value = false }
}
const onArgsNumberChange = (spec) => {
if (hasUnit(spec)) {
argsValues[spec.id] = Math.round(toBaseUnit(argsDisplayValues[spec.id] || 0, argsDisplayUnits[spec.id], getArgKey(spec)))
} else {
argsValues[spec.id] = argsDisplayValues[spec.id]
}
buildArgsJson()
}
const onArgsUnitChange = (spec, newUnit) => {
const argKey = getArgKey(spec)
const oldUnit = argsDisplayUnits[spec.id]
const oldDisplay = argsDisplayValues[spec.id] || 0
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
argsDisplayUnits[spec.id] = newUnit
argsDisplayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
argsValues[spec.id] = Math.round(baseValue)
buildArgsJson()
}
const buildArgsJson = () => {
const argsArray = []
for (const spec of argsSpecList.value) {
const val = argsValues[spec.id]
if (val === undefined || val === '') continue
if (spec.type === 'select') {
const attr = spec.attrs?.find(a => a.id === val)
if (attr) argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: attr.id, value: attr.value || '' })
} else if (spec.type === 'number') {
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, number: Number(val) })
} else {
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(val) })
}
}
editForm.order_args = argsArray.length > 0 ? JSON.stringify(argsArray) : ''
}
const loadVmListForItem = async () => {
vmListLoading.value = true
try {
const params = { page: vmListQuery.page, count: vmListQuery.count }
if (vmListQuery.key) params.key = vmListQuery.key
if (vmListQuery.user_id) params.user_id = parseInt(vmListQuery.user_id) || undefined
if (vmListQuery.status) params.status = vmListQuery.status
// good_id parameter
if (vmItemTarget.value === 'edit' && editForm._goodId) {
params.good_id = editForm._goodId
} else if (vmItemTarget.value === 'create' && createForm.good_id) {
params.good_id = createForm.good_id
} else if (vmItemTarget.value === 'bind' && bindGoodsForm.good_id) {
params.good_id = bindGoodsForm.good_id
}
const res = await getUserVmList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
vmListForItem.value = d.data || (Array.isArray(d) ? d : [])
vmListTotal.value = d.all_count ?? d.total ?? vmListForItem.value.length
}
} catch { vmListForItem.value = [] } finally { vmListLoading.value = false }
}
const confirmVmForItem = () => {
if (!vmListSelected.value) return
const vm = vmListSelected.value
if (vmItemTarget.value === 'edit') {
editForm.item_id = vm.id
editForm._itemName = `虚拟机 #${vm.id}`
} else if (vmItemTarget.value === 'bind') {
bindGoodsForm.item_id = vm.id
bindGoodsForm._itemName = `虚拟机 #${vm.id}`
} else {
createForm.item_id = vm.id
createForm._itemName = `虚拟机 #${vm.id}`
}
showVmListDialog.value = false
vmListSelected.value = null
ElMessage.success('虚拟机已选择')
}
const resetVmListFilters = () => {
vmListQuery.key = ''
vmListQuery.user_id = ''
vmListQuery.status = ''
vmListQuery._userName = ''
vmListQuery.page = 1
loadVmListForItem()
}
const handleVmUserSelect = (user) => {
vmListQuery.user_id = user.user_id
vmListQuery._userName = user.user_name
showVmUserSelector.value = false
loadVmListForItem()
}
const getStatusType = (status) => {
switch (status) {
case 'running': return 'success'
case 'stop': return 'danger'
case 'stopped': return 'danger'
default: return 'info'
}
}
const getStatusText = (status) => {
switch (status) {
case 'running': return '运行中'
case 'stop': return '已停止'
case 'stopped': return '已停止'
default: return status || '未知'
}
}
// ---- 更多操作 ----
const handleMoreCmd = (cmd, row) => {
if (cmd === 'remind') openRemindList(row)
else if (cmd === 'send') handleSendRemind(row)
else if (cmd === 'delete') handleDeleteGoods(row)
}
// ---- 删除用户商品 ----
const handleDeleteGoods = (row) => {
const hasBoundVm = row.itemId && row.itemId !== 0
const msg = hasBoundVm
? `该商品(ID: ${row.id})已绑定虚拟机(VM ID: ${row.itemId}),删除后虚拟机将不再关联此商品记录,确定删除?`
: `确定删除该用户商品(ID: ${row.id})吗?`
ElMessageBox.confirm(msg, '删除确认', { type: 'error', confirmButtonText: '确认删除' })
.then(async () => {
try {
const res = await deleteUserGoods({ 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(() => {})
}
// ---- 到期提醒 ----
const remindVisible = ref(false)
const remindLoading = ref(false)
const remindList = ref([])
const remindTotal = ref(0)
const remindGoodsId = ref(0)
const remindQuery = reactive({ page: 1, count: 10 })
const openRemindList = (row) => {
remindGoodsId.value = row.id
remindQuery.page = 1
remindVisible.value = true
loadRemindList()
}
const formatRemindTime = (row) => {
const t = row.CreatedAt || row.created_at || row.send_time
if (!t || t.startsWith('0001')) return '-'
try { return dayjs(t).format('YYYY-MM-DD HH:mm:ss') } catch { return t }
}
const loadRemindList = async () => {
remindLoading.value = true
try {
const res = await getExpireRemindList({ user_goods_id: remindGoodsId.value, page: remindQuery.page, count: remindQuery.count })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
remindList.value = d.data || d.list || (Array.isArray(d) ? d : [])
remindTotal.value = d.all_count ?? d.meta?.count ?? d.total ?? remindList.value.length
} else { remindList.value = []; remindTotal.value = 0 }
} catch { remindList.value = []; remindTotal.value = 0 }
finally { remindLoading.value = false }
}
const handleSendRemind = (row) => {
ElMessageBox.confirm(`确定手动发送到期提醒给该用户商品(ID: ${row.id})吗?`, '发送确认', { type: 'warning', confirmButtonText: '确认发送' })
.then(async () => {
try {
const res = await sendExpireRemind({ user_goods_id: row.id, user_id: row.userId || row.user_id })
if (res?.data?.code === 200) ElMessage.success('发送成功')
else ElMessage.error(extractApiError(res?.data, '发送失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '发送失败')) }
}).catch(() => {})
}
// ---- 绑定虚拟机 ----
const bindVmDialogVisible = ref(false)
const bindTargetRow = ref(null)
const bindVmList = ref([])
const bindVmLoading = ref(false)
const bindVmTotal = ref(0)
const bindVmQuery = reactive({ page: 1, count: 10, key: '', status: '' })
const bindSubmitLoading = ref(false)
const bindSubmitVmId = ref(0)
const bindSelectedVm = ref(null)
const bindSelectedProduct = ref(null)
const showBindProductSelector = ref(false)
const bindGoodsDialogVisible = ref(false)
const bindGoodsList = ref([])
const bindGoodsLoading = ref(false)
const bindGoodsTotal = ref(0)
const bindGoodsSelected = ref(null)
const bindGoodsQuery = reactive({ page: 1, count: 10, user_id: '', _userName: '' })
const showBindGoodsUserSelector = ref(false)
const handleGlobalBindVm = () => {
bindTargetRow.value = null
bindSelectedProduct.value = null
bindVmList.value = []
bindVmTotal.value = 0
bindVmQuery.page = 1
bindVmQuery.key = ''
bindVmQuery.status = ''
bindVmDialogVisible.value = true
}
const handleBindProductSelected = (product) => {
bindSelectedProduct.value = product
bindVmQuery.page = 1
bindVmQuery.key = ''
bindVmQuery.status = ''
loadBindVmList()
}
const handleBindVm = (row) => {
bindTargetRow.value = row
bindSelectedProduct.value = null
bindVmQuery.page = 1
bindVmQuery.key = ''
bindVmQuery.status = ''
bindVmDialogVisible.value = true
loadBindVmList()
}
const loadBindVmList = async () => {
bindVmLoading.value = true
try {
const params = { page: bindVmQuery.page, count: bindVmQuery.count }
if (bindTargetRow.value) {
const goodId = bindTargetRow.value.good?.id || bindTargetRow.value.goodId || 0
if (goodId) params.good_id = goodId
} else if (bindSelectedProduct.value) {
params.good_id = bindSelectedProduct.value.id
}
if (bindVmQuery.key) params.key = bindVmQuery.key
if (bindVmQuery.status) params.status = bindVmQuery.status
const res = await getUserVmList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
bindVmList.value = d.data || (Array.isArray(d) ? d : [])
bindVmTotal.value = d.all_count ?? d.total ?? bindVmList.value.length
} else { bindVmList.value = []; bindVmTotal.value = 0 }
} catch { bindVmList.value = [] } finally { bindVmLoading.value = false }
}
const onBindVmClick = (vm) => {
if (bindTargetRow.value) {
submitBindVm(vm)
} else {
bindSelectedVm.value = vm
bindGoodsSelected.value = null
bindGoodsQuery.page = 1
bindGoodsQuery.user_id = ''
bindGoodsQuery._userName = ''
bindGoodsDialogVisible.value = true
loadBindGoodsList()
}
}
const handleBindGoodsUserSelect = (user) => {
bindGoodsQuery.user_id = String(user.user_id || user.id || '')
bindGoodsQuery._userName = user.user_name || user.name || ''
showBindGoodsUserSelector.value = false
loadBindGoodsList()
}
const bindCreateGoodsVisible = ref(false)
const bindCreateGoodsLoading = ref(false)
const bindCreateGoodsFormRef = ref(null)
const showBindCreateProductSelector = ref(false)
const showBindCreateUserSelector = ref(false)
const bindCreateGoodsForm = reactive({
good_id: 0, _goodName: '', user_id: 0, _userName: '',
_renewYuan: 0, _baseYuan: 0, expire_time: '', note: ''
})
const bindCreateGoodsRules = {
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' }]
}
const handleCreateFromBind = () => {
Object.assign(bindCreateGoodsForm, {
good_id: 0, _goodName: '', user_id: 0, _userName: '',
_renewYuan: 0, _baseYuan: 0, expire_time: '', note: ''
})
bindCreateGoodsVisible.value = true
}
const submitBindCreateGoods = async () => {
try { await bindCreateGoodsFormRef.value?.validate() } catch { return }
bindCreateGoodsLoading.value = true
try {
const payload = {
good_id: bindCreateGoodsForm.good_id,
user_id: bindCreateGoodsForm.user_id,
renew_price: Math.round((bindCreateGoodsForm._renewYuan || 0) * 100),
base_price: Math.round((bindCreateGoodsForm._baseYuan || 0) * 100),
note: bindCreateGoodsForm.note || ''
}
if (bindCreateGoodsForm.expire_time) payload.expire_time = formatToApiTime(bindCreateGoodsForm.expire_time)
const res = await createUserGoods(payload)
if (res?.data?.code === 200) {
ElMessage.success('创建用户商品成功')
bindCreateGoodsVisible.value = false
loadBindGoodsList()
} else {
ElMessage.error(extractApiError(res?.data, '创建失败'))
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '创建失败'))
} finally { bindCreateGoodsLoading.value = false }
}
const loadBindGoodsList = async () => {
bindGoodsLoading.value = true
try {
const params = { page: bindGoodsQuery.page, count: bindGoodsQuery.count, tag: '云服务器' }
if (bindGoodsQuery.user_id) params.user_id = bindGoodsQuery.user_id
const res = await getUserGoodsList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
bindGoodsList.value = d.data || (Array.isArray(d) ? d : [])
bindGoodsTotal.value = d.meta?.count ?? d.all_count ?? d.total ?? bindGoodsList.value.length
} else { bindGoodsList.value = []; bindGoodsTotal.value = 0 }
} catch { bindGoodsList.value = [] } finally { bindGoodsLoading.value = false }
}
const confirmGlobalBind = async () => {
if (!bindSelectedVm.value || !bindGoodsSelected.value) return
bindSubmitLoading.value = true
try {
const res = await bindUserVm({
user_goods_id: bindGoodsSelected.value.id,
vm_id: bindSelectedVm.value.id
})
if (res?.data?.code === 200) {
ElMessage.success('绑定虚拟机成功')
bindGoodsDialogVisible.value = false
loadBindVmList()
loadList()
} else {
ElMessage.error(extractApiError(res?.data, '绑定失败'))
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '绑定失败'))
} finally { bindSubmitLoading.value = false }
}
const submitBindVm = async (vm) => {
if (!bindTargetRow.value) return
bindSubmitLoading.value = true
bindSubmitVmId.value = vm.id
try {
const res = await bindUserVm({
user_goods_id: bindTargetRow.value.id,
vm_id: vm.id
})
if (res?.data?.code === 200) {
ElMessage.success('绑定虚拟机成功')
loadBindVmList()
loadList()
} else {
ElMessage.error(extractApiError(res?.data, '绑定失败'))
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '绑定失败'))
} finally { bindSubmitLoading.value = false; bindSubmitVmId.value = 0 }
}
onMounted(() => {
loadList()
})
</script>
<style scoped>
.user-vm-list { padding: 0; }
.main-container { border: 1px solid #e1e8ed; background: #ffffff; }
:deep(.el-card__body) { padding: 0; }
.filter-section { padding: 0; border-bottom: 1px solid #e1e8ed; background: #fafbfc; }
.filter-content { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; gap: 20px; flex-wrap: wrap; }
.search-form { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0; }
.search-form :deep(.el-form-item) { margin-bottom: 0; margin-right: 0; }
.action-bar { display: flex; gap: 12px; flex-shrink: 0; flex-wrap: wrap; align-items: center; }
.table-section { padding: 0; }
.pagination { padding: 16px 20px; border-top: 1px solid #e1e8ed; background: #fafbfc; justify-content: flex-end; }
.selector-row { display: flex; align-items: center; width: 100%; gap: 8px; }
.selector-row .el-input { flex: 1; }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-select { width: 90px; flex-shrink: 0; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; min-width: 24px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 0; }
.form-hint { font-size: 12px; color: #909399; margin-top: 4px; }
.args-spec-item { margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #f0f0f0; }
.args-spec-item:last-child { border-bottom: none; }
.args-spec-label { font-size: 14px; font-weight: 500; color: #303133; margin-bottom: 8px; }
: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; }
@media (max-width: 768px) {
.filter-content { flex-direction: column; align-items: stretch; gap: 12px; }
.search-form { flex-direction: column; width: 100%; }
.search-form :deep(.el-form-item) { width: 100%; }
.action-bar { width: 100%; justify-content: flex-start; }
}
</style>