Files
ApiServer-Web-admin_dashboa…/src/views/product/UserGoodsDetail.vue
T
shiran 6f82e5e79d
Build and Deploy Vue3 / build (push) Successful in 1m46s
Build and Deploy Vue3 / deploy (push) Successful in 39s
feat: 用户商品状态筛选与统计对接
- 新增 getUserGoodsCount 接口对接,列表页/虚拟机列表页增加状态筛选与统计卡片

- 已删除/已到期商品适配及相关页面更新

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 22:12:50 +08:00

593 lines
30 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>
<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 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>
<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="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="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="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>
<!-- 数据指标卡片行 -->
<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>
<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, onActivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
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()
const router = useRouter()
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) => {
if (!t) return '-'
const d = dayjs(t)
if (d.year() < 2000) return '永久'
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
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 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)
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}` : ''
})
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(() => {})
}
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; }
/* ---- 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; }
/* ---- Main Content ---- */
.main-content { padding: 20px; min-height: 300px; }
/* ---- 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;
}
.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);
}
.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; }
.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; }
/* ---- Detail Section (collapsible) ---- */
.detail-section {
margin-top: 16px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
overflow: hidden;
}
.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; }
.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; }
/* ---- 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>