feat: 用户商品状态筛选与统计对接
- 新增 getUserGoodsCount 接口对接,列表页/虚拟机列表页增加状态筛选与统计卡片 - 已删除/已到期商品适配及相关页面更新 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -711,6 +711,16 @@
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">限制单用户最大购买数量,0 表示不限制</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="首购最大时长" prop="max_first_purchase_duration">
|
||||
<el-input-number
|
||||
v-model="productForm.max_first_purchase_duration"
|
||||
:min="0"
|
||||
placeholder="0 表示不限"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">限制用户首次购买的最大时长(单位:月),0 表示不限制</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -819,7 +829,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onActivated, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -832,10 +843,14 @@ import {
|
||||
getProductGroupTagList,
|
||||
deleteProductGroupTag,
|
||||
getProductList,
|
||||
getProductDetail,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
} from '@/api/admin/product'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
|
||||
import GroupTagManager from './components/GroupTagManager.vue'
|
||||
import ProductParameterManager from './components/ProductParameterManager.vue'
|
||||
@@ -931,6 +946,7 @@ const productForm = reactive({
|
||||
require_real_name: false,
|
||||
sold_out: false,
|
||||
max_per_user: 0,
|
||||
max_first_purchase_duration: 0,
|
||||
send_notice: false,
|
||||
renew_price: 0,
|
||||
renew_recommend_rebate: 0
|
||||
@@ -1584,7 +1600,9 @@ const handleAddProduct = () => {
|
||||
recommend: false,
|
||||
recommend_rebate: 0,
|
||||
arg_type: 'all',
|
||||
require_real_name: false
|
||||
require_real_name: false,
|
||||
max_per_user: 0,
|
||||
max_first_purchase_duration: 0
|
||||
})
|
||||
|
||||
selectedProductGroup.value = null
|
||||
@@ -1621,6 +1639,7 @@ const handleEditProduct = (product, parentGroupId) => {
|
||||
require_real_name: product.requireRealName ?? product.require_real_name ?? false,
|
||||
sold_out: !!product.soldOut,
|
||||
max_per_user: product.maxPerUser ?? product.max_per_user ?? 0,
|
||||
max_first_purchase_duration: product.maxFirstPurchaseDuration ?? product.max_first_purchase_duration ?? 0,
|
||||
send_notice: !!product.sendNotice,
|
||||
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
|
||||
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0
|
||||
@@ -1652,6 +1671,7 @@ const submitProductForm = () => {
|
||||
require_real_name: productForm.require_real_name,
|
||||
sold_out: productForm.sold_out === true,
|
||||
max_per_user: Number(productForm.max_per_user) || 0,
|
||||
max_first_purchase_duration: Number(productForm.max_first_purchase_duration) || 0,
|
||||
send_notice: productForm.send_notice === true,
|
||||
renew_price: Number(productForm.renew_price) || 0,
|
||||
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0
|
||||
@@ -1866,9 +1886,45 @@ watch(activeTab, (newVal) => {
|
||||
const groupTagManagerRef = ref(null)
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchGroupList()
|
||||
// 根据 good_id 请求商品详情并弹出对应商品弹窗(用于从订单详情等页面跳转)
|
||||
const openProductByGoodId = async (goodId) => {
|
||||
const id = Number(goodId)
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await getProductDetail({ good_id: id })
|
||||
if (res.data.code === 200 && res.data.data) {
|
||||
handleEditProduct(res.data.data)
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '获取商品详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取商品详情失败:', error)
|
||||
ElMessage.error('获取商品详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并处理 good_id 参数
|
||||
const checkAndOpenGoodId = async () => {
|
||||
if (route.query.good_id) {
|
||||
await openProductByGoodId(route.query.good_id)
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchGroupList()
|
||||
fetchAllTagOptions()
|
||||
await checkAndOpenGoodId()
|
||||
})
|
||||
|
||||
// 组件被 keep-alive 缓存后重新激活时,再次检查 good_id
|
||||
onActivated(() => {
|
||||
checkAndOpenGoodId()
|
||||
})
|
||||
|
||||
// 同一页面内 query 变化时也触发(如从其他标签页点击跳转)
|
||||
watch(() => route.query.good_id, (newId) => {
|
||||
if (newId) checkAndOpenGoodId()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,94 +3,237 @@
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回所有商品列表
|
||||
<el-icon><ArrowLeft /></el-icon> 返回列表
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">所有商品详情</span>
|
||||
<span class="page-title">用户商品详情</span>
|
||||
<el-tag v-if="detail" :type="currentTag === '云服务器' ? 'primary' : 'info'" size="small" style="margin-left:8px">{{ detail.tag || detail.good?.tag || '通用' }}</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" plain @click="loadDetail" :loading="loading">
|
||||
<el-button plain @click="loadDetail" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon> 刷新
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openEdit" :disabled="!detail">
|
||||
<el-icon><Edit /></el-icon> 编辑
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="handleDelete" :disabled="!detail">
|
||||
<el-icon><Delete /></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>
|
||||
<template v-if="detail">
|
||||
<!-- 已删除提示 -->
|
||||
<el-alert
|
||||
v-if="isDeleted"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="deleted-alert"
|
||||
title="该用户商品已删除"
|
||||
:description="`删除时间:${deletedTime}。已删除商品仅展示上游快照信息(itemArg),不再请求虚拟机等实时详情。`"
|
||||
/>
|
||||
|
||||
<!-- Hero 概览区 -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-left">
|
||||
<div class="hero-icon">
|
||||
<svg viewBox="0 0 48 48" width="44" height="44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="8" width="40" height="28" rx="4" stroke="currentColor" stroke-width="2.5" />
|
||||
<path d="M16 40h16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||
<path d="M24 36v4" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||
<circle cx="24" cy="22" r="6" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M24 19v3l2 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</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 class="hero-info">
|
||||
<h1 class="hero-name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
|
||||
<div class="hero-meta">
|
||||
<span class="meta-chip">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM1 8a7 7 0 1114 0A7 7 0 011 8z" fill="currentColor"/><path d="M8 4v4.5l3 1.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
{{ expireLabel }}
|
||||
</span>
|
||||
<span class="meta-chip" :class="expireStatusClass">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
|
||||
{{ expireStatusText }}
|
||||
</span>
|
||||
<span class="meta-chip id-chip">ID: {{ detail.id || goodsId }}</span>
|
||||
<span v-if="isDeleted" class="meta-chip status-deleted">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
|
||||
已删除
|
||||
</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 class="hero-right">
|
||||
<div class="price-block">
|
||||
<div class="price-label">续费价格</div>
|
||||
<div class="price-value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">备注</div>
|
||||
<div class="stat-value note-value">{{ detail.note || '-' }}</div>
|
||||
<div class="price-divider"></div>
|
||||
<div class="price-block">
|
||||
<div class="price-label">基础价格</div>
|
||||
<div class="price-value secondary">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</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 class="metrics-row">
|
||||
<div class="metric-card is-link" @click="goToUser">
|
||||
<div class="metric-icon" style="background:#eef3ff;color:#4f6ef7">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><circle cx="10" cy="7" r="3.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 17c0-2.76 3.13-5 7-5s7 2.24 7 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">所属用户</div>
|
||||
<div class="metric-value">#{{ detail.userId || detail.user_id || '-' }}</div>
|
||||
<div class="metric-name">{{ detail.user?.userName || '-' }}</div>
|
||||
</div>
|
||||
<div class="metric-arrow">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card is-link" @click="goToProduct">
|
||||
<div class="metric-icon" style="background:#f0faf0;color:#52c41a">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M17 7l-7-4-7 4v6l7 4 7-4V7z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M10 11v7M3 7l7 4 7-4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">关联商品</div>
|
||||
<div class="metric-value">#{{ detail.goodId || '-' }}</div>
|
||||
<div class="metric-name">{{ detail.good?.name || '-' }}</div>
|
||||
</div>
|
||||
<div class="metric-arrow">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card is-link" @click="goToOrder">
|
||||
<div class="metric-icon" style="background:#fff7ed;color:#f5a623">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><rect x="3" y="3" width="14" height="14" rx="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5.5M6 13h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">关联订单</div>
|
||||
<div class="metric-value">#{{ detail.orderId || '-' }}</div>
|
||||
<div class="metric-name">{{ detail.order?.name || '-' }}</div>
|
||||
</div>
|
||||
<div class="metric-arrow">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon" style="background:#fef0f0;color:#f56c6c">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M10 2l2.47 5.01L18 7.75l-4 3.9.94 5.51L10 14.51l-4.94 2.65.94-5.51-4-3.9 5.53-.74z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">套餐ID</div>
|
||||
<div class="metric-value">#{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon" style="background:#f5f0ff;color:#722ed1">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M7 3h6l4 4v9a1 1 0 01-1 1H4a1 1 0 01-1-1V7l4-4z" stroke="currentColor" stroke-width="1.5"/><path d="M7 3v4H3M10 9v5M8 12h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">归属项ID</div>
|
||||
<div class="metric-value">#{{ detail.itemId || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息折叠区 -->
|
||||
<div class="detail-section">
|
||||
<div class="section-header" @click="detailExpanded = !detailExpanded">
|
||||
<div class="section-header-left">
|
||||
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span class="section-title">详细信息</span>
|
||||
</div>
|
||||
<el-icon :class="{ 'is-expanded': detailExpanded }"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div v-show="detailExpanded" class="section-body">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">商品标签</span>
|
||||
<span class="info-value"><el-tag size="small" type="info">{{ detail.good?.tag || detail.tag || '-' }}</el-tag></span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">商品Table</span>
|
||||
<span class="info-value">{{ detail.good?.table || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">备注</span>
|
||||
<span class="info-value">{{ detail.note || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">订单状态</span>
|
||||
<span class="info-value">
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">创建时间</span>
|
||||
<span class="info-value">{{ formatTime(detail.CreatedAt) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">更新时间</span>
|
||||
<span class="info-value">{{ formatTime(detail.UpdatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- 已删除:展示 itemArg 上游快照信息,不再请求虚拟机详情 -->
|
||||
<el-card v-if="isDeleted" shadow="hover" class="related-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span class="card-title">上游快照信息 (itemArg)</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions v-if="itemArgEntries.length" :column="2" border size="small">
|
||||
<el-descriptions-item v-for="item in itemArgEntries" :key="item.key" :label="item.label">
|
||||
{{ item.value }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-empty v-else description="该商品无 itemArg 快照信息" :image-size="100" />
|
||||
</el-card>
|
||||
|
||||
<!-- 类型适配栏目区域 -->
|
||||
<UserVmDetail v-else-if="currentTag === '云服务器'" embedded :goods-id="goodsId" />
|
||||
|
||||
<el-card v-else-if="detail.good" shadow="hover" class="related-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><path d="M10 2l2.47 5.01L18 7.75l-4 3.9.94 5.51L10 14.51l-4.94 2.65.94-5.51-4-3.9 5.53-.74z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||
<span class="card-title">关联信息</span>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
</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>
|
||||
@@ -142,14 +285,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onActivated, 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 { ArrowLeft, ArrowDown, Refresh, Edit, Delete } 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 UserVmDetail from '@/views/user-vm/UserVmDetail.vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -159,6 +303,7 @@ const goodsId = computed(() => parseInt(route.params.id) || 0)
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const detailExpanded = ref(false)
|
||||
|
||||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
const formatExpireTime = (t) => {
|
||||
@@ -168,8 +313,82 @@ const formatExpireTime = (t) => {
|
||||
return d.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const expireLabel = computed(() => formatExpireTime(detail.value?.expireTime || detail.value?.expire_time))
|
||||
|
||||
const expireStatusText = computed(() => {
|
||||
const t = detail.value?.expireTime || detail.value?.expire_time
|
||||
if (!t) return '未知'
|
||||
const d = dayjs(t)
|
||||
if (d.year() < 2000) return '永久有效'
|
||||
const diff = d.diff(dayjs(), 'day')
|
||||
if (diff < 0) return '已到期'
|
||||
if (diff <= 7) return `即将到期 (${diff}天)`
|
||||
return '正常'
|
||||
})
|
||||
|
||||
const expireStatusClass = computed(() => {
|
||||
const t = detail.value?.expireTime || detail.value?.expire_time
|
||||
if (!t) return ''
|
||||
const d = dayjs(t)
|
||||
if (d.year() < 2000) return 'status-forever'
|
||||
const diff = d.diff(dayjs(), 'day')
|
||||
if (diff < 0) return 'status-expired'
|
||||
if (diff <= 7) return 'status-warning'
|
||||
return 'status-ok'
|
||||
})
|
||||
|
||||
const goBack = () => router.push('/user-goods/list')
|
||||
|
||||
const currentTag = computed(() => (detail.value?.tag || detail.value?.good?.tag || '').toLowerCase())
|
||||
|
||||
// 是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||
const isDeleted = computed(() => !!(detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at))
|
||||
const deletedTime = computed(() => {
|
||||
const t = detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at
|
||||
return t ? formatTime(t) : '-'
|
||||
})
|
||||
|
||||
// 解析 itemArg(可能是 JSON 字符串或对象),用于已删除商品展示上游快照信息
|
||||
const parsedItemArg = computed(() => {
|
||||
const raw = detail.value?.itemArg ?? detail.value?.ItemArg ?? detail.value?.item_arg
|
||||
if (!raw) return null
|
||||
if (typeof raw === 'string') {
|
||||
try { return JSON.parse(raw) } catch { return null }
|
||||
}
|
||||
if (typeof raw === 'object') return raw
|
||||
return null
|
||||
})
|
||||
|
||||
// itemArg 字段中文标签映射
|
||||
const itemArgLabelMap = {
|
||||
id: '实例ID',
|
||||
name: '实例名称',
|
||||
vcpu: 'CPU核数',
|
||||
memory: '内存',
|
||||
status: '运行状态',
|
||||
state: '运行状态',
|
||||
ips: 'IP地址',
|
||||
ip: 'IP地址',
|
||||
disk: '磁盘',
|
||||
bandwidth: '带宽',
|
||||
image: '镜像',
|
||||
image_name: '镜像名称',
|
||||
os_type: '系统类型'
|
||||
}
|
||||
|
||||
// 将 itemArg 转换为可展示的键值对列表
|
||||
const itemArgEntries = computed(() => {
|
||||
const obj = parsedItemArg.value
|
||||
if (!obj || typeof obj !== 'object') return []
|
||||
return Object.entries(obj)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== '' && typeof v !== 'object')
|
||||
.map(([k, v]) => ({
|
||||
key: k,
|
||||
label: itemArgLabelMap[k] || k,
|
||||
value: String(v)
|
||||
}))
|
||||
})
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!goodsId.value) return
|
||||
loading.value = true
|
||||
@@ -182,6 +401,22 @@ const loadDetail = async () => {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const goToUser = () => {
|
||||
const uid = detail.value?.userId || detail.value?.user_id
|
||||
if (!uid) return
|
||||
router.push({ path: '/user/detail', query: { user_id: uid } })
|
||||
}
|
||||
const goToProduct = () => {
|
||||
const gid = detail.value?.goodId
|
||||
if (!gid) return
|
||||
router.push({ path: '/product/manage', query: { good_id: gid } })
|
||||
}
|
||||
const goToOrder = () => {
|
||||
const oid = detail.value?.orderId
|
||||
if (!oid) return
|
||||
router.push({ path: '/order/list', query: { order_id: oid } })
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -202,7 +437,6 @@ const openEdit = () => {
|
||||
_serviceName: '',
|
||||
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
|
||||
})
|
||||
if (detail.value?.good?.table === 'kvm_service') { /* 通过选择器弹窗选择,无需预加载 */ }
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
@@ -233,186 +467,126 @@ const handleDelete = () => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
let isInitialMount = true
|
||||
onMounted(loadDetail)
|
||||
onActivated(() => {
|
||||
if (isInitialMount) {
|
||||
isInitialMount = false
|
||||
return
|
||||
}
|
||||
detail.value = null
|
||||
loadDetail()
|
||||
})
|
||||
watch(goodsId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.goods-detail-page {
|
||||
padding: 0;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
/* ---- Page Header ---- */
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 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; }
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
/* ---- Main Content ---- */
|
||||
.main-content { padding: 20px; min-height: 300px; }
|
||||
|
||||
.back-btn {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
/* ---- Hero Section ---- */
|
||||
.hero-section {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: linear-gradient(135deg, #f0f5ff 0%, #f8faff 60%, #fff 100%);
|
||||
border: 1px solid #e1e8f0; border-radius: 10px;
|
||||
padding: 24px 28px; gap: 24px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: #409eff;
|
||||
.hero-left { display: flex; align-items: center; gap: 20px; flex: 1; min-width: 0; }
|
||||
.hero-icon {
|
||||
width: 72px; height: 72px; border-radius: 16px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #6366f1 100%);
|
||||
color: #fff; display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; box-shadow: 0 6px 16px rgba(64, 158, 255, 0.25);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
.hero-info { min-width: 0; flex: 1; }
|
||||
.hero-name { font-size: 20px; font-weight: 700; color: #1a1a2e; margin: 0 0 10px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hero-meta { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.meta-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 12px; color: #606266; background: #fff; border: 1px solid #e8e8e8;
|
||||
padding: 3px 10px; border-radius: 20px; white-space: nowrap;
|
||||
}
|
||||
.meta-chip.id-chip { font-family: 'SF Mono', 'Consolas', monospace; letter-spacing: 0.3px; }
|
||||
.meta-chip.status-ok { color: #52c41a; border-color: #b7eb8f; background: #f6ffed; }
|
||||
.meta-chip.status-warning { color: #fa8c16; border-color: #ffd591; background: #fff7e6; }
|
||||
.meta-chip.status-expired { color: #f5222d; border-color: #ffa39e; background: #fff1f0; }
|
||||
.meta-chip.status-forever { color: #722ed1; border-color: #d3adf7; background: #f9f0ff; }
|
||||
.meta-chip.status-deleted { color: #f5222d; border-color: #ffa39e; background: #fff1f0; }
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.deleted-alert { margin-bottom: 16px; }
|
||||
|
||||
.hero-right { display: flex; align-items: center; gap: 0; flex-shrink: 0; }
|
||||
.price-block { text-align: center; padding: 0 20px; }
|
||||
.price-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
|
||||
.price-value { font-size: 22px; font-weight: 700; color: #1a1a2e; }
|
||||
.price-value.secondary { color: #606266; font-weight: 600; }
|
||||
.price-divider { width: 1px; height: 36px; background: #e0e0e0; }
|
||||
|
||||
/* ---- Metrics Row ---- */
|
||||
.metrics-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 16px; }
|
||||
.metric-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
|
||||
padding: 14px 16px; transition: all 0.2s; position: relative;
|
||||
}
|
||||
.metric-card:hover { border-color: #d0d5dd; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
|
||||
.metric-card.is-link { cursor: pointer; }
|
||||
.metric-card.is-link:hover { border-color: #409eff; background: #f8fbff; box-shadow: 0 2px 12px rgba(64, 158, 255, 0.08); }
|
||||
.metric-card.is-link:hover .metric-arrow { color: #409eff; transform: translateX(2px); }
|
||||
.metric-card.is-link:hover .metric-value { color: #409eff; }
|
||||
.metric-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.metric-body { min-width: 0; flex: 1; }
|
||||
.metric-label { font-size: 12px; color: #909399; margin-bottom: 2px; }
|
||||
.metric-value { font-size: 15px; font-weight: 600; color: #303133; font-family: 'SF Mono', 'Consolas', monospace; transition: color 0.2s; }
|
||||
.metric-name { font-size: 12px; color: #909399; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metric-arrow { color: #c0c4cc; transition: all 0.2s; flex-shrink: 0; }
|
||||
|
||||
.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;
|
||||
/* ---- Detail Section (collapsible) ---- */
|
||||
.detail-section {
|
||||
margin-top: 16px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.related-card {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #e1e8ed;
|
||||
border-radius: 8px;
|
||||
.section-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 14px 18px; cursor: pointer; user-select: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.section-header:hover { background: #fafbfc; }
|
||||
.section-header-left { display: flex; align-items: center; gap: 8px; color: #303133; }
|
||||
.section-title { font-size: 14px; font-weight: 600; }
|
||||
.section-header .el-icon { transition: transform 0.25s; color: #909399; font-size: 14px; }
|
||||
.section-header .el-icon.is-expanded { transform: rotate(180deg); }
|
||||
.section-body { padding: 0 18px 18px; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px 24px; }
|
||||
.info-item { display: flex; flex-direction: column; gap: 4px; }
|
||||
.info-label { font-size: 12px; color: #909399; }
|
||||
.info-value { font-size: 14px; color: #303133; font-weight: 500; }
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.slide-enter-active, .slide-leave-active { transition: all 0.25s ease; overflow: hidden; }
|
||||
.slide-enter-from, .slide-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
|
||||
.slide-enter-to, .slide-leave-from { opacity: 1; max-height: 300px; }
|
||||
|
||||
.selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__label) {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
/* ---- Related Card (fallback for non-VM) ---- */
|
||||
.related-card { margin-top: 16px; border: 1px solid #ebeef5; border-radius: 8px; }
|
||||
.card-header-row { display: flex; align-items: center; gap: 8px; color: #303133; }
|
||||
.card-title { font-size: 15px; font-weight: 600; color: #303133; }
|
||||
|
||||
/* ---- Shared ---- */
|
||||
.selector-row { display: flex; align-items: center; width: 100%; }
|
||||
.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; }
|
||||
|
||||
:deep(.el-descriptions__label) { font-weight: 500; color: #606266; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
<template>
|
||||
<div class="user-goods-list">
|
||||
<el-card class="main-container" shadow="never">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card" :class="{ active: query.status === 'all' }" @click="handleStatusCard('all')">
|
||||
<div class="stat-icon stat-icon-total"><el-icon><Box /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">商品总数</div>
|
||||
<div class="stat-value">{{ countTotal }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ active: query.status === 'normal' }" @click="handleStatusCard('normal')">
|
||||
<div class="stat-icon stat-icon-normal"><el-icon><CircleCheck /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">正常</div>
|
||||
<div class="stat-value">{{ counts.normal }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ active: query.status === 'expired' }" @click="handleStatusCard('expired')">
|
||||
<div class="stat-icon stat-icon-expired"><el-icon><Clock /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">已到期</div>
|
||||
<div class="stat-value">{{ counts.expired }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ active: query.status === 'deleted' }" @click="handleStatusCard('deleted')">
|
||||
<div class="stat-icon stat-icon-deleted"><el-icon><Delete /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">已删除</div>
|
||||
<div class="stat-value">{{ counts.deleted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选与操作栏 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-content">
|
||||
@@ -23,11 +55,19 @@
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" style="width:120px" @change="handleSearch">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="正常" value="normal" />
|
||||
<el-option label="已到期" value="expired" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</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 = ''; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.status = 'all'; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="action-bar">
|
||||
@@ -87,12 +127,21 @@
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="isDeleted(row)" :content="`删除于 ${formatTime(row.deleteAt || row.DeleteAt || row.deleted_at)}`" placement="top">
|
||||
<el-tag size="small" type="danger">已删除</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag v-else-if="isExpired(row)" size="small" type="warning">已到期</el-tag>
|
||||
<el-tag v-else size="small" type="success">正常</el-tag>
|
||||
</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="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.orderId" type="primary" :underline="false" @click.stop="router.push({ path: '/order/list', query: { key: row.orderId } })">{{ row.order?.name || `订单 #${row.orderId}` }}</el-link>
|
||||
<el-link v-if="row.orderId" type="primary" :underline="false" @click.stop="router.push({ path: '/order/list', query: { order_id: row.orderId } })">{{ row.order?.name || `订单 #${row.orderId}` }}</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -464,8 +513,8 @@
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
|
||||
import { Plus, Refresh, Search, ArrowDown, Box, CircleCheck, Clock, Delete } from '@element-plus/icons-vue'
|
||||
import { getUserGoodsList, getUserGoodsCount, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { formatToApiTime } from '@/utils/tool'
|
||||
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
|
||||
@@ -480,12 +529,16 @@ const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' })
|
||||
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '', status: 'all' })
|
||||
const filterUserName = ref('')
|
||||
const filterGoodName = ref('')
|
||||
const showFilterUserSelector = ref(false)
|
||||
const showFilterProductSelector = ref(false)
|
||||
|
||||
// 用户商品数量统计
|
||||
const counts = reactive({ normal: 0, deleted: 0, expired: 0 })
|
||||
const countTotal = computed(() => (counts.normal || 0) + (counts.deleted || 0) + (counts.expired || 0))
|
||||
|
||||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
|
||||
const formatExpireTime = (t) => {
|
||||
@@ -495,6 +548,17 @@ const formatExpireTime = (t) => {
|
||||
return d.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||
const isDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
|
||||
|
||||
// 判断用户商品是否已到期(永久商品除外)
|
||||
const isExpired = (row) => {
|
||||
const t = row?.expireTime || row?.expire_time
|
||||
if (!t) return false
|
||||
const d = dayjs(t)
|
||||
return d.year() >= 2000 && d.isBefore(dayjs())
|
||||
}
|
||||
|
||||
const formatMemory = (kb) => {
|
||||
if (!kb) return '-'
|
||||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
||||
@@ -520,13 +584,20 @@ const getStatusText = (status) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 组装筛选参数(count 与 list 共用)
|
||||
const buildFilterParams = () => {
|
||||
const params = {}
|
||||
if (query.key) params.key = query.key
|
||||
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
|
||||
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
|
||||
return params
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: query.page, count: query.count }
|
||||
if (query.key) params.key = query.key
|
||||
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
|
||||
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
|
||||
const params = { page: query.page, count: query.count, ...buildFilterParams() }
|
||||
if (query.status && query.status !== 'all') params.status = query.status
|
||||
const res = await getUserGoodsList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
@@ -536,7 +607,26 @@ const loadList = async () => {
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = () => { query.page = 1; loadList() }
|
||||
// 加载数量统计(透传筛选条件,与列表联动)
|
||||
const loadCount = async () => {
|
||||
try {
|
||||
const res = await getUserGoodsCount(buildFilterParams())
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
counts.normal = d.normal ?? 0
|
||||
counts.deleted = d.deleted ?? 0
|
||||
counts.expired = d.expired ?? 0
|
||||
}
|
||||
} catch { /* 统计失败不阻断列表 */ }
|
||||
}
|
||||
|
||||
const handleSearch = () => { query.page = 1; loadList(); loadCount() }
|
||||
|
||||
// 点击统计卡片快速切换状态筛选
|
||||
const handleStatusCard = (status) => {
|
||||
query.status = status
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// ---- 订单参数配置 ----
|
||||
const argsSpecList = ref([])
|
||||
@@ -841,12 +931,7 @@ const submitEdit = async () => {
|
||||
|
||||
// ---- 详情 / 删除 ----
|
||||
const handleDetail = (row) => {
|
||||
const tag = (row.tag || row.good?.tag || '').toLowerCase()
|
||||
if (tag === '云服务器') {
|
||||
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
|
||||
} else {
|
||||
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
||||
}
|
||||
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
||||
}
|
||||
|
||||
const handleMoreCmd = (cmd, row) => {
|
||||
@@ -911,7 +996,7 @@ const handleSendRemind = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
onMounted(() => { loadList(); loadCount() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -924,6 +1009,60 @@ onMounted(loadList)
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e1e8ed;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-card.active {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon-total { background: #eef3ff; color: #4f6ef7; }
|
||||
.stat-icon-normal { background: #f0faf0; color: #52c41a; }
|
||||
.stat-icon-expired { background: #fff7e6; color: #fa8c16; }
|
||||
.stat-icon-deleted { background: #fff1f0; color: #f5222d; }
|
||||
|
||||
.stat-body { display: flex; flex-direction: column; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-bottom: 2px; }
|
||||
.stat-value { font-size: 22px; font-weight: 600; color: #2c3e50; line-height: 1.1; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user