6 Commits

Author SHA1 Message Date
shiran f62b4db0e1 Merge pull request 'feat: 用户详情代金券管理与优惠模块修复' (#26) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m44s
Build and Deploy Vue3 / deploy (push) Successful in 40s
Reviewed-on: #26
2026-06-26 17:12:38 +08:00
shiran cae5551107 feat: 镜像管理新增mode模式字段支持(local/remote)
Build and Deploy Vue3 / build (push) Successful in 1m40s
Build and Deploy Vue3 / deploy (push) Successful in 40s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 17:08:01 +08:00
shiran 4bf7c4857b feat: 用户详情代金券管理与优惠模块修复
Build and Deploy Vue3 / build (push) Successful in 1m39s
Build and Deploy Vue3 / deploy (push) Successful in 42s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 16:46:57 +08:00
shiran 6f82e5e79d feat: 用户商品状态筛选与统计对接
Build and Deploy Vue3 / build (push) Successful in 1m46s
Build and Deploy Vue3 / deploy (push) Successful in 39s
- 新增 getUserGoodsCount 接口对接,列表页/虚拟机列表页增加状态筛选与统计卡片

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 22:12:50 +08:00
shiran a8954bd85d fix: 修复用户详情页订单列表字段映射错误
Build and Deploy Vue3 / build (push) Successful in 4m30s
Build and Deploy Vue3 / deploy (push) Successful in 50s
- state替代status、CreatedAt替代created_at

- 新增订单类型列(create/renew/upgrade)与到期时间列

- 金额列显示续费价格、详情跳转改用row.id

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 21:37:49 +08:00
shiran bdf6dd9382 feat: 优惠管理合并重构与商品续费价格参数
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 39s
- 合并优惠码/代金券为商品管理下优惠管理页面,卡片化展示与过期遮罩

- 用户组新增优惠绑定,商品关联改用懒加载树选择器

- 商品/套餐表单新增 renew_price、renew_recommend_rebate、renew_fixed_price

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 17:06:23 +08:00
34 changed files with 4199 additions and 1903 deletions
+33
View File
@@ -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 })
}
+4
View File
@@ -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')
+30
View File
@@ -0,0 +1,30 @@
import { http2 } from '@/utils/request.js'
const formHeaders = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
// ========== 用户组优惠(用户组 与 商品/商品组 绑定优惠) ==========
/** 获取用户组优惠列表(可按 user_group_id / good_id / good_group_id 过滤) */
export const getUserGroupDiscountList = (params) => {
return http2.get('/api/v1/admin/user_group/discount/list', { params })
}
/** 获取用户组优惠详情 */
export const getUserGroupDiscountDetail = (params) => {
return http2.get('/api/v1/admin/user_group/discount/detail', { params })
}
/** 添加用户组优惠(将用户组与商品/商品组绑定优惠) */
export const addUserGroupDiscount = (data) => {
return http2.post('/api/v1/admin/user_group/discount/add', data, formHeaders)
}
/** 修改用户组优惠(可改绑商品/商品组) */
export const updateUserGroupDiscount = (data) => {
return http2.post('/api/v1/admin/user_group/discount/update', data, formHeaders)
}
/** 删除用户组优惠(解绑用户组与商品/商品组的优惠) */
export const deleteUserGroupDiscount = (params) => {
return http2.delete('/api/v1/admin/user_group/discount/delete', { params })
}
+2
View File
@@ -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' } })
@@ -36,13 +36,18 @@
<el-table-column prop="gateway" label="网关" width="130" /> <el-table-column prop="gateway" label="网关" width="130" />
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip /> <el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
<el-table-column prop="bridge_name" label="网桥名称" width="100" /> <el-table-column prop="bridge_name" label="网桥名称" width="100" />
<el-table-column label="状态" width="80" align="center"> <el-table-column label="占用" width="80" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag> <el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag>
<el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag> <el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag>
<el-tag v-else type="info" size="small">-</el-tag> <el-tag v-else type="info" size="small">-</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="禁用" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.disable" type="danger" size="small">禁用</el-tag>
</template>
</el-table-column>
</el-table> </el-table>
<div class="pagination-wrapper" v-if="total > 0"> <div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" <el-pagination v-model:current-page="page" v-model:page-size="pageSize"
@@ -118,6 +123,7 @@ const loadList = async () => {
const effectiveUsed = props.filterUsed || usedFilter.value const effectiveUsed = props.filterUsed || usedFilter.value
if (effectiveUsed) params.used = effectiveUsed if (effectiveUsed) params.used = effectiveUsed
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
params.disable = false
const res = await getNetworkList(params) const res = await getNetworkList(params)
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data const inner = res.data.data
+14 -8
View File
@@ -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,13 +298,8 @@ const goToTicket = (item) => {
const goToGoods = (item) => { const goToGoods = (item) => {
visible.value = false visible.value = false
const tag = (item.tag || item.good?.tag || '').toLowerCase()
if (tag === '云服务器') {
router.push({ path: '/user-goods/vm-detail', query: { id: item.id } })
} else {
router.push({ name: 'UserGoodsDetail', params: { id: item.id } }) router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
} }
}
const orderStatusText = (status) => { const orderStatusText = (status) => {
const map = { 0: '待支付', 1: '已完成', 2: '已取消', 3: '已退款' } const map = { 0: '待支付', 1: '已完成', 2: '已取消', 3: '已退款' }
@@ -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;
+2 -17
View File
@@ -47,7 +47,8 @@ export const menus = [
title: '商品管理', title: '商品管理',
icon: 'Goods', icon: 'Goods',
children: [ children: [
{ path: '/product/manage', title: '商品管理' } { path: '/product/manage', title: '商品管理' },
{ path: '/product/discount', title: '优惠管理' }
] ]
}, },
{ {
@@ -70,22 +71,6 @@ export const menus = [
} }
] ]
}, },
{
path: '/marketing',
title: '优惠营销',
icon: 'Present',
children: [
{
path: '/marketing/discount',
title: '优惠码管理'
},
{
path: '/marketing/voucher',
title: '代金券管理'
},
]
},
{ {
path: '/activity', path: '/activity',
title: '活动管理', title: '活动管理',
+19 -36
View File
@@ -253,6 +253,18 @@ const routes = [
component: () => import('../views/product/ProductGroup.vue'), component: () => import('../views/product/ProductGroup.vue'),
meta: { title: '商品管理' } meta: { title: '商品管理' }
}, },
{
path: 'discount',
name: 'DiscountManage',
component: () => import('../views/product/DiscountManage.vue'),
meta: { title: '优惠管理' }
},
{
path: 'discount/voucher/:id/manage',
name: 'VoucherManagement',
component: () => import('../views/marketing/VoucherManagement.vue'),
meta: { title: '代金券分发管理', hidden: true, activeMenu: '/product/discount' }
},
{ path: 'list', redirect: '/product/manage' }, { path: 'list', redirect: '/product/manage' },
{ path: 'group', redirect: '/product/manage' } { path: 'group', redirect: '/product/manage' }
] ]
@@ -282,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' }
}
] ]
}, },
// 订单管理路由 // 订单管理路由
@@ -310,43 +316,20 @@ const routes = [
} }
] ]
}, },
// 优惠营销路由 // 优惠营销路由(已合并至 /product/discount,保留重定向兼容旧链接)
{ {
path: 'marketing', path: 'marketing',
name: 'Marketing', name: 'Marketing',
meta: { meta: {
title: '优惠营销', title: '优惠营销',
icon: 'Present' icon: 'Present',
hidden: true
}, },
redirect: '/marketing/discount', redirect: '/product/discount',
children: [ children: [
{ { path: 'discount', redirect: '/product/discount' },
path: 'discount', { path: 'voucher', redirect: '/product/discount' },
name: 'DiscountCode', { path: 'voucher/:id/manage', redirect: to => `/product/discount/voucher/${to.params.id}/manage` }
component: () => import('../views/marketing/DiscountCode.vue'),
meta: {
title: '优惠码管理'
}
},
{
path: 'voucher',
name: 'Voucher',
component: () => import('../views/marketing/Voucher.vue'),
meta: {
title: '代金券管理'
}
},
{
path: 'voucher/:id/manage',
name: 'VoucherManagement',
component: () => import('../views/marketing/VoucherManagement.vue'),
meta: {
title: '代金券详情管理',
hidden: true,
activeMenu: '/marketing/voucher'
}
},
] ]
}, },
// 活动管理路由 // 活动管理路由
-677
View File
@@ -1,677 +0,0 @@
<template>
<div class="discount-code-container">
<!-- 主容器 -->
<el-card class="main-container" shadow="never">
<!-- 搜索和操作栏 -->
<div class="filter-section">
<div class="filter-content">
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增优惠码
</el-button>
<el-button type="success" @click="fetchDiscountList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除
</el-button>
</div>
</div>
</div>
<!-- 优惠码列表 -->
<div class="table-section">
<!-- 骨架屏 -->
<div v-if="loading" class="skeleton-container">
<div v-for="i in 5" :key="i" class="skeleton-row">
<div class="skeleton-cell skeleton-checkbox"></div>
<div class="skeleton-cell skeleton-id"></div>
<div class="skeleton-cell skeleton-code"></div>
<div class="skeleton-cell skeleton-name"></div>
<div class="skeleton-cell skeleton-type"></div>
<div class="skeleton-cell skeleton-value"></div>
<div class="skeleton-cell skeleton-min"></div>
<div class="skeleton-cell skeleton-max"></div>
<div class="skeleton-cell skeleton-times"></div>
<div class="skeleton-cell skeleton-action"></div>
</div>
</div>
<el-table
v-else
v-loading="loading"
:data="discountList"
@selection-change="handleSelectionChange"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="code" label="优惠码" min-width="150" />
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column label="优惠类型" width="120">
<template #default="{ row }">
<el-tag :type="row.percentage ? 'success' : 'primary'">
{{ row.percentage ? '百分比折扣' : '固定金额' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="优惠值" width="120">
<template #default="{ row }">
<span v-if="row.percentage" class="discount-value">{{ (row.percentage / 100).toFixed(0) }}%</span>
<span v-else class="amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="最低消费" width="120">
<template #default="{ row }">
¥{{ (row.minAmount / 100).toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="最大抵扣" width="120">
<template #default="{ row }">
<span v-if="row.maxAmount">¥{{ (row.maxAmount / 100).toFixed(2) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="maxTimes" label="最大使用次数" width="120" />
<el-table-column prop="userTimes" label="单用户次数" width="120" />
<el-table-column label="可叠加" width="100" align="center">
<template #default="{ row }">
<el-icon v-if="row.canStacking" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
</template>
</el-table-column>
<el-table-column label="续费可用" width="100" align="center">
<template #default="{ row }">
<el-icon v-if="row.renew" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="success" link @click="handleView(row)">查看</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"
/>
</div>
</el-card>
<!-- 优惠码表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增优惠码' : '编辑优惠码'"
width="700px"
append-to-body
>
<el-form
ref="discountFormRef"
:model="discountForm"
:rules="discountRules"
label-width="140px"
>
<el-form-item label="优惠码" prop="code">
<el-input v-model="discountForm.code" placeholder="请输入优惠码" />
</el-form-item>
<el-form-item label="优惠码名称" prop="name">
<el-input v-model="discountForm.name" placeholder="请输入优惠码名称" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input v-model="discountForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="优惠类型" prop="discount_mode">
<el-radio-group v-model="discountForm.discount_mode">
<el-radio label="amount">固定金额</el-radio>
<el-radio label="percentage">百分比折扣</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额" prop="amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage">
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
</el-form-item>
<el-form-item label="最低消费" prop="min_amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大抵扣" prop="max_amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="单用户最大次数" prop="user_times">
<el-input-number v-model="discountForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="有效期" prop="timeRange">
<el-date-picker
v-model="discountForm.timeRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:teleported="true"
popper-class="discount-date-picker"
placement="top-start"
:editable="true"
:clearable="true"
style="width: 100%"
@keyup.enter="handleDatePickerEnter"
/>
</el-form-item>
<el-form-item label="续费可用" prop="renew">
<el-switch v-model="discountForm.renew" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="同类型可叠加" prop="can_stacking">
<el-switch v-model="discountForm.can_stacking" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="其他类型可叠加" prop="can_combine">
<el-switch v-model="discountForm.can_combine" active-text="是" inactive-text="否" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 详情查看对话框 -->
<DiscountDetailDialog
v-model="detailDialogVisible"
type="code"
:detail-data="currentDetail"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search, Refresh, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import {
getDiscountCodeList,
getDiscountCodeDetail,
createDiscountCode,
updateDiscountCode,
deleteDiscountCode
} from '@/api/admin/discount'
import { timeToTimestamp } from '@/utils/tool'
import DiscountDetailDialog from '@/components/marketing/DiscountDetailDialog.vue'
// 查询参数
const queryParams = reactive({
discount_type: 'code', // 固定为code表示优惠码
page: 1,
count: 10
})
// 优惠码表单
const discountForm = reactive({
code_id: undefined,
discount_type: 'code', // 固定为code
code: '',
name: '',
note: '',
discount_mode: 'amount', // amount 或 percentage
amount: 0,
percentage: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
const discountRules = {
code: [
{ required: true, message: '请输入优惠码', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入优惠码名称', trigger: 'blur' }
],
discount_mode: [
{ required: true, message: '请选择优惠类型', trigger: 'change' }
]
}
// 状态数据
const loading = ref(false)
const discountList = ref([])
const total = ref(0)
const selectedRows = ref([])
const dialogVisible = ref(false)
const dialogType = ref('add')
const discountFormRef = ref(null)
const detailDialogVisible = ref(false)
const currentDetail = ref(null)
// 获取优惠码列表
const fetchDiscountList = async () => {
loading.value = true
try {
const res = await getDiscountCodeList(queryParams)
console.log('优惠码列表数据:', res.data)
if (res.data.code === 200) {
discountList.value = res.data.data?.data || []
total.value = res.data.data?.all_count || 0
}
} catch (error) {
console.error('获取优惠码列表失败:', error)
ElMessage.error('获取优惠码列表失败')
} finally {
loading.value = false
}
}
// 选择项变化
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
// 分页
const handleSizeChange = (size) => {
queryParams.count = size
fetchDiscountList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchDiscountList()
}
// 新增优惠码
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
Object.assign(discountForm, {
code_id: undefined,
discount_type: 'code',
code: '',
name: '',
note: '',
discount_mode: 'amount',
amount: 0,
percentage: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
discountFormRef.value?.resetFields()
}
// 编辑优惠码
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
// 转换日期字符串为日期选择器格式
const startTime = row.startTime ? new Date(row.startTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
const endTime = row.endTime ? new Date(row.endTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
Object.assign(discountForm, {
code_id: row.id,
discount_type: 'code',
code: row.code,
name: row.name,
note: row.note || '',
discount_mode: row.percentage ? 'percentage' : 'amount',
amount: row.amount ? row.amount / 100 : 0,
percentage: row.percentage ? row.percentage / 100 : 0,
min_amount: row.minAmount ? row.minAmount / 100 : 0,
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
max_times: row.maxTimes || 0,
user_times: row.userTimes || 0,
timeRange: startTime && endTime ? [startTime, endTime] : [],
renew: row.renew || false,
can_stacking: row.canStacking || false,
can_combine: row.canCombine || false
})
}
// 查看优惠码详情
const handleView = async (row) => {
try {
const res = await getDiscountCodeDetail({ code_id: row.id })
console.log('优惠码详情:', res.data)
if (res.data.code === 200) {
currentDetail.value = res.data.data
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取优惠码详情失败:', error)
ElMessage.error('获取优惠码详情失败')
}
}
// 删除优惠码
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除优惠码 ${row.code} 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteDiscountCode({ code_id: row.id })
console.log('删除响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchDiscountList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
loading.value = true
try {
const deletePromises = selectedRows.value.map(row =>
deleteDiscountCode({ code_id: row.id })
)
const results = await Promise.allSettled(deletePromises)
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.code === 200).length
const failCount = results.length - successCount
if (failCount === 0) {
ElMessage.success(`批量删除成功,共删除 ${successCount} 条记录`)
} else if (successCount === 0) {
ElMessage.error(`批量删除失败,所有 ${failCount} 条记录删除失败`)
} else {
ElMessage.warning(`批量删除完成,成功 ${successCount} 条,失败 ${failCount}`)
}
fetchDiscountList()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除操作异常')
} finally {
loading.value = false
}
}).catch(() => {})
}
// 处理日期选择器回车事件
const handleDatePickerEnter = (event) => {
// 回车键确认日期选择
const datePicker = event.target.closest('.el-date-editor')
if (datePicker) {
// 触发失焦事件,确认日期选择
event.target.blur()
}
}
// 提交表单
const submitForm = () => {
discountFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
discount_type: 'code',
code: discountForm.code,
name: discountForm.name,
note: discountForm.note,
min_amount: Math.round(discountForm.min_amount * 100),
max_amount: Math.round(discountForm.max_amount * 100),
max_times: discountForm.max_times || 0,
user_times: discountForm.user_times || 0,
renew: discountForm.renew,
can_stacking: discountForm.can_stacking,
can_combine: discountForm.can_combine
}
// 根据优惠类型设置amount或percentage
if (discountForm.discount_mode === 'percentage') {
submitData.percentage = Math.round(discountForm.percentage * 100)
submitData.amount = 0
} else {
submitData.amount = Math.round(discountForm.amount * 100)
submitData.percentage = 0
}
// 处理时间(转换为秒级时间戳)
if (discountForm.timeRange && discountForm.timeRange.length === 2) {
submitData.start_time = timeToTimestamp(discountForm.timeRange[0])
submitData.end_time = timeToTimestamp(discountForm.timeRange[1])
} else {
submitData.start_time = ''
submitData.end_time = ''
}
// 如果是编辑,添加code_id
if (dialogType.value === 'edit') {
submitData.code_id = discountForm.code_id
}
console.log('提交优惠码数据:', submitData)
let res
if (dialogType.value === 'add') {
res = await createDiscountCode(submitData)
} else {
res = await updateDiscountCode(submitData)
}
console.log('提交响应:', res.data)
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchDiscountList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
})
}
// 初始化
onMounted(() => {
fetchDiscountList()
})
</script>
<style scoped>
.discount-code-container {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
.filter-section {
padding: 0;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
}
.filter-content {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 16px 20px;
gap: 20px;
flex-wrap: wrap;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.table-section {
padding: 0;
}
.action-buttons {
display: flex;
gap: 8px;
align-items: center;
}
.amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
.discount-value {
color: #67c23a;
font-weight: bold;
font-size: 14px;
}
.pagination {
margin-top: 20px;
padding: 16px 20px;
border-top: 1px solid #e1e8ed;
background: #fafbfc;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0;
}
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) {
padding: 0;
}
/* 骨架屏样式 */
.skeleton-container {
padding: 20px;
}
.skeleton-row {
display: flex;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
gap: 16px;
}
.skeleton-row:last-child {
border-bottom: none;
}
.skeleton-cell {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: 4px;
}
.skeleton-checkbox { width: 55px; }
.skeleton-id { width: 80px; }
.skeleton-code { width: 150px; }
.skeleton-name { width: 180px; }
.skeleton-type { width: 120px; }
.skeleton-value { width: 120px; }
.skeleton-min { width: 120px; }
.skeleton-max { width: 120px; }
.skeleton-times { width: 120px; }
.skeleton-action { width: 200px; height: 32px; }
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>
<style>
/* 时间选择器弹出层样式 - 非 scoped */
.discount-date-picker {
z-index: 9999 !important;
}
.discount-date-picker .el-picker-panel {
max-width: 90vw;
}
</style>
+213 -137
View File
@@ -161,47 +161,39 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="选择类型" prop="select_type" v-if="dialogType === 'add'"> <el-form-item label="选择关联对象" v-if="dialogType === 'add'">
<el-radio-group v-model="form.select_type" @change="handleSelectTypeChange"> <div class="goods-tree-wrapper">
<el-radio value="product">商品</el-radio> <div class="goods-tree-toolbar">
<el-radio value="product_group">商品组</el-radio> <span class="tree-tip">可自由勾选商品组与商品展开层级查看下属内容</span>
</el-radio-group> <div class="tree-summary">
</el-form-item> 已选 <b>{{ checkedSummary.groupCount }}</b> 个商品组 / <b>{{ checkedSummary.productCount }}</b> 个商品
</div>
<el-form-item label="选择商品" prop="selected_product" v-if="dialogType === 'add' && form.select_type === 'product'"> </div>
<el-select <el-tree
v-model="form.selected_product" ref="goodsTreeRef"
placeholder="请选择商品" :props="treeProps"
filterable :load="loadTreeNode"
clearable lazy
style="width: 100%" show-checkbox
@change="handleProductChange" check-strictly
node-key="key"
class="goods-tree"
@check="handleTreeCheck"
> >
<el-option <template #default="{ data }">
v-for="item in productOptions" <span class="tree-node">
:key="item.id" <el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain">
:label="`${item.name} (ID: ${item.id})`" {{ data.nodeType === 'group' ? '组' : '品' }}
:value="item.id" </el-tag>
/> <span class="tree-node-label">{{ data.label }}</span>
</el-select> <span class="tree-node-id">ID: {{ data.rawId }}</span>
</el-form-item> <span v-if="data.nodeType === 'product' && data.price != null" class="tree-node-price">
¥{{ (data.price / 100).toFixed(2) }}
<el-form-item label="选择商品组" prop="selected_group" v-if="dialogType === 'add' && form.select_type === 'product_group'"> </span>
<el-select </span>
v-model="form.selected_group" </template>
placeholder="请选择商品组" </el-tree>
filterable </div>
clearable
style="width: 100%"
@change="handleProductGroupChange"
>
<el-option
v-for="item in productGroupOptions"
:key="item.id"
:label="`${item.name} (ID: ${item.id})`"
:value="item.id"
/>
</el-select>
</el-form-item> </el-form-item>
<!-- 编辑模式显示字段 --> <!-- 编辑模式显示字段 -->
@@ -234,7 +226,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Search, Plus, Refresh } from '@element-plus/icons-vue' import { Delete, Search, Plus, Refresh } from '@element-plus/icons-vue'
import { import {
@@ -276,36 +268,27 @@ const form = reactive({
code_id: undefined, code_id: undefined,
goods_id: undefined, goods_id: undefined,
goods_name: '', goods_name: '',
goods_type: '', goods_type: ''
select_type: 'product', // 选择类型:product 或 product_group
selected_product: undefined, // 选中的商品ID
selected_group: undefined // 选中的商品组ID
}) })
const formRules = { const formRules = {
code_id: [ code_id: [
{ required: true, message: '请选择代金券', trigger: 'change' } { required: true, message: '请选择代金券', trigger: 'change' }
], ],
select_type: [
{ required: true, message: '请选择类型', trigger: 'change' }
],
selected_product: [
{ required: true, message: '请选择商品', trigger: 'change' }
],
selected_group: [
{ required: true, message: '请选择商品组', trigger: 'change' }
],
goods_id: [ goods_id: [
{ required: true, message: '请输入商品ID', trigger: 'blur' } { required: true, message: '请输入商品ID', trigger: 'blur' }
],
goods_name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' }
],
goods_type: [
{ required: true, message: '请选择商品类型', trigger: 'change' }
] ]
} }
// 折叠层级选择器相关
const goodsTreeRef = ref(null)
const treeProps = {
label: 'label',
children: 'children',
isLeaf: 'isLeaf'
}
const checkedSummary = reactive({ groupCount: 0, productCount: 0 })
// 状态数据 // 状态数据
const loading = ref(false) const loading = ref(false)
const goodsList = ref([]) const goodsList = ref([])
@@ -436,33 +419,79 @@ const fetchProductGroupList = async () => {
} }
} }
// 选择类型变化 // 折叠层级选择器:懒加载节点
const handleSelectTypeChange = (type) => { // node.level === 0 时加载顶级商品组;展开商品组时加载其子分组与下属商品
form.selected_product = undefined const loadTreeNode = async (node, resolve) => {
form.selected_group = undefined try {
form.goods_id = undefined // 根节点:仅加载 level=1 的顶级商品组
form.goods_name = '' if (node.level === 0) {
form.goods_type = '' const res = await getProductGroupList({ level: 1 })
if (res.data.code === 200) {
const groups = res.data.data?.data || []
return resolve(groups.map(buildGroupNode))
}
return resolve([])
} }
// 选择商品变化 // 商品组节点:逐级加载子分组 + 下属商品
const handleProductChange = (productId) => { if (node.data?.nodeType === 'group') {
const product = productOptions.value.find(item => item.id === productId) const groupId = node.data.rawId
if (product) { const childLevel = (node.data.level || 1) + 1
form.goods_id = product.id const tasks = [
form.goods_name = product.goodsName || product.name || '' getProductList({ good_group_id: groupId, delete: false })
form.goods_type = 'product' ]
// 仅当存在子分组时才请求下一级分组
if (node.data.existSub) {
tasks.push(getProductGroupList({ parent_id: groupId, level: childLevel }))
}
const results = await Promise.all(tasks)
const productRes = results[0]
const productNodes = (productRes.data.code === 200 ? (productRes.data.data?.data || []) : [])
.map(buildProductNode)
let groupNodes = []
if (node.data.existSub && results[1]?.data.code === 200) {
groupNodes = (results[1].data.data?.data || []).map(buildGroupNode)
}
return resolve([...groupNodes, ...productNodes])
}
return resolve([])
} catch (error) {
console.error('加载层级数据失败:', error)
ElMessage.error('加载层级数据失败')
return resolve([])
} }
} }
// 选择商品组变化 // 构建商品组节点
const handleProductGroupChange = (groupId) => { const buildGroupNode = (group) => ({
const group = productGroupOptions.value.find(item => item.id === groupId) key: `group_${group.id}`,
if (group) { rawId: group.id,
form.goods_id = group.id nodeType: 'group',
form.goods_name = group.name || '' label: group.name,
form.goods_type = 'product_group' level: group.level || 1,
} existSub: group.existSub || false,
isLeaf: false
})
// 构建商品节点
const buildProductNode = (product) => ({
key: `product_${product.id}`,
rawId: product.id,
nodeType: 'product',
label: product.name,
price: product.price,
isLeaf: true
})
// 勾选变化时更新汇总
const handleTreeCheck = () => {
const nodes = goodsTreeRef.value?.getCheckedNodes() || []
checkedSummary.groupCount = nodes.filter(n => n.nodeType === 'group').length
checkedSummary.productCount = nodes.filter(n => n.nodeType === 'product').length
} }
// 获取商品关联列表 // 获取商品关联列表
@@ -527,16 +556,15 @@ const handleAdd = () => {
code_id: queryParams.code_id ? Number(queryParams.code_id) : undefined, code_id: queryParams.code_id ? Number(queryParams.code_id) : undefined,
goods_id: undefined, goods_id: undefined,
goods_name: '', goods_name: '',
goods_type: '', goods_type: ''
select_type: 'product',
selected_product: undefined,
selected_group: undefined
}) })
checkedSummary.groupCount = 0
checkedSummary.productCount = 0
formRef.value?.resetFields() formRef.value?.resetFields()
// 等待对话框渲染后清空树的勾选状态
// 加载商品和商品组列表 nextTick(() => {
fetchProductList() goodsTreeRef.value?.setCheckedKeys([])
fetchProductGroupList() })
} }
// 编辑商品关联 // 编辑商品关联
@@ -641,69 +669,43 @@ const handleBatchDelete = () => {
// 提交表单 // 提交表单
const submitForm = () => { const submitForm = () => {
// 新增模式下的额外验证
if (dialogType.value === 'add') {
if (!form.code_id) { if (!form.code_id) {
ElMessage.warning('请选择代金券') ElMessage.warning('请选择代金券')
return return
} }
if (form.select_type === 'product' && !form.selected_product) {
ElMessage.warning('请选择商品') if (dialogType.value === 'add') {
// 收集树中勾选的商品组与商品
const checkedNodes = goodsTreeRef.value?.getCheckedNodes() || []
const goodIds = checkedNodes.filter(n => n.nodeType === 'product').map(n => n.rawId)
const goodGroupIds = checkedNodes.filter(n => n.nodeType === 'group').map(n => n.rawId)
if (goodIds.length === 0 && goodGroupIds.length === 0) {
ElMessage.warning('请至少勾选一个商品或商品组')
return return
} }
if (form.select_type === 'product_group' && !form.selected_group) {
ElMessage.warning('请选择商品组')
return
}
if (!form.goods_id || !form.goods_name || !form.goods_type) {
ElMessage.warning('请先选择商品或商品组')
return
}
}
submitAdd(goodIds, goodGroupIds)
return
}
// 编辑模式
formRef.value?.validate(async (valid) => { formRef.value?.validate(async (valid) => {
if (valid) { if (!valid) return
try { try {
const submitData = { const submitData = {
code_id: String(form.code_id) code_id: String(form.code_id),
discount_good_id: String(form.id)
} }
if (form.goods_type === 'product_group') {
// 根据选择类型决定传 good_id 还是 good_group_id
if (dialogType.value === 'add') {
if (form.select_type === 'product') {
// 选择的是商品,传 good_id
submitData.good_ids = String(form.goods_id)
} else if (form.select_type === 'product_group') {
// 选择的是商品组,传 good_group_id
submitData.good_group_ids = String(form.goods_id)
}
} else {
// 编辑模式:传 discount_good_id
submitData.discount_good_id = String(form.id)
// 根据 goods_type 判断传 good_id 还是 good_group_id
if (form.goods_type === 'product') {
submitData.good_id = String(form.goods_id)
} else if (form.goods_type === 'product_group') {
submitData.good_group_id = String(form.goods_id) submitData.good_group_id = String(form.goods_id)
} else { } else {
// 其他类型默认使用 good_id
submitData.good_id = String(form.goods_id) submitData.good_id = String(form.goods_id)
} }
}
console.log('提交商品关联数据:', submitData) const res = await updateDiscountGoods(submitData)
let res
if (dialogType.value === 'add') {
res = await addDiscountGoods(submitData)
} else {
res = await updateDiscountGoods(submitData)
}
console.log('提交响应:', res.data)
if (res.data.code === 200) { if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功') ElMessage.success('修改成功')
dialogVisible.value = false dialogVisible.value = false
fetchGoodsList() fetchGoodsList()
} }
@@ -711,10 +713,33 @@ const submitForm = () => {
console.error('操作失败:', error) console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败') ElMessage.error(error.response?.data?.message || '操作失败')
} }
}
}) })
} }
// 新增提交:根据勾选构建 good_ids / good_group_ids(逗号分隔)
const submitAdd = async (goodIds, goodGroupIds) => {
try {
const submitData = { code_id: String(form.code_id) }
if (goodIds.length > 0) {
submitData.good_ids = goodIds.join(',')
}
if (goodGroupIds.length > 0) {
submitData.good_group_ids = goodGroupIds.join(',')
}
console.log('提交商品关联数据:', submitData)
const res = await addDiscountGoods(submitData)
if (res.data.code === 200) {
ElMessage.success('新增成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
// 初始化 // 初始化
onMounted(() => { onMounted(() => {
fetchVoucherListOptions() fetchVoucherListOptions()
@@ -798,6 +823,57 @@ onMounted(() => {
padding: 0; padding: 0;
} }
/* 折叠层级选择器样式 */
.goods-tree-wrapper {
width: 100%;
border: 1px solid #e1e8ed;
border-radius: 6px;
overflow: hidden;
}
.goods-tree-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafbfc;
border-bottom: 1px solid #e1e8ed;
font-size: 12px;
color: #909399;
}
.tree-summary b {
color: #409eff;
}
.goods-tree {
max-height: 320px;
overflow-y: auto;
padding: 8px;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.tree-node-label {
color: #2c3e50;
}
.tree-node-id {
color: #909399;
font-size: 12px;
}
.tree-node-price {
color: #f56c6c;
font-size: 12px;
font-weight: bold;
}
:deep(.el-card__body) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
-569
View File
@@ -1,569 +0,0 @@
<template>
<div class="voucher-container">
<!-- 搜索和操作栏 -->
<el-card class="filter-container" shadow="never">
<div class="action-bar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增代金券
</el-button>
<el-button type="success" @click="fetchVoucherList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除
</el-button>
</div>
</el-card>
<!-- 代金券列表 -->
<el-card class="table-container" shadow="never">
<el-table
v-loading="loading"
:data="voucherList"
@selection-change="handleSelectionChange"
style="width: 100%"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="代金券名称" min-width="200" />
<el-table-column label="面额" width="120">
<template #default="{ row }">
<span class="amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="最低消费" width="120">
<template #default="{ row }">
¥{{ (row.minAmount / 100).toFixed(2) }}
</template>
</el-table-column>
<el-table-column label="最大抵扣" width="120">
<template #default="{ row }">
<span v-if="row.maxAmount">¥{{ (row.maxAmount / 100).toFixed(2) }}</span>
<span v-else>无限制</span>
</template>
</el-table-column>
<el-table-column prop="maxTimes" label="最大使用次数" width="130">
<template #default="{ row }">
{{ row.maxTimes || '无限制' }}
</template>
</el-table-column>
<el-table-column prop="userTimes" label="单用户次数" width="120">
<template #default="{ row }">
{{ row.userTimes || '无限制' }}
</template>
</el-table-column>
<el-table-column label="有效期(天)" width="100">
<template #default="{ row }">
{{ row.duration ? (row.duration / 86400).toFixed(0) : '-' }}
</template>
</el-table-column>
<el-table-column label="续费可用" width="100" align="center">
<template #default="{ row }">
<el-icon v-if="row.renew" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" link @click="handleManage(row)">管理</el-button>
<el-button type="success" link @click="handleView(row)">查看</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"
/>
</el-card>
<!-- 代金券表单对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '新增代金券' : '编辑代金券'"
width="700px"
>
<el-form
ref="voucherFormRef"
:model="voucherForm"
:rules="voucherRules"
label-width="140px"
>
<el-form-item label="代金券名称" prop="name">
<el-input v-model="voucherForm.name" placeholder="请输入代金券名称" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="面额" prop="amount">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最低消费" prop="min_amount">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大抵扣" prop="max_amount">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="单用户最大次数" prop="user_times">
<el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="有效期" prop="duration_days">
<div class="unit-input-row">
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="flex:1" />
<span class="unit-text"></span>
</div>
<div class="form-tip">代金券领取后的有效持续时间</div>
</el-form-item>
<el-form-item label="发放时间范围" prop="timeRange">
<el-date-picker
v-model="voucherForm.timeRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:teleported="true"
popper-class="voucher-date-picker"
placement="top-start"
:editable="true"
:clearable="true"
style="width: 100%"
@keyup.enter="handleDatePickerEnter"
/>
<div class="form-tip">代金券可以发放给用户的时间范围</div>
</el-form-item>
<el-form-item label="续费可用" prop="renew">
<el-switch v-model="voucherForm.renew" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="同类型可叠加" prop="can_stacking">
<el-switch v-model="voucherForm.can_stacking" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="其他类型可叠加" prop="can_combine">
<el-switch v-model="voucherForm.can_combine" active-text="是" inactive-text="否" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</template>
</el-dialog>
<!-- 详情查看对话框 -->
<DiscountDetailDialog
v-model="detailDialogVisible"
type="coupon"
:detail-data="currentDetail"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Refresh, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import {
getDiscountCodeList,
getDiscountCodeDetail,
createDiscountCode,
updateDiscountCode,
deleteDiscountCode
} from '@/api/admin/discount'
import { timeToTimestamp } from '@/utils/tool'
import DiscountDetailDialog from '@/components/marketing/DiscountDetailDialog.vue'
const router = useRouter()
// 查询参数
const queryParams = reactive({
discount_type: 'coupon', // 固定为coupon表示代金券
page: 1,
count: 10
})
// 代金券表单
const voucherForm = reactive({
code_id: undefined,
discount_type: 'coupon', // 固定为coupon
name: '',
note: '',
amount: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
duration_days: 30, // 默认30天
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
const voucherRules = {
name: [
{ required: true, message: '请输入代金券名称', trigger: 'blur' }
],
amount: [
{ required: true, message: '请输入面额', trigger: 'blur' }
],
duration_days: [
{ required: true, message: '请输入有效期天数', trigger: 'blur' }
]
}
// 状态数据
const loading = ref(false)
const voucherList = ref([])
const total = ref(0)
const selectedRows = ref([])
const dialogVisible = ref(false)
const dialogType = ref('add')
const voucherFormRef = ref(null)
const detailDialogVisible = ref(false)
const currentDetail = ref(null)
// 获取代金券列表
const fetchVoucherList = async () => {
loading.value = true
try {
const res = await getDiscountCodeList(queryParams)
console.log('代金券列表数据:', res.data)
if (res.data.code === 200) {
voucherList.value = res.data.data?.data || []
total.value = res.data.data?.all_count || 0
}
} catch (error) {
console.error('获取代金券列表失败:', error)
ElMessage.error('获取代金券列表失败')
} finally {
loading.value = false
}
}
// 选择项变化
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
// 分页
const handleSizeChange = (size) => {
queryParams.count = size
fetchVoucherList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchVoucherList()
}
// 新增代金券
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
Object.assign(voucherForm, {
code_id: undefined,
discount_type: 'coupon',
name: '',
note: '',
amount: 0,
min_amount: 0,
max_amount: 0,
max_times: 0,
user_times: 0,
duration_days: 30,
timeRange: [],
renew: false,
can_stacking: false,
can_combine: false
})
voucherFormRef.value?.resetFields()
}
// 编辑代金券
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
console.log('编辑代金券原始数据:', row)
// 转换时间为日期字符串(YYYY-MM-DD HH:mm:ss 格式)
let startTime = ''
let endTime = ''
if (row.startTime) {
// 处理字符串格式的时间(如 "2026-01-08T00:00:00+08:00"
const start = new Date(row.startTime)
if (!isNaN(start.getTime())) {
startTime = start.getFullYear() + '-' +
String(start.getMonth() + 1).padStart(2, '0') + '-' +
String(start.getDate()).padStart(2, '0') + ' ' +
String(start.getHours()).padStart(2, '0') + ':' +
String(start.getMinutes()).padStart(2, '0') + ':' +
String(start.getSeconds()).padStart(2, '0')
}
}
if (row.endTime) {
// 处理字符串格式的时间(如 "2026-02-25T00:00:00+08:00"
const end = new Date(row.endTime)
if (!isNaN(end.getTime())) {
endTime = end.getFullYear() + '-' +
String(end.getMonth() + 1).padStart(2, '0') + '-' +
String(end.getDate()).padStart(2, '0') + ' ' +
String(end.getHours()).padStart(2, '0') + ':' +
String(end.getMinutes()).padStart(2, '0') + ':' +
String(end.getSeconds()).padStart(2, '0')
}
}
console.log('转换后的时间:', { startTime, endTime })
Object.assign(voucherForm, {
code_id: row.id,
discount_type: 'coupon',
name: row.name,
note: row.note || '',
amount: row.amount ? row.amount / 100 : 0,
min_amount: row.minAmount ? row.minAmount / 100 : 0,
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
max_times: row.maxTimes || 0,
user_times: row.userTimes || 0,
duration_days: row.duration ? Math.round(row.duration / 86400) : 30, // 秒转天
timeRange: startTime && endTime ? [startTime, endTime] : [],
renew: row.renew || false,
can_stacking: row.canStacking || false,
can_combine: row.canCombine || false
})
console.log('表单数据:', voucherForm)
}
// 管理代金券
const handleManage = (row) => {
router.push(`/marketing/voucher/${row.id}/manage`)
}
// 查看代金券详情
const handleView = async (row) => {
try {
const res = await getDiscountCodeDetail({ code_id: row.id })
console.log('代金券详情:', res.data)
if (res.data.code === 200) {
currentDetail.value = res.data.data
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取代金券详情失败:', error)
ElMessage.error('获取代金券详情失败')
}
}
// 删除代金券
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除代金券 ${row.name} 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteDiscountCode({ code_id: row.id })
console.log('删除响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchVoucherList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
loading.value = true
try {
const deletePromises = selectedRows.value.map(row =>
deleteDiscountCode({ code_id: row.id })
)
const results = await Promise.allSettled(deletePromises)
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.code === 200).length
const failCount = results.length - successCount
if (failCount === 0) {
ElMessage.success(`批量删除成功,共删除 ${successCount} 条记录`)
} else if (successCount === 0) {
ElMessage.error(`批量删除失败,所有 ${failCount} 条记录删除失败`)
} else {
ElMessage.warning(`批量删除完成,成功 ${successCount} 条,失败 ${failCount}`)
}
fetchVoucherList()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除操作异常')
} finally {
loading.value = false
}
}).catch(() => {})
}
// 处理日期选择器回车事件
const handleDatePickerEnter = (event) => {
// 回车键确认日期选择
const datePicker = event.target.closest('.el-date-editor')
if (datePicker) {
// 触发失焦事件,确认日期选择
event.target.blur()
}
}
// 提交代金券表单
const submitForm = () => {
voucherFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
discount_type: 'coupon',
name: voucherForm.name,
note: voucherForm.note,
amount: Math.round(voucherForm.amount * 100),
percentage: 0, // 代金券固定为0
min_amount: Math.round(voucherForm.min_amount * 100),
max_amount: Math.round(voucherForm.max_amount * 100),
max_times: voucherForm.max_times || 0,
user_times: voucherForm.user_times || 0,
duration: voucherForm.duration_days * 86400, // 天转秒
renew: voucherForm.renew,
can_stacking: voucherForm.can_stacking,
can_combine: voucherForm.can_combine
}
// 处理时间(转换为秒级时间戳)
if (voucherForm.timeRange && voucherForm.timeRange.length === 2) {
submitData.start_time = timeToTimestamp(voucherForm.timeRange[0])
submitData.end_time = timeToTimestamp(voucherForm.timeRange[1])
} else {
submitData.start_time = ''
submitData.end_time = ''
}
// 如果是编辑,添加code_id
if (dialogType.value === 'edit') {
submitData.code_id = voucherForm.code_id
}
console.log('提交代金券数据:', submitData)
let res
if (dialogType.value === 'add') {
res = await createDiscountCode(submitData)
} else {
res = await updateDiscountCode(submitData)
}
console.log('提交响应:', res.data)
if (res.data.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchVoucherList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
}
})
}
// 初始化
onMounted(() => {
fetchVoucherList()
})
</script>
<style scoped>
.voucher-container {
padding: 0;
}
.filter-container {
margin-bottom: 20px;
border-radius: 8px;
}
.action-bar {
display: flex;
gap: 12px;
}
.table-container {
border-radius: 8px;
}
.amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.pagination {
margin-top: 24px;
justify-content: flex-end;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
</style>
<style>
/* 时间选择器弹出层样式 - 非 scoped */
.voucher-date-picker {
z-index: 9999 !important;
}
.voucher-date-picker .el-picker-panel {
max-width: 90vw;
}
</style>
+8 -15
View File
@@ -15,9 +15,6 @@
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="关联信息ID">
<el-input v-model="queryParams.id" placeholder="请输入关联信息ID" clearable style="width: 180px" />
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleQuery"> <el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询 <el-icon><Search /></el-icon>查询
@@ -203,15 +200,14 @@ const router = useRouter()
// 查询参数 // 查询参数
const queryParams = reactive({ const queryParams = reactive({
user_id: undefined, user_id: undefined,
code_id: props.codeId || undefined, id: props.codeId || undefined,
id: '',
page: 1, page: 1,
count: 10 count: 10
}) })
watch(() => props.codeId, (newVal) => { watch(() => props.codeId, (newVal) => {
if (newVal) { if (newVal) {
queryParams.code_id = newVal queryParams.id = newVal
fetchHistoryList() fetchHistoryList()
} }
}) })
@@ -285,8 +281,8 @@ const statistics = computed(() => {
}) })
// 获取查询用户名称 // 获取查询用户名称
const getQueryUserName = () => { const getQueryUserName = () => {
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id) const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}` return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
} }
// 获取使用记录列表 // 获取使用记录列表
@@ -347,15 +343,13 @@ const confirmUserSelection = (user) => {
} }
if (selectorType.value === 'query') { if (selectorType.value === 'query') {
// 查询表单选择 queryParams.user_id = user.user_id
queryParams.user_id = user.UserId
} else { } else {
// 编辑表单选择 editForm.user_id = user.user_id
editForm.user_id = user.UserId
} }
// 将选中的用户添加到 UserOptions 中(如果不存在) // 将选中的用户添加到 UserOptions 中(如果不存在)
if (!UserOptions.value.find(u => u.UserId === user.UserId)) { if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
UserOptions.value.push(user) UserOptions.value.push(user)
} }
@@ -372,8 +366,7 @@ const handleQuery = () => {
// 重置查询 // 重置查询
const resetQuery = () => { const resetQuery = () => {
queryParams.user_id = undefined queryParams.user_id = undefined
queryParams.code_id = undefined queryParams.id = undefined
queryParams.id = ''
queryParams.page = 1 queryParams.page = 1
fetchHistoryList() fetchHistoryList()
} }
+7 -9
View File
@@ -423,15 +423,13 @@ const confirmUserSelection = (user) => {
} }
if (selectorType.value === 'query') { if (selectorType.value === 'query') {
// 查询表单选择 queryParams.user_id = user.user_id
queryParams.user_id = user.UserId
} else { } else {
// 编辑表单选择 editForm.user_id = user.user_id
editForm.user_id = user.UserId
} }
// 将选中的用户添加到 UserOptions 中(如果不存在) // 将选中的用户添加到 UserOptions 中(如果不存在)
if (!UserOptions.value.find(u => u.UserId === user.UserId)) { if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
UserOptions.value.push(user) UserOptions.value.push(user)
} }
@@ -451,14 +449,14 @@ const clearEditUser = () => {
// 获取查询用户名称 // 获取查询用户名称
const getQueryUserName = () => { const getQueryUserName = () => {
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id) const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}` return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
} }
// 获取编辑用户名称 // 获取编辑用户名称
const getEditUserName = () => { const getEditUserName = () => {
const user = UserOptions.value.find(u => u.UserId === editForm.user_id) const user = UserOptions.value.find(u => u.user_id === editForm.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${editForm.user_id}` return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${editForm.user_id}`
} }
// 获取代金券列表 // 获取代金券列表
+1 -1
View File
@@ -50,7 +50,7 @@ const activeTab = ref('user-distribution')
const voucherId = computed(() => route.params.id) const voucherId = computed(() => route.params.id)
const goBack = () => { const goBack = () => {
router.push('/marketing/voucher') router.push('/product/discount')
} }
</script> </script>
+446 -58
View File
@@ -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>
<el-descriptions-item label="套餐ID">{{ orderDetail.planId || '-' }}</el-descriptions-item>
<el-descriptions-item label="表名">{{ orderDetail.table }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ orderDetail.payNum }}</el-descriptions-item>
<el-descriptions-item label="订单金额">¥{{ (orderDetail.price / 100).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="续费价格">¥{{ (orderDetail.renewPrice / 100).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag :type="getStatusType(orderDetail.state)">
{{ getStatusText(orderDetail.state) }} {{ getStatusText(orderDetail.state) }}
</el-tag> </el-tag>
<el-tag type="info" size="small" effect="plain">{{ getTypeText(orderDetail.type) }}</el-tag>
</div>
<div class="od-hero-sub">
<span>订单号 #{{ orderDetail.id }}</span>
<el-divider direction="vertical" />
<span>创建于 {{ formatDate(orderDetail.createdAt || orderDetail.CreatedAt) }}</span>
</div>
</div>
<div class="od-hero-amount">
<span class="od-amount-label">订单金额</span>
<span class="od-amount-value">¥{{ formatYuan(orderDetail.price) }}</span>
</div>
</div>
<!-- 订单信息 -->
<div class="od-section">
<div class="od-section-title"><el-icon><Document /></el-icon><span>订单信息</span></div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="订单ID">{{ orderDetail.id }}</el-descriptions-item>
<el-descriptions-item label="订单类型">
<el-tag size="small" effect="plain">{{ getTypeText(orderDetail.type) }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="支付方式">{{ orderDetail.payType || '-' }}</el-descriptions-item> <el-descriptions-item label="数量">{{ orderDetail.payNum }}</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ formatDate(orderDetail.expireTime) }}</el-descriptions-item> <el-descriptions-item label="表名">{{ orderDetail.table || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(orderDetail.CreatedAt) }}</el-descriptions-item> <el-descriptions-item label="订单金额">¥{{ formatYuan(orderDetail.price) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(orderDetail.UpdatedAt) }}</el-descriptions-item> <el-descriptions-item label="续费价格">¥{{ formatYuan(orderDetail.renewPrice) }}</el-descriptions-item>
<el-descriptions-item label="参数信息">{{ orderDetail.args || '-' }}</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-descriptions-item v-if="orderDetail.error" label="错误信息" :span="2">
<el-tag type="danger" size="small" style="margin-right: 6px;">异常</el-tag> <el-tag type="danger" size="small" style="margin-right: 6px;">异常</el-tag>
<span style="color: #f56c6c;">{{ orderDetail.error }}</span> <span style="color: #f56c6c;">{{ orderDetail.error }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ orderDetail.note || '无' }}</el-descriptions-item>
</el-descriptions> </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,9 +926,11 @@ 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(() => {
fetchProductParams(row.commodityId).then(() => { // 如果订单有套餐,按套餐结构恢复参数
// 从已有 args 中恢复参数值 if (row.planId && planList.value.length) {
handlePlanChange(row.planId)
// 从已有 args 恢复额外参数的选择值
if (row.args) { if (row.args) {
try { try {
const existingArgs = JSON.parse(row.args) const existingArgs = JSON.parse(row.args)
@@ -770,6 +949,28 @@ const handleEdit = (row) => {
} }
} catch {} } catch {}
} }
}
})
fetchProductParams(row.commodityId).then(() => {
// 无套餐时从已有 args 恢复全量参数值
if (!row.planId && 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 {}
}
}) })
} }
dialogVisible.value = true dialogVisible.value = true
@@ -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,9 +1094,17 @@ 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 = [] }
} catch { productParams.value = []; allProductParams.value = [] }
finally { paramsLoading.value = false }
}
// 为参数列表初始化默认值
const initArgDefaults = (params) => {
for (const param of params) {
if (argValues[param.id] !== undefined) continue if (argValues[param.id] !== undefined) continue
if (param.type === 'select' && param.attrs?.length) { if (param.type === 'select' && param.attrs?.length) {
argValues[param.id] = param.attrs[0].id argValues[param.id] = param.attrs[0].id
@@ -905,31 +1114,53 @@ const fetchProductParams = async (goodId) => {
argValues[param.id] = '' argValues[param.id] = ''
} }
} }
} else { productParams.value = [] }
} catch { productParams.value = [] }
finally { paramsLoading.value = false }
} }
// 根据参数表单值构建 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>
File diff suppressed because it is too large Load Diff
+126 -12
View File
@@ -122,6 +122,12 @@
<el-tag v-else-if="row.isProduct" type="success" size="small" style="margin-left: 8px;">商品</el-tag> <el-tag v-else-if="row.isProduct" type="success" size="small" style="margin-left: 8px;">商品</el-tag>
<span class="group-name">{{ row.name }}</span> <span class="group-name">{{ row.name }}</span>
<el-tag
v-if="(row.isGroup && row.recommendWords) || (row.isProduct && row.data?.recommendWords)"
type="danger"
size="small"
style="margin-left: 6px;"
>{{ row.isGroup ? row.recommendWords : row.data?.recommendWords }}</el-tag>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -207,6 +213,7 @@
<div class="group-name-cell"> <div class="group-name-cell">
<el-avatar v-if="row.cover" :size="32" :src="row.cover" /> <el-avatar v-if="row.cover" :size="32" :src="row.cover" />
<span class="group-name">{{ row.name }}</span> <span class="group-name">{{ row.name }}</span>
<el-tag v-if="row.recommendWords" type="danger" size="small" style="margin-left: 6px;">{{ row.recommendWords }}</el-tag>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -393,6 +400,11 @@
</div> </div>
</div> </div>
<!-- 推荐词 -->
<el-form-item prop="recommend_words" label="推荐词">
<el-input v-model="groupForm.recommend_words" placeholder="推荐词(可选,如:热销、新品)" clearable />
</el-form-item>
<!-- 备注 --> <!-- 备注 -->
<el-form-item prop="note" label="备注" class="note-form-item"> <el-form-item prop="note" label="备注" class="note-form-item">
<el-input <el-input
@@ -637,6 +649,20 @@
inactive-text="禁止" inactive-text="禁止"
/> />
</el-form-item> </el-form-item>
<el-form-item prop="renew_price" v-if="productForm.can_renew">
<template #label>
<span>续费价格<span class="unit-suffix">0 沿用商品价格</span></span>
</template>
<el-input-number
v-model="productForm.renew_price"
:min="0"
:precision="2"
:step="0.01"
placeholder="续费基础价格"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="购买类型" prop="arg_type"> <el-form-item label="购买类型" prop="arg_type">
<el-select <el-select
v-model="productForm.arg_type" v-model="productForm.arg_type"
@@ -697,6 +723,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>
@@ -723,6 +759,23 @@
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item prop="renew_recommend_rebate">
<template #label>
<span>续费推介返还<span class="unit-suffix">%0 沿用推荐返还</span></span>
</template>
<el-input-number
v-model="productForm.renew_recommend_rebate"
:min="0"
:max="100"
:disabled="!productForm.recommend"
placeholder="续费推介返还百分比"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="推荐词" prop="recommend_words">
<el-input v-model="productForm.recommend_words" placeholder="推荐词(可选,如:热销、新品)" clearable style="width: 100%" />
</el-form-item>
<el-form-item label="购买通知" prop="send_notice"> <el-form-item label="购买通知" prop="send_notice">
<el-switch <el-switch
v-model="productForm.send_notice" v-model="productForm.send_notice"
@@ -791,7 +844,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 {
@@ -804,10 +858,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'
@@ -850,7 +908,8 @@ const groupForm = reactive({
cover_id: undefined, cover_id: undefined,
cover_url: '', cover_url: '',
tag_id: undefined, tag_id: undefined,
index: 0 index: 0,
recommend_words: ''
}) })
const groupRules = { const groupRules = {
@@ -903,7 +962,11 @@ 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,
send_notice: false max_first_purchase_duration: 0,
send_notice: false,
renew_price: 0,
renew_recommend_rebate: 0,
recommend_words: ''
}) })
const productRules = { const productRules = {
@@ -1450,7 +1513,8 @@ const handleAdd = (parentRow) => {
cover_id: undefined, cover_id: undefined,
cover_url: '', cover_url: '',
tag_id: parentTagId || undefined, tag_id: parentTagId || undefined,
index: 0 index: 0,
recommend_words: ''
}) })
console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag) console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag)
} else { } else {
@@ -1465,7 +1529,8 @@ const handleAdd = (parentRow) => {
cover_id: undefined, cover_id: undefined,
cover_url: '', cover_url: '',
tag_id: undefined, tag_id: undefined,
index: 0 index: 0,
recommend_words: ''
}) })
} }
@@ -1486,7 +1551,8 @@ const handleEdit = (row) => {
cover_id: row.coverId || undefined, cover_id: row.coverId || undefined,
cover_url: row.cover || '', cover_url: row.cover || '',
tag_id: row.tag?.id || row.tagId || undefined, tag_id: row.tag?.id || row.tagId || undefined,
index: row.index || 0 index: row.index || 0,
recommend_words: row.recommendWords || ''
}) })
dialogVisible.value = true dialogVisible.value = true
@@ -1554,7 +1620,10 @@ 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,
recommend_words: ''
}) })
selectedProductGroup.value = null selectedProductGroup.value = null
@@ -1591,7 +1660,11 @@ 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,
send_notice: !!product.sendNotice max_first_purchase_duration: product.maxFirstPurchaseDuration ?? product.max_first_purchase_duration ?? 0,
send_notice: !!product.sendNotice,
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0,
recommend_words: product.recommendWords ?? product.recommend_words ?? ''
}) })
productDialogVisible.value = true productDialogVisible.value = true
@@ -1620,7 +1693,11 @@ 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,
send_notice: productForm.send_notice === true max_first_purchase_duration: Number(productForm.max_first_purchase_duration) || 0,
send_notice: productForm.send_notice === true,
renew_price: Number(productForm.renew_price) || 0,
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0,
recommend_words: productForm.recommend_words || ''
} }
let res let res
@@ -1759,7 +1836,8 @@ const submitForm = () => {
name: groupForm.name.trim(), name: groupForm.name.trim(),
note: groupForm.note || '', note: groupForm.note || '',
disable: groupForm.disable, disable: groupForm.disable,
index: Number(groupForm.index) || 0 index: Number(groupForm.index) || 0,
recommend_words: groupForm.recommend_words || ''
} }
if (groupForm.parent_id) { if (groupForm.parent_id) {
@@ -1832,9 +1910,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>
+379 -205
View File
@@ -3,77 +3,218 @@
<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> <div class="metrics-row">
<el-descriptions-item label="订单ID">{{ detail.orderId || '-' }}</el-descriptions-item> <div class="metric-card is-link" @click="goToUser">
<el-descriptions-item label="归属项ID">{{ detail.itemId || '-' }}</el-descriptions-item> <div class="metric-icon" style="background:#eef3ff;color:#4f6ef7">
<el-descriptions-item label="基础价格">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</el-descriptions-item> <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="创建时间">{{ formatTime(detail.CreatedAt) }}</el-descriptions-item> </div>
<el-descriptions-item label="更新时间">{{ formatTime(detail.UpdatedAt) }}</el-descriptions-item> <div class="metric-body">
<div class="metric-label">所属用户</div>
<div class="metric-value">#{{ detail.userId || detail.user_id || '-' }}</div>
<div class="metric-name">{{ detail.user?.userName || '-' }}</div>
</div>
<div class="metric-arrow">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="metric-card is-link" @click="goToProduct">
<div class="metric-icon" style="background:#f0faf0;color:#52c41a">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M17 7l-7-4-7 4v6l7 4 7-4V7z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M10 11v7M3 7l7 4 7-4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">关联商品</div>
<div class="metric-value">#{{ detail.goodId || '-' }}</div>
<div class="metric-name">{{ detail.good?.name || '-' }}</div>
</div>
<div class="metric-arrow">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="metric-card is-link" @click="goToOrder">
<div class="metric-icon" style="background:#fff7ed;color:#f5a623">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><rect x="3" y="3" width="14" height="14" rx="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5.5M6 13h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">关联订单</div>
<div class="metric-value">#{{ detail.orderId || '-' }}</div>
<div class="metric-name">{{ detail.order?.name || '-' }}</div>
</div>
<div class="metric-arrow">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="metric-card">
<div class="metric-icon" style="background:#fef0f0;color:#f56c6c">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M10 2l2.47 5.01L18 7.75l-4 3.9.94 5.51L10 14.51l-4.94 2.65.94-5.51-4-3.9 5.53-.74z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">套餐ID</div>
<div class="metric-value">#{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon" style="background:#f5f0ff;color:#722ed1">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M7 3h6l4 4v9a1 1 0 01-1 1H4a1 1 0 01-1-1V7l4-4z" stroke="currentColor" stroke-width="1.5"/><path d="M7 3v4H3M10 9v5M8 12h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">归属项ID</div>
<div class="metric-value">#{{ detail.itemId || '-' }}</div>
</div>
</div>
</div>
<!-- 详细信息折叠区 -->
<div class="detail-section">
<div class="section-header" @click="detailExpanded = !detailExpanded">
<div class="section-header-left">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span class="section-title">详细信息</span>
</div>
<el-icon :class="{ 'is-expanded': detailExpanded }"><ArrowDown /></el-icon>
</div>
<transition name="slide">
<div v-show="detailExpanded" class="section-body">
<div class="info-grid">
<div class="info-item">
<span class="info-label">商品标签</span>
<span class="info-value"><el-tag size="small" type="info">{{ detail.good?.tag || detail.tag || '-' }}</el-tag></span>
</div>
<div class="info-item">
<span class="info-label">商品Table</span>
<span class="info-value">{{ detail.good?.table || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">备注</span>
<span class="info-value">{{ detail.note || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">订单状态</span>
<span class="info-value">
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
</el-tag>
<span v-else>-</span>
</span>
</div>
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ formatTime(detail.CreatedAt) }}</span>
</div>
<div class="info-item">
<span class="info-label">更新时间</span>
<span class="info-value">{{ formatTime(detail.UpdatedAt) }}</span>
</div>
</div>
</div>
</transition>
</div>
<!-- 已删除展示 itemArg 上游快照信息不再请求虚拟机详情 -->
<el-card v-if="isDeleted" shadow="hover" class="related-card">
<template #header>
<div class="card-header-row">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span class="card-title">上游快照信息 (itemArg)</span>
</div>
</template>
<el-descriptions v-if="itemArgEntries.length" :column="2" border size="small">
<el-descriptions-item v-for="item in itemArgEntries" :key="item.key" :label="item.label">
{{ item.value }}
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-empty v-else description="该商品无 itemArg 快照信息" :image-size="100" />
</el-card> </el-card>
<el-card shadow="hover" v-if="detail" class="related-card"> <!-- 类型适配栏目区域 -->
<UserVmDetail v-else-if="currentTag === '云服务器'" embedded :goods-id="goodsId" />
<el-card v-else-if="detail.good" shadow="hover" class="related-card">
<template #header> <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> <span class="card-title">关联信息</span>
</div>
</template> </template>
<el-descriptions :column="2" border size="small"> <el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item> <el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
@@ -89,8 +230,10 @@
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item> <el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </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>
+166 -16
View File
@@ -1,6 +1,45 @@
<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 === 'pending' }" @click="handleStatusCard('pending')">
<div class="stat-icon stat-icon-pending"><el-icon><Timer /></el-icon></div>
<div class="stat-body">
<div class="stat-label">待开通</div>
<div class="stat-value">{{ counts.pending }}</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 +62,20 @@
<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="pending" />
<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 +135,22 @@
<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-if="row.status === 'pending'" size="small" type="info">待开通</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 +522,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, Timer } 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 +538,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, pending: 0 })
const countTotal = computed(() => (counts.normal || 0) + (counts.deleted || 0) + (counts.expired || 0) + (counts.pending || 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 +557,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 +593,20 @@ const getStatusText = (status) => {
} }
} }
const loadList = async () => { // count list
loading.value = true const buildFilterParams = () => {
try { const params = {}
const params = { page: query.page, count: query.count }
if (query.key) params.key = query.key if (query.key) params.key = query.key
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
return params
}
const loadList = async () => {
loading.value = true
try {
const params = { page: query.page, count: query.count, ...buildFilterParams() }
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
@@ -536,7 +616,27 @@ 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
counts.pending = d.pending ?? 0
}
} catch { /* 统计失败不阻断列表 */ }
}
const handleSearch = () => { query.page = 1; loadList(); loadCount() }
//
const handleStatusCard = (status) => {
query.status = status
handleSearch()
}
// ---- ---- // ---- ----
const argsSpecList = ref([]) const argsSpecList = ref([])
@@ -841,13 +941,8 @@ const submitEdit = async () => {
// ---- / ---- // ---- / ----
const handleDetail = (row) => { const handleDetail = (row) => {
const tag = (row.tag || row.good?.tag || '').toLowerCase()
if (tag === '云服务器') {
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
} else {
router.push({ name: 'UserGoodsDetail', params: { id: row.id } }) router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
} }
}
const handleMoreCmd = (cmd, row) => { const handleMoreCmd = (cmd, row) => {
if (cmd === 'remind') openRemindList(row) if (cmd === 'remind') openRemindList(row)
@@ -911,7 +1006,7 @@ const handleSendRemind = (row) => {
}).catch(() => {}) }).catch(() => {})
} }
onMounted(loadList) onMounted(() => { loadList(); loadCount() })
</script> </script>
<style scoped> <style scoped>
@@ -924,6 +1019,61 @@ 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-icon-pending { background: #e6f7ff; color: #1890ff; }
.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;
} }
@@ -47,6 +47,9 @@
<span class="price-symbol">¥</span> <span class="price-symbol">¥</span>
<span class="price-amount">{{ formatPlanPrice(row) }}</span> <span class="price-amount">{{ formatPlanPrice(row) }}</span>
</div> </div>
<div v-if="isFixedPrice(row) && getRenewPrice(row) > 0" class="plan-renew-price">
续费 ¥{{ formatRenewPrice(row) }}
</div>
</div> </div>
<div class="plan-stat plan-stat-inventory" :class="getInventoryClass(row)"> <div class="plan-stat plan-stat-inventory" :class="getInventoryClass(row)">
<div class="plan-stat-label"> <div class="plan-stat-label">
@@ -274,6 +277,13 @@
<span class="unit-text"></span> <span class="unit-text"></span>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="续费固定价格" prop="renew_fixed_price" v-if="planForm.enable_fixed_price === true">
<div class="unit-input-row">
<el-input-number v-model="planForm.renew_fixed_price" :min="0" :precision="2" :step="0.01" style="flex:1" placeholder="0 表示沿用固定价格" />
<span class="unit-text"></span>
</div>
<div class="form-tip">续费时使用的固定价格 0 时沿用上方固定价格</div>
</el-form-item>
<el-form-item label="排序索引" prop="index"> <el-form-item label="排序索引" prop="index">
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" /> <el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
</el-form-item> </el-form-item>
@@ -386,7 +396,8 @@ const planForm = reactive({
disable: false, disable: false,
show_home: false, show_home: false,
can_update: false, can_update: false,
max_per_user: 0 max_per_user: 0,
renew_fixed_price: 0
}) })
const planFormRules = { const planFormRules = {
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }] name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }]
@@ -463,6 +474,14 @@ const formatPlanPrice = (row) => {
return (Number(raw) / 100).toFixed(2) return (Number(raw) / 100).toFixed(2)
} }
const getRenewPrice = (row) => {
return Number(row.renewFixedPrice ?? row.renew_fixed_price ?? 0)
}
const formatRenewPrice = (row) => {
return (getRenewPrice(row) / 100).toFixed(2)
}
const getInventoryNum = (row) => { const getInventoryNum = (row) => {
return Number(row.inventory ?? 0) || 0 return Number(row.inventory ?? 0) || 0
} }
@@ -794,7 +813,8 @@ const handleEditPlan = async (row) => {
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price), enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price),
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home), index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home),
can_update: !!(data.canUpdate || data.can_update), can_update: !!(data.canUpdate || data.can_update),
max_per_user: data.maxPerUser ?? data.max_per_user ?? 0 max_per_user: data.maxPerUser ?? data.max_per_user ?? 0,
renew_fixed_price: ((data.renewFixedPrice ?? data.renew_fixed_price ?? 0) / 100).toFixed(2) * 1
}) })
initSelectedArgsFromJson(data.args, extraArgIdsArray) initSelectedArgsFromJson(data.args, extraArgIdsArray)
planFormDialogVisible.value = true planFormDialogVisible.value = true
@@ -852,7 +872,8 @@ const submitPlanForm = () => {
inventory: Number(planForm.inventory) || 0, fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0, inventory: Number(planForm.inventory) || 0, fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0,
index: Number(planForm.index) || 0, show_home: planForm.show_home === true, index: Number(planForm.index) || 0, show_home: planForm.show_home === true,
can_update: planForm.can_update === true, can_update: planForm.can_update === true,
max_per_user: Number(planForm.max_per_user) || 0 max_per_user: Number(planForm.max_per_user) || 0,
renew_fixed_price: Math.round(Number(planForm.renew_fixed_price) * 100) || 0
} }
if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true
let res let res
@@ -1008,6 +1029,14 @@ watch(() => props.visible, (val) => {
color: #f56c6c; color: #f56c6c;
font-weight: 700; font-weight: 700;
} }
.plan-renew-price {
font-size: 11px;
color: #e6a23c;
font-weight: 500;
margin-top: 2px;
line-height: 1;
}
.plan-stat-price.is-dynamic { .plan-stat-price.is-dynamic {
background: linear-gradient(135deg, #f0f7ff 0%, #e6f2ff 100%); background: linear-gradient(135deg, #f0f7ff 0%, #e6f2ff 100%);
border-color: #c6e2ff; border-color: #c6e2ff;
+1 -7
View File
@@ -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')
} }
+20 -8
View File
@@ -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(parseInt(newVal) || 0) reloadWithNewId(newVal || 0)
}
}) })
} else {
watch(() => route.query.id, (newVal) => {
reloadWithNewId(parseInt(newVal) || 0)
})
}
onMounted(() => { loadDetail() }) onMounted(() => { loadDetail() })
onActivated(() => { onActivated(() => {
if (!props.embedded) {
reloadWithNewId(parseInt(route.query.id) || 0) 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; }
+29 -3
View File
@@ -28,11 +28,20 @@
<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="pending" />
<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 +82,15 @@
</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-if="row.status === 'pending'" size="small" type="info">待开通</el-tag>
<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 +957,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 +979,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 +1001,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 +1027,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 } })
} }
// ---- ---- // ---- ----
+363 -18
View File
@@ -181,18 +181,29 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="订单列表" name="3"> <el-tab-pane label="订单列表" name="3">
<el-table :data="userOrderList" v-loading="orderListLoading" stripe style="width: 100%"> <el-table :data="userOrderList" v-loading="orderListLoading" stripe style="width: 100%">
<el-table-column prop="id" label="订单ID" width="100" /> <el-table-column prop="id" label="订单ID" width="80" />
<el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip /> <el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="price" label="金额" width="100"> <el-table-column label="类型" width="80">
<template #default="{row}">¥{{ (row.price / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}"> <template #default="{row}">
<el-tag :type="getOrderStatusType(row.status)" size="small">{{ getOrderStatusText(row.status) }}</el-tag> <el-tag :type="getOrderTypeTag(row.type)" size="small">{{ getOrderTypeText(row.type) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160"> <el-table-column label="金额" width="120">
<template #default="{row}">{{ formatDate(row.created_at) }}</template> <template #default="{row}">
<span>¥{{ (row.price / 100).toFixed(2) }}</span>
<div v-if="row.renewPrice" style="font-size:12px;color:#909399">续费价: ¥{{ (row.renewPrice / 100).toFixed(2) }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{row}">
<el-tag :type="getOrderStatusType(row.state)" size="small">{{ getOrderStatusText(row.state) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" width="160">
<template #default="{row}">{{ row.expireTime ? formatDate(row.expireTime) : '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" width="160">
<template #default="{row}">{{ formatDate(row.CreatedAt) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="80" fixed="right"> <el-table-column label="操作" width="80" fixed="right">
<template #default="scope"> <template #default="scope">
@@ -256,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>
@@ -276,6 +294,64 @@
</div> </div>
<el-empty v-else description="暂无已购商品" :image-size="100" /> <el-empty v-else description="暂无已购商品" :image-size="100" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="代金券" name="6">
<div class="voucher-action-bar">
<el-button type="primary" size="small" @click="handleAddVoucher">
<el-icon><Plus /></el-icon>添加代金券
</el-button>
<el-button type="success" size="small" @click="fetchUserVoucherListData">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<el-table :data="userVoucherList" v-loading="voucherListLoading" stripe style="width: 100%">
<el-table-column prop="Id" label="ID" width="70" />
<el-table-column label="代金券名称" min-width="150" show-overflow-tooltip>
<template #default="{row}">{{ row.discount?.name || '-' }}</template>
</el-table-column>
<el-table-column label="面额" min-width="100">
<template #default="{row}">
<span class="voucher-amount">¥{{ row.discount?.amount ? (row.discount.amount / 100).toFixed(2) : '0.00' }}</span>
</template>
</el-table-column>
<el-table-column label="已使用/最大" min-width="100">
<template #default="{row}">
{{ row.useTimes || 0 }} / {{ row.maxUseTimes || '∞' }}
</template>
</el-table-column>
<el-table-column label="状态" min-width="80">
<template #default="{row}">
<el-tag :type="getVoucherStatusType(row)" size="small">
{{ getVoucherStatusText(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" min-width="140">
<template #default="{row}">{{ formatVoucherDate(row.expireAt) }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="140">
<template #default="{row}">{{ formatDate(row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{row}">
<el-button type="warning" link size="small" @click="handleEditVoucher(row)">编辑</el-button>
<el-divider direction="vertical" />
<el-button type="danger" link size="small" @click="handleDeleteVoucher(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="voucherListTotal > 0">
<el-pagination
v-model:current-page="voucherListPage"
v-model:page-size="voucherListPageSize"
:total="voucherListTotal"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
@size-change="handleVoucherListSizeChange"
@current-change="handleVoucherListPageChange"
/>
</div>
<el-empty v-if="!voucherListLoading && !userVoucherList.length" description="暂无代金券" :image-size="100" />
</el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
</div> </div>
@@ -451,7 +527,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">
@@ -478,6 +554,49 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 代金券添加/编辑对话框 -->
<el-dialog
v-model="voucherDialogVisible"
:title="voucherDialogType === 'add' ? '添加代金券' : '编辑代金券'"
width="500px"
append-to-body
>
<el-form ref="voucherFormRef" :model="voucherForm" :rules="voucherFormRules" label-width="120px">
<el-form-item label="代金券" prop="discount_id" v-if="voucherDialogType === 'add'">
<el-select v-model="voucherForm.discount_id" placeholder="请选择代金券" filterable style="width: 100%">
<el-option
v-for="item in discountCouponOptions"
:key="item.id"
:label="`${item.name} (¥${(item.amount/100).toFixed(2)})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="已使用次数" prop="use_times" v-if="voucherDialogType === 'edit'">
<el-input-number v-model="voucherForm.use_times" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="最大使用次数" prop="max_use_times" v-if="voucherDialogType === 'edit'">
<el-input-number v-model="voucherForm.max_use_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="过期时间" prop="expire_at" v-if="voucherDialogType === 'edit'">
<el-date-picker
v-model="voucherForm.expire_at"
type="datetime"
placeholder="选择过期时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="X"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="voucherDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitVoucherForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 管理员权限管理对话框 --> <!-- 管理员权限管理对话框 -->
<el-dialog v-model="adminDialogVisible" title="修改管理员权限" width="500px" append-to-body> <el-dialog v-model="adminDialogVisible" title="修改管理员权限" width="500px" append-to-body>
<el-form :model="adminForm" label-width="100px"> <el-form :model="adminForm" label-width="100px">
@@ -518,7 +637,7 @@ import UserListSelector from '@/components/admin/UserListSelector.vue'
import { import {
ArrowLeft, Refresh, Edit as EditIcon, Delete, Wallet, Avatar, Lock, ArrowLeft, Refresh, Edit as EditIcon, Delete, Wallet, Avatar, Lock,
UserFilled, Document, Clock, List, Switch, User, Camera, Upload, UserFilled, Document, Clock, List, Switch, User, Camera, Upload,
UploadFilled, Key, Monitor, Setting, Close UploadFilled, Key, Monitor, Setting, Close, Plus
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { getUserGroupList, getUserBalanceCount } from '@/api/admin/user' import { getUserGroupList, getUserBalanceCount } from '@/api/admin/user'
import { getFileDetail } from '@/api/admin/file' import { getFileDetail } from '@/api/admin/file'
@@ -531,6 +650,13 @@ import { getAdminGroupList } from '@/api/admin/group'
import { getOrderList } from '@/api/admin/order' import { getOrderList } from '@/api/admin/order'
import { getTickerList } from '@/api/ticket' import { getTickerList } from '@/api/ticket'
import { getUserGoodsList } from '@/api/admin/product' import { getUserGoodsList } from '@/api/admin/product'
import {
getUserVoucherList,
allocateVoucher,
updateUserVoucher,
deleteUserVoucher,
getDiscountCodeList
} from '@/api/admin/discount'
const Edit = EditIcon const Edit = EditIcon
const route = useRoute() const route = useRoute()
@@ -578,11 +704,41 @@ 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)
const goodsListPageSize = ref(10) const goodsListPageSize = ref(10)
//
const userVoucherList = ref([])
const voucherListLoading = ref(false)
const voucherListTotal = ref(0)
const voucherListPage = ref(1)
const voucherListPageSize = ref(10)
const voucherDialogVisible = ref(false)
const voucherDialogType = ref('add')
const voucherFormRef = ref(null)
const discountCouponOptions = ref([])
const voucherForm = reactive({
discount_id: undefined,
id: undefined,
use_times: 0,
max_use_times: 0,
expire_at: undefined
})
const voucherFormRules = {
discount_id: [{ required: true, message: '请选择代金券', trigger: 'change' }]
}
// //
const userBalance = ref({ const userBalance = ref({
balance: 0, balance: 0,
@@ -680,7 +836,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'
} }
@@ -707,6 +863,8 @@ const handleTabClick = (tab) => {
fetchUserTicketList() fetchUserTicketList()
} else if (tab.props.name === '5') { } else if (tab.props.name === '5') {
fetchUserGoodsList() fetchUserGoodsList()
} else if (tab.props.name === '6') {
fetchUserVoucherListData()
} }
}; };
@@ -1017,7 +1175,6 @@ const fetchUserOrderList = async () => {
page: orderListPage.value, page: orderListPage.value,
count: orderListPageSize.value count: orderListPageSize.value
}) })
console.log('111',res)
if (res.data.code === 200) { if (res.data.code === 200) {
userOrderList.value = res.data.data.list || [] userOrderList.value = res.data.data.list || []
orderListTotal.value = res.data.data.all_count || 0 orderListTotal.value = res.data.data.all_count || 0
@@ -1100,6 +1257,169 @@ const handleGoodsListPageChange = (page) => {
fetchUserGoodsList() fetchUserGoodsList()
} }
//
const fetchUserVoucherListData = async () => {
if (!route.query.user_id) return
voucherListLoading.value = true
try {
const res = await getUserVoucherList({
user_id: route.query.user_id,
page: voucherListPage.value,
count: voucherListPageSize.value
})
if (res.data.code === 200) {
userVoucherList.value = res.data.data?.data || []
voucherListTotal.value = res.data.data?.all_count || 0
}
} catch (error) {
ElMessage.error('获取代金券列表失败')
} finally {
voucherListLoading.value = false
}
}
const handleVoucherListSizeChange = (size) => {
voucherListPageSize.value = size
voucherListPage.value = 1
fetchUserVoucherListData()
}
const handleVoucherListPageChange = (page) => {
voucherListPage.value = page
fetchUserVoucherListData()
}
//
const getVoucherStatusText = (row) => {
if (row.useTimes >= row.maxUseTimes && row.maxUseTimes > 0) return '已用完'
const expireAt = row.expireAt ? new Date(row.expireAt).getTime() : 0
if (expireAt > 0 && new Date(row.expireAt).getFullYear() !== 1970 && expireAt < Date.now()) return '已过期'
return '可使用'
}
const getVoucherStatusType = (row) => {
if (row.useTimes >= row.maxUseTimes && row.maxUseTimes > 0) return 'info'
const expireAt = row.expireAt ? new Date(row.expireAt).getTime() : 0
if (expireAt > 0 && new Date(row.expireAt).getFullYear() !== 1970 && expireAt < Date.now()) return 'warning'
return 'success'
}
//
const formatVoucherDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
if (isNaN(date.getTime()) || date.getFullYear() === 1970 || date.getFullYear() === 1) return '-'
return formatDate(dateStr)
}
//
const fetchDiscountCouponOptions = async () => {
try {
const res = await getDiscountCodeList({ discount_type: 'coupon', page: 1, count: 100 })
if (res.data.code === 200) {
discountCouponOptions.value = res.data.data?.data || []
}
} catch (error) {
console.error('获取代金券选项失败:', error)
}
}
//
const handleAddVoucher = () => {
voucherDialogType.value = 'add'
voucherDialogVisible.value = true
Object.assign(voucherForm, {
discount_id: undefined,
id: undefined,
use_times: 0,
max_use_times: 0,
expire_at: undefined
})
voucherFormRef.value?.resetFields()
fetchDiscountCouponOptions()
}
//
const handleEditVoucher = (row) => {
voucherDialogType.value = 'edit'
voucherDialogVisible.value = true
let expireTime = undefined
if (row.expireAt && row.expireAt !== '0001-01-01T00:00:00Z') {
const date = new Date(row.expireAt)
if (!isNaN(date.getTime()) && date.getFullYear() !== 1970) {
expireTime = Math.floor(date.getTime() / 1000)
}
}
Object.assign(voucherForm, {
discount_id: row.discountId,
id: row.Id,
use_times: row.useTimes || 0,
max_use_times: row.maxUseTimes || 0,
expire_at: expireTime
})
}
//
const handleDeleteVoucher = (row) => {
const name = row.discount?.name || '该代金券'
ElMessageBox.confirm(`确认删除代金券「${name}」吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteUserVoucher({
user_id: route.query.user_id,
id: row.Id
})
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchUserVoucherListData()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
//
const submitVoucherForm = () => {
voucherFormRef.value?.validate(async (valid) => {
if (!valid) return
try {
let res
if (voucherDialogType.value === 'add') {
res = await allocateVoucher({
user_id: route.query.user_id,
code_id: voucherForm.discount_id
})
} else {
res = await updateUserVoucher({
user_id: route.query.user_id,
id: voucherForm.id,
discount_id: voucherForm.discount_id,
use_times: voucherForm.use_times,
max_use_times: voucherForm.max_use_times,
expire_at: voucherForm.expire_at
})
}
if (res.data.code === 200) {
ElMessage.success(voucherDialogType.value === 'add' ? '添加成功' : '修改成功')
voucherDialogVisible.value = false
fetchUserVoucherListData()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
// //
const fetchUserBalance = async () => { const fetchUserBalance = async () => {
if (!route.query.user_id) return if (!route.query.user_id) return
@@ -1119,14 +1439,25 @@ const fetchUserBalance = async () => {
} }
// //
const getOrderStatusText = (status) => { const getOrderStatusText = (state) => {
const map = { 0: '待支付', 1: '已支付', 2: '已取消', 3: '已退款', 4: '已完成' } const map = { 0: '待支付', 1: '已支付', 2: '已取消', 3: '已退款', 4: '已完成' }
return map[status] || '未知' return map[state] || '未知'
} }
const getOrderStatusType = (status) => { const getOrderStatusType = (state) => {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger', 4: 'success' } const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger', 4: 'success' }
return map[status] || 'info' return map[state] || 'info'
}
//
const getOrderTypeText = (type) => {
const map = { create: '新购', renew: '续费', upgrade: '升级' }
return map[type] || type || '未知'
}
const getOrderTypeTag = (type) => {
const map = { create: 'primary', renew: 'success', upgrade: 'warning' }
return map[type] || 'info'
} }
// //
@@ -1144,7 +1475,7 @@ const getTicketStatusType = (status) => {
const handleViewOrder = (row) => { const handleViewOrder = (row) => {
router.push({ router.push({
path: '/order/list', path: '/order/list',
query: { order_id: row.order_id } query: { order_id: row.id }
}) })
} }
@@ -1413,6 +1744,7 @@ const fetchActiveTabData = () => {
else if (tab === '3') fetchUserOrderList() else if (tab === '3') fetchUserOrderList()
else if (tab === '4') fetchUserTicketList() else if (tab === '4') fetchUserTicketList()
else if (tab === '5') fetchUserGoodsList() else if (tab === '5') fetchUserGoodsList()
else if (tab === '6') fetchUserVoucherListData()
} }
const loadUserData = async () => { const loadUserData = async () => {
@@ -1831,4 +2163,17 @@ onActivated(() => {
min-width: 60px; min-width: 60px;
} }
} }
/* 代金券操作栏 */
.voucher-action-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.voucher-amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
</style> </style>
+541 -3
View File
@@ -85,7 +85,7 @@
{{ row.create_time || row.CreateTime || row.CreatedAt || '-' }} {{ row.create_time || row.CreateTime || row.CreatedAt || '-' }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="280" fixed="right"> <el-table-column label="操作" width="340" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="action-buttons"> <div class="action-buttons">
<el-button type="primary" link @click="handleEdit(row)"> <el-button type="primary" link @click="handleEdit(row)">
@@ -94,6 +94,9 @@
<el-button type="success" link @click="handleViewMembers(row)"> <el-button type="success" link @click="handleViewMembers(row)">
<el-icon><User /></el-icon>成员 <el-icon><User /></el-icon>成员
</el-button> </el-button>
<el-button type="warning" link @click="handleViewDiscount(row)">
<el-icon><Present /></el-icon>优惠
</el-button>
<el-button type="danger" link @click="handleDelete(row)"> <el-button type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>删除 <el-icon><Delete /></el-icon>删除
</el-button> </el-button>
@@ -288,13 +291,181 @@
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 用户组优惠管理对话框 -->
<el-dialog
v-model="discountDialogVisible"
:title="`优惠管理 - ${currentGroupName}`"
width="900px"
append-to-body
class="custom-dialog"
>
<div class="discount-action-bar">
<el-button type="primary" @click="handleAddDiscount">
<el-icon><Plus /></el-icon>新增优惠绑定
</el-button>
<el-button type="success" @click="fetchDiscountList">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<el-table
v-loading="discountLoading"
:data="discountList"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="绑定对象" min-width="220">
<template #default="{ row }">
<el-tag v-if="row.good && row.good.id" type="primary" effect="plain">
商品{{ row.good.name }} (ID:{{ row.good.id }})
</el-tag>
<el-tag v-else-if="row.goodGroup && row.goodGroup.id" type="warning" effect="plain">
商品组{{ row.goodGroup.name }} (ID:{{ row.goodGroup.id }})
</el-tag>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="固定抵扣额" width="130">
<template #default="{ row }">
<span v-if="row.amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="百分比抵扣" width="120">
<template #default="{ row }">
<span v-if="row.percentage">{{ (row.percentage / 100).toFixed(0) }}%</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEditDiscount(row)">编辑</el-button>
<el-button type="danger" link @click="handleDeleteDiscount(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无优惠绑定" :image-size="80" />
</template>
</el-table>
<el-pagination
v-if="discountTotal > 0"
v-model:current-page="discountPage"
v-model:page-size="discountPageSize"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
:total="discountTotal"
@size-change="handleDiscountSizeChange"
@current-change="handleDiscountPageChange"
background
class="discount-pagination"
/>
</el-dialog>
<!-- 用户组优惠 新增/编辑 对话框 -->
<el-dialog
v-model="discountFormVisible"
:title="discountFormType === 'add' ? '新增优惠绑定' : '编辑优惠绑定'"
width="620px"
append-to-body
>
<el-form
ref="discountFormRef"
:model="discountForm"
:rules="discountFormRules"
label-width="120px"
>
<!-- 新增模式折叠层级选择器 -->
<el-form-item v-if="discountFormType === 'add'" label="选择商品">
<div class="goods-tree-wrapper">
<div class="goods-tree-toolbar">
<span class="tree-tip">勾选商品组或商品仅可选一个</span>
<div class="tree-summary">
已选: <b>{{ discountTreeChecked.name || '无' }}</b>
</div>
</div>
<el-tree
ref="discountTreeRef"
:props="discountTreeProps"
:load="loadDiscountTreeNode"
lazy
show-checkbox
check-strictly
node-key="key"
class="goods-tree"
@check="handleDiscountTreeCheck"
>
<template #default="{ data }">
<span class="tree-node">
<el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain">
{{ data.nodeType === 'group' ? '组' : '品' }}
</el-tag>
<span class="tree-node-label">{{ data.label }}</span>
<span class="tree-node-id">ID: {{ data.rawId }}</span>
<span v-if="data.nodeType === 'product' && data.price != null" class="tree-node-price">
¥{{ (data.price / 100).toFixed(2) }}
</span>
</span>
</template>
</el-tree>
</div>
</el-form-item>
<!-- 编辑模式保留选择器 -->
<template v-if="discountFormType === 'edit'">
<el-form-item label="绑定类型" prop="bind_type">
<el-radio-group v-model="discountForm.bind_type" @change="handleBindTypeChange">
<el-radio label="good">商品</el-radio>
<el-radio label="good_group">商品组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="discountForm.bind_type === 'good'" label="选择商品" prop="good_id">
<el-select v-model="discountForm.good_id" placeholder="请选择商品" filterable style="width: 100%">
<el-option
v-for="item in productOptions"
:key="item.id"
:label="`${item.name} (ID:${item.id})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item v-else label="选择商品组" prop="good_group_id">
<el-select v-model="discountForm.good_group_id" placeholder="请选择商品组" filterable style="width: 100%">
<el-option
v-for="item in productGroupOptions"
:key="item.id"
:label="`${item.name} (ID:${item.id})`"
:value="item.id"
/>
</el-select>
</el-form-item>
</template>
<el-form-item label="固定抵扣额" prop="amount">
<div class="unit-input-row">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="0表示不使用" style="flex:1" />
<span class="unit-text"></span>
</div>
</el-form-item>
<el-form-item label="百分比抵扣" prop="percentage">
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="0表示不使用" style="width: 100%" />
<div style="color:#909399;font-size:12px;margin-top:4px">固定抵扣额与百分比抵扣二选一填写</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="discountFormVisible = false">取消</el-button>
<el-button type="primary" @click="submitDiscountForm">确定</el-button>
</div>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Edit, User, Delete, Connection, Close } from '@element-plus/icons-vue' import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue'
import { import {
getUserGroupList, getUserGroupList,
getUserGroupMemberList, getUserGroupMemberList,
@@ -303,6 +474,13 @@ import {
deleteUserGroup, deleteUserGroup,
addUserGroupMember addUserGroupMember
} from '@/api/admin/user' } from '@/api/admin/user'
import {
getUserGroupDiscountList,
addUserGroupDiscount,
updateUserGroupDiscount,
deleteUserGroupDiscount
} from '@/api/admin/userGroupDiscount'
import { getProductList, getProductGroupList } from '@/api/admin/product'
import { formatTime } from '@/utils/tool' import { formatTime } from '@/utils/tool'
import UserGroupSelector from '@/components/admin/UserGroupSelector.vue' import UserGroupSelector from '@/components/admin/UserGroupSelector.vue'
@@ -624,6 +802,301 @@ const clearHigherGroup = () => {
selectedHigherGroupInfo.value = null selectedHigherGroupInfo.value = null
} }
/** -------------------- 用户组优惠管理 -------------------- */
const discountDialogVisible = ref(false)
const discountFormVisible = ref(false)
const discountLoading = ref(false)
const discountList = ref([])
const discountTotal = ref(0)
const discountPage = ref(1)
const discountPageSize = ref(10)
const discountFormType = ref('add')
const discountFormRef = ref(null)
const currentGroupId = ref(undefined)
const currentGroupName = ref('')
const productOptions = ref([])
const productGroupOptions = ref([])
const discountForm = reactive({
id: undefined,
bind_type: 'good',
good_id: undefined,
good_group_id: undefined,
amount: 0,
percentage: 0
})
const discountFormRules = {
bind_type: [{ required: true, message: '请选择绑定类型', trigger: 'change' }],
good_id: [{ required: true, message: '请选择商品', trigger: 'change' }],
good_group_id: [{ required: true, message: '请选择商品组', trigger: 'change' }]
}
//
const discountTreeRef = ref(null)
const discountTreeProps = {
label: 'label',
children: 'children',
isLeaf: 'isLeaf'
}
const discountTreeChecked = reactive({ name: '', nodeType: '', rawId: undefined })
//
const loadDiscountTreeNode = async (node, resolve) => {
try {
if (node.level === 0) {
const res = await getProductGroupList({ level: 1 })
if (res.data.code === 200) {
const groups = res.data.data?.data || []
return resolve(groups.map(buildDiscountGroupNode))
}
return resolve([])
}
if (node.data?.nodeType === 'group') {
const groupId = node.data.rawId
const childLevel = (node.data.level || 1) + 1
const tasks = [
getProductList({ good_group_id: groupId, delete: false })
]
if (node.data.existSub) {
tasks.push(getProductGroupList({ parent_id: groupId, level: childLevel }))
}
const results = await Promise.all(tasks)
const productNodes = (results[0].data.code === 200 ? (results[0].data.data?.data || []) : [])
.map(buildDiscountProductNode)
let groupNodes = []
if (node.data.existSub && results[1]?.data.code === 200) {
groupNodes = (results[1].data.data?.data || []).map(buildDiscountGroupNode)
}
return resolve([...groupNodes, ...productNodes])
}
return resolve([])
} catch (error) {
console.error('加载层级数据失败:', error)
return resolve([])
}
}
const buildDiscountGroupNode = (group) => ({
key: `group_${group.id}`,
rawId: group.id,
nodeType: 'group',
label: group.name,
level: group.level || 1,
existSub: group.existSub || false,
isLeaf: false
})
const buildDiscountProductNode = (product) => ({
key: `product_${product.id}`,
rawId: product.id,
nodeType: 'product',
label: product.name,
price: product.price,
isLeaf: true
})
//
const handleDiscountTreeCheck = (data, { checkedKeys }) => {
if (checkedKeys.length > 1) {
discountTreeRef.value?.setCheckedKeys([data.key])
}
const nodes = discountTreeRef.value?.getCheckedNodes() || []
if (nodes.length > 0) {
const node = nodes[0]
discountTreeChecked.name = node.label
discountTreeChecked.nodeType = node.nodeType
discountTreeChecked.rawId = node.rawId
} else {
discountTreeChecked.name = ''
discountTreeChecked.nodeType = ''
discountTreeChecked.rawId = undefined
}
}
// /
const loadProductOptions = async () => {
try {
const [pRes, gRes] = await Promise.all([
getProductList({ page: 1, count: 100 }),
getProductGroupList({ page: 1, count: 100 })
])
if (pRes.data.code === 200) {
productOptions.value = pRes.data.data?.data || pRes.data.data || []
}
if (gRes.data.code === 200) {
productGroupOptions.value = gRes.data.data?.data || gRes.data.data || []
}
} catch (error) {
console.error('加载商品选项失败:', error)
}
}
//
const fetchDiscountList = async () => {
if (!currentGroupId.value) return
discountLoading.value = true
try {
const res = await getUserGroupDiscountList({
user_group_id: currentGroupId.value,
page: discountPage.value,
count: discountPageSize.value
})
if (res.data.code === 200) {
const resData = res.data.data
if (Array.isArray(resData)) {
discountList.value = resData
discountTotal.value = res.data.all_count || resData.length
} else {
discountList.value = resData?.data || []
discountTotal.value = resData?.all_count || discountList.value.length
}
}
} catch (error) {
console.error('获取用户组优惠列表失败:', error)
ElMessage.error('获取用户组优惠列表失败')
} finally {
discountLoading.value = false
}
}
const handleDiscountSizeChange = (size) => {
discountPageSize.value = size
discountPage.value = 1
fetchDiscountList()
}
const handleDiscountPageChange = (page) => {
discountPage.value = page
fetchDiscountList()
}
//
const handleViewDiscount = (row) => {
currentGroupId.value = row.group_id || row.GroupId || row.id || row.Id
currentGroupName.value = row.group_name || row.name || row.Name || ''
discountDialogVisible.value = true
discountPage.value = 1
loadProductOptions()
fetchDiscountList()
}
//
const handleBindTypeChange = () => {
discountForm.good_id = undefined
discountForm.good_group_id = undefined
}
//
const handleAddDiscount = () => {
discountFormType.value = 'add'
discountFormVisible.value = true
Object.assign(discountForm, {
id: undefined,
bind_type: 'good',
good_id: undefined,
good_group_id: undefined,
amount: 0,
percentage: 0
})
Object.assign(discountTreeChecked, { name: '', nodeType: '', rawId: undefined })
discountFormRef.value?.resetFields()
nextTick(() => {
discountTreeRef.value?.setCheckedKeys([])
})
}
//
const handleEditDiscount = (row) => {
discountFormType.value = 'edit'
discountFormVisible.value = true
const isGroup = !!(row.goodGroup && row.goodGroup.id)
Object.assign(discountForm, {
id: row.id,
bind_type: isGroup ? 'good_group' : 'good',
good_id: (row.good && row.good.id) ? row.good.id : undefined,
good_group_id: (row.goodGroup && row.goodGroup.id) ? row.goodGroup.id : undefined,
amount: row.amount ? row.amount / 100 : 0,
percentage: row.percentage ? row.percentage / 100 : 0
})
loadProductOptions()
}
//
const handleDeleteDiscount = (row) => {
ElMessageBox.confirm('确认删除该优惠绑定吗?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteUserGroupDiscount({ discount_id: row.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchDiscountList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
//
const submitDiscountForm = () => {
discountFormRef.value?.validate(async (valid) => {
if (!valid && discountFormType.value === 'edit') return
try {
const params = new URLSearchParams()
params.append('user_group_id', currentGroupId.value)
if (discountFormType.value === 'add') {
//
if (!discountTreeChecked.rawId) {
ElMessage.warning('请选择一个商品或商品组')
return
}
if (discountTreeChecked.nodeType === 'group') {
params.append('good_group_id', discountTreeChecked.rawId)
} else {
params.append('good_id', discountTreeChecked.rawId)
}
} else {
//
if (discountForm.bind_type === 'good') {
params.append('good_id', discountForm.good_id)
} else {
params.append('good_group_id', discountForm.good_group_id)
}
params.append('id', discountForm.id)
}
params.append('amount', Math.round((discountForm.amount || 0) * 100))
params.append('percentage', Math.round((discountForm.percentage || 0) * 100))
let res
if (discountFormType.value === 'add') {
res = await addUserGroupDiscount(params)
} else {
res = await updateUserGroupDiscount(params)
}
if (res.data.code === 200) {
ElMessage.success(discountFormType.value === 'add' ? '新增成功' : '修改成功')
discountFormVisible.value = false
fetchDiscountList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
// //
onMounted(() => { onMounted(() => {
fetchGroupList() fetchGroupList()
@@ -696,6 +1169,71 @@ onMounted(() => {
padding: 0; padding: 0;
} }
.discount-action-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.discount-pagination {
margin-top: 16px;
justify-content: flex-end;
}
/* 折叠层级选择器样式 */
.goods-tree-wrapper {
width: 100%;
border: 1px solid #e1e8ed;
border-radius: 6px;
overflow: hidden;
}
.goods-tree-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafbfc;
border-bottom: 1px solid #e1e8ed;
font-size: 12px;
color: #909399;
}
.tree-summary b {
color: #409eff;
}
.goods-tree {
max-height: 320px;
overflow-y: auto;
padding: 8px;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.tree-node-label {
color: #2c3e50;
}
.tree-node-id {
color: #909399;
font-size: 12px;
}
.tree-node-price {
color: #f56c6c;
font-size: 12px;
font-weight: bold;
}
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
/* 表格样式优化 */ /* 表格样式优化 */
:deep(.el-table) { :deep(.el-table) {
border: none; border: none;
+2 -12
View File
@@ -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({
+13 -5
View File
@@ -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)) {
@@ -1940,10 +1945,13 @@ const initPage = () => {
if (activeTab.value === 'monitor') loadHistoricalMetrics() if (activeTab.value === 'monitor') loadHistoricalMetrics()
} }
watch(hostId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtHostDetail'
onActivated(() => {
watch(hostId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => {
isPageActive = true isPageActive = true
if (loadedHostId !== hostId.value) initPage() await nextTick()
if (isCurrentRoute() && loadedHostId !== hostId.value) initPage()
}) })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
onDeactivated(() => { isPageActive = false }) onDeactivated(() => { isPageActive = false })
+18 -6
View File
@@ -32,8 +32,13 @@
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
<el-tag :type="statusType(detail.status)" size="small">{{ statusLabel(detail.status) }}</el-tag> <el-tag :type="statusType(detail.status)" size="small">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="模式">
<el-tag :type="detail.mode === 'remote' ? 'warning' : 'success'" size="small">{{ detail.mode === 'remote' ? '远端模式' : '本地模式' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="大小">{{ detail.size ? formatSize(detail.size) : '-' }}</el-descriptions-item> <el-descriptions-item label="大小">{{ detail.size ? formatSize(detail.size) : '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ detail.path || '-' }}</span></el-descriptions-item> <el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ detail.path || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="下载URL" :span="2"><span class="mono-text">{{ detail.url || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="来源地址" :span="2"><span class="mono-text">{{ detail.source || '-' }}</span></el-descriptions-item>
<el-descriptions-item label="介绍" :span="2">{{ detail.description || '-' }}</el-descriptions-item> <el-descriptions-item label="介绍" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item> <el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item> <el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
@@ -79,6 +84,11 @@
<el-option label="系统镜像" value="system" /><el-option label="数据镜像" value="data" /> <el-option label="系统镜像" value="system" /><el-option label="数据镜像" value="data" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="镜像模式">
<el-select v-model="formData.mode" style="width: 100%">
<el-option label="本地模式" value="local" /><el-option label="远端模式" value="remote" />
</el-select>
</el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="formData.status" style="width: 100%"> <el-select v-model="formData.status" style="width: 100%">
<el-option label="等待中" value="pending" /><el-option label="下载中" value="downloading" /> <el-option label="等待中" value="pending" /><el-option label="下载中" value="downloading" />
@@ -127,7 +137,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, 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 { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue' import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
@@ -160,7 +170,7 @@ const syncHostId = ref('')
const reloadHostId = ref('') const reloadHostId = ref('')
const formRef = ref(null) const formRef = ref(null)
const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '' }) const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', mode: 'local' })
const formRules = { const formRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }], name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
path: [{ required: true, message: '请输入路径', trigger: 'blur' }] path: [{ required: true, message: '请输入路径', trigger: 'blur' }]
@@ -232,7 +242,7 @@ const loadHostStatus = async () => {
const handleEdit = () => { const handleEdit = () => {
if (!detail.value) return if (!detail.value) return
const d = detail.value const d = detail.value
Object.assign(formData, { name: d.name || '', path: d.path || '', os_type: d.os_type || 'linux', type: d.type || 'system', description: d.description || '', status: d.status || '' }) Object.assign(formData, { name: d.name || '', path: d.path || '', os_type: d.os_type || 'linux', type: d.type || 'system', description: d.description || '', status: d.status || '', mode: d.mode || 'local' })
editDialogVisible.value = true editDialogVisible.value = true
} }
@@ -241,7 +251,7 @@ const handleSubmitEdit = () => {
if (!valid) return if (!valid) return
submitLoading.value = true submitLoading.value = true
try { try {
const payload = { image_id: imageId.value, service_id: serviceId.value, image_name: formData.name, path: formData.path, os_type: formData.os_type, type: formData.type, description: formData.description || undefined, status: formData.status || undefined } const payload = { image_id: imageId.value, service_id: serviceId.value, image_name: formData.name, path: formData.path, os_type: formData.os_type, type: formData.type, description: formData.description || undefined, status: formData.status || undefined, mode: formData.mode }
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] }) Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
const res = await updateImage(payload) const res = await updateImage(payload)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() } if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
@@ -319,8 +329,10 @@ const initPage = async () => {
loadHostStatus() loadHostStatus()
} }
watch(imageId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtImageDetail'
onActivated(() => { isPageActive = true; if (loadedImageId !== imageId.value) initPage() })
watch(imageId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedImageId !== imageId.value) initPage() })
onDeactivated(() => { isPageActive = false }) onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
</script> </script>
+29 -5
View File
@@ -54,6 +54,11 @@
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag> <el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="模式" width="90">
<template #default="{ row }">
<el-tag :type="row.mode === 'remote' ? 'warning' : 'success'" size="small">{{ row.mode === 'remote' ? '远端' : '本地' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="主控状态" width="100"> <el-table-column label="主控状态" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag> <el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
@@ -109,6 +114,13 @@
<el-option label="数据镜像" value="data" /> <el-option label="数据镜像" value="data" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="镜像模式" prop="mode">
<el-select v-model="formData.mode" style="width: 100%">
<el-option label="本地模式(下载到本地提供服务)" value="local" />
<el-option label="远端模式(直接使用远端URL" value="remote" />
</el-select>
<div style="color:#909399;font-size:12px;margin-top:4px">本地模式会下载镜像到主控远端模式宿主机直接从远端URL下载</div>
</el-form-item>
<el-form-item label="介绍"> <el-form-item label="介绍">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍(可选)" /> <el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍(可选)" />
</el-form-item> </el-form-item>
@@ -148,10 +160,19 @@
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
<el-tag :type="statusType(currentDetail.status)" size="small">{{ statusLabel(currentDetail.status) }}</el-tag> <el-tag :type="statusType(currentDetail.status)" size="small">{{ statusLabel(currentDetail.status) }}</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="模式">
<el-tag :type="currentDetail.mode === 'remote' ? 'warning' : 'success'" size="small">{{ currentDetail.mode === 'remote' ? '远端模式' : '本地模式' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="大小">{{ currentDetail.size ? formatSize(currentDetail.size) : '-' }}</el-descriptions-item> <el-descriptions-item label="大小">{{ currentDetail.size ? formatSize(currentDetail.size) : '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2"> <el-descriptions-item label="路径" :span="2">
<span class="mono-text">{{ currentDetail.path || '-' }}</span> <span class="mono-text">{{ currentDetail.path || '-' }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="下载URL" :span="2">
<span class="mono-text">{{ currentDetail.url || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="来源地址" :span="2">
<span class="mono-text">{{ currentDetail.source || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item> <el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item> <el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item> <el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
@@ -291,7 +312,7 @@ const currentHostLabel = computed(() => {
const formData = reactive({ const formData = reactive({
image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system',
description: '', status: '', size: 0, image_name: '' description: '', status: '', size: 0, image_name: '', mode: 'local'
}) })
const formRules = { const formRules = {
@@ -400,7 +421,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => { const handleAdd = () => {
dialogType.value = 'add' dialogType.value = 'add'
Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0 }) Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0, mode: 'local' })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -409,7 +430,8 @@ const handleEdit = (row) => {
Object.assign(formData, { Object.assign(formData, {
image_id: row.id, name: row.name, image_name: row.name, path: row.path || '', image_id: row.id, name: row.name, image_name: row.name, path: row.path || '',
os_type: row.os_type || 'linux', type: row.type || 'system', os_type: row.os_type || 'linux', type: row.type || 'system',
description: row.description || '', status: row.status || '', size: row.size || 0 description: row.description || '', status: row.status || '', size: row.size || 0,
mode: row.mode || 'local'
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -423,7 +445,8 @@ const handleSubmit = () => {
if (dialogType.value === 'add') { if (dialogType.value === 'add') {
res = await createImage({ res = await createImage({
service_id: serviceId.value, name: formData.name, path: formData.path, service_id: serviceId.value, name: formData.name, path: formData.path,
os_type: formData.os_type, type: formData.type, description: formData.description || undefined os_type: formData.os_type, type: formData.type, description: formData.description || undefined,
mode: formData.mode
}) })
} else { } else {
const payload = { const payload = {
@@ -431,7 +454,8 @@ const handleSubmit = () => {
image_name: formData.name, path: formData.path, image_name: formData.name, path: formData.path,
os_type: formData.os_type, type: formData.type, os_type: formData.os_type, type: formData.type,
description: formData.description || undefined, description: formData.description || undefined,
status: formData.status || undefined, size: formData.size || undefined status: formData.status || undefined, size: formData.size || undefined,
mode: formData.mode
} }
// undefined // undefined
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] }) Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
+93 -4
View File
@@ -33,6 +33,14 @@
<el-option label="IPv4" value="ipv4" /> <el-option label="IPv4" value="ipv4" />
<el-option label="IPv6" value="ipv6" /> <el-option label="IPv6" value="ipv6" />
</el-select> </el-select>
<el-select v-model="filterUsed" placeholder="使用状态" clearable style="width: 120px" @change="handleSearch">
<el-option label="已用" value="true" />
<el-option label="未用" value="false" />
</el-select>
<el-select v-model="filterDisable" placeholder="禁用状态" clearable style="width: 120px" @change="handleSearch">
<el-option label="已禁用" value="true" />
<el-option label="已启用" value="false" />
</el-select>
</div> </div>
<!-- 网络列表 --> <!-- 网络列表 -->
@@ -54,10 +62,34 @@
<el-table-column label="宿主机" width="140"> <el-table-column label="宿主机" width="140">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template> <template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="使用状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="isNetworkUsed(row) ? 'success' : 'info'" size="small">
{{ isNetworkUsed(row) ? '已用' : '未用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="虚拟机" width="120">
<template #default="{ row }">
<el-button v-if="isNetworkUsed(row)" link type="primary" @click="goToVmDetail(row.vm_id)">
{{ row.vm_id }}
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="禁用" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.disable" type="danger" size="small">已禁用</el-tag>
<el-tag v-else type="success" size="small">启用</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button> <el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button> <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link :type="row.disable ? 'success' : 'warning'" @click="handleToggleDisable(row)">
{{ row.disable ? '启用' : '禁用' }}
</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -113,6 +145,10 @@
<el-form-item label="逻辑端口名"> <el-form-item label="逻辑端口名">
<el-input v-model="formData.ls_name" placeholder="不填使用默认" /> <el-input v-model="formData.ls_name" placeholder="不填使用默认" />
</el-form-item> </el-form-item>
<el-form-item label="禁用网络" v-if="dialogType === 'edit'">
<el-switch v-model="formData.disable" active-text="禁用" inactive-text="启用" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">禁用后该网络不参与自动分配</div>
</el-form-item>
</div> </div>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -142,6 +178,27 @@
<el-descriptions-item label="逻辑网桥">{{ currentDetail.ls_bridge_name || '-' }}</el-descriptions-item> <el-descriptions-item label="逻辑网桥">{{ currentDetail.ls_bridge_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="逻辑端口">{{ currentDetail.ls_name || '-' }}</el-descriptions-item> <el-descriptions-item label="逻辑端口">{{ currentDetail.ls_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标设备">{{ currentDetail.target_device || '-' }}</el-descriptions-item> <el-descriptions-item label="目标设备">{{ currentDetail.target_device || '-' }}</el-descriptions-item>
<el-descriptions-item label="使用状态">
<el-tag :type="isNetworkUsed(currentDetail) ? 'success' : 'info'" size="small">
{{ isNetworkUsed(currentDetail) ? '已用' : '未用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="虚拟机">
<el-button v-if="isNetworkUsed(currentDetail)" link type="primary" @click="goToVmDetail(currentDetail.vm_id)">
{{ currentDetail.vm_id }}
</el-button>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="主网卡">
<el-tag :type="currentDetail.is_primary ? 'warning' : 'info'" size="small">
{{ currentDetail.is_primary ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="禁用状态">
<el-tag :type="currentDetail.disable ? 'danger' : 'success'" size="small">
{{ currentDetail.disable ? '已禁用' : '启用' }}
</el-tag>
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template> <template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog> </el-dialog>
@@ -221,6 +278,15 @@ const total = ref(0)
const keyword = ref('') const keyword = ref('')
const filterType = ref('') const filterType = ref('')
const filterIpVersion = ref('') const filterIpVersion = ref('')
const filterUsed = ref('')
const filterDisable = ref('')
const isNetworkUsed = (row) => !!row?.vm_id
const goToVmDetail = (vmId) => {
if (!vmId) return
router.push({ path: '/virtualization/vm-detail', query: { service_id: serviceId.value, service_name: serviceName.value, vm_id: vmId } })
}
const hostIdInput = ref(0) const hostIdInput = ref(0)
const hostOptions = ref([]) const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 }) const queryParams = reactive({ page: 1, page_size: 10 })
@@ -254,7 +320,8 @@ const currentDetail = ref(null)
const formData = reactive({ const formData = reactive({
id: undefined, name: '', address: '', gateway: '', nameservers: '', id: undefined, name: '', address: '', gateway: '', nameservers: '',
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0 type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0,
disable: false
}) })
const formRules = { const formRules = {
@@ -275,6 +342,8 @@ const loadList = async () => {
if (keyword.value) params.key = keyword.value if (keyword.value) params.key = keyword.value
if (filterType.value) params.type = filterType.value if (filterType.value) params.type = filterType.value
if (filterIpVersion.value) params.ip_version = filterIpVersion.value if (filterIpVersion.value) params.ip_version = filterIpVersion.value
if (filterUsed.value !== '') params.used = filterUsed.value
if (filterDisable.value !== '') params.disable = filterDisable.value
const res = await getNetworkList(params) const res = await getNetworkList(params)
const body = res?.data const body = res?.data
if (body?.code === 200 && body?.data) { if (body?.code === 200 && body?.data) {
@@ -295,7 +364,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => { const handleAdd = () => {
dialogType.value = 'add' dialogType.value = 'add'
Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0 }) Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0, disable: false })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -305,7 +374,7 @@ const handleEdit = (row) => {
id: row.id, name: row.name, address: row.address, gateway: row.gateway, id: row.id, name: row.name, address: row.address, gateway: row.gateway,
nameservers: row.nameservers || '', type: row.type, mac_address: row.mac_address || '', nameservers: row.nameservers || '', type: row.type, mac_address: row.mac_address || '',
bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '', bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '',
ls_name: row.ls_name || '', host_id: row.host_id ls_name: row.ls_name || '', host_id: row.host_id, disable: !!row.disable
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -332,6 +401,7 @@ const handleSubmit = () => {
res = await createNetwork(fd) res = await createNetwork(fd)
} else { } else {
fd.append('id', formData.id) fd.append('id', formData.id)
fd.append('disable', formData.disable)
res = await updateNetwork(fd) res = await updateNetwork(fd)
} }
if (res?.data?.code === 200) { if (res?.data?.code === 200) {
@@ -359,6 +429,25 @@ const handleViewDetail = async (row) => {
} catch { /* fallback */ } } catch { /* fallback */ }
} }
const handleToggleDisable = (row) => {
const nextState = !row.disable
const actionText = nextState ? '禁用' : '启用'
ElMessageBox.confirm(`确定要${actionText}网络「${row.name}」吗?${nextState ? '禁用后该网络不参与自动分配。' : ''}`, `${actionText}确认`, {
confirmButtonText: `确定${actionText}`, cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('id', row.id)
fd.append('host_id', row.host_id)
fd.append('disable', nextState)
const res = await updateNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success(`${actionText}成功`); loadList() }
else ElMessage.error(extractApiError(res?.data, `${actionText}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${actionText}失败`)) }
}).catch(() => {})
}
const handleDelete = (row) => { const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', { ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
@@ -0,0 +1,374 @@
<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="!canRestore(row.status)">恢复</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="520px" 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="外网网络">
<div class="net-select-area">
<div v-if="selectedBridgeNetwork" class="net-selected-item">
<el-tag closable @close="selectedBridgeNetwork = null">
{{ selectedBridgeNetwork.name }} ({{ selectedBridgeNetwork.address }})
</el-tag>
</div>
<el-button size="small" @click="showBridgeNetSelector = true">
{{ selectedBridgeNetwork ? '更换' : '选择外网' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="内网网络">
<div class="net-select-area">
<div v-if="selectedNatNetworks.length" class="net-selected-list">
<el-tag v-for="net in selectedNatNetworks" :key="net.id" closable
@close="removeNatNetwork(net.id)" style="margin-right: 4px; margin-bottom: 4px">
{{ net.name }} ({{ net.address }})
</el-tag>
</div>
<el-button size="small" @click="showNatNetSelector = true">添加内网</el-button>
</div>
</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>
<!-- 恢复 - 外网选择器 -->
<NetworkSelectorPopup v-model="showBridgeNetSelector"
:service-id="serviceId" :host-id="restoreRow?.host_id || 0"
filter-type="bridge" filter-used="false"
@confirm="handleBridgeNetConfirm" />
<!-- 恢复 - 内网选择器 -->
<NetworkSelectorPopup v-model="showNatNetSelector"
:service-id="serviceId" :host-id="restoreRow?.host_id || 0"
filter-type="nat" filter-used="false"
@confirm="handleNatNetConfirm" />
</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'
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
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: 'ready', label: '就绪' },
{ value: 'archived', label: '已归档' },
{ value: 'restoring', label: '恢复中' },
{ value: 'purging', label: '清理中' }
]
const statusLabelMap = {
pending: '等待归档', archiving: '归档中', ready: '就绪', archived: '已归档',
restoring: '恢复中', purging: '清理中', failed: '失败', error: '错误'
}
const statusLabel = (s) => statusLabelMap[s] || s || '-'
const statusTagType = (s) => ({
ready: 'success', archived: 'success', pending: 'info', archiving: 'warning',
restoring: 'primary', purging: 'danger', failed: 'danger', error: 'danger'
}[s] || 'info')
// ready archived
const canRestore = (status) => status === 'ready' || status === 'archived'
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 showBridgeNetSelector = ref(false)
const showNatNetSelector = ref(false)
const selectedBridgeNetwork = ref(null)
const selectedNatNetworks = ref([])
const handleRestore = (row) => {
restoreRow.value = row
selectedBridgeNetwork.value = null
selectedNatNetworks.value = []
restoreVisible.value = true
}
const handleBridgeNetConfirm = (net) => { selectedBridgeNetwork.value = net }
const handleNatNetConfirm = (net) => {
if (!selectedNatNetworks.value.find(n => n.id === net.id)) {
selectedNatNetworks.value.push(net)
}
}
const removeNatNetwork = (id) => {
selectedNatNetworks.value = selectedNatNetworks.value.filter(n => n.id !== id)
}
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 (selectedNatNetworks.value.length) {
selectedNatNetworks.value.forEach(n => fd.append('network_ids', n.id))
}
if (selectedBridgeNetwork.value) {
fd.append('internet_network_id', selectedBridgeNetwork.value.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; }
.net-select-area { display: flex; flex-direction: column; gap: 6px; }
.net-selected-list { display: flex; flex-wrap: wrap; gap: 4px; }
</style>
+6 -3
View File
@@ -3956,11 +3956,14 @@ const loadSgLockInfo = async () => {
} }
} }
watch(vmId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtVmDetail'
watch(vmId, () => { if (isPageActive && isCurrentRoute()) initPage() })
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) }) watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
onActivated(() => { onActivated(async () => {
isPageActive = true isPageActive = true
if (loadedVmId !== vmId.value) initPage() await nextTick()
if (isCurrentRoute() && loadedVmId !== vmId.value) initPage()
}) })
onDeactivated(() => { isPageActive = false; stopMigratePolling() }) onDeactivated(() => { isPageActive = false; stopMigratePolling() })
onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() }) onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() })
+106 -2
View File
@@ -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>
+5 -3
View File
@@ -110,7 +110,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, 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 { ArrowLeft, Refresh } from '@element-plus/icons-vue' import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
@@ -273,8 +273,10 @@ const initPage = () => {
loadDetail() loadDetail()
} }
watch(volumeId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtVolumeDetail'
onActivated(() => { isPageActive = true; if (loadedVolumeId !== volumeId.value) initPage() })
watch(volumeId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedVolumeId !== volumeId.value) initPage() })
onDeactivated(() => { isPageActive = false }) onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
</script> </script>