feat: 用户商品状态筛选与统计对接 #25
@@ -816,3 +816,36 @@ export const removeUserNetworkingNetwork = (data) => {
|
|||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ================================
|
||||||
|
* 回收站管理 API
|
||||||
|
* ================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 获取回收站列表 */
|
||||||
|
export const getRecycleBinList = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/recycle_bin/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取回收站记录详情 */
|
||||||
|
export const getRecycleBinDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/server/host_service/point/recycle_bin/detail', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从回收站恢复虚拟机 */
|
||||||
|
export const restoreRecycleBin = (data) => {
|
||||||
|
return http2.post('/api/v1/admin/server/host_service/point/recycle_bin/restore', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 永久删除回收站记录 */
|
||||||
|
export const deleteRecycleBin = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/recycle_bin/delete', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空回收站(all=true 全部清空,all=false 仅清理到期) */
|
||||||
|
export const cleanRecycleBin = (params) => {
|
||||||
|
return http2.delete('/api/v1/admin/server/host_service/point/recycle_bin/clean', { params })
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ export const deleteProductGroup = (data) => {
|
|||||||
export const getProductList = (params) => {
|
export const getProductList = (params) => {
|
||||||
return http2.get('/api/v1/admin/good/goods/list', {params: params})
|
return http2.get('/api/v1/admin/good/goods/list', {params: params})
|
||||||
}
|
}
|
||||||
|
/**获取商品详情(含商品参数列表) */
|
||||||
|
export const getProductDetail = (params) => {
|
||||||
|
return http2.get('/api/v1/admin/good/goods/detail', {params: params})
|
||||||
|
}
|
||||||
/**获取商品标签列表 */
|
/**获取商品标签列表 */
|
||||||
export const getProductTagList = () => {
|
export const getProductTagList = () => {
|
||||||
return http2.get('/api/v1/admin/good/goods/tag_list')
|
return http2.get('/api/v1/admin/good/goods/tag_list')
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export const deleteUserVmNetworking = (params) => http2.delete(`${BASE}/networki
|
|||||||
|
|
||||||
// ========== 用户商品 ==========
|
// ========== 用户商品 ==========
|
||||||
export const getUserGoodsList = (params) => http2.get(`${GOODS_BASE}/list`, { params })
|
export const getUserGoodsList = (params) => http2.get(`${GOODS_BASE}/list`, { params })
|
||||||
|
// 用户商品数量统计(正常 / 已删除 / 已到期),参数与列表接口一致但不分页
|
||||||
|
export const getUserGoodsCount = (params) => http2.get(`${GOODS_BASE}/count`, { params })
|
||||||
export const getUserGoodsDetail = (params) => http2.get(`${GOODS_BASE}/detail`, { params })
|
export const getUserGoodsDetail = (params) => http2.get(`${GOODS_BASE}/detail`, { params })
|
||||||
export const createUserGoods = (data) => http2.post(`${GOODS_BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
export const createUserGoods = (data) => http2.post(`${GOODS_BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
|||||||
@@ -130,7 +130,10 @@
|
|||||||
<el-icon :size="18"><Box /></el-icon>
|
<el-icon :size="18"><Box /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<span class="result-title" v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span>
|
<span class="result-title">
|
||||||
|
<span v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span>
|
||||||
|
<el-tag v-if="isGoodsDeleted(item)" size="small" type="danger" class="deleted-flag">已删除</el-tag>
|
||||||
|
</span>
|
||||||
<span class="result-desc">用户: {{ item.user?.UserName || item.userId }} · 到期: {{ formatTime(item.expireTime) }}</span>
|
<span class="result-desc">用户: {{ item.user?.UserName || item.userId }} · 到期: {{ formatTime(item.expireTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-icon class="result-arrow"><ArrowRight /></el-icon>
|
<el-icon class="result-arrow"><ArrowRight /></el-icon>
|
||||||
@@ -225,7 +228,7 @@ const searchOrders = async (key) => {
|
|||||||
results.order.loading = true
|
results.order.loading = true
|
||||||
results.order.list = []
|
results.order.list = []
|
||||||
try {
|
try {
|
||||||
const res = await getOrderList({ page: results.order.page, count: pageSize, keyword: key })
|
const res = await getOrderList({ page: results.order.page, count: pageSize, key })
|
||||||
if (res.data?.code === 200) {
|
if (res.data?.code === 200) {
|
||||||
results.order.list = res.data.data?.list || []
|
results.order.list = res.data.data?.list || []
|
||||||
results.order.total = res.data.data?.all_count || results.order.list.length
|
results.order.total = res.data.data?.all_count || results.order.list.length
|
||||||
@@ -251,7 +254,7 @@ const searchGoods = async (key) => {
|
|||||||
results.goods.loading = true
|
results.goods.loading = true
|
||||||
results.goods.list = []
|
results.goods.list = []
|
||||||
try {
|
try {
|
||||||
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, keyword: key })
|
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, key })
|
||||||
if (res.data?.code === 200) {
|
if (res.data?.code === 200) {
|
||||||
results.goods.list = res.data.data?.data || []
|
results.goods.list = res.data.data?.data || []
|
||||||
results.goods.total = res.data.data?.all_count || results.goods.list.length
|
results.goods.total = res.data.data?.all_count || results.goods.list.length
|
||||||
@@ -260,6 +263,9 @@ const searchGoods = async (key) => {
|
|||||||
results.goods.loading = false
|
results.goods.loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||||
|
const isGoodsDeleted = (item) => !!(item?.deleteAt || item?.DeleteAt || item?.deleted_at)
|
||||||
|
|
||||||
const highlight = (text) => {
|
const highlight = (text) => {
|
||||||
if (!text || !keyword.value) return text
|
if (!text || !keyword.value) return text
|
||||||
const key = keyword.value.trim()
|
const key = keyword.value.trim()
|
||||||
@@ -292,12 +298,7 @@ const goToTicket = (item) => {
|
|||||||
|
|
||||||
const goToGoods = (item) => {
|
const goToGoods = (item) => {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
const tag = (item.tag || item.good?.tag || '').toLowerCase()
|
router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
|
||||||
if (tag === '云服务器') {
|
|
||||||
router.push({ path: '/user-goods/vm-detail', query: { id: item.id } })
|
|
||||||
} else {
|
|
||||||
router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderStatusText = (status) => {
|
const orderStatusText = (status) => {
|
||||||
@@ -528,6 +529,11 @@ onUnmounted(() => {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-title .deleted-flag {
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.result-desc {
|
.result-desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #909399;
|
color: #909399;
|
||||||
|
|||||||
@@ -294,12 +294,6 @@ const routes = [
|
|||||||
component: () => import('../views/user-vm/UserVmList.vue'),
|
component: () => import('../views/user-vm/UserVmList.vue'),
|
||||||
meta: { title: '云服务器' }
|
meta: { title: '云服务器' }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'vm-detail',
|
|
||||||
name: 'UserVmDetail',
|
|
||||||
component: () => import('../views/user-vm/UserVmDetail.vue'),
|
|
||||||
meta: { title: '用户虚拟机详情', hidden: true, activeMenu: '/user-goods/vm-list' }
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// 订单管理路由
|
// 订单管理路由
|
||||||
|
|||||||
+462
-74
@@ -86,7 +86,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="商品ID" width="100">
|
<el-table-column label="商品ID" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-link v-if="row.commodityId" type="primary" :underline="false" @click.stop="router.push({ path: '/user-goods/list', query: { good_id: row.commodityId } })">{{ row.commodityId }}</el-link>
|
<el-link v-if="row.commodityId" type="primary" :underline="false" @click.stop="goToCommodity(row.commodityId, row.table)">{{ row.commodityId }}</el-link>
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -173,38 +173,148 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="detailDialogVisible"
|
v-model="detailDialogVisible"
|
||||||
title="订单详情"
|
title="订单详情"
|
||||||
width="800px"
|
width="860px"
|
||||||
append-to-body
|
append-to-body
|
||||||
|
class="order-detail-dialog"
|
||||||
>
|
>
|
||||||
<el-descriptions :column="2" border v-if="orderDetail">
|
<div v-if="orderDetail" class="order-detail">
|
||||||
<el-descriptions-item label="订单ID">{{ orderDetail.id }}</el-descriptions-item>
|
<!-- 顶部概览 -->
|
||||||
<el-descriptions-item label="订单名称">{{ orderDetail.name }}</el-descriptions-item>
|
<div class="od-hero">
|
||||||
<el-descriptions-item label="订单类型">
|
<div class="od-hero-main">
|
||||||
<el-tag size="small">{{ getTypeText(orderDetail.type) }}</el-tag>
|
<div class="od-hero-title">
|
||||||
</el-descriptions-item>
|
<span class="od-order-name">{{ orderDetail.name || '未命名订单' }}</span>
|
||||||
<el-descriptions-item label="用户ID">{{ orderDetail.userId }}</el-descriptions-item>
|
<el-tag :type="getStatusType(orderDetail.state)" size="small">
|
||||||
<el-descriptions-item label="商品ID">{{ orderDetail.commodityId }}</el-descriptions-item>
|
{{ getStatusText(orderDetail.state) }}
|
||||||
<el-descriptions-item label="套餐ID">{{ orderDetail.planId || '-' }}</el-descriptions-item>
|
</el-tag>
|
||||||
<el-descriptions-item label="表名">{{ orderDetail.table }}</el-descriptions-item>
|
<el-tag type="info" size="small" effect="plain">{{ getTypeText(orderDetail.type) }}</el-tag>
|
||||||
<el-descriptions-item label="数量">{{ orderDetail.payNum }}</el-descriptions-item>
|
</div>
|
||||||
<el-descriptions-item label="订单金额">¥{{ (orderDetail.price / 100).toFixed(2) }}</el-descriptions-item>
|
<div class="od-hero-sub">
|
||||||
<el-descriptions-item label="续费价格">¥{{ (orderDetail.renewPrice / 100).toFixed(2) }}</el-descriptions-item>
|
<span>订单号 #{{ orderDetail.id }}</span>
|
||||||
<el-descriptions-item label="订单状态">
|
<el-divider direction="vertical" />
|
||||||
<el-tag :type="getStatusType(orderDetail.state)">
|
<span>创建于 {{ formatDate(orderDetail.createdAt || orderDetail.CreatedAt) }}</span>
|
||||||
{{ getStatusText(orderDetail.state) }}
|
</div>
|
||||||
</el-tag>
|
</div>
|
||||||
</el-descriptions-item>
|
<div class="od-hero-amount">
|
||||||
<el-descriptions-item label="支付方式">{{ orderDetail.payType || '-' }}</el-descriptions-item>
|
<span class="od-amount-label">订单金额</span>
|
||||||
<el-descriptions-item label="过期时间">{{ formatDate(orderDetail.expireTime) }}</el-descriptions-item>
|
<span class="od-amount-value">¥{{ formatYuan(orderDetail.price) }}</span>
|
||||||
<el-descriptions-item label="创建时间">{{ formatDate(orderDetail.CreatedAt) }}</el-descriptions-item>
|
</div>
|
||||||
<el-descriptions-item label="更新时间">{{ formatDate(orderDetail.UpdatedAt) }}</el-descriptions-item>
|
</div>
|
||||||
<el-descriptions-item label="参数信息">{{ orderDetail.args || '-' }}</el-descriptions-item>
|
|
||||||
<el-descriptions-item v-if="orderDetail.error" label="错误信息" :span="2">
|
<!-- 订单信息 -->
|
||||||
<el-tag type="danger" size="small" style="margin-right: 6px;">异常</el-tag>
|
<div class="od-section">
|
||||||
<span style="color: #f56c6c;">{{ orderDetail.error }}</span>
|
<div class="od-section-title"><el-icon><Document /></el-icon><span>订单信息</span></div>
|
||||||
</el-descriptions-item>
|
<el-descriptions :column="2" border size="small">
|
||||||
<el-descriptions-item label="备注" :span="2">{{ orderDetail.note || '无' }}</el-descriptions-item>
|
<el-descriptions-item label="订单ID">{{ orderDetail.id }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
<el-descriptions-item label="订单类型">
|
||||||
|
<el-tag size="small" effect="plain">{{ getTypeText(orderDetail.type) }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="数量">{{ orderDetail.payNum }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="表名">{{ orderDetail.table || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="订单金额">¥{{ formatYuan(orderDetail.price) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="续费价格">¥{{ formatYuan(orderDetail.renewPrice) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="支付方式">{{ getPayTypeText(orderDetail.payType) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="支付平台订单号">
|
||||||
|
<span class="od-mono">{{ orderDetail.paymentOrderId || '-' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="到期时间">{{ formatDate(orderDetail.expireTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="退款时间">{{ orderDetail.refundTime ? formatDate(orderDetail.refundTime) : '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatDate(orderDetail.createdAt || orderDetail.CreatedAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="更新时间">{{ formatDate(orderDetail.updatedAt || orderDetail.UpdatedAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="参数信息" :span="2">
|
||||||
|
<span class="od-mono od-args">{{ orderDetail.args || '-' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注" :span="2">{{ orderDetail.note || '无' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="orderDetail.error" label="错误信息" :span="2">
|
||||||
|
<el-tag type="danger" size="small" style="margin-right: 6px;">异常</el-tag>
|
||||||
|
<span style="color: #f56c6c;">{{ orderDetail.error }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户信息 -->
|
||||||
|
<div class="od-section">
|
||||||
|
<div class="od-section-title">
|
||||||
|
<el-icon><User /></el-icon><span>用户信息</span>
|
||||||
|
<el-link
|
||||||
|
v-if="orderDetail.userId"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
class="od-section-link"
|
||||||
|
@click="goToUserDetail(orderDetail.userId)"
|
||||||
|
>查看用户 <el-icon><ArrowRight /></el-icon></el-link>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="用户ID">
|
||||||
|
<el-link type="primary" :underline="false" @click="goToUserDetail(orderDetail.userId)">{{ orderDetail.userId || '-' }}</el-link>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户名">{{ orderDetail.user?.userName || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="邮箱">{{ orderDetail.user?.email || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="手机号">{{ orderDetail.user?.phone || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 商品信息 -->
|
||||||
|
<div class="od-section">
|
||||||
|
<div class="od-section-title">
|
||||||
|
<el-icon><Goods /></el-icon><span>商品信息</span>
|
||||||
|
<el-link
|
||||||
|
v-if="orderDetail.commodityId"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
class="od-section-link"
|
||||||
|
@click="goToCommodity(orderDetail.commodityId, orderDetail.table)"
|
||||||
|
>{{ orderDetail.table === 'good' ? '查看商品' : '查看用户商品' }} <el-icon><ArrowRight /></el-icon></el-link>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="商品ID">
|
||||||
|
<el-link type="primary" :underline="false" @click="goToCommodity(orderDetail.commodityId, orderDetail.table)">{{ orderDetail.commodityId || '-' }}</el-link>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品名称">{{ orderDetail.good?.name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品标签">
|
||||||
|
<el-tag v-if="orderDetail.good?.tag" size="small" effect="plain">{{ orderDetail.good.tag }}</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="所属分组">{{ orderDetail.good?.groupName || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 套餐信息 -->
|
||||||
|
<div class="od-section" v-if="orderDetail.plan || orderDetail.planId">
|
||||||
|
<div class="od-section-title"><el-icon><Box /></el-icon><span>套餐信息</span></div>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="套餐ID">{{ orderDetail.plan?.id || orderDetail.planId || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="套餐名称">{{ orderDetail.plan?.name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="套餐说明" :span="2">{{ orderDetail.plan?.note || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 所属用户商品 -->
|
||||||
|
<div class="od-section" v-if="orderDetail.userGoods">
|
||||||
|
<div class="od-section-title">
|
||||||
|
<el-icon><ShoppingCart /></el-icon><span>关联用户商品</span>
|
||||||
|
<el-link
|
||||||
|
v-if="orderDetail.userGoods?.id"
|
||||||
|
type="primary"
|
||||||
|
:underline="false"
|
||||||
|
class="od-section-link"
|
||||||
|
@click="goToUserGoodsDetail(orderDetail.userGoods.id)"
|
||||||
|
>查看用户商品 <el-icon><ArrowRight /></el-icon></el-link>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item label="用户商品ID">
|
||||||
|
<el-link type="primary" :underline="false" @click="goToUserGoodsDetail(orderDetail.userGoods.id)">{{ orderDetail.userGoods.id || '-' }}</el-link>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="上游资源ID">{{ orderDetail.userGoods.itemId || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="到期时间">{{ formatDate(orderDetail.userGoods.expireTime) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="续费价格">¥{{ formatYuan(orderDetail.userGoods.renewPrice) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品标签">
|
||||||
|
<el-tag v-if="orderDetail.userGoods.tag" size="small" effect="plain">{{ orderDetail.userGoods.tag }}</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注">{{ orderDetail.userGoods.note || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 订单表单对话框(分步向导) -->
|
<!-- 订单表单对话框(分步向导) -->
|
||||||
@@ -217,7 +327,7 @@
|
|||||||
>
|
>
|
||||||
<div class="wizard-container">
|
<div class="wizard-container">
|
||||||
<el-steps :active="currentStep" finish-status="success" align-center class="wizard-steps">
|
<el-steps :active="currentStep" finish-status="success" align-center class="wizard-steps">
|
||||||
<el-step title="选择用户" description="指定订单用户与类型" />
|
<el-step title="选择用户" description="指定用户与订单类型" />
|
||||||
<el-step title="商品配置" description="选择商品与套餐" />
|
<el-step title="商品配置" description="选择商品与套餐" />
|
||||||
<el-step title="订单详情" description="价格、支付与优惠" />
|
<el-step title="订单详情" description="价格、支付与优惠" />
|
||||||
</el-steps>
|
</el-steps>
|
||||||
@@ -256,9 +366,6 @@
|
|||||||
<el-option label="IPv6" value="ipv6" />
|
<el-option label="IPv6" value="ipv6" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="订单名称" prop="name">
|
|
||||||
<el-input v-model="orderForm.name" placeholder="请输入订单名称" maxlength="200" show-word-limit />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<!-- Step 2: 商品与套餐 -->
|
<!-- Step 2: 商品与套餐 -->
|
||||||
@@ -284,18 +391,43 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="套餐" prop="plan_id">
|
<el-form-item label="套餐" prop="plan_id">
|
||||||
<el-select v-model="orderForm.plan_id" placeholder="请选择套餐(可选)" clearable style="width: 100%" :loading="planLoading" @change="handlePlanChange">
|
<el-select v-model="orderForm.plan_id" placeholder="请选择套餐(可选)" clearable style="width: 100%" :loading="planLoading" @change="handlePlanChange">
|
||||||
<el-option v-for="p in planList" :key="p.id" :label="`${p.name} - ¥${(p.price / 100).toFixed(2)}`" :value="p.id" />
|
<el-option
|
||||||
|
v-for="p in planList"
|
||||||
|
:key="p.id"
|
||||||
|
:label="`${p.name} - ¥${((p.enableFixedPrice ? p.fixedPrice : p.argsPrice || 0) / 100).toFixed(2)}${p.inventory !== undefined ? ` (库存: ${p.inventory})` : ''}`"
|
||||||
|
:value="p.id"
|
||||||
|
:disabled="p.disable"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<div v-if="!planList.length && orderForm.commodity_id && !planLoading" class="form-tip">该商品暂无套餐,可直接进入下一步手动设置价格</div>
|
<div v-if="!planList.length && orderForm.commodity_id && !planLoading" class="form-tip">该商品暂无套餐,可直接进入下一步手动设置价格</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="所属表">
|
<el-form-item label="所属表">
|
||||||
<el-input v-model="orderForm.table" placeholder="选择商品后自动填充,或手动输入" />
|
<el-input v-model="orderForm.table" placeholder="选择商品后自动填充,或手动输入" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="订单名称" prop="name">
|
||||||
|
<el-input v-model="orderForm.name" placeholder="选择商品后自动生成,可手动修改" maxlength="200" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 动态商品参数 -->
|
<!-- 套餐固定参数(只读展示) -->
|
||||||
|
<div v-if="planFixedArgs.length" class="args-section plan-fixed-args">
|
||||||
|
<div class="args-section-title">
|
||||||
|
<el-icon><Lock /></el-icon>套餐固定参数
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" border size="small">
|
||||||
|
<el-descriptions-item
|
||||||
|
v-for="arg in planFixedArgs"
|
||||||
|
:key="arg.arg_id"
|
||||||
|
:label="arg.name"
|
||||||
|
>
|
||||||
|
{{ arg.number !== undefined && arg.number !== null ? arg.number : (arg.value || '-') }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 动态商品参数(套餐模式下仅展示额外配置参数) -->
|
||||||
<div v-if="productParams.length" class="args-section">
|
<div v-if="productParams.length" class="args-section">
|
||||||
<div class="args-section-title">
|
<div class="args-section-title">
|
||||||
<el-icon><Setting /></el-icon>商品参数配置
|
<el-icon><Setting /></el-icon>{{ orderForm.plan_id ? '额外配置参数' : '商品参数配置' }}
|
||||||
</div>
|
</div>
|
||||||
<el-form-item
|
<el-form-item
|
||||||
v-for="param in productParams"
|
v-for="param in productParams"
|
||||||
@@ -467,10 +599,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted, onActivated, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close, Setting, Loading } from '@element-plus/icons-vue'
|
import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close, Setting, Loading, Lock, Document, ArrowRight, Goods, Box } from '@element-plus/icons-vue'
|
||||||
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder, retryOrderHook } from '@/api/admin/order'
|
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder, retryOrderHook } from '@/api/admin/order'
|
||||||
import { getProductPlanList, getProductParameterList, getProductParameterDetail } from '@/api/admin/product'
|
import { getProductPlanList, getProductParameterList, getProductParameterDetail } from '@/api/admin/product'
|
||||||
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||||
@@ -518,11 +650,11 @@ const orderForm = reactive({
|
|||||||
// 分步验证规则
|
// 分步验证规则
|
||||||
const step1Rules = {
|
const step1Rules = {
|
||||||
user_id: [{ required: true, message: '请选择用户', trigger: 'change' }],
|
user_id: [{ required: true, message: '请选择用户', trigger: 'change' }],
|
||||||
type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
|
type: [{ required: true, message: '请选择订单类型', trigger: 'change' }]
|
||||||
name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }]
|
|
||||||
}
|
}
|
||||||
const step2Rules = {
|
const step2Rules = {
|
||||||
commodity_id: [{ required: true, message: '请选择商品', trigger: 'change' }]
|
commodity_id: [{ required: true, message: '请选择商品', trigger: 'change' }],
|
||||||
|
name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
const step3Rules = {
|
const step3Rules = {
|
||||||
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
||||||
@@ -539,9 +671,11 @@ const step3FormRef = ref(null)
|
|||||||
// 套餐相关
|
// 套餐相关
|
||||||
const planList = ref([])
|
const planList = ref([])
|
||||||
const planLoading = ref(false)
|
const planLoading = ref(false)
|
||||||
|
const planFixedArgs = ref([])
|
||||||
|
|
||||||
// 商品参数相关
|
// 商品参数相关
|
||||||
const productParams = ref([])
|
const productParams = ref([])
|
||||||
|
const allProductParams = ref([])
|
||||||
const paramsLoading = ref(false)
|
const paramsLoading = ref(false)
|
||||||
const argValues = reactive({})
|
const argValues = reactive({})
|
||||||
|
|
||||||
@@ -625,10 +759,47 @@ const getStatusText = (status) => {
|
|||||||
|
|
||||||
// 获取订单类型文本
|
// 获取订单类型文本
|
||||||
const getTypeText = (type) => {
|
const getTypeText = (type) => {
|
||||||
const typeMap = { create: '新购', renew: '续费', update: '升级', snapshot: '快照', backup: '备份', data_volume: '数据盘', ipv4: 'IPv4', ipv6: 'IPv6' }
|
const typeMap = { create: '新购', renew: '续费', update: '升级', snapshot: '快照', backup: '备份', data_volume: '数据盘', ipv4: 'IPv4', ipv6: 'IPv6', traffic: '流量', rebuild_image_diff: '重装差价' }
|
||||||
return typeMap[type] || type || '-'
|
return typeMap[type] || type || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取支付方式文本
|
||||||
|
const getPayTypeText = (payType) => {
|
||||||
|
const map = { ali: '支付宝', wx: '微信', default: '余额/默认' }
|
||||||
|
return map[payType] || payType || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分转元
|
||||||
|
const formatYuan = (fen) => {
|
||||||
|
return ((Number(fen) || 0) / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转:用户详情
|
||||||
|
const goToUserDetail = (userId) => {
|
||||||
|
if (!userId) return
|
||||||
|
detailDialogVisible.value = false
|
||||||
|
router.push({ path: '/user/detail', query: { user_id: userId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转:根据 table 类型决定 commodityId 跳转目标
|
||||||
|
// table=='good' → 商品管理弹窗;其他情况(如 userGoods) → 用户商品详情
|
||||||
|
const goToCommodity = (commodityId, table) => {
|
||||||
|
if (!commodityId) return
|
||||||
|
detailDialogVisible.value = false
|
||||||
|
if (table === 'good') {
|
||||||
|
router.push({ path: '/product/manage', query: { good_id: commodityId } })
|
||||||
|
} else {
|
||||||
|
router.push({ name: 'UserGoodsDetail', params: { id: commodityId } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转:用户商品详情
|
||||||
|
const goToUserGoodsDetail = (userGoodsId) => {
|
||||||
|
if (!userGoodsId) return
|
||||||
|
detailDialogVisible.value = false
|
||||||
|
router.push({ name: 'UserGoodsDetail', params: { id: userGoodsId } })
|
||||||
|
}
|
||||||
|
|
||||||
// 查询
|
// 查询
|
||||||
const handleQuery = () => {
|
const handleQuery = () => {
|
||||||
queryParams.page = 1
|
queryParams.page = 1
|
||||||
@@ -668,7 +839,9 @@ const handleAdd = () => {
|
|||||||
currentStep.value = 0
|
currentStep.value = 0
|
||||||
clearAllSelections()
|
clearAllSelections()
|
||||||
planList.value = []
|
planList.value = []
|
||||||
|
planFixedArgs.value = []
|
||||||
productParams.value = []
|
productParams.value = []
|
||||||
|
allProductParams.value = []
|
||||||
Object.keys(argValues).forEach(k => delete argValues[k])
|
Object.keys(argValues).forEach(k => delete argValues[k])
|
||||||
Object.assign(orderForm, {
|
Object.assign(orderForm, {
|
||||||
order_id: undefined,
|
order_id: undefined,
|
||||||
@@ -693,10 +866,12 @@ const handleAdd = () => {
|
|||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看订单详情
|
// 查看订单详情(支持传入 row 对象或直接传 orderId)
|
||||||
const handleView = async (row) => {
|
const handleView = async (rowOrId) => {
|
||||||
|
const orderId = typeof rowOrId === 'object' ? rowOrId.id : rowOrId
|
||||||
|
if (!orderId) return
|
||||||
try {
|
try {
|
||||||
const res = await getOrderDetail({ order_id: row.id })
|
const res = await getOrderDetail({ order_id: orderId })
|
||||||
if (res.data.code === 200) {
|
if (res.data.code === 200) {
|
||||||
orderDetail.value = res.data.data
|
orderDetail.value = res.data.data
|
||||||
detailDialogVisible.value = true
|
detailDialogVisible.value = true
|
||||||
@@ -713,7 +888,9 @@ const handleEdit = (row) => {
|
|||||||
currentStep.value = 0
|
currentStep.value = 0
|
||||||
clearAllSelections()
|
clearAllSelections()
|
||||||
planList.value = []
|
planList.value = []
|
||||||
|
planFixedArgs.value = []
|
||||||
productParams.value = []
|
productParams.value = []
|
||||||
|
allProductParams.value = []
|
||||||
Object.keys(argValues).forEach(k => delete argValues[k])
|
Object.keys(argValues).forEach(k => delete argValues[k])
|
||||||
|
|
||||||
let expireTimeMs = null
|
let expireTimeMs = null
|
||||||
@@ -749,10 +926,34 @@ const handleEdit = (row) => {
|
|||||||
}
|
}
|
||||||
if (row.commodityId) {
|
if (row.commodityId) {
|
||||||
selectedProductInfo.value = { id: row.commodityId, name: `商品${row.commodityId}` }
|
selectedProductInfo.value = { id: row.commodityId, name: `商品${row.commodityId}` }
|
||||||
fetchPlanList(row.commodityId)
|
fetchPlanList(row.commodityId).then(() => {
|
||||||
|
// 如果订单有套餐,按套餐结构恢复参数
|
||||||
|
if (row.planId && planList.value.length) {
|
||||||
|
handlePlanChange(row.planId)
|
||||||
|
// 从已有 args 恢复额外参数的选择值
|
||||||
|
if (row.args) {
|
||||||
|
try {
|
||||||
|
const existingArgs = JSON.parse(row.args)
|
||||||
|
if (Array.isArray(existingArgs)) {
|
||||||
|
for (const a of existingArgs) {
|
||||||
|
const param = productParams.value.find(p => p.id === a.arg_id)
|
||||||
|
if (!param) continue
|
||||||
|
if (param.type === 'select') {
|
||||||
|
argValues[param.id] = a.attr_id
|
||||||
|
} else if (param.type === 'number') {
|
||||||
|
argValues[param.id] = a.number
|
||||||
|
} else {
|
||||||
|
argValues[param.id] = a.value || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
fetchProductParams(row.commodityId).then(() => {
|
fetchProductParams(row.commodityId).then(() => {
|
||||||
// 从已有 args 中恢复参数值
|
// 无套餐时从已有 args 恢复全量参数值
|
||||||
if (row.args) {
|
if (!row.planId && row.args) {
|
||||||
try {
|
try {
|
||||||
const existingArgs = JSON.parse(row.args)
|
const existingArgs = JSON.parse(row.args)
|
||||||
if (Array.isArray(existingArgs)) {
|
if (Array.isArray(existingArgs)) {
|
||||||
@@ -877,7 +1078,7 @@ const fetchPlanList = async (goodId) => {
|
|||||||
|
|
||||||
// 加载商品参数列表
|
// 加载商品参数列表
|
||||||
const fetchProductParams = async (goodId) => {
|
const fetchProductParams = async (goodId) => {
|
||||||
if (!goodId) { productParams.value = []; return }
|
if (!goodId) { productParams.value = []; allProductParams.value = []; return }
|
||||||
paramsLoading.value = true
|
paramsLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getProductParameterList({ good_id: goodId })
|
const res = await getProductParameterList({ good_id: goodId })
|
||||||
@@ -893,43 +1094,73 @@ const fetchProductParams = async (goodId) => {
|
|||||||
} else { param.attrs = [] }
|
} else { param.attrs = [] }
|
||||||
} catch { param.attrs = [] }
|
} catch { param.attrs = [] }
|
||||||
}))
|
}))
|
||||||
|
allProductParams.value = list
|
||||||
productParams.value = list
|
productParams.value = list
|
||||||
// 初始化默认值
|
initArgDefaults(list)
|
||||||
for (const param of list) {
|
} else { productParams.value = []; allProductParams.value = [] }
|
||||||
if (argValues[param.id] !== undefined) continue
|
} catch { productParams.value = []; allProductParams.value = [] }
|
||||||
if (param.type === 'select' && param.attrs?.length) {
|
|
||||||
argValues[param.id] = param.attrs[0].id
|
|
||||||
} else if (param.type === 'number') {
|
|
||||||
argValues[param.id] = param.min || 0
|
|
||||||
} else {
|
|
||||||
argValues[param.id] = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else { productParams.value = [] }
|
|
||||||
} catch { productParams.value = [] }
|
|
||||||
finally { paramsLoading.value = false }
|
finally { paramsLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为参数列表初始化默认值
|
||||||
|
const initArgDefaults = (params) => {
|
||||||
|
for (const param of params) {
|
||||||
|
if (argValues[param.id] !== undefined) continue
|
||||||
|
if (param.type === 'select' && param.attrs?.length) {
|
||||||
|
argValues[param.id] = param.attrs[0].id
|
||||||
|
} else if (param.type === 'number') {
|
||||||
|
argValues[param.id] = param.min || 0
|
||||||
|
} else {
|
||||||
|
argValues[param.id] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据参数表单值构建 args JSON
|
// 根据参数表单值构建 args JSON
|
||||||
const buildArgsJson = () => {
|
const buildArgsJson = () => {
|
||||||
if (!productParams.value.length) return ''
|
|
||||||
const args = []
|
const args = []
|
||||||
|
|
||||||
|
// 套餐模式:先包含套餐固定参数
|
||||||
|
if (orderForm.plan_id && planFixedArgs.value.length) {
|
||||||
|
args.push(...planFixedArgs.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加用户选择的额外参数(或无套餐时的全部参数)
|
||||||
for (const param of productParams.value) {
|
for (const param of productParams.value) {
|
||||||
const val = argValues[param.id]
|
const val = argValues[param.id]
|
||||||
if (val === undefined || val === null || val === '') continue
|
if (val === undefined || val === null || val === '') continue
|
||||||
if (param.type === 'select') {
|
if (param.type === 'select') {
|
||||||
const attr = param.attrs?.find(a => a.id === val)
|
const attr = param.attrs?.find(a => a.id === val)
|
||||||
if (attr) {
|
if (attr) {
|
||||||
args.push({ arg_id: param.id, name: param.name, attr_id: attr.id, value: attr.value || attr.name })
|
args.push({
|
||||||
|
arg_id: param.id,
|
||||||
|
name: param.name,
|
||||||
|
attr_id: attr.id,
|
||||||
|
number: Number(attr.value) || 0,
|
||||||
|
key: param.key || ''
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} else if (param.type === 'number') {
|
} else if (param.type === 'number') {
|
||||||
const matchedAttr = findMatchedAttr(param, val)
|
const matchedAttr = findMatchedAttr(param, val)
|
||||||
args.push({ arg_id: param.id, name: param.name, attr_id: matchedAttr?.id || (param.attrs?.[0]?.id || 0), number: val })
|
args.push({
|
||||||
|
arg_id: param.id,
|
||||||
|
name: param.name,
|
||||||
|
attr_id: matchedAttr?.id || (param.attrs?.[0]?.id || 0),
|
||||||
|
number: val,
|
||||||
|
key: param.key || ''
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
const attr = param.attrs?.[0]
|
const attr = param.attrs?.[0]
|
||||||
args.push({ arg_id: param.id, name: param.name, attr_id: attr?.id || 0, value: val })
|
args.push({
|
||||||
|
arg_id: param.id,
|
||||||
|
name: param.name,
|
||||||
|
attr_id: attr?.id || 0,
|
||||||
|
value: val,
|
||||||
|
key: param.key || ''
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return args.length > 0 ? JSON.stringify(args) : ''
|
return args.length > 0 ? JSON.stringify(args) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,12 +1178,33 @@ const findMatchedAttr = (param, numValue) => {
|
|||||||
|
|
||||||
// 套餐选择变更
|
// 套餐选择变更
|
||||||
const handlePlanChange = (planId) => {
|
const handlePlanChange = (planId) => {
|
||||||
if (!planId) return
|
Object.keys(argValues).forEach(k => delete argValues[k])
|
||||||
const plan = planList.value.find(p => p.id === planId)
|
|
||||||
if (plan) {
|
if (!planId) {
|
||||||
if (plan.price) orderForm.price = plan.price
|
planFixedArgs.value = []
|
||||||
if (plan.renew_price) orderForm.renew_price = plan.renew_price
|
productParams.value = allProductParams.value
|
||||||
|
initArgDefaults(allProductParams.value)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plan = planList.value.find(p => p.id === planId)
|
||||||
|
if (!plan) return
|
||||||
|
|
||||||
|
// 设置正确的价格
|
||||||
|
orderForm.price = plan.enableFixedPrice ? (plan.fixedPrice || 0) : (plan.argsPrice || 0)
|
||||||
|
orderForm.renew_price = plan.renewFixedPrice || 0
|
||||||
|
|
||||||
|
// 解析套餐固定参数(只读展示)
|
||||||
|
try {
|
||||||
|
planFixedArgs.value = plan.args ? JSON.parse(plan.args) : []
|
||||||
|
} catch {
|
||||||
|
planFixedArgs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用套餐的 extraArgs 作为可编辑参数
|
||||||
|
const extraArgs = plan.extraArgs || []
|
||||||
|
productParams.value = extraArgs
|
||||||
|
initArgDefaults(extraArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
@@ -1035,6 +1287,10 @@ const handleProductSelect = (product) => {
|
|||||||
selectedProductInfo.value = product
|
selectedProductInfo.value = product
|
||||||
if (product.table) orderForm.table = product.table
|
if (product.table) orderForm.table = product.table
|
||||||
orderForm.plan_id = null
|
orderForm.plan_id = null
|
||||||
|
planFixedArgs.value = []
|
||||||
|
// 自动生成订单名称:商品名-时间戳
|
||||||
|
const ts = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '').replace(/\s/g, '-').replace(/:/g, '')
|
||||||
|
orderForm.name = `${product.name}-${ts}`
|
||||||
// 清空旧参数值
|
// 清空旧参数值
|
||||||
Object.keys(argValues).forEach(k => delete argValues[k])
|
Object.keys(argValues).forEach(k => delete argValues[k])
|
||||||
fetchPlanList(product.id)
|
fetchPlanList(product.id)
|
||||||
@@ -1046,7 +1302,9 @@ const clearProduct = () => {
|
|||||||
orderForm.plan_id = null
|
orderForm.plan_id = null
|
||||||
selectedProductInfo.value = null
|
selectedProductInfo.value = null
|
||||||
planList.value = []
|
planList.value = []
|
||||||
|
planFixedArgs.value = []
|
||||||
productParams.value = []
|
productParams.value = []
|
||||||
|
allProductParams.value = []
|
||||||
Object.keys(argValues).forEach(k => delete argValues[k])
|
Object.keys(argValues).forEach(k => delete argValues[k])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1080,12 +1338,27 @@ const clearAllSelections = () => {
|
|||||||
selectedVoucherInfo.value = null
|
selectedVoucherInfo.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查并处理 order_id 参数,自动打开订单详情弹窗
|
||||||
|
const checkAndOpenOrderId = async () => {
|
||||||
|
if (route.query.order_id) {
|
||||||
|
await handleView(Number(route.query.order_id))
|
||||||
|
router.replace({ query: { ...route.query, order_id: undefined } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
if (route.query.key) queryParams.key = String(route.query.key)
|
if (route.query.key) queryParams.key = String(route.query.key)
|
||||||
if (route.query.user_id) queryParams.user_id = String(route.query.user_id)
|
if (route.query.user_id) queryParams.user_id = String(route.query.user_id)
|
||||||
if (route.query.state) queryParams.state = String(route.query.state)
|
if (route.query.state) queryParams.state = String(route.query.state)
|
||||||
fetchOrderList()
|
fetchOrderList()
|
||||||
|
await checkAndOpenOrderId()
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => { checkAndOpenOrderId() })
|
||||||
|
|
||||||
|
watch(() => route.query.order_id, (newId) => {
|
||||||
|
if (newId) checkAndOpenOrderId()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1200,6 +1473,26 @@ onMounted(() => {
|
|||||||
border: 1px solid #ebeef5;
|
border: 1px solid #ebeef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-fixed-args {
|
||||||
|
background: #f0f5ff;
|
||||||
|
border-color: #d6e4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-fixed-args .args-section-title {
|
||||||
|
color: #1d39c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-fixed-args :deep(.el-descriptions__label) {
|
||||||
|
background: #e8edfb;
|
||||||
|
color: #1d39c4;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-fixed-args :deep(.el-descriptions__content) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
.args-section-title {
|
.args-section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1319,4 +1612,99 @@ onMounted(() => {
|
|||||||
.text-muted {
|
.text-muted {
|
||||||
color: #c0c4cc;
|
color: #c0c4cc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 订单详情弹窗 ===== */
|
||||||
|
.order-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部概览 */
|
||||||
|
.od-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border: 1px solid #e8eaed;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.od-hero-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.od-hero-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.od-order-name {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
.od-hero-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
.od-hero-sub :deep(.el-divider--vertical) {
|
||||||
|
background-color: #dcdfe6;
|
||||||
|
}
|
||||||
|
.od-hero-amount {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.od-amount-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
.od-amount-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分区 */
|
||||||
|
.od-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.od-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.od-section-title .el-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.od-section-link {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.od-section-link .el-icon {
|
||||||
|
color: inherit;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.od-mono {
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.od-args {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -711,6 +711,16 @@
|
|||||||
/>
|
/>
|
||||||
<div style="font-size:12px;color:#909399;margin-top:4px">限制单用户最大购买数量,0 表示不限制</div>
|
<div style="font-size:12px;color:#909399;margin-top:4px">限制单用户最大购买数量,0 表示不限制</div>
|
||||||
</el-form-item>
|
</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>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
@@ -819,7 +829,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue'
|
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
@@ -832,10 +843,14 @@ import {
|
|||||||
getProductGroupTagList,
|
getProductGroupTagList,
|
||||||
deleteProductGroupTag,
|
deleteProductGroupTag,
|
||||||
getProductList,
|
getProductList,
|
||||||
|
getProductDetail,
|
||||||
createProduct,
|
createProduct,
|
||||||
updateProduct,
|
updateProduct,
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
} from '@/api/admin/product'
|
} from '@/api/admin/product'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
|
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
|
||||||
import GroupTagManager from './components/GroupTagManager.vue'
|
import GroupTagManager from './components/GroupTagManager.vue'
|
||||||
import ProductParameterManager from './components/ProductParameterManager.vue'
|
import ProductParameterManager from './components/ProductParameterManager.vue'
|
||||||
@@ -931,6 +946,7 @@ const productForm = reactive({
|
|||||||
require_real_name: false,
|
require_real_name: false,
|
||||||
sold_out: false,
|
sold_out: false,
|
||||||
max_per_user: 0,
|
max_per_user: 0,
|
||||||
|
max_first_purchase_duration: 0,
|
||||||
send_notice: false,
|
send_notice: false,
|
||||||
renew_price: 0,
|
renew_price: 0,
|
||||||
renew_recommend_rebate: 0
|
renew_recommend_rebate: 0
|
||||||
@@ -1584,7 +1600,9 @@ const handleAddProduct = () => {
|
|||||||
recommend: false,
|
recommend: false,
|
||||||
recommend_rebate: 0,
|
recommend_rebate: 0,
|
||||||
arg_type: 'all',
|
arg_type: 'all',
|
||||||
require_real_name: false
|
require_real_name: false,
|
||||||
|
max_per_user: 0,
|
||||||
|
max_first_purchase_duration: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
selectedProductGroup.value = null
|
selectedProductGroup.value = null
|
||||||
@@ -1621,6 +1639,7 @@ const handleEditProduct = (product, parentGroupId) => {
|
|||||||
require_real_name: product.requireRealName ?? product.require_real_name ?? false,
|
require_real_name: product.requireRealName ?? product.require_real_name ?? false,
|
||||||
sold_out: !!product.soldOut,
|
sold_out: !!product.soldOut,
|
||||||
max_per_user: product.maxPerUser ?? product.max_per_user ?? 0,
|
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,
|
send_notice: !!product.sendNotice,
|
||||||
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
|
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
|
||||||
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0
|
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0
|
||||||
@@ -1652,6 +1671,7 @@ const submitProductForm = () => {
|
|||||||
require_real_name: productForm.require_real_name,
|
require_real_name: productForm.require_real_name,
|
||||||
sold_out: productForm.sold_out === true,
|
sold_out: productForm.sold_out === true,
|
||||||
max_per_user: Number(productForm.max_per_user) || 0,
|
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,
|
send_notice: productForm.send_notice === true,
|
||||||
renew_price: Number(productForm.renew_price) || 0,
|
renew_price: Number(productForm.renew_price) || 0,
|
||||||
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0
|
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0
|
||||||
@@ -1866,9 +1886,45 @@ watch(activeTab, (newVal) => {
|
|||||||
const groupTagManagerRef = ref(null)
|
const groupTagManagerRef = ref(null)
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
// 根据 good_id 请求商品详情并弹出对应商品弹窗(用于从订单详情等页面跳转)
|
||||||
fetchGroupList()
|
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()
|
fetchAllTagOptions()
|
||||||
|
await checkAndOpenGoodId()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件被 keep-alive 缓存后重新激活时,再次检查 good_id
|
||||||
|
onActivated(() => {
|
||||||
|
checkAndOpenGoodId()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 同一页面内 query 变化时也触发(如从其他标签页点击跳转)
|
||||||
|
watch(() => route.query.good_id, (newId) => {
|
||||||
|
if (newId) checkAndOpenGoodId()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -3,94 +3,237 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-button @click="goBack" link class="back-btn">
|
<el-button @click="goBack" link class="back-btn">
|
||||||
<el-icon><ArrowLeft /></el-icon> 返回所有商品列表
|
<el-icon><ArrowLeft /></el-icon> 返回列表
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-divider direction="vertical" />
|
<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>
|
||||||
<div class="header-right">
|
<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-icon><Refresh /></el-icon> 刷新
|
||||||
</el-button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="main-content" v-loading="loading">
|
<div class="main-content" v-loading="loading">
|
||||||
<!-- 空状态 -->
|
|
||||||
<el-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160">
|
<el-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160">
|
||||||
<el-button type="primary" @click="loadDetail">重新加载</el-button>
|
<el-button type="primary" @click="loadDetail">重新加载</el-button>
|
||||||
</el-empty>
|
</el-empty>
|
||||||
|
|
||||||
<el-card class="profile-card" shadow="hover" v-if="detail">
|
<template v-if="detail">
|
||||||
<div class="profile-header">
|
<!-- 已删除提示 -->
|
||||||
<div class="profile-basic">
|
<el-alert
|
||||||
<div class="icon-wrapper">
|
v-if="isDeleted"
|
||||||
<el-icon :size="48" color="#409eff"><Monitor /></el-icon>
|
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>
|
||||||
<div class="identity">
|
<div class="hero-info">
|
||||||
<div class="name-row">
|
<h1 class="hero-name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
|
||||||
<h1 class="name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
|
<div class="hero-meta">
|
||||||
<el-button size="small" type="primary" plain @click="openEdit">编辑</el-button>
|
<span class="meta-chip">
|
||||||
<el-button size="small" type="danger" plain @click="handleDelete">删除</el-button>
|
<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>
|
||||||
</div>
|
{{ expireLabel }}
|
||||||
<div class="id-row">
|
</span>
|
||||||
<span class="label">ID:</span>
|
<span class="meta-chip" :class="expireStatusClass">
|
||||||
<span class="value">{{ detail.id || goodsId }}</span>
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
|
||||||
<el-divider direction="vertical" />
|
{{ expireStatusText }}
|
||||||
<span class="label">用户ID:</span>
|
</span>
|
||||||
<span class="value">{{ detail.userId || detail.user_id || '-' }}</span>
|
<span class="meta-chip id-chip">ID: {{ detail.id || goodsId }}</span>
|
||||||
<el-divider direction="vertical" />
|
<span v-if="isDeleted" class="meta-chip status-deleted">
|
||||||
<span class="label">到期:</span>
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
|
||||||
<span class="value">{{ formatExpireTime(detail.expireTime || detail.expire_time) }}</span>
|
已删除
|
||||||
<el-divider direction="vertical" />
|
</span>
|
||||||
<span class="label">续费价:</span>
|
|
||||||
<span class="value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-stats">
|
<div class="hero-right">
|
||||||
<div class="stat-item">
|
<div class="price-block">
|
||||||
<div class="stat-label">套餐ID</div>
|
<div class="price-label">续费价格</div>
|
||||||
<div class="stat-value">{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
|
<div class="price-value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="price-divider"></div>
|
||||||
<div class="stat-label">备注</div>
|
<div class="price-block">
|
||||||
<div class="stat-value note-value">{{ detail.note || '-' }}</div>
|
<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>
|
||||||
</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>
|
<div class="metrics-row">
|
||||||
<span class="card-title">关联信息</span>
|
<div class="metric-card is-link" @click="goToUser">
|
||||||
</template>
|
<div class="metric-icon" style="background:#eef3ff;color:#4f6ef7">
|
||||||
<el-descriptions :column="2" border size="small">
|
<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>
|
||||||
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
|
</div>
|
||||||
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
|
<div class="metric-body">
|
||||||
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
|
<div class="metric-label">所属用户</div>
|
||||||
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
|
<div class="metric-value">#{{ detail.userId || detail.user_id || '-' }}</div>
|
||||||
<el-descriptions-item label="订单状态">
|
<div class="metric-name">{{ detail.user?.userName || '-' }}</div>
|
||||||
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
|
</div>
|
||||||
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
|
<div class="metric-arrow">
|
||||||
</el-tag>
|
<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>
|
||||||
<span v-else>-</span>
|
</div>
|
||||||
</el-descriptions-item>
|
</div>
|
||||||
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
|
<div class="metric-card is-link" @click="goToProduct">
|
||||||
</el-descriptions>
|
<div class="metric-icon" style="background:#f0faf0;color:#52c41a">
|
||||||
</el-card>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑弹窗 -->
|
||||||
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
|
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
|
||||||
<el-form :model="editForm" label-width="110px">
|
<el-form :model="editForm" label-width="110px">
|
||||||
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
|
||||||
@@ -142,14 +285,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 { getUserGoodsDetail, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm'
|
||||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||||
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
|
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
|
||||||
|
import UserVmDetail from '@/views/user-vm/UserVmDetail.vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -159,6 +303,7 @@ const goodsId = computed(() => parseInt(route.params.id) || 0)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitLoading = ref(false)
|
const submitLoading = ref(false)
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
|
const detailExpanded = ref(false)
|
||||||
|
|
||||||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||||
const formatExpireTime = (t) => {
|
const formatExpireTime = (t) => {
|
||||||
@@ -168,8 +313,82 @@ const formatExpireTime = (t) => {
|
|||||||
return d.format('YYYY-MM-DD HH:mm:ss')
|
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 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 () => {
|
const loadDetail = async () => {
|
||||||
if (!goodsId.value) return
|
if (!goodsId.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -182,6 +401,22 @@ const loadDetail = async () => {
|
|||||||
finally { loading.value = false }
|
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 editVisible = ref(false)
|
||||||
const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' })
|
const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' })
|
||||||
const showVmSelector = ref(false)
|
const showVmSelector = ref(false)
|
||||||
@@ -202,7 +437,6 @@ const openEdit = () => {
|
|||||||
_serviceName: '',
|
_serviceName: '',
|
||||||
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
|
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
|
||||||
})
|
})
|
||||||
if (detail.value?.good?.table === 'kvm_service') { /* 通过选择器弹窗选择,无需预加载 */ }
|
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,186 +467,126 @@ const handleDelete = () => {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let isInitialMount = true
|
||||||
onMounted(loadDetail)
|
onMounted(loadDetail)
|
||||||
|
onActivated(() => {
|
||||||
|
if (isInitialMount) {
|
||||||
|
isInitialMount = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail.value = null
|
||||||
|
loadDetail()
|
||||||
|
})
|
||||||
watch(goodsId, (newId, oldId) => {
|
watch(goodsId, (newId, oldId) => {
|
||||||
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
|
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.goods-detail-page {
|
.goods-detail-page { padding: 0; }
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
/* ---- Page Header ---- */
|
||||||
display: flex;
|
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||||
justify-content: space-between;
|
.header-left { display: flex; align-items: center; gap: 0; }
|
||||||
align-items: center;
|
.back-btn { font-size: 14px; color: #606266; }
|
||||||
padding: 16px 20px;
|
.back-btn:hover { color: #409eff; }
|
||||||
background: #fff;
|
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||||
border-bottom: 1px solid #e1e8ed;
|
.header-right { display: flex; gap: 8px; }
|
||||||
}
|
|
||||||
|
|
||||||
.header-left {
|
/* ---- Main Content ---- */
|
||||||
display: flex;
|
.main-content { padding: 20px; min-height: 300px; }
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-btn {
|
/* ---- Hero Section ---- */
|
||||||
font-size: 14px;
|
.hero-section {
|
||||||
color: #606266;
|
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; }
|
||||||
.back-btn:hover {
|
.hero-icon {
|
||||||
color: #409eff;
|
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; }
|
||||||
.page-title {
|
.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; }
|
||||||
font-size: 16px;
|
.hero-meta { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
font-weight: 600;
|
.meta-chip {
|
||||||
color: #303133;
|
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 {
|
.deleted-alert { margin-bottom: 16px; }
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
.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 {
|
/* ---- Detail Section (collapsible) ---- */
|
||||||
padding: 20px;
|
.detail-section {
|
||||||
min-height: 300px;
|
margin-top: 16px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
.section-header {
|
||||||
.related-card {
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
margin-top: 20px;
|
padding: 14px 18px; cursor: pointer; user-select: none;
|
||||||
border: 1px solid #e1e8ed;
|
transition: background 0.15s;
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.slide-enter-active, .slide-leave-active { transition: all 0.25s ease; overflow: hidden; }
|
||||||
font-size: 15px;
|
.slide-enter-from, .slide-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
|
||||||
font-weight: 600;
|
.slide-enter-to, .slide-leave-from { opacity: 1; max-height: 300px; }
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-row {
|
/* ---- Related Card (fallback for non-VM) ---- */
|
||||||
display: flex;
|
.related-card { margin-top: 16px; border: 1px solid #ebeef5; border-radius: 8px; }
|
||||||
align-items: center;
|
.card-header-row { display: flex; align-items: center; gap: 8px; color: #303133; }
|
||||||
width: 100%;
|
.card-title { font-size: 15px; font-weight: 600; color: #303133; }
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-descriptions__label) {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #606266;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* ---- Shared ---- */
|
||||||
|
.selector-row { display: flex; align-items: center; width: 100%; }
|
||||||
.unit-input-row { display: flex; align-items: center; gap: 6px; 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; }
|
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||||
|
|
||||||
|
:deep(.el-descriptions__label) { font-weight: 500; color: #606266; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-goods-list">
|
<div class="user-goods-list">
|
||||||
<el-card class="main-container" shadow="never">
|
<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-section">
|
||||||
<div class="filter-content">
|
<div class="filter-content">
|
||||||
@@ -23,11 +55,19 @@
|
|||||||
<template #prefix><el-icon><Search /></el-icon></template>
|
<template #prefix><el-icon><Search /></el-icon></template>
|
||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</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-form-item>
|
||||||
<el-button type="primary" @click="handleSearch">
|
<el-button type="primary" @click="handleSearch">
|
||||||
<el-icon><Search /></el-icon>查询
|
<el-icon><Search /></el-icon>查询
|
||||||
</el-button>
|
</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-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
@@ -87,12 +127,21 @@
|
|||||||
<span v-else class="text-muted">-</span>
|
<span v-else class="text-muted">-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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">
|
<el-table-column label="套餐ID" width="90">
|
||||||
<template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template>
|
<template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="订单" min-width="180" show-overflow-tooltip>
|
<el-table-column label="订单" min-width="180" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<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>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -464,8 +513,8 @@
|
|||||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Refresh, Search, ArrowDown } from '@element-plus/icons-vue'
|
import { Plus, Refresh, Search, ArrowDown, Box, CircleCheck, Clock, Delete } from '@element-plus/icons-vue'
|
||||||
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
|
import { getUserGoodsList, getUserGoodsCount, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
|
||||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
import { formatToApiTime } from '@/utils/tool'
|
import { formatToApiTime } from '@/utils/tool'
|
||||||
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
|
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
|
||||||
@@ -480,12 +529,16 @@ const router = useRouter()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
const total = ref(0)
|
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 filterUserName = ref('')
|
||||||
const filterGoodName = ref('')
|
const filterGoodName = ref('')
|
||||||
const showFilterUserSelector = ref(false)
|
const showFilterUserSelector = ref(false)
|
||||||
const showFilterProductSelector = 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 formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||||
|
|
||||||
const formatExpireTime = (t) => {
|
const formatExpireTime = (t) => {
|
||||||
@@ -495,6 +548,17 @@ const formatExpireTime = (t) => {
|
|||||||
return d.format('YYYY-MM-DD HH:mm:ss')
|
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) => {
|
const formatMemory = (kb) => {
|
||||||
if (!kb) return '-'
|
if (!kb) return '-'
|
||||||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
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 () => {
|
const loadList = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const params = { page: query.page, count: query.count }
|
const params = { page: query.page, count: query.count, ...buildFilterParams() }
|
||||||
if (query.key) params.key = query.key
|
if (query.status && query.status !== 'all') params.status = query.status
|
||||||
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 res = await getUserGoodsList(params)
|
const res = await getUserGoodsList(params)
|
||||||
if (res?.data?.code === 200 && res?.data?.data) {
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
const d = 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 }
|
} 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([])
|
const argsSpecList = ref([])
|
||||||
@@ -841,12 +931,7 @@ const submitEdit = async () => {
|
|||||||
|
|
||||||
// ---- 详情 / 删除 ----
|
// ---- 详情 / 删除 ----
|
||||||
const handleDetail = (row) => {
|
const handleDetail = (row) => {
|
||||||
const tag = (row.tag || row.good?.tag || '').toLowerCase()
|
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
||||||
if (tag === '云服务器') {
|
|
||||||
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
|
|
||||||
} else {
|
|
||||||
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoreCmd = (cmd, row) => {
|
const handleMoreCmd = (cmd, row) => {
|
||||||
@@ -911,7 +996,7 @@ const handleSendRemind = (row) => {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadList)
|
onMounted(() => { loadList(); loadCount() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -924,6 +1009,60 @@ onMounted(loadList)
|
|||||||
background: #ffffff;
|
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) {
|
:deep(.el-card__body) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1130,13 +1130,7 @@ const goToUserDetail = () => {
|
|||||||
const goToUserGoods = () => {
|
const goToUserGoods = () => {
|
||||||
const goods = ticketInfo.value?.userGoods
|
const goods = ticketInfo.value?.userGoods
|
||||||
if (!goods) return
|
if (!goods) return
|
||||||
const tag = (goods.tag || goods.good?.tag || '').toLowerCase()
|
const href = router.resolve({ name: 'UserGoodsDetail', params: { id: goods.id } }).href
|
||||||
let href
|
|
||||||
if (tag === '云服务器') {
|
|
||||||
href = router.resolve({ path: '/user-goods/vm-detail', query: { id: goods.id } }).href
|
|
||||||
} else {
|
|
||||||
href = router.resolve({ name: 'UserGoodsDetail', params: { id: goods.id } }).href
|
|
||||||
}
|
|
||||||
window.open(href, '_blank')
|
window.open(href, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="uvm-detail">
|
<div class="uvm-detail" :class="{ 'is-embedded': embedded }">
|
||||||
<div class="page-header">
|
<div class="page-header" v-if="!embedded">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<el-button link @click="goBack"><el-icon><ArrowLeft /></el-icon>返回列表</el-button>
|
<el-button link @click="goBack"><el-icon><ArrowLeft /></el-icon>返回列表</el-button>
|
||||||
<el-divider direction="vertical" />
|
<el-divider direction="vertical" />
|
||||||
@@ -1182,6 +1182,11 @@ import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, onActivated
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue'
|
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
embedded: { type: Boolean, default: false },
|
||||||
|
goodsId: { type: Number, default: 0 }
|
||||||
|
})
|
||||||
import {
|
import {
|
||||||
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
|
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
|
||||||
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
|
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
|
||||||
@@ -1211,7 +1216,7 @@ import * as echarts from 'echarts'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userGoodsId = ref(parseInt(route.query.id) || 0)
|
const userGoodsId = ref(props.embedded ? props.goodsId : (parseInt(route.query.id) || 0))
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const actionLoading = ref(false)
|
const actionLoading = ref(false)
|
||||||
@@ -1319,7 +1324,7 @@ const vmStatusType = (s) => vmStatusTypeUtil(s)
|
|||||||
const vmStatusLabel = (s) => vmStatusLabelUtil(s)
|
const vmStatusLabel = (s) => vmStatusLabelUtil(s)
|
||||||
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
|
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
|
||||||
|
|
||||||
const goBack = () => router.back()
|
const goBack = () => { if (!props.embedded) router.back() }
|
||||||
|
|
||||||
const loadDetail = async () => {
|
const loadDetail = async () => {
|
||||||
if (!userGoodsId.value) return
|
if (!userGoodsId.value) return
|
||||||
@@ -2673,16 +2678,22 @@ const reloadWithNewId = (newId) => {
|
|||||||
loadDetail()
|
loadDetail()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => route.query.id, (newVal) => {
|
if (props.embedded) {
|
||||||
if (route.path === '/user-goods/vm-detail') {
|
watch(() => props.goodsId, (newVal) => {
|
||||||
|
reloadWithNewId(newVal || 0)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
watch(() => route.query.id, (newVal) => {
|
||||||
reloadWithNewId(parseInt(newVal) || 0)
|
reloadWithNewId(parseInt(newVal) || 0)
|
||||||
}
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
onMounted(() => { loadDetail() })
|
onMounted(() => { loadDetail() })
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
reloadWithNewId(parseInt(route.query.id) || 0)
|
if (!props.embedded) {
|
||||||
|
reloadWithNewId(parseInt(route.query.id) || 0)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => { disposeCharts() })
|
onBeforeUnmount(() => { disposeCharts() })
|
||||||
@@ -2690,6 +2701,7 @@ onBeforeUnmount(() => { disposeCharts() })
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.uvm-detail { padding: 0; }
|
.uvm-detail { padding: 0; }
|
||||||
|
.uvm-detail.is-embedded .main-content { padding: 0; }
|
||||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
.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; }
|
.header-left { display: flex; align-items: center; gap: 0; }
|
||||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||||
|
|||||||
@@ -28,11 +28,19 @@
|
|||||||
<el-option label="未绑定" :value="false" />
|
<el-option label="未绑定" :value="false" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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-form-item>
|
||||||
<el-button type="primary" @click="handleSearch">
|
<el-button type="primary" @click="handleSearch">
|
||||||
<el-icon><Search /></el-icon>查询
|
<el-icon><Search /></el-icon>查询
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.bound = null; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.bound = null; query.status = 'all'; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
@@ -73,6 +81,14 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip v-if="isDeleted(row)" :content="`删除于 ${deletedTimeText(row)}`" placement="top">
|
||||||
|
<el-tag size="small" type="danger">已删除</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else size="small" type="success">正常</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="续费价格" width="100">
|
<el-table-column label="续费价格" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span v-if="row.renewPrice">¥{{ (row.renewPrice / 100).toFixed(2) }}</span>
|
<span v-if="row.renewPrice">¥{{ (row.renewPrice / 100).toFixed(2) }}</span>
|
||||||
@@ -939,7 +955,7 @@ const router = useRouter()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const query = reactive({ page: 1, count: 10, bound: null, user_id: '', good_id: '', key: '' })
|
const query = reactive({ page: 1, count: 10, bound: null, user_id: '', good_id: '', key: '', status: 'all' })
|
||||||
const filterUserName = ref('')
|
const filterUserName = ref('')
|
||||||
const filterGoodName = ref('')
|
const filterGoodName = ref('')
|
||||||
const showFilterUserSelector = ref(false)
|
const showFilterUserSelector = ref(false)
|
||||||
@@ -961,6 +977,13 @@ const formatExpireTime = (t) => {
|
|||||||
return d.format('YYYY-MM-DD HH:mm')
|
return d.format('YYYY-MM-DD HH:mm')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||||
|
const isDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
|
||||||
|
const deletedTimeText = (row) => {
|
||||||
|
const t = row?.deleteAt || row?.DeleteAt || row?.deleted_at
|
||||||
|
return t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
const loadGoods = async () => {
|
const loadGoods = async () => {
|
||||||
// 不再需要加载商品列表,直接加载用户商品
|
// 不再需要加载商品列表,直接加载用户商品
|
||||||
loadList()
|
loadList()
|
||||||
@@ -976,6 +999,7 @@ const loadList = async () => {
|
|||||||
if (query.user_id) params.user_id = query.user_id
|
if (query.user_id) params.user_id = query.user_id
|
||||||
if (query.good_id) params.good_id = query.good_id
|
if (query.good_id) params.good_id = query.good_id
|
||||||
if (query.key) params.key = query.key
|
if (query.key) params.key = query.key
|
||||||
|
if (query.status && query.status !== 'all') params.status = query.status
|
||||||
const res = await getUserGoodsList(params)
|
const res = await getUserGoodsList(params)
|
||||||
if (res?.data?.code === 200 && res?.data?.data) {
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
const d = res.data.data
|
const d = res.data.data
|
||||||
@@ -1001,7 +1025,7 @@ const handleSearch = () => { query.page = 1; loadList() }
|
|||||||
|
|
||||||
const goDetail = (row) => {
|
const goDetail = (row) => {
|
||||||
if (!row.id) return
|
if (!row.id) return
|
||||||
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
|
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 新建 ----
|
// ---- 新建 ----
|
||||||
|
|||||||
@@ -267,6 +267,13 @@
|
|||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" min-width="90">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag v-if="isGoodsDeleted(row)" size="small" type="danger">已删除</el-tag>
|
||||||
|
<el-tag v-else-if="isGoodsExpired(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="到期时间" min-width="140">
|
<el-table-column label="到期时间" min-width="140">
|
||||||
<template #default="{row}">{{ formatDate(row.expireTime) }}</template>
|
<template #default="{row}">{{ formatDate(row.expireTime) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -462,7 +469,7 @@
|
|||||||
<el-option label="正式环境" value="production">
|
<el-option label="正式环境" value="production">
|
||||||
<div class="env-option">
|
<div class="env-option">
|
||||||
<span>正式环境</span>
|
<span>正式环境</span>
|
||||||
<span class="env-url">www.007yjs.com</span>
|
<span class="env-url">console.007yjs.com</span>
|
||||||
</div>
|
</div>
|
||||||
</el-option>
|
</el-option>
|
||||||
<el-option label="测试环境" value="test">
|
<el-option label="测试环境" value="test">
|
||||||
@@ -589,6 +596,15 @@ const ticketListPageSize = ref(10)
|
|||||||
|
|
||||||
// 已购商品列表相关
|
// 已购商品列表相关
|
||||||
const userGoodsList = ref([])
|
const userGoodsList = ref([])
|
||||||
|
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||||
|
const isGoodsDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
|
||||||
|
// 判断用户商品是否已到期(永久商品除外)
|
||||||
|
const isGoodsExpired = (row) => {
|
||||||
|
const t = row?.expireTime || row?.expire_time
|
||||||
|
if (!t) return false
|
||||||
|
const d = new Date(t)
|
||||||
|
return d.getFullYear() >= 2000 && d.getTime() < Date.now()
|
||||||
|
}
|
||||||
const goodsListLoading = ref(false)
|
const goodsListLoading = ref(false)
|
||||||
const goodsListTotal = ref(0)
|
const goodsListTotal = ref(0)
|
||||||
const goodsListPage = ref(1)
|
const goodsListPage = ref(1)
|
||||||
@@ -691,7 +707,7 @@ const selectedEnvironment = ref('')
|
|||||||
|
|
||||||
// 环境配置
|
// 环境配置
|
||||||
const environments = {
|
const environments = {
|
||||||
production: 'https://www.007yjs.com',
|
production: 'https://console.007yjs.com',
|
||||||
test: 'https://apiserver.s1f.ren',
|
test: 'https://apiserver.s1f.ren',
|
||||||
local: 'http://localhost:5173'
|
local: 'http://localhost:5173'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,11 @@
|
|||||||
<div class="search-group">
|
<div class="search-group">
|
||||||
<span class="search-label">用户ID</span>
|
<span class="search-label">用户ID</span>
|
||||||
<el-input
|
<el-input
|
||||||
:model-value="jumpUserName || (jumpUserId ? jumpUserId : '')"
|
v-model="jumpUserId"
|
||||||
placeholder="输入ID跳转"
|
placeholder="输入ID跳转"
|
||||||
readonly
|
|
||||||
clearable
|
clearable
|
||||||
class="search-input-small"
|
class="search-input-small"
|
||||||
style="cursor:pointer"
|
@keyup.enter="handleJumpToUser"
|
||||||
@click="showJumpUserSelector = true"
|
|
||||||
@clear="jumpUserId = ''; jumpUserName = ''"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-buttons">
|
<div class="search-buttons">
|
||||||
@@ -341,11 +338,6 @@
|
|||||||
:current-user-id="userForm.recommend_id"
|
:current-user-id="userForm.recommend_id"
|
||||||
@confirm="handleRecommendUserConfirm"
|
@confirm="handleRecommendUserConfirm"
|
||||||
/>
|
/>
|
||||||
<!-- 筛选用户ID选择器 -->
|
|
||||||
<UserListSelector
|
|
||||||
v-model="showJumpUserSelector"
|
|
||||||
@confirm="u => { jumpUserId = String(u.user_id); jumpUserName = u.user_name || `用户 #${u.user_id}` }"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 修改头像对话框 -->
|
<!-- 修改头像对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -670,8 +662,6 @@ const router = useRouter()
|
|||||||
|
|
||||||
// 跳转用户ID
|
// 跳转用户ID
|
||||||
const jumpUserId = ref('')
|
const jumpUserId = ref('')
|
||||||
const jumpUserName = ref('')
|
|
||||||
const showJumpUserSelector = ref(false)
|
|
||||||
|
|
||||||
// 查询参数
|
// 查询参数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
|
|||||||
@@ -556,6 +556,9 @@
|
|||||||
<el-tab-pane label="备份管理" name="backup">
|
<el-tab-pane label="备份管理" name="backup">
|
||||||
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
|
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="回收站" name="recycleBin">
|
||||||
|
<RecycleBinManage v-if="hostTabLoaded['recycleBin']" ref="recycleBinManageRef" />
|
||||||
|
</el-tab-pane>
|
||||||
<el-tab-pane label="虚拟机监控" name="vmMonitor">
|
<el-tab-pane label="虚拟机监控" name="vmMonitor">
|
||||||
<VmMonitor v-if="hostTabLoaded['vmMonitor']" ref="vmMonitorRef" />
|
<VmMonitor v-if="hostTabLoaded['vmMonitor']" ref="vmMonitorRef" />
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
@@ -954,6 +957,7 @@ import VolumeManage from '@/views/virtualization/VolumeManage.vue'
|
|||||||
import VmManage from '@/views/virtualization/VmManage.vue'
|
import VmManage from '@/views/virtualization/VmManage.vue'
|
||||||
import SnapshotManage from '@/views/virtualization/SnapshotManage.vue'
|
import SnapshotManage from '@/views/virtualization/SnapshotManage.vue'
|
||||||
import BackupManage from '@/views/virtualization/BackupManage.vue'
|
import BackupManage from '@/views/virtualization/BackupManage.vue'
|
||||||
|
import RecycleBinManage from '@/views/virtualization/RecycleBinManage.vue'
|
||||||
import VmMonitor from '@/views/virtualization/VmMonitor.vue'
|
import VmMonitor from '@/views/virtualization/VmMonitor.vue'
|
||||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||||
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||||
@@ -969,7 +973,7 @@ const serviceName = computed(() => route.query.service_name || '')
|
|||||||
const hostId = computed(() => parseInt(route.query.id) || 0)
|
const hostId = computed(() => parseInt(route.query.id) || 0)
|
||||||
|
|
||||||
const activeTab = ref('info')
|
const activeTab = ref('info')
|
||||||
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, vmMonitor: false, networking: false })
|
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, recycleBin: false, vmMonitor: false, networking: false })
|
||||||
|
|
||||||
const imageManageRef = ref(null)
|
const imageManageRef = ref(null)
|
||||||
const networkManageRef = ref(null)
|
const networkManageRef = ref(null)
|
||||||
@@ -977,8 +981,9 @@ const volumeManageRef = ref(null)
|
|||||||
const vmManageRef = ref(null)
|
const vmManageRef = ref(null)
|
||||||
const snapshotManageRef = ref(null)
|
const snapshotManageRef = ref(null)
|
||||||
const backupManageRef = ref(null)
|
const backupManageRef = ref(null)
|
||||||
|
const recycleBinManageRef = ref(null)
|
||||||
const vmMonitorRef = ref(null)
|
const vmMonitorRef = ref(null)
|
||||||
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef, vmMonitor: vmMonitorRef }
|
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef, recycleBin: recycleBinManageRef, vmMonitor: vmMonitorRef }
|
||||||
|
|
||||||
watch(activeTab, (tab) => {
|
watch(activeTab, (tab) => {
|
||||||
if (!['info', 'monitor', 'networking'].includes(tab)) {
|
if (!['info', 'monitor', 'networking'].includes(tab)) {
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<template>
|
||||||
|
<div class="recycle-bin-manage">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-input v-model="keyword" placeholder="按虚拟机名称搜索" style="width: 200px" size="small" clearable
|
||||||
|
@clear="loadList" @keyup.enter="loadList" />
|
||||||
|
<el-select v-model="filterStatus" placeholder="按状态过滤" style="width: 140px" size="small" clearable
|
||||||
|
@change="() => { currentPage = 1; loadList() }">
|
||||||
|
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" :icon="Search" @click="loadList">搜索</el-button>
|
||||||
|
<el-button size="small" :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
|
||||||
|
<el-dropdown trigger="click" @command="handleClean" style="margin-left: auto">
|
||||||
|
<el-button size="small" type="danger">
|
||||||
|
清空回收站<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="expired">清理已到期</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="all">清空全部</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="list" v-loading="loading" stripe size="small" style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="vm_name" label="虚拟机名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="vm_id" label="原虚拟机ID" width="100" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="任务ID" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="mono-text">{{ row.task_id || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="到期时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="isExpired(row.expire_at) ? 'color: #f56c6c' : ''">{{ formatTs(row.expire_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatTs(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="更新时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatTs(row.updated_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
|
||||||
|
<el-button link type="success" size="small" @click="handleRestore(row)"
|
||||||
|
:disabled="row.status !== 'archived'">恢复</el-button>
|
||||||
|
<el-button link type="danger" size="small" @click="handleDelete(row)"
|
||||||
|
:disabled="row.status === 'restoring' || row.status === 'purging'">永久删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-empty v-if="!list.length && !loading" description="回收站为空" :image-size="60" />
|
||||||
|
<div class="pagination-wrapper" v-if="total > 0">
|
||||||
|
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||||
|
@size-change="s => { pageSize = s; currentPage = 1; loadList() }"
|
||||||
|
@current-change="p => { currentPage = p; loadList() }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详情弹窗 -->
|
||||||
|
<el-dialog v-model="detailVisible" title="回收站记录详情" width="720px" destroy-on-close>
|
||||||
|
<div v-loading="detailLoading">
|
||||||
|
<el-descriptions :column="2" border size="small" v-if="detailData?.recycle" style="margin-bottom: 16px">
|
||||||
|
<el-descriptions-item label="记录ID">{{ detailData.recycle.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="原虚拟机ID">{{ detailData.recycle.vm_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="虚拟机名称">{{ detailData.recycle.vm_name }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="statusTagType(detailData.recycle.status)" size="small">{{ statusLabel(detailData.recycle.status) }}</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="宿主机ID">{{ detailData.recycle.host_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="归档目录">
|
||||||
|
<span class="mono-text">{{ detailData.recycle.recycle_dir || '-' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="到期时间">{{ formatTs(detailData.recycle.expire_at) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建时间">{{ formatTs(detailData.recycle.created_at) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<template v-if="detailData">
|
||||||
|
<div v-for="snap in snapshotSections" :key="snap.key">
|
||||||
|
<div class="snapshot-title">{{ snap.label }}</div>
|
||||||
|
<el-input type="textarea" :model-value="formatSnapshot(detailData[snap.key])" :rows="6" readonly
|
||||||
|
style="margin-bottom: 12px; font-family: Consolas, Monaco, monospace; font-size: 12px" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-empty v-if="!detailData" description="暂无详情数据" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="detailVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 恢复弹窗(可选指定网络) -->
|
||||||
|
<el-dialog v-model="restoreVisible" title="恢复虚拟机" width="480px" destroy-on-close>
|
||||||
|
<el-form label-width="120px">
|
||||||
|
<el-form-item label="虚拟机名称">
|
||||||
|
<span>{{ restoreRow?.vm_name }} (ID: {{ restoreRow?.vm_id }})</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="内网网络ID">
|
||||||
|
<el-input v-model="restoreForm.network_ids" placeholder="可选,多个用逗号分隔" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="外网网络ID">
|
||||||
|
<el-input v-model="restoreForm.internet_network_id" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="restoreVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="restoreLoading" @click="submitRestore">确认恢复</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, inject, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
getRecycleBinList, getRecycleBinDetail,
|
||||||
|
restoreRecycleBin, deleteRecycleBin, cleanRecycleBin
|
||||||
|
} from '@/api/admin/kvmService'
|
||||||
|
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||||
|
|
||||||
|
const serviceId = inject('serviceId')
|
||||||
|
const hostId = inject('hostId')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const keyword = ref('')
|
||||||
|
const filterStatus = ref('')
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'pending', label: '等待归档' },
|
||||||
|
{ value: 'archiving', label: '归档中' },
|
||||||
|
{ value: 'archived', label: '已归档' },
|
||||||
|
{ value: 'restoring', label: '恢复中' },
|
||||||
|
{ value: 'purging', label: '清理中' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusLabelMap = {
|
||||||
|
pending: '等待归档', archiving: '归档中', archived: '已归档',
|
||||||
|
restoring: '恢复中', purging: '清理中', failed: '失败', error: '错误'
|
||||||
|
}
|
||||||
|
const statusLabel = (s) => statusLabelMap[s] || s || '-'
|
||||||
|
const statusTagType = (s) => ({
|
||||||
|
archived: 'success', pending: 'info', archiving: 'warning',
|
||||||
|
restoring: 'primary', purging: 'danger', failed: 'danger', error: 'danger'
|
||||||
|
}[s] || 'info')
|
||||||
|
|
||||||
|
const formatTs = (ts) => {
|
||||||
|
if (!ts) return '-'
|
||||||
|
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||||
|
if (typeof ts === 'string' || typeof ts === 'number') {
|
||||||
|
const d = new Date(ts)
|
||||||
|
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpired = (ts) => {
|
||||||
|
if (!ts) return false
|
||||||
|
let t
|
||||||
|
if (typeof ts === 'object' && ts.seconds) t = Number(ts.seconds) * 1000
|
||||||
|
else t = new Date(ts).getTime()
|
||||||
|
return !isNaN(t) && t < Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
service_id: serviceId.value,
|
||||||
|
host_id: hostId.value,
|
||||||
|
page: currentPage.value,
|
||||||
|
count: pageSize.value
|
||||||
|
}
|
||||||
|
if (keyword.value) params.keyword = keyword.value
|
||||||
|
if (filterStatus.value) params.status = filterStatus.value
|
||||||
|
|
||||||
|
const res = await getRecycleBinList(params)
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
const d = res.data.data
|
||||||
|
list.value = d.data || d.list || (Array.isArray(d) ? d : [])
|
||||||
|
total.value = d.meta?.count ?? d.total ?? list.value.length
|
||||||
|
} else { list.value = []; total.value = 0 }
|
||||||
|
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 详情 ---- */
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailData = ref(null)
|
||||||
|
|
||||||
|
const snapshotSections = [
|
||||||
|
{ key: 'vm_snapshot', label: '虚拟机快照' },
|
||||||
|
{ key: 'volumes_snapshot', label: '磁盘快照' },
|
||||||
|
{ key: 'networks_snapshot', label: '网络快照' },
|
||||||
|
{ key: 'traffic_policy_snapshot', label: '流量策略快照' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatSnapshot = (raw) => {
|
||||||
|
if (!raw) return '(无)'
|
||||||
|
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch { return raw }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDetail = async (row) => {
|
||||||
|
detailData.value = null
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getRecycleBinDetail({
|
||||||
|
service_id: serviceId.value,
|
||||||
|
host_id: hostId.value,
|
||||||
|
recycle_id: row.id
|
||||||
|
})
|
||||||
|
if (res?.data?.code === 200 && res?.data?.data) {
|
||||||
|
detailData.value = res.data.data.data ?? res.data.data
|
||||||
|
} else { ElMessage.warning('获取详情失败') }
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(extractApiError(e?.response?.data, '获取详情失败'))
|
||||||
|
} finally { detailLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 恢复 ---- */
|
||||||
|
const restoreVisible = ref(false)
|
||||||
|
const restoreLoading = ref(false)
|
||||||
|
const restoreRow = ref(null)
|
||||||
|
const restoreForm = reactive({ network_ids: '', internet_network_id: '' })
|
||||||
|
|
||||||
|
const handleRestore = (row) => {
|
||||||
|
restoreRow.value = row
|
||||||
|
Object.assign(restoreForm, { network_ids: '', internet_network_id: '' })
|
||||||
|
restoreVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitRestore = async () => {
|
||||||
|
restoreLoading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('host_id', hostId.value)
|
||||||
|
fd.append('recycle_id', restoreRow.value.id)
|
||||||
|
if (restoreForm.network_ids) {
|
||||||
|
restoreForm.network_ids.split(',').map(s => s.trim()).filter(Boolean).forEach(id => {
|
||||||
|
fd.append('network_ids', id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (restoreForm.internet_network_id) {
|
||||||
|
fd.append('internet_network_id', restoreForm.internet_network_id)
|
||||||
|
}
|
||||||
|
const res = await restoreRecycleBin(fd)
|
||||||
|
if (res?.data?.code === 200) {
|
||||||
|
ElMessage.success('恢复任务已提交')
|
||||||
|
restoreVisible.value = false
|
||||||
|
loadList()
|
||||||
|
} else { ElMessage.error(extractApiError(res?.data, '恢复失败')) }
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(extractApiError(e?.response?.data, '恢复失败'))
|
||||||
|
} finally { restoreLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 永久删除 ---- */
|
||||||
|
const handleDelete = (row) => {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
`确定要永久删除「${row.vm_name}」的回收站记录吗?此操作不可恢复!`,
|
||||||
|
'永久删除确认',
|
||||||
|
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteRecycleBin({
|
||||||
|
service_id: serviceId.value,
|
||||||
|
host_id: hostId.value,
|
||||||
|
recycle_id: row.id
|
||||||
|
})
|
||||||
|
if (res?.data?.code === 200) { ElMessage.success('删除任务已提交'); loadList() }
|
||||||
|
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 清空回收站 ---- */
|
||||||
|
const handleClean = (command) => {
|
||||||
|
const isAll = command === 'all'
|
||||||
|
const msg = isAll ? '确定要清空全部回收站记录吗?此操作不可恢复!' : '确定要清理所有已到期的回收站记录吗?'
|
||||||
|
ElMessageBox.confirm(msg, '清空回收站', {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await cleanRecycleBin({
|
||||||
|
service_id: serviceId.value,
|
||||||
|
host_id: hostId.value,
|
||||||
|
all: isAll
|
||||||
|
})
|
||||||
|
if (res?.data?.code === 200) {
|
||||||
|
const purged = res.data.data?.purged ?? 0
|
||||||
|
ElMessage.success(`已提交清理任务,清理 ${purged} 条记录`)
|
||||||
|
loadList()
|
||||||
|
} else { ElMessage.error(extractApiError(res?.data, '清空失败')) }
|
||||||
|
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '清空失败')) }
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadList() })
|
||||||
|
|
||||||
|
defineExpose({ loadList })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.recycle-bin-manage { padding: 0; }
|
||||||
|
.toolbar { display: flex; gap: 8px; align-items: center; margin-top: 12px; margin-bottom: 12px; }
|
||||||
|
.mono-text { font-family: Consolas, Monaco, monospace; font-size: 12px; }
|
||||||
|
.snapshot-title { font-size: 13px; font-weight: 600; color: #606266; margin: 12px 0 6px; }
|
||||||
|
.pagination-wrapper { margin-top: 12px; display: flex; justify-content: flex-end; }
|
||||||
|
</style>
|
||||||
@@ -28,8 +28,24 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 批量操作栏 -->
|
||||||
|
<div class="batch-bar" v-if="selectedVms.length">
|
||||||
|
<span class="batch-info">已选择 <strong>{{ selectedVms.length }}</strong> 台虚拟机</span>
|
||||||
|
<el-button type="success" size="small" @click="handleBatchPower('start')" :loading="batchLoading">
|
||||||
|
<el-icon><VideoPlay /></el-icon>批量开机
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" size="small" @click="handleBatchPower('stop')" :loading="batchLoading">
|
||||||
|
<el-icon><SwitchButton /></el-icon>批量关机
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="handleBatchDelete" :loading="batchLoading">
|
||||||
|
<el-icon><Delete /></el-icon>批量删除
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="clearSelection">取消选择</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 虚拟机列表 -->
|
<!-- 虚拟机列表 -->
|
||||||
<el-table :data="vmList" v-loading="loading" stripe>
|
<el-table ref="vmTableRef" :data="vmList" v-loading="loading" stripe @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="45" />
|
||||||
<el-table-column prop="id" label="ID" width="70" />
|
<el-table-column prop="id" label="ID" width="70" />
|
||||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column label="配置" min-width="200">
|
<el-table-column label="配置" min-width="200">
|
||||||
@@ -433,7 +449,7 @@
|
|||||||
import { ref, reactive, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, reactive, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue'
|
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled, VideoPlay, SwitchButton, Delete } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
getRemoteHostList, getVmList, getVmDetail, getVmStatus,
|
getRemoteHostList, getVmList, getVmDetail, getVmStatus,
|
||||||
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
||||||
@@ -466,6 +482,7 @@ const serviceName = computed(() => injectedServiceName?.value || route.query.ser
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitLoading = ref(false)
|
const submitLoading = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
|
const batchLoading = ref(false)
|
||||||
const vmList = ref([])
|
const vmList = ref([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
@@ -473,6 +490,12 @@ const filterStatus = ref('')
|
|||||||
const hostOptions = ref([])
|
const hostOptions = ref([])
|
||||||
const queryParams = reactive({ page: 1, page_size: 10 })
|
const queryParams = reactive({ page: 1, page_size: 10 })
|
||||||
|
|
||||||
|
// 批量操作
|
||||||
|
const vmTableRef = ref(null)
|
||||||
|
const selectedVms = ref([])
|
||||||
|
const handleSelectionChange = (selection) => { selectedVms.value = selection }
|
||||||
|
const clearSelection = () => { vmTableRef.value?.clearSelection() }
|
||||||
|
|
||||||
// 选择器
|
// 选择器
|
||||||
const showCreateImageSelector = ref(false)
|
const showCreateImageSelector = ref(false)
|
||||||
const showRebuildImageSelector = ref(false)
|
const showRebuildImageSelector = ref(false)
|
||||||
@@ -989,6 +1012,72 @@ const handleDelete = (row) => {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量电源操作(开机/关机)
|
||||||
|
const handleBatchPower = (action) => {
|
||||||
|
const label = action === 'start' ? '开机' : '关机'
|
||||||
|
const targets = selectedVms.value
|
||||||
|
const skipped = targets.filter(v =>
|
||||||
|
action === 'start' ? v.status === 'running' : (v.status === 'stopped' || v.status === 'stop')
|
||||||
|
)
|
||||||
|
const toOperate = targets.filter(v => !skipped.includes(v))
|
||||||
|
|
||||||
|
if (!toOperate.length) {
|
||||||
|
ElMessage.warning(`所选虚拟机均已处于目标状态,无需${label}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = skipped.length
|
||||||
|
? `将对 ${toOperate.length} 台虚拟机执行${label}(${skipped.length} 台已跳过),是否继续?`
|
||||||
|
: `确定要对 ${toOperate.length} 台虚拟机执行批量${label}吗?`
|
||||||
|
|
||||||
|
ElMessageBox.confirm(msg, `批量${label}`, {
|
||||||
|
confirmButtonText: '确定', cancelButtonText: '取消',
|
||||||
|
type: action === 'stop' ? 'warning' : 'info'
|
||||||
|
}).then(async () => {
|
||||||
|
batchLoading.value = true
|
||||||
|
const api = action === 'start' ? startVm : stopVm
|
||||||
|
let success = 0, fail = 0
|
||||||
|
await Promise.allSettled(toOperate.map(async (vm) => {
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('service_id', serviceId.value)
|
||||||
|
fd.append('vm_id', vm.id)
|
||||||
|
const res = await api(fd)
|
||||||
|
if (res?.data?.code === 200) success++
|
||||||
|
else fail++
|
||||||
|
} catch { fail++ }
|
||||||
|
}))
|
||||||
|
batchLoading.value = false
|
||||||
|
clearSelection()
|
||||||
|
ElMessage[fail ? 'warning' : 'success'](`批量${label}完成:成功 ${success},失败 ${fail}`)
|
||||||
|
loadList()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
const count = selectedVms.value.length
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
`确定要删除选中的 ${count} 台虚拟机吗?此操作不可恢复。`,
|
||||||
|
'批量删除',
|
||||||
|
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
).then(async () => {
|
||||||
|
batchLoading.value = true
|
||||||
|
let success = 0, fail = 0
|
||||||
|
await Promise.allSettled(selectedVms.value.map(async (vm) => {
|
||||||
|
try {
|
||||||
|
const res = await deleteVm({ service_id: serviceId.value, vm_id: vm.id })
|
||||||
|
if (res?.data?.code === 200) success++
|
||||||
|
else fail++
|
||||||
|
} catch { fail++ }
|
||||||
|
}))
|
||||||
|
batchLoading.value = false
|
||||||
|
clearSelection()
|
||||||
|
ElMessage[fail ? 'warning' : 'success'](`批量删除完成:成功 ${success},失败 ${fail}`)
|
||||||
|
loadList()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -1007,4 +1096,19 @@ defineExpose({ loadList })
|
|||||||
.migrate-inline-status { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
|
.migrate-inline-status { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
|
||||||
.migrate-inline-label { color: #e6a23c; font-size: 13px; font-weight: 600; white-space: nowrap; }
|
.migrate-inline-label { color: #e6a23c; font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||||
.migrate-inline-pct { color: #e6a23c; font-size: 12px; white-space: nowrap; }
|
.migrate-inline-pct { color: #e6a23c; font-size: 12px; white-space: nowrap; }
|
||||||
|
.batch-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #ecf5ff;
|
||||||
|
border: 1px solid #d9ecff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.batch-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #409eff;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user