Files
ApiServer-Web-admin_dashboa…/src/views/product/UserGoodsDetail.vue
T
lin b3ed406f84
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m9s
fix: 提交修改
2026-04-15 16:02:36 +08:00

419 lines
15 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="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-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160">
<el-button type="primary" @click="loadDetail">重新加载</el-button>
</el-empty>
<el-card class="profile-card" shadow="hover" 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.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 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="hover" v-if="detail" class="related-card">
<template #header>
<span class="card-title">关联信息</span>
</template>
<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="续费价格">
<div class="unit-input-row">
<el-input-number v-model="editForm.renew_price" :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.base_price" :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" 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.params.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 #e1e8ed;
}
.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;
min-height: 300px;
}
.profile-card {
margin-bottom: 0;
border: 1px solid #e1e8ed;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
}
.profile-basic {
display: flex;
align-items: center;
gap: 20px;
}
.icon-wrapper {
width: 80px;
height: 80px;
border-radius: 12px;
background: linear-gradient(135deg, #e8f4fd 0%, #d6eaff 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.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: 24px;
flex-shrink: 0;
}
.stat-item {
text-align: center;
min-width: 80px;
padding: 10px 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
}
.stat-value {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.note-value {
font-weight: 400;
font-size: 13px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.related-card {
margin-top: 20px;
border: 1px solid #e1e8ed;
border-radius: 8px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.selector-row {
display: flex;
align-items: center;
width: 100%;
}
:deep(.el-descriptions__label) {
font-weight: 500;
color: #606266;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>