feat: 添加用户虚拟机商品管理
This commit is contained in:
@@ -108,7 +108,7 @@
|
||||
<el-avatar v-else-if="row.isGroup" :size="32" :style="{ background: getLevelColor(row.level) }">
|
||||
<el-icon><Folder /></el-icon>
|
||||
</el-avatar>
|
||||
<el-avatar v-else-if="row.isProduct && row.data?.coverId" :size="32" :src="getFileUrl(row.data.coverId)" />
|
||||
<el-avatar v-else-if="row.isProduct && row.data?.cover" :size="32" :src="row.data.cover" />
|
||||
<el-avatar v-else-if="row.isProduct" :size="32" :style="{ background: '#409eff' }">
|
||||
<el-icon><Document /></el-icon>
|
||||
</el-avatar>
|
||||
@@ -760,6 +760,19 @@
|
||||
<el-form-item label="是否必选" prop="must">
|
||||
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">权限控制</el-divider>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
<template v-if="paramForm.arg_type === 'number'">
|
||||
<el-divider content-position="left">数值参数配置</el-divider>
|
||||
<el-form-item label="步进值" prop="arg_step">
|
||||
@@ -1089,7 +1102,6 @@ import {
|
||||
disablePlanFixedPrice,
|
||||
enablePlanFixedPrice
|
||||
} from '@/api/admin/product'
|
||||
import { getFileDetail } from '@/api/admin/file'
|
||||
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
|
||||
|
||||
// Tab切换
|
||||
@@ -1161,24 +1173,6 @@ const expandedGroups = ref(new Set()) // 记录展开的分组ID
|
||||
const loadedProductGroups = ref(new Set()) // 记录已加载商品的分组ID,避免重复请求
|
||||
const groupProductsMap = ref(new Map()) // 按分组ID存储商品列表 Map<groupId, product[]>
|
||||
|
||||
// 文件URL缓存 Map<fileId, url>
|
||||
const fileUrlCache = ref(new Map())
|
||||
|
||||
// 获取文件URL(带缓存)
|
||||
const getFileUrl = (fileId) => {
|
||||
if (!fileId) return ''
|
||||
const cached = fileUrlCache.value.get(fileId)
|
||||
if (cached) return cached
|
||||
// 异步加载
|
||||
getFileDetail({ file_id: fileId }).then(res => {
|
||||
if (res.data.code === 200 && res.data.data?.url) {
|
||||
const newCache = new Map(fileUrlCache.value)
|
||||
newCache.set(fileId, res.data.data.url)
|
||||
fileUrlCache.value = newCache
|
||||
}
|
||||
}).catch(() => {})
|
||||
return '' // 先返回空,加载完成后会响应式更新
|
||||
}
|
||||
|
||||
// 商品表单
|
||||
const productForm = reactive({
|
||||
@@ -2317,7 +2311,10 @@ const paramForm = reactive({
|
||||
must: false,
|
||||
arg_step: 1,
|
||||
arg_min: 0,
|
||||
arg_max: 100
|
||||
arg_max: 100,
|
||||
user_add: false,
|
||||
use_user_group_discount: false,
|
||||
use_user_discount: false
|
||||
})
|
||||
const paramRules = {
|
||||
arg_name: [{ required: true, message: '请输入参数名称', trigger: 'blur' }],
|
||||
@@ -2384,14 +2381,14 @@ const getRangeTypeText = (type) => {
|
||||
const handleAddParameter = () => {
|
||||
paramFormType.value = 'add'
|
||||
paramFormDialogVisible.value = true
|
||||
Object.assign(paramForm, { arg_id: undefined, arg_name: '', arg_type: 'string', must: false, arg_step: 1, arg_min: 0, arg_max: 100 })
|
||||
Object.assign(paramForm, { arg_id: undefined, arg_name: '', arg_type: 'string', must: false, arg_step: 1, arg_min: 0, arg_max: 100, user_add: false, use_user_group_discount: false, use_user_discount: false })
|
||||
nextTick(() => { paramFormRef.value?.resetFields() })
|
||||
}
|
||||
|
||||
const handleEditParameter = (row) => {
|
||||
paramFormType.value = 'edit'
|
||||
paramFormDialogVisible.value = true
|
||||
Object.assign(paramForm, { arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false, arg_step: row.step || 1, arg_min: row.min || 0, arg_max: row.max || 100 })
|
||||
Object.assign(paramForm, { arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false, arg_step: row.step || 1, arg_min: row.min || 0, arg_max: row.max || 100, user_add: row.userAdd ?? row.user_add ?? false, use_user_group_discount: row.useUserGroupDiscount ?? row.use_user_group_discount ?? false, use_user_discount: row.useUserDiscount ?? row.use_user_discount ?? false })
|
||||
}
|
||||
|
||||
const handleDeleteParameter = (row) => {
|
||||
@@ -2409,7 +2406,7 @@ const submitParamForm = () => {
|
||||
paramFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const submitData = { good_id: Number(currentProductId.value), arg_name: paramForm.arg_name, arg_type: paramForm.arg_type, must: paramForm.must === true }
|
||||
const submitData = { good_id: Number(currentProductId.value), arg_name: paramForm.arg_name, arg_type: paramForm.arg_type, must: paramForm.must === true, user_add: paramForm.user_add === true, use_user_group_discount: paramForm.use_user_group_discount === true, use_user_discount: paramForm.use_user_discount === true }
|
||||
if (paramForm.arg_type === 'number') {
|
||||
submitData.arg_step = Number(paramForm.arg_step)
|
||||
submitData.arg_min = Number(paramForm.arg_min)
|
||||
|
||||
@@ -334,6 +334,19 @@
|
||||
inactive-text="可选"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">权限控制</el-divider>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
<!-- number 类型参数的额外配置 -->
|
||||
<template v-if="paramForm.arg_type === 'number'">
|
||||
<el-divider content-position="left">数值参数配置</el-divider>
|
||||
@@ -816,7 +829,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { getFileDetail } from '@/api/admin/file'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, Search, Refresh, Picture, ArrowRight, Loading, View } from '@element-plus/icons-vue'
|
||||
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
|
||||
@@ -1033,24 +1045,9 @@ const fetchProductList = async () => {
|
||||
// 使用后端返回的总数
|
||||
total.value = res.data.data.all_count || allData.length
|
||||
|
||||
// 异步获取所有商品的封面图片
|
||||
const imagePromises = productList.value.map(async (item) => {
|
||||
if (item.coverId) {
|
||||
try {
|
||||
const fileRes = await getFileDetail({ file_id: item.coverId })
|
||||
item.image = fileRes.data?.data?.url || ''
|
||||
} catch (error) {
|
||||
console.error('获取商品图片失败:', error)
|
||||
item.image = ''
|
||||
}
|
||||
} else {
|
||||
item.image = ''
|
||||
}
|
||||
return item
|
||||
productList.value.forEach(item => {
|
||||
item.image = item.cover || ''
|
||||
})
|
||||
|
||||
// 等待所有图片加载完成
|
||||
await Promise.all(imagePromises)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取商品列表失败')
|
||||
@@ -1184,8 +1181,7 @@ const handleEdit = (row) => {
|
||||
recommend_rebate: row.recommendRebate,
|
||||
arg_type: row.argType || 'all'
|
||||
})
|
||||
// 加载封面预览
|
||||
loadCoverPreview(row.coverId)
|
||||
coverPreviewUrl.value = row.cover || ''
|
||||
}
|
||||
|
||||
// 规格管理
|
||||
@@ -1318,21 +1314,9 @@ const clearCover = () => {
|
||||
coverPreviewUrl.value = ''
|
||||
}
|
||||
|
||||
// 加载封面预览
|
||||
const loadCoverPreview = async (coverId) => {
|
||||
if (!coverId) {
|
||||
coverPreviewUrl.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getFileDetail({ file_id: coverId })
|
||||
if (res.data.code === 200) {
|
||||
coverPreviewUrl.value = res.data.data.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载封面预览失败:', error)
|
||||
coverPreviewUrl.value = ''
|
||||
}
|
||||
const loadCoverPreview = (coverId) => {
|
||||
const item = productList.value.find(p => p.coverId === coverId)
|
||||
coverPreviewUrl.value = item?.cover || ''
|
||||
}
|
||||
|
||||
// 初始化
|
||||
@@ -1362,7 +1346,10 @@ const paramForm = reactive({
|
||||
must: false,
|
||||
arg_step: 1,
|
||||
arg_min: 0,
|
||||
arg_max: 100
|
||||
arg_max: 100,
|
||||
user_add: false,
|
||||
use_user_group_discount: false,
|
||||
use_user_discount: false
|
||||
})
|
||||
|
||||
const paramRules = {
|
||||
@@ -1443,7 +1430,10 @@ const handleAddParameter = () => {
|
||||
must: false,
|
||||
arg_step: 1,
|
||||
arg_min: 0,
|
||||
arg_max: 100
|
||||
arg_max: 100,
|
||||
user_add: false,
|
||||
use_user_group_discount: false,
|
||||
use_user_discount: false
|
||||
})
|
||||
nextTick(() => {
|
||||
paramFormRef.value?.resetFields()
|
||||
@@ -1461,7 +1451,10 @@ const handleEditParameter = (row) => {
|
||||
must: row.must || false,
|
||||
arg_step: row.step || 1,
|
||||
arg_min: row.min || 0,
|
||||
arg_max: row.max || 100
|
||||
arg_max: row.max || 100,
|
||||
user_add: row.userAdd ?? row.user_add ?? false,
|
||||
use_user_group_discount: row.useUserGroupDiscount ?? row.use_user_group_discount ?? false,
|
||||
use_user_discount: row.useUserDiscount ?? row.use_user_discount ?? false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1491,7 +1484,10 @@ const submitParamForm = () => {
|
||||
good_id: Number(currentProductId.value),
|
||||
arg_name: paramForm.arg_name,
|
||||
arg_type: paramForm.arg_type,
|
||||
must: paramForm.must === true
|
||||
must: paramForm.must === true,
|
||||
user_add: paramForm.user_add === true,
|
||||
use_user_group_discount: paramForm.use_user_group_discount === true,
|
||||
use_user_discount: paramForm.use_user_discount === true
|
||||
}
|
||||
// number 类型添加额外参数
|
||||
if (paramForm.arg_type === 'number') {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="goods-detail-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回所有商品列表
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">所有商品详情</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" plain @click="loadDetail" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon> 刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<el-card class="profile-card" shadow="never" v-if="detail">
|
||||
<div class="profile-header">
|
||||
<div class="profile-basic">
|
||||
<div class="icon-wrapper">
|
||||
<el-icon :size="48" color="#409eff"><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="identity">
|
||||
<div class="name-row">
|
||||
<h1 class="name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
|
||||
<el-button size="small" type="primary" plain @click="openEdit">编辑</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
<div class="id-row">
|
||||
<span class="label">ID:</span>
|
||||
<span class="value">{{ detail.id || goodsId }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="label">用户ID:</span>
|
||||
<span class="value">{{ detail.userId || detail.user_id || '-' }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="label">到期:</span>
|
||||
<span class="value">{{ formatExpireTime(detail.expireTime || detail.expire_time) }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="label">续费价:</span>
|
||||
<span class="value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">套餐ID</div>
|
||||
<div class="stat-value">{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">备注</div>
|
||||
<div class="stat-value note-value">{{ detail.note || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider style="margin: 16px 0 12px" />
|
||||
<el-descriptions :column="3" border size="small" style="width:100%">
|
||||
<el-descriptions-item label="商品ID">{{ detail.goodId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单ID">{{ detail.orderId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="归属项ID">{{ detail.itemId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="基础价格">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detail.CreatedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTime(detail.UpdatedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" v-if="detail" style="margin-top:20px">
|
||||
<h3 style="margin:0 0 16px;font-size:16px;font-weight:600;color:#303133">关联信息</h3>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单状态">
|
||||
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
|
||||
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="110px">
|
||||
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="editForm.renew_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)"><el-input-number v-model="editForm.base_price" :min="0" :precision="2" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="到期时间"><el-date-picker v-model="editForm.expire_time" type="datetime" placeholder="选择到期时间" 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="归属项">
|
||||
<div style="width:100%">
|
||||
<template v-if="detail?.good?.table === 'kvm_service'">
|
||||
<div class="selector-row" style="margin-bottom:8px">
|
||||
<el-input :model-value="editForm._serviceName || (editForm._serviceId ? `主控服务 #${editForm._serviceId}` : '')"
|
||||
readonly placeholder="1. 选择主控服务" style="flex:1" />
|
||||
<el-button type="primary" @click="showServiceSelector = true" style="margin-left:8px">选择</el-button>
|
||||
<el-button v-if="editForm._serviceId" @click="editForm._serviceId = 0; editForm._serviceName = ''; editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
|
||||
</div>
|
||||
<div class="selector-row">
|
||||
<el-input :model-value="editForm._itemName || (editForm.item_id ? `虚拟机 #${editForm.item_id}` : '')"
|
||||
readonly placeholder="2. 选择虚拟机" style="flex:1" />
|
||||
<el-button type="primary" @click="showVmSelector = true" :disabled="!editForm._serviceId" style="margin-left:8px">选择</el-button>
|
||||
<el-button v-if="editForm.item_id" @click="editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
|
||||
</div>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">归属项为虚拟机ID,需先选择主控服务</div>
|
||||
</template>
|
||||
<el-input-number v-else v-model="editForm.item_id" :min="0" controls-position="right" style="width:100%" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitEdit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<VmSelectorPopup v-model="showVmSelector" :service-id="editForm._serviceId || 0"
|
||||
@confirm="vm => { editForm.item_id = vm.id; editForm._itemName = vm.name }" />
|
||||
<KvmServiceSelector v-model="showServiceSelector"
|
||||
@confirm="s => { editForm._serviceId = s.id; editForm._serviceName = s.name; editForm.item_id = 0; editForm._itemName = '' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Monitor } from '@element-plus/icons-vue'
|
||||
import { getUserGoodsDetail, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const goodsId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detail = ref(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:ss')
|
||||
}
|
||||
|
||||
const goBack = () => router.push('/user-goods/list')
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!goodsId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUserGoodsDetail({ id: goodsId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
detail.value = res.data.data.data ?? res.data.data
|
||||
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' })
|
||||
const showVmSelector = ref(false)
|
||||
const showServiceSelector = ref(false)
|
||||
|
||||
const openEdit = () => {
|
||||
const rawRenew = detail.value?.renewPrice || detail.value?.renew_price || 0
|
||||
const rawBase = detail.value?.basePrice || detail.value?.base_price || 0
|
||||
Object.assign(editForm, {
|
||||
note: detail.value?.note || '',
|
||||
renew_price: rawRenew / 100,
|
||||
base_price: rawBase / 100,
|
||||
expire_time: detail.value?.expireTime || detail.value?.expire_time
|
||||
? dayjs(detail.value?.expireTime || detail.value?.expire_time).format('YYYY-MM-DD HH:mm:ss')
|
||||
: '',
|
||||
item_id: detail.value?.itemId || detail.value?.item_id || 0,
|
||||
_serviceId: 0,
|
||||
_serviceName: '',
|
||||
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
|
||||
})
|
||||
if (detail.value?.good?.table === 'kvm_service') { /* 通过选择器弹窗选择,无需预加载 */ }
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const data = { id: goodsId.value }
|
||||
if (editForm.note !== undefined) data.note = editForm.note
|
||||
if (editForm.renew_price) data.renew_price = Math.round(editForm.renew_price * 100)
|
||||
if (editForm.base_price) data.base_price = Math.round(editForm.base_price * 100)
|
||||
if (editForm.expire_time) data.expire_time = editForm.expire_time
|
||||
if (editForm.item_id) data.item_id = editForm.item_id
|
||||
const res = await updateUserGoods(data)
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) }
|
||||
finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
ElMessageBox.confirm('确定删除该用户商品吗?', '删除确认', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await deleteUserGoods({ id: goodsId.value })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
|
||||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
watch(goodsId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.goods-detail-page { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.back-btn { font-size: 14px; color: #606266; }
|
||||
.back-btn:hover { color: #409eff; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.main-content { padding: 20px; }
|
||||
.profile-card { margin-bottom: 0; }
|
||||
.profile-header { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.profile-basic { display: flex; align-items: center; gap: 20px; }
|
||||
.icon-wrapper { width: 80px; height: 80px; border-radius: 12px; background: linear-gradient(135deg, #e8f4fd, #d6eaff); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.identity { display: flex; flex-direction: column; gap: 8px; }
|
||||
.name-row { display: flex; align-items: center; gap: 10px; }
|
||||
.name { font-size: 22px; font-weight: 600; color: #303133; margin: 0; }
|
||||
.id-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #909399; flex-wrap: wrap; }
|
||||
.id-row .label { color: #909399; }
|
||||
.id-row .value { color: #606266; font-weight: 500; }
|
||||
.profile-stats { display: flex; gap: 32px; flex-shrink: 0; }
|
||||
.stat-item { text-align: center; min-width: 80px; }
|
||||
.stat-label { font-size: 12px; color: #909399; margin-bottom: 4px; }
|
||||
.stat-value { font-size: 14px; font-weight: 600; color: #303133; }
|
||||
.note-value { font-weight: 400; font-size: 13px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.selector-row { display: flex; align-items: center; width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="user-goods-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-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="用户" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.user?.UserName || 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="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.good?.name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="套餐ID" width="90">
|
||||
<template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.order?.name || (row.orderId ? `订单 #${row.orderId}` : '-') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="续费价格" width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.renewPrice || row.renew_price">¥{{ ((row.renewPrice || row.renew_price) / 100).toFixed(2) }}</span>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="170">
|
||||
<template #default="{ row }">{{ formatExpireTime(row.expireTime || row.expire_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @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="560px" destroy-on-close>
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px">
|
||||
<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>
|
||||
<el-button v-if="createForm.good_id" @click="createForm.good_id = 0; createForm._goodName = ''" 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="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>
|
||||
<el-button v-if="createForm.user_id" @click="createForm.user_id = 0; createForm._userName = ''" style="margin-left:4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-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-form-item label="套餐">
|
||||
<div class="selector-row">
|
||||
<el-input :model-value="createForm._planName || (createForm.good_plan_id ? `套餐 #${createForm.good_plan_id}` : '')"
|
||||
readonly placeholder="可选" style="flex:1" />
|
||||
<el-button type="primary" @click="showPlanSelector = true" style="margin-left:8px">选择</el-button>
|
||||
<el-button v-if="createForm.good_plan_id" @click="createForm.good_plan_id = 0; createForm._planName = ''" style="margin-left:4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="createForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)">
|
||||
<el-input-number v-model="createForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<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-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="submitLoading" @click="submitCreate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑用户商品" width="480px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="110px">
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="editForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)">
|
||||
<el-input-number v-model="editForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
</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>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @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 }" />
|
||||
<PlanSelector v-model="showPlanSelector" :good-id="createForm.good_id" @confirm="p => { createForm.good_plan_id = p.id; createForm._planName = p.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 { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods } 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 PlanSelector from '@/components/admin/PlanSelector.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: '' })
|
||||
|
||||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
|
||||
// 过期时间为 0001-01-01 时视为无到期时间
|
||||
const formatExpireTime = (t) => {
|
||||
if (!t) return '-'
|
||||
const d = dayjs(t)
|
||||
if (d.year() < 2000) return '永久'
|
||||
return d.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: query.page, count: query.count }
|
||||
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 : [])
|
||||
console.log("用户商品列表:",list.value)
|
||||
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 handleDetail = (row) => {
|
||||
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
|
||||
}
|
||||
|
||||
// ---- 新增 ----
|
||||
const createVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const createFormRef = ref(null)
|
||||
const showProductSelector = ref(false)
|
||||
const showUserSelector = ref(false)
|
||||
const showOrderSelector = ref(false)
|
||||
const showPlanSelector = ref(false)
|
||||
const createForm = reactive({
|
||||
good_id: 0, _goodName: '', user_id: 0, _userName: '',
|
||||
order_id: 0, _orderName: '', good_plan_id: 0, _planName: '',
|
||||
_renewYuan: 0, _baseYuan: 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' }]
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
Object.assign(createForm, { good_id: 0, _goodName: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', good_plan_id: 0, _planName: '', _renewYuan: 0, _baseYuan: 0, note: '', expire_time: '' })
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
const submitCreate = () => {
|
||||
createFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
good_id: createForm.good_id, user_id: createForm.user_id,
|
||||
order_id: createForm.order_id, good_plan_id: createForm.good_plan_id,
|
||||
note: createForm.note,
|
||||
renew_price: Math.round((createForm._renewYuan || 0) * 100),
|
||||
base_price: Math.round((createForm._baseYuan || 0) * 100)
|
||||
}
|
||||
if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.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 { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 编辑 ----
|
||||
const editVisible = ref(false)
|
||||
const editForm = reactive({ id: 0, note: '', _renewYuan: 0, _baseYuan: 0, expire_time: '' })
|
||||
|
||||
const handleEdit = (row) => {
|
||||
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 || ''
|
||||
})
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
submitLoading.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)
|
||||
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 { submitLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 删除 ----
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定删除该用户商品吗?`, '删除确认', { type: 'warning' })
|
||||
.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(() => {})
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-goods-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%; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user