419 lines
15 KiB
Vue
419 lines
15 KiB
Vue
<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>
|