20 Commits

Author SHA1 Message Date
shiran 34c47afd10 Merge pull request 'feat: 邮箱平台管理与商品购买限制 - 新增邮箱平台主控服务管理(页面/API/路由/菜单) - 商品与套餐表单新增max_per_user单用户购买限制 - 邮件主控控制台跳转改为/ui/index.html?token=' (#22) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m44s
Build and Deploy Vue3 / deploy (push) Successful in 36s
Reviewed-on: #22
2026-06-17 16:12:56 +08:00
shiran 2a0196d332 Merge pull request 'feat(admin): 新增短信平台管理功能' (#21) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m39s
Build and Deploy Vue3 / deploy (push) Successful in 35s
Reviewed-on: #21
2026-06-16 17:17:32 +08:00
shiran e4bec368f6 Merge pull request 'feat(system): 管理员权限页重构与用户选择器升级' (#20) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m37s
Build and Deploy Vue3 / deploy (push) Successful in 34s
Reviewed-on: #20
2026-06-04 18:17:23 +08:00
shiran 860ca1002e Merge pull request 'feat(kvm): 宿主机管理树表点击体验优化' (#19) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m50s
Build and Deploy Vue3 / deploy (push) Successful in 35s
Reviewed-on: #19
2026-06-04 16:41:00 +08:00
shiran 60120395d5 Merge pull request 'feat: 工单系统优化 - 修复自动跳转问题并添加用户筛选功能' (#18) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m38s
Build and Deploy Vue3 / deploy (push) Successful in 32s
Reviewed-on: #18
2026-06-02 17:54:48 +08:00
shiran 8c49c74b72 Merge pull request 'master' (#17) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m34s
Build and Deploy Vue3 / deploy (push) Successful in 1m57s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#17
2026-01-06 21:58:14 +08:00
shiran f8cac7e976 Merge pull request 'master' (#16) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m20s
Build and Deploy Vue3 / deploy (push) Successful in 1m52s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#16
2026-01-01 03:30:09 +08:00
shiran deebef26dd Merge pull request 'master' (#15) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m30s
Build and Deploy Vue3 / deploy (push) Successful in 2m12s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#15
2025-12-20 15:47:29 +08:00
shiran 5fb53a2fdd Merge pull request 'master' (#14) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 2m6s
Build and Deploy Vue3 / deploy (push) Successful in 2m12s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#14
2025-12-16 15:36:59 +08:00
shiran 8897a62dc7 Merge pull request '更新 .gitea/workflows/build-service-server.yaml' (#13) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m46s
Build and Deploy Vue3 / deploy (push) Successful in 2m8s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#13
2025-12-12 21:19:43 +08:00
shiran baec1e3685 Merge pull request 'master' (#12) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m44s
Build and Deploy Vue3 / deploy (push) Has been cancelled
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#12
2025-12-12 19:12:25 +08:00
shiran 4b73cb3ea0 Merge pull request 'fix:修改获取镜像套餐参数' (#9) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 17m43s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#9
2025-10-15 17:16:21 +08:00
shiran 8ba17ff6d0 Merge pull request 'fix:修改镜像展示label' (#8) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 22m3s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#8
2025-10-07 16:36:07 +08:00
wlkjyy 05ad6f8a44 Merge pull request 'fix: 修复了创建虚拟机无法获取镜像列表的BUG' (#7) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 9m0s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#7
2025-10-07 00:24:13 +08:00
shiran fcfde5191e Merge pull request 'master' (#6) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 3m14s
Build and Deploy Vue3 / deploy (push) Successful in 17m10s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#6
2025-10-06 21:36:43 +08:00
shiran 41295f27f0 Merge pull request 'fix:edit' (#5) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m22s
Build and Deploy Vue3 / deploy (push) Successful in 1m51s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#5
2025-10-05 15:52:12 +08:00
shiran 225228f666 Merge pull request 'feat:添加服务器新建容器' (#4) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m19s
Build and Deploy Vue3 / deploy (push) Successful in 20m53s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#4
2025-10-04 23:25:58 +08:00
shiran 2e79be0b0f Merge pull request 'feat:添加新增虚拟机' (#3) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 2m53s
Build and Deploy Vue3 / deploy (push) Successful in 24m39s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#3
2025-10-01 19:01:36 +08:00
shiran 8847848d59 Merge pull request 'master' (#2) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m23s
Build and Deploy Vue3 / deploy (push) Successful in 1m47s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#2
2025-10-01 01:17:14 +08:00
shiran 8e698c2644 Merge pull request '更新 .gitea/workflows/build-service-server.yaml' (#1) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 4m13s
Build and Deploy Vue3 / deploy (push) Successful in 16m55s
Reviewed-on: lin/ApiServer-Web-admin_dashboard_pc#1
2025-09-29 23:21:02 +08:00
34 changed files with 1902 additions and 4198 deletions
-33
View File
@@ -816,36 +816,3 @@ export const removeUserNetworkingNetwork = (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,10 +57,6 @@ export const deleteProductGroup = (data) => {
export const getProductList = (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 = () => {
return http2.get('/api/v1/admin/good/goods/tag_list')
-30
View File
@@ -1,30 +0,0 @@
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,8 +103,6 @@ export const deleteUserVmNetworking = (params) => http2.delete(`${BASE}/networki
// ========== 用户商品 ==========
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 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' } })
@@ -36,18 +36,13 @@
<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="bridge_name" label="网桥名称" width="100" />
<el-table-column label="占用" width="80" align="center">
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<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 type="info" size="small">-</el-tag>
</template>
</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>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
@@ -123,7 +118,6 @@ const loadList = async () => {
const effectiveUsed = props.filterUsed || usedFilter.value
if (effectiveUsed) params.used = effectiveUsed
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
params.disable = false
const res = await getNetworkList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
+9 -15
View File
@@ -130,10 +130,7 @@
<el-icon :size="18"><Box /></el-icon>
</div>
<div class="result-info">
<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-title" v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span>
<span class="result-desc">用户: {{ item.user?.UserName || item.userId }} · 到期: {{ formatTime(item.expireTime) }}</span>
</div>
<el-icon class="result-arrow"><ArrowRight /></el-icon>
@@ -228,7 +225,7 @@ const searchOrders = async (key) => {
results.order.loading = true
results.order.list = []
try {
const res = await getOrderList({ page: results.order.page, count: pageSize, key })
const res = await getOrderList({ page: results.order.page, count: pageSize, keyword: key })
if (res.data?.code === 200) {
results.order.list = res.data.data?.list || []
results.order.total = res.data.data?.all_count || results.order.list.length
@@ -254,7 +251,7 @@ const searchGoods = async (key) => {
results.goods.loading = true
results.goods.list = []
try {
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, key })
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, keyword: key })
if (res.data?.code === 200) {
results.goods.list = res.data.data?.data || []
results.goods.total = res.data.data?.all_count || results.goods.list.length
@@ -263,9 +260,6 @@ const searchGoods = async (key) => {
results.goods.loading = false
}
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
const isGoodsDeleted = (item) => !!(item?.deleteAt || item?.DeleteAt || item?.deleted_at)
const highlight = (text) => {
if (!text || !keyword.value) return text
const key = keyword.value.trim()
@@ -298,7 +292,12 @@ const goToTicket = (item) => {
const goToGoods = (item) => {
visible.value = false
router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
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 } })
}
}
const orderStatusText = (status) => {
@@ -529,11 +528,6 @@ onUnmounted(() => {
border-radius: 2px;
}
.result-title .deleted-flag {
margin-left: 6px;
vertical-align: middle;
}
.result-desc {
font-size: 12px;
color: #909399;
+17 -2
View File
@@ -47,8 +47,7 @@ export const menus = [
title: '商品管理',
icon: 'Goods',
children: [
{ path: '/product/manage', title: '商品管理' },
{ path: '/product/discount', title: '优惠管理' }
{ path: '/product/manage', title: '商品管理' }
]
},
{
@@ -71,6 +70,22 @@ export const menus = [
}
]
},
{
path: '/marketing',
title: '优惠营销',
icon: 'Present',
children: [
{
path: '/marketing/discount',
title: '优惠码管理'
},
{
path: '/marketing/voucher',
title: '代金券管理'
},
]
},
{
path: '/activity',
title: '活动管理',
+36 -19
View File
@@ -253,18 +253,6 @@ const routes = [
component: () => import('../views/product/ProductGroup.vue'),
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: 'group', redirect: '/product/manage' }
]
@@ -294,6 +282,12 @@ const routes = [
component: () => import('../views/user-vm/UserVmList.vue'),
meta: { title: '云服务器' }
},
{
path: 'vm-detail',
name: 'UserVmDetail',
component: () => import('../views/user-vm/UserVmDetail.vue'),
meta: { title: '用户虚拟机详情', hidden: true, activeMenu: '/user-goods/vm-list' }
}
]
},
// 订单管理路由
@@ -316,20 +310,43 @@ const routes = [
}
]
},
// 优惠营销路由(已合并至 /product/discount,保留重定向兼容旧链接)
// 优惠营销路由
{
path: 'marketing',
name: 'Marketing',
meta: {
title: '优惠营销',
icon: 'Present',
hidden: true
icon: 'Present'
},
redirect: '/product/discount',
redirect: '/marketing/discount',
children: [
{ path: 'discount', redirect: '/product/discount' },
{ path: 'voucher', redirect: '/product/discount' },
{ path: 'voucher/:id/manage', redirect: to => `/product/discount/voucher/${to.params.id}/manage` }
{
path: 'discount',
name: 'DiscountCode',
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
@@ -0,0 +1,677 @@
<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>
+156 -232
View File
@@ -161,39 +161,47 @@
</el-select>
</el-form-item>
<el-form-item label="选择关联对象" v-if="dialogType === 'add'">
<div class="goods-tree-wrapper">
<div class="goods-tree-toolbar">
<span class="tree-tip">可自由勾选商品组与商品展开层级查看下属内容</span>
<div class="tree-summary">
已选 <b>{{ checkedSummary.groupCount }}</b> 个商品组 / <b>{{ checkedSummary.productCount }}</b> 个商品
</div>
</div>
<el-tree
ref="goodsTreeRef"
:props="treeProps"
:load="loadTreeNode"
lazy
show-checkbox
check-strictly
node-key="key"
class="goods-tree"
@check="handleTreeCheck"
>
<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 label="选择类型" prop="select_type" v-if="dialogType === 'add'">
<el-radio-group v-model="form.select_type" @change="handleSelectTypeChange">
<el-radio value="product">商品</el-radio>
<el-radio value="product_group">商品组</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="选择商品" prop="selected_product" v-if="dialogType === 'add' && form.select_type === 'product'">
<el-select
v-model="form.selected_product"
placeholder="请选择商品"
filterable
clearable
style="width: 100%"
@change="handleProductChange"
>
<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 label="选择商品组" prop="selected_group" v-if="dialogType === 'add' && form.select_type === 'product_group'">
<el-select
v-model="form.selected_group"
placeholder="请选择商品组"
filterable
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>
<!-- 编辑模式显示字段 -->
@@ -226,7 +234,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, watch, nextTick } from 'vue'
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Delete, Search, Plus, Refresh } from '@element-plus/icons-vue'
import {
@@ -268,27 +276,36 @@ const form = reactive({
code_id: undefined,
goods_id: undefined,
goods_name: '',
goods_type: ''
goods_type: '',
select_type: 'product', // 选择类型:product 或 product_group
selected_product: undefined, // 选中的商品ID
selected_group: undefined // 选中的商品组ID
})
const formRules = {
code_id: [
{ 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: [
{ 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 goodsList = ref([])
@@ -419,79 +436,33 @@ const fetchProductGroupList = async () => {
}
}
// 折叠层级选择器:懒加载节点
// node.level === 0 时加载顶级商品组;展开商品组时加载其子分组与下属商品
const loadTreeNode = async (node, resolve) => {
try {
// 根节点:仅加载 level=1 的顶级商品组
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(buildGroupNode))
}
return resolve([])
}
// 选择类型变化
const handleSelectTypeChange = (type) => {
form.selected_product = undefined
form.selected_group = undefined
form.goods_id = undefined
form.goods_name = ''
form.goods_type = ''
}
// 商品组节点:逐级加载子分组 + 下属商品
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 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 handleProductChange = (productId) => {
const product = productOptions.value.find(item => item.id === productId)
if (product) {
form.goods_id = product.id
form.goods_name = product.goodsName || product.name || ''
form.goods_type = 'product'
}
}
// 构建商品组节点
const buildGroupNode = (group) => ({
key: `group_${group.id}`,
rawId: group.id,
nodeType: 'group',
label: group.name,
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
// 选择商品组变化
const handleProductGroupChange = (groupId) => {
const group = productGroupOptions.value.find(item => item.id === groupId)
if (group) {
form.goods_id = group.id
form.goods_name = group.name || ''
form.goods_type = 'product_group'
}
}
// 获取商品关联列表
@@ -556,15 +527,16 @@ const handleAdd = () => {
code_id: queryParams.code_id ? Number(queryParams.code_id) : undefined,
goods_id: undefined,
goods_name: '',
goods_type: ''
goods_type: '',
select_type: 'product',
selected_product: undefined,
selected_group: undefined
})
checkedSummary.groupCount = 0
checkedSummary.productCount = 0
formRef.value?.resetFields()
// 等待对话框渲染后清空树的勾选状态
nextTick(() => {
goodsTreeRef.value?.setCheckedKeys([])
})
// 加载商品和商品组列表
fetchProductList()
fetchProductGroupList()
}
// 编辑商品关联
@@ -669,77 +641,80 @@ const handleBatchDelete = () => {
// 提交表单
const submitForm = () => {
if (!form.code_id) {
ElMessage.warning('请选择代金券')
return
}
// 新增模式下的额外验证
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('请至少勾选一个商品或商品组')
if (!form.code_id) {
ElMessage.warning('请选择代金券')
return
}
if (form.select_type === 'product' && !form.selected_product) {
ElMessage.warning('请选择商品')
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) => {
if (!valid) return
try {
const submitData = {
code_id: String(form.code_id),
discount_good_id: String(form.id)
if (valid) {
try {
const submitData = {
code_id: String(form.code_id)
}
// 根据选择类型决定传 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)
} else {
// 其他类型默认使用 good_id
submitData.good_id = String(form.goods_id)
}
}
console.log('提交商品关联数据:', 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) {
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
}
if (form.goods_type === 'product_group') {
submitData.good_group_id = String(form.goods_id)
} else {
submitData.good_id = String(form.goods_id)
}
const res = await updateDiscountGoods(submitData)
if (res.data.code === 200) {
ElMessage.success('修改成功')
dialogVisible.value = false
fetchGoodsList()
}
} catch (error) {
console.error('操作失败:', error)
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(() => {
fetchVoucherListOptions()
@@ -823,57 +798,6 @@ onMounted(() => {
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) {
padding: 0;
}
+569
View File
@@ -0,0 +1,569 @@
<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>
+15 -8
View File
@@ -15,6 +15,9 @@
</el-button>
</div>
</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-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询
@@ -200,14 +203,15 @@ const router = useRouter()
// 查询参数
const queryParams = reactive({
user_id: undefined,
id: props.codeId || undefined,
code_id: props.codeId || undefined,
id: '',
page: 1,
count: 10
})
watch(() => props.codeId, (newVal) => {
if (newVal) {
queryParams.id = newVal
queryParams.code_id = newVal
fetchHistoryList()
}
})
@@ -281,8 +285,8 @@ const statistics = computed(() => {
})
// 获取查询用户名称
const getQueryUserName = () => {
const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}`
}
// 获取使用记录列表
@@ -343,13 +347,15 @@ const confirmUserSelection = (user) => {
}
if (selectorType.value === 'query') {
queryParams.user_id = user.user_id
// 查询表单选择
queryParams.user_id = user.UserId
} else {
editForm.user_id = user.user_id
// 编辑表单选择
editForm.user_id = user.UserId
}
// 将选中的用户添加到 UserOptions 中(如果不存在)
if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
if (!UserOptions.value.find(u => u.UserId === user.UserId)) {
UserOptions.value.push(user)
}
@@ -366,7 +372,8 @@ const handleQuery = () => {
// 重置查询
const resetQuery = () => {
queryParams.user_id = undefined
queryParams.id = undefined
queryParams.code_id = undefined
queryParams.id = ''
queryParams.page = 1
fetchHistoryList()
}
+9 -7
View File
@@ -423,13 +423,15 @@ const confirmUserSelection = (user) => {
}
if (selectorType.value === 'query') {
queryParams.user_id = user.user_id
// 查询表单选择
queryParams.user_id = user.UserId
} else {
editForm.user_id = user.user_id
// 编辑表单选择
editForm.user_id = user.UserId
}
// 将选中的用户添加到 UserOptions 中(如果不存在)
if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
if (!UserOptions.value.find(u => u.UserId === user.UserId)) {
UserOptions.value.push(user)
}
@@ -449,14 +451,14 @@ const clearEditUser = () => {
// 获取查询用户名称
const getQueryUserName = () => {
const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}`
}
// 获取编辑用户名称
const getEditUserName = () => {
const user = UserOptions.value.find(u => u.user_id === editForm.user_id)
return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${editForm.user_id}`
const user = UserOptions.value.find(u => u.UserId === editForm.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${editForm.user_id}`
}
// 获取代金券列表
+1 -1
View File
@@ -50,7 +50,7 @@ const activeTab = ref('user-distribution')
const voucherId = computed(() => route.params.id)
const goBack = () => {
router.push('/product/discount')
router.push('/marketing/voucher')
}
</script>
+73 -461
View File
@@ -86,7 +86,7 @@
</el-table-column>
<el-table-column label="商品ID" width="100">
<template #default="{ row }">
<el-link v-if="row.commodityId" type="primary" :underline="false" @click.stop="goToCommodity(row.commodityId, row.table)">{{ row.commodityId }}</el-link>
<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>
<span v-else>-</span>
</template>
</el-table-column>
@@ -173,148 +173,38 @@
<el-dialog
v-model="detailDialogVisible"
title="订单详情"
width="860px"
width="800px"
append-to-body
class="order-detail-dialog"
>
<div v-if="orderDetail" class="order-detail">
<!-- 顶部概览 -->
<div class="od-hero">
<div class="od-hero-main">
<div class="od-hero-title">
<span class="od-order-name">{{ orderDetail.name || '未命名订单' }}</span>
<el-tag :type="getStatusType(orderDetail.state)" size="small">
{{ getStatusText(orderDetail.state) }}
</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 label="数量">{{ orderDetail.payNum }}</el-descriptions-item>
<el-descriptions-item label="表名">{{ orderDetail.table || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单金额">¥{{ formatYuan(orderDetail.price) }}</el-descriptions-item>
<el-descriptions-item label="续费价格">¥{{ formatYuan(orderDetail.renewPrice) }}</el-descriptions-item>
<el-descriptions-item label="支付方式">{{ getPayTypeText(orderDetail.payType) }}</el-descriptions-item>
<el-descriptions-item label="支付平台订单号">
<span class="od-mono">{{ orderDetail.paymentOrderId || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="到期时间">{{ formatDate(orderDetail.expireTime) }}</el-descriptions-item>
<el-descriptions-item label="退款时间">{{ orderDetail.refundTime ? formatDate(orderDetail.refundTime) : '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(orderDetail.createdAt || orderDetail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(orderDetail.updatedAt || orderDetail.UpdatedAt) }}</el-descriptions-item>
<el-descriptions-item label="参数信息" :span="2">
<span class="od-mono od-args">{{ orderDetail.args || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ orderDetail.note || '无' }}</el-descriptions-item>
<el-descriptions-item v-if="orderDetail.error" label="错误信息" :span="2">
<el-tag type="danger" size="small" style="margin-right: 6px;">异常</el-tag>
<span style="color: #f56c6c;">{{ orderDetail.error }}</span>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 用户信息 -->
<div class="od-section">
<div class="od-section-title">
<el-icon><User /></el-icon><span>用户信息</span>
<el-link
v-if="orderDetail.userId"
type="primary"
:underline="false"
class="od-section-link"
@click="goToUserDetail(orderDetail.userId)"
>查看用户 <el-icon><ArrowRight /></el-icon></el-link>
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="用户ID">
<el-link type="primary" :underline="false" @click="goToUserDetail(orderDetail.userId)">{{ orderDetail.userId || '-' }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="用户名">{{ orderDetail.user?.userName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ orderDetail.user?.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ orderDetail.user?.phone || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 商品信息 -->
<div class="od-section">
<div class="od-section-title">
<el-icon><Goods /></el-icon><span>商品信息</span>
<el-link
v-if="orderDetail.commodityId"
type="primary"
:underline="false"
class="od-section-link"
@click="goToCommodity(orderDetail.commodityId, orderDetail.table)"
>{{ orderDetail.table === 'good' ? '查看商品' : '查看用户商品' }} <el-icon><ArrowRight /></el-icon></el-link>
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品ID">
<el-link type="primary" :underline="false" @click="goToCommodity(orderDetail.commodityId, orderDetail.table)">{{ orderDetail.commodityId || '-' }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="商品名称">{{ orderDetail.good?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品标签">
<el-tag v-if="orderDetail.good?.tag" size="small" effect="plain">{{ orderDetail.good.tag }}</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="所属分组">{{ orderDetail.good?.groupName || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 套餐信息 -->
<div class="od-section" v-if="orderDetail.plan || orderDetail.planId">
<div class="od-section-title"><el-icon><Box /></el-icon><span>套餐信息</span></div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="套餐ID">{{ orderDetail.plan?.id || orderDetail.planId || '-' }}</el-descriptions-item>
<el-descriptions-item label="套餐名称">{{ orderDetail.plan?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="套餐说明" :span="2">{{ orderDetail.plan?.note || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 所属用户商品 -->
<div class="od-section" v-if="orderDetail.userGoods">
<div class="od-section-title">
<el-icon><ShoppingCart /></el-icon><span>关联用户商品</span>
<el-link
v-if="orderDetail.userGoods?.id"
type="primary"
:underline="false"
class="od-section-link"
@click="goToUserGoodsDetail(orderDetail.userGoods.id)"
>查看用户商品 <el-icon><ArrowRight /></el-icon></el-link>
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="用户商品ID">
<el-link type="primary" :underline="false" @click="goToUserGoodsDetail(orderDetail.userGoods.id)">{{ orderDetail.userGoods.id || '-' }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="上游资源ID">{{ orderDetail.userGoods.itemId || '-' }}</el-descriptions-item>
<el-descriptions-item label="到期时间">{{ formatDate(orderDetail.userGoods.expireTime) }}</el-descriptions-item>
<el-descriptions-item label="续费价格">¥{{ formatYuan(orderDetail.userGoods.renewPrice) }}</el-descriptions-item>
<el-descriptions-item label="商品标签">
<el-tag v-if="orderDetail.userGoods.tag" size="small" effect="plain">{{ orderDetail.userGoods.tag }}</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="备注">{{ orderDetail.userGoods.note || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
<el-descriptions :column="2" border v-if="orderDetail">
<el-descriptions-item label="订单ID">{{ orderDetail.id }}</el-descriptions-item>
<el-descriptions-item label="订单名称">{{ orderDetail.name }}</el-descriptions-item>
<el-descriptions-item label="订单类型">
<el-tag size="small">{{ getTypeText(orderDetail.type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ orderDetail.userId }}</el-descriptions-item>
<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) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="支付方式">{{ orderDetail.payType || '-' }}</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ formatDate(orderDetail.expireTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(orderDetail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(orderDetail.UpdatedAt) }}</el-descriptions-item>
<el-descriptions-item label="参数信息">{{ orderDetail.args || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="orderDetail.error" label="错误信息" :span="2">
<el-tag type="danger" size="small" style="margin-right: 6px;">异常</el-tag>
<span style="color: #f56c6c;">{{ orderDetail.error }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ orderDetail.note || '无' }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
<!-- 订单表单对话框分步向导 -->
@@ -327,7 +217,7 @@
>
<div class="wizard-container">
<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-steps>
@@ -366,6 +256,9 @@
<el-option label="IPv6" value="ipv6" />
</el-select>
</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>
<!-- Step 2: 商品与套餐 -->
@@ -391,43 +284,18 @@
</el-form-item>
<el-form-item label="套餐" prop="plan_id">
<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.enableFixedPrice ? p.fixedPrice : p.argsPrice || 0) / 100).toFixed(2)}${p.inventory !== undefined ? ` (库存: ${p.inventory})` : ''}`"
:value="p.id"
:disabled="p.disable"
/>
<el-option v-for="p in planList" :key="p.id" :label="`${p.name} - ¥${(p.price / 100).toFixed(2)}`" :value="p.id" />
</el-select>
<div v-if="!planList.length && orderForm.commodity_id && !planLoading" class="form-tip">该商品暂无套餐可直接进入下一步手动设置价格</div>
</el-form-item>
<el-form-item label="所属表">
<el-input v-model="orderForm.table" placeholder="选择商品后自动填充,或手动输入" />
</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 class="args-section-title">
<el-icon><Setting /></el-icon>{{ orderForm.plan_id ? '额外配置参数' : '商品参数配置' }}
<el-icon><Setting /></el-icon>商品参数配置
</div>
<el-form-item
v-for="param in productParams"
@@ -599,10 +467,10 @@
</template>
<script setup>
import { ref, reactive, onMounted, onActivated, watch } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close, Setting, Loading, Lock, Document, ArrowRight, Goods, Box } from '@element-plus/icons-vue'
import { Plus, Delete, Search, Download, Refresh, User, ShoppingCart, Ticket, Money, Close, Setting, Loading } from '@element-plus/icons-vue'
import { getOrderList, getOrderDetail, createOrder, updateOrder, deleteOrder, retryOrderHook } from '@/api/admin/order'
import { getProductPlanList, getProductParameterList, getProductParameterDetail } from '@/api/admin/product'
import UserListSelector from '@/components/admin/UserListSelector.vue'
@@ -650,11 +518,11 @@ const orderForm = reactive({
// 分步验证规则
const step1Rules = {
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 = {
commodity_id: [{ required: true, message: '请选择商品', trigger: 'change' }],
name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }]
commodity_id: [{ required: true, message: '请选择商品', trigger: 'change' }]
}
const step3Rules = {
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
@@ -671,11 +539,9 @@ const step3FormRef = ref(null)
// 套餐相关
const planList = ref([])
const planLoading = ref(false)
const planFixedArgs = ref([])
// 商品参数相关
const productParams = ref([])
const allProductParams = ref([])
const paramsLoading = ref(false)
const argValues = reactive({})
@@ -759,47 +625,10 @@ const getStatusText = (status) => {
// 获取订单类型文本
const getTypeText = (type) => {
const typeMap = { create: '新购', renew: '续费', update: '升级', snapshot: '快照', backup: '备份', data_volume: '数据盘', ipv4: 'IPv4', ipv6: 'IPv6', traffic: '流量', rebuild_image_diff: '重装差价' }
const typeMap = { create: '新购', renew: '续费', update: '升级', snapshot: '快照', backup: '备份', data_volume: '数据盘', ipv4: 'IPv4', ipv6: 'IPv6' }
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 = () => {
queryParams.page = 1
@@ -839,9 +668,7 @@ const handleAdd = () => {
currentStep.value = 0
clearAllSelections()
planList.value = []
planFixedArgs.value = []
productParams.value = []
allProductParams.value = []
Object.keys(argValues).forEach(k => delete argValues[k])
Object.assign(orderForm, {
order_id: undefined,
@@ -866,12 +693,10 @@ const handleAdd = () => {
dialogVisible.value = true
}
// 查看订单详情(支持传入 row 对象或直接传 orderId
const handleView = async (rowOrId) => {
const orderId = typeof rowOrId === 'object' ? rowOrId.id : rowOrId
if (!orderId) return
// 查看订单详情
const handleView = async (row) => {
try {
const res = await getOrderDetail({ order_id: orderId })
const res = await getOrderDetail({ order_id: row.id })
if (res.data.code === 200) {
orderDetail.value = res.data.data
detailDialogVisible.value = true
@@ -888,9 +713,7 @@ const handleEdit = (row) => {
currentStep.value = 0
clearAllSelections()
planList.value = []
planFixedArgs.value = []
productParams.value = []
allProductParams.value = []
Object.keys(argValues).forEach(k => delete argValues[k])
let expireTimeMs = null
@@ -926,34 +749,10 @@ const handleEdit = (row) => {
}
if (row.commodityId) {
selectedProductInfo.value = { id: row.commodityId, name: `商品${row.commodityId}` }
fetchPlanList(row.commodityId).then(() => {
// 如果订单有套餐,按套餐结构恢复参数
if (row.planId && planList.value.length) {
handlePlanChange(row.planId)
// 从已有 args 恢复额外参数的选择值
if (row.args) {
try {
const existingArgs = JSON.parse(row.args)
if (Array.isArray(existingArgs)) {
for (const a of existingArgs) {
const param = productParams.value.find(p => p.id === a.arg_id)
if (!param) continue
if (param.type === 'select') {
argValues[param.id] = a.attr_id
} else if (param.type === 'number') {
argValues[param.id] = a.number
} else {
argValues[param.id] = a.value || ''
}
}
}
} catch {}
}
}
})
fetchPlanList(row.commodityId)
fetchProductParams(row.commodityId).then(() => {
// 无套餐时从已有 args 恢复全量参数值
if (!row.planId && row.args) {
// 从已有 args 恢复参数值
if (row.args) {
try {
const existingArgs = JSON.parse(row.args)
if (Array.isArray(existingArgs)) {
@@ -1078,7 +877,7 @@ const fetchPlanList = async (goodId) => {
// 加载商品参数列表
const fetchProductParams = async (goodId) => {
if (!goodId) { productParams.value = []; allProductParams.value = []; return }
if (!goodId) { productParams.value = []; return }
paramsLoading.value = true
try {
const res = await getProductParameterList({ good_id: goodId })
@@ -1094,73 +893,43 @@ const fetchProductParams = async (goodId) => {
} else { param.attrs = [] }
} catch { param.attrs = [] }
}))
allProductParams.value = list
productParams.value = list
initArgDefaults(list)
} else { productParams.value = []; allProductParams.value = [] }
} catch { productParams.value = []; allProductParams.value = [] }
// 初始化默认值
for (const param of list) {
if (argValues[param.id] !== undefined) continue
if (param.type === 'select' && param.attrs?.length) {
argValues[param.id] = param.attrs[0].id
} else if (param.type === 'number') {
argValues[param.id] = param.min || 0
} else {
argValues[param.id] = ''
}
}
} else { productParams.value = [] }
} catch { productParams.value = [] }
finally { paramsLoading.value = false }
}
// 为参数列表初始化默认值
const initArgDefaults = (params) => {
for (const param of params) {
if (argValues[param.id] !== undefined) continue
if (param.type === 'select' && param.attrs?.length) {
argValues[param.id] = param.attrs[0].id
} else if (param.type === 'number') {
argValues[param.id] = param.min || 0
} else {
argValues[param.id] = ''
}
}
}
// 根据参数表单值构建 args JSON
const buildArgsJson = () => {
if (!productParams.value.length) return ''
const args = []
// 套餐模式:先包含套餐固定参数
if (orderForm.plan_id && planFixedArgs.value.length) {
args.push(...planFixedArgs.value)
}
// 追加用户选择的额外参数(或无套餐时的全部参数)
for (const param of productParams.value) {
const val = argValues[param.id]
if (val === undefined || val === null || val === '') continue
if (param.type === 'select') {
const attr = param.attrs?.find(a => a.id === val)
if (attr) {
args.push({
arg_id: param.id,
name: param.name,
attr_id: attr.id,
number: Number(attr.value) || 0,
key: param.key || ''
})
args.push({ arg_id: param.id, name: param.name, attr_id: attr.id, value: attr.value || attr.name })
}
} else if (param.type === 'number') {
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,
key: param.key || ''
})
args.push({ arg_id: param.id, name: param.name, attr_id: matchedAttr?.id || (param.attrs?.[0]?.id || 0), number: val })
} else {
const attr = param.attrs?.[0]
args.push({
arg_id: param.id,
name: param.name,
attr_id: attr?.id || 0,
value: val,
key: param.key || ''
})
args.push({ arg_id: param.id, name: param.name, attr_id: attr?.id || 0, value: val })
}
}
return args.length > 0 ? JSON.stringify(args) : ''
}
@@ -1178,33 +947,12 @@ const findMatchedAttr = (param, numValue) => {
// 套餐选择变更
const handlePlanChange = (planId) => {
Object.keys(argValues).forEach(k => delete argValues[k])
if (!planId) {
planFixedArgs.value = []
productParams.value = allProductParams.value
initArgDefaults(allProductParams.value)
return
}
if (!planId) 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 = []
if (plan) {
if (plan.price) orderForm.price = plan.price
if (plan.renew_price) orderForm.renew_price = plan.renew_price
}
// 使用套餐的 extraArgs 作为可编辑参数
const extraArgs = plan.extraArgs || []
productParams.value = extraArgs
initArgDefaults(extraArgs)
}
// 提交表单
@@ -1287,10 +1035,6 @@ const handleProductSelect = (product) => {
selectedProductInfo.value = product
if (product.table) orderForm.table = product.table
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])
fetchPlanList(product.id)
@@ -1302,9 +1046,7 @@ const clearProduct = () => {
orderForm.plan_id = null
selectedProductInfo.value = null
planList.value = []
planFixedArgs.value = []
productParams.value = []
allProductParams.value = []
Object.keys(argValues).forEach(k => delete argValues[k])
}
@@ -1338,27 +1080,12 @@ const clearAllSelections = () => {
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(async () => {
onMounted(() => {
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.state) queryParams.state = String(route.query.state)
fetchOrderList()
await checkAndOpenOrderId()
})
onActivated(() => { checkAndOpenOrderId() })
watch(() => route.query.order_id, (newId) => {
if (newId) checkAndOpenOrderId()
})
</script>
@@ -1473,26 +1200,6 @@ watch(() => route.query.order_id, (newId) => {
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 {
display: flex;
align-items: center;
@@ -1612,99 +1319,4 @@ watch(() => route.query.order_id, (newId) => {
.text-muted {
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>
File diff suppressed because it is too large Load Diff
+12 -126
View File
@@ -122,12 +122,6 @@
<el-tag v-else-if="row.isProduct" type="success" size="small" style="margin-left: 8px;">商品</el-tag>
<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>
</template>
</el-table-column>
@@ -213,7 +207,6 @@
<div class="group-name-cell">
<el-avatar v-if="row.cover" :size="32" :src="row.cover" />
<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>
</template>
</el-table-column>
@@ -400,11 +393,6 @@
</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-input
@@ -649,20 +637,6 @@
inactive-text="禁止"
/>
</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-select
v-model="productForm.arg_type"
@@ -723,16 +697,6 @@
/>
<div style="font-size:12px;color:#909399;margin-top:4px">限制单用户最大购买数量0 表示不限制</div>
</el-form-item>
<el-form-item label="首购最大时长" prop="max_first_purchase_duration">
<el-input-number
v-model="productForm.max_first_purchase_duration"
:min="0"
placeholder="0 表示不限"
controls-position="right"
style="width: 100%"
/>
<div style="font-size:12px;color:#909399;margin-top:4px">限制用户首次购买的最大时长单位0 表示不限制</div>
</el-form-item>
</div>
</el-tab-pane>
@@ -759,23 +723,6 @@
style="width: 100%"
/>
</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-switch
v-model="productForm.send_notice"
@@ -844,8 +791,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onActivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, reactive, computed, onMounted, watch } from 'vue'
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 {
@@ -858,14 +804,10 @@ import {
getProductGroupTagList,
deleteProductGroupTag,
getProductList,
getProductDetail,
createProduct,
updateProduct,
deleteProduct,
} from '@/api/admin/product'
const route = useRoute()
const router = useRouter()
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
import GroupTagManager from './components/GroupTagManager.vue'
import ProductParameterManager from './components/ProductParameterManager.vue'
@@ -908,8 +850,7 @@ const groupForm = reactive({
cover_id: undefined,
cover_url: '',
tag_id: undefined,
index: 0,
recommend_words: ''
index: 0
})
const groupRules = {
@@ -962,11 +903,7 @@ const productForm = reactive({
require_real_name: false,
sold_out: false,
max_per_user: 0,
max_first_purchase_duration: 0,
send_notice: false,
renew_price: 0,
renew_recommend_rebate: 0,
recommend_words: ''
send_notice: false
})
const productRules = {
@@ -1513,8 +1450,7 @@ const handleAdd = (parentRow) => {
cover_id: undefined,
cover_url: '',
tag_id: parentTagId || undefined,
index: 0,
recommend_words: ''
index: 0
})
console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag)
} else {
@@ -1529,8 +1465,7 @@ const handleAdd = (parentRow) => {
cover_id: undefined,
cover_url: '',
tag_id: undefined,
index: 0,
recommend_words: ''
index: 0
})
}
@@ -1551,8 +1486,7 @@ const handleEdit = (row) => {
cover_id: row.coverId || undefined,
cover_url: row.cover || '',
tag_id: row.tag?.id || row.tagId || undefined,
index: row.index || 0,
recommend_words: row.recommendWords || ''
index: row.index || 0
})
dialogVisible.value = true
@@ -1620,10 +1554,7 @@ const handleAddProduct = () => {
recommend: false,
recommend_rebate: 0,
arg_type: 'all',
require_real_name: false,
max_per_user: 0,
max_first_purchase_duration: 0,
recommend_words: ''
require_real_name: false
})
selectedProductGroup.value = null
@@ -1660,11 +1591,7 @@ const handleEditProduct = (product, parentGroupId) => {
require_real_name: product.requireRealName ?? product.require_real_name ?? false,
sold_out: !!product.soldOut,
max_per_user: product.maxPerUser ?? product.max_per_user ?? 0,
max_first_purchase_duration: product.maxFirstPurchaseDuration ?? product.max_first_purchase_duration ?? 0,
send_notice: !!product.sendNotice,
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0,
recommend_words: product.recommendWords ?? product.recommend_words ?? ''
send_notice: !!product.sendNotice
})
productDialogVisible.value = true
@@ -1693,11 +1620,7 @@ const submitProductForm = () => {
require_real_name: productForm.require_real_name,
sold_out: productForm.sold_out === true,
max_per_user: Number(productForm.max_per_user) || 0,
max_first_purchase_duration: Number(productForm.max_first_purchase_duration) || 0,
send_notice: productForm.send_notice === true,
renew_price: Number(productForm.renew_price) || 0,
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0,
recommend_words: productForm.recommend_words || ''
send_notice: productForm.send_notice === true
}
let res
@@ -1836,8 +1759,7 @@ const submitForm = () => {
name: groupForm.name.trim(),
note: groupForm.note || '',
disable: groupForm.disable,
index: Number(groupForm.index) || 0,
recommend_words: groupForm.recommend_words || ''
index: Number(groupForm.index) || 0
}
if (groupForm.parent_id) {
@@ -1910,45 +1832,9 @@ watch(activeTab, (newVal) => {
const groupTagManagerRef = ref(null)
// 初始化
// 根据 good_id 请求商品详情并弹出对应商品弹窗(用于从订单详情等页面跳转)
const openProductByGoodId = async (goodId) => {
const id = Number(goodId)
if (!id) return
try {
const res = await getProductDetail({ good_id: id })
if (res.data.code === 200 && res.data.data) {
handleEditProduct(res.data.data)
} else {
ElMessage.error(res.data.message || '获取商品详情失败')
}
} catch (error) {
console.error('获取商品详情失败:', error)
ElMessage.error('获取商品详情失败')
}
}
// 检查并处理 good_id 参数
const checkAndOpenGoodId = async () => {
if (route.query.good_id) {
await openProductByGoodId(route.query.good_id)
router.replace({ query: {} })
}
}
onMounted(async () => {
await fetchGroupList()
onMounted(() => {
fetchGroupList()
fetchAllTagOptions()
await checkAndOpenGoodId()
})
// 组件被 keep-alive 缓存后重新激活时,再次检查 good_id
onActivated(() => {
checkAndOpenGoodId()
})
// 同一页面内 query 变化时也触发(如从其他标签页点击跳转)
watch(() => route.query.good_id, (newId) => {
if (newId) checkAndOpenGoodId()
})
</script>
+227 -401
View File
@@ -3,237 +3,94 @@
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" link class="back-btn">
<el-icon><ArrowLeft /></el-icon> 返回列表
<el-icon><ArrowLeft /></el-icon> 返回所有商品列表
</el-button>
<el-divider direction="vertical" />
<span class="page-title">用户商品详情</span>
<el-tag v-if="detail" :type="currentTag === '云服务器' ? 'primary' : 'info'" size="small" style="margin-left:8px">{{ detail.tag || detail.good?.tag || '通用' }}</el-tag>
<span class="page-title">所有商品详情</span>
</div>
<div class="header-right">
<el-button plain @click="loadDetail" :loading="loading">
<el-button type="primary" plain @click="loadDetail" :loading="loading">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
<el-button type="primary" plain @click="openEdit" :disabled="!detail">
<el-icon><Edit /></el-icon> 编辑
</el-button>
<el-button type="danger" plain @click="handleDelete" :disabled="!detail">
<el-icon><Delete /></el-icon> 删除
</el-button>
</div>
</div>
<div class="main-content" v-loading="loading">
<!-- 空状态 -->
<el-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160">
<el-button type="primary" @click="loadDetail">重新加载</el-button>
</el-empty>
<template v-if="detail">
<!-- 已删除提示 -->
<el-alert
v-if="isDeleted"
type="error"
:closable="false"
show-icon
class="deleted-alert"
title="该用户商品已删除"
:description="`删除时间:${deletedTime}。已删除商品仅展示上游快照信息(itemArg),不再请求虚拟机等实时详情。`"
/>
<!-- Hero 概览区 -->
<div class="hero-section">
<div class="hero-left">
<div class="hero-icon">
<svg viewBox="0 0 48 48" width="44" height="44" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="8" width="40" height="28" rx="4" stroke="currentColor" stroke-width="2.5" />
<path d="M16 40h16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
<path d="M24 36v4" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
<circle cx="24" cy="22" r="6" stroke="currentColor" stroke-width="2" />
<path d="M24 19v3l2 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<el-card class="profile-card" shadow="hover" v-if="detail">
<div class="profile-header">
<div class="profile-basic">
<div class="icon-wrapper">
<el-icon :size="48" color="#409eff"><Monitor /></el-icon>
</div>
<div class="hero-info">
<h1 class="hero-name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
<div class="hero-meta">
<span class="meta-chip">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM1 8a7 7 0 1114 0A7 7 0 011 8z" fill="currentColor"/><path d="M8 4v4.5l3 1.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
{{ expireLabel }}
</span>
<span class="meta-chip" :class="expireStatusClass">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
{{ expireStatusText }}
</span>
<span class="meta-chip id-chip">ID: {{ detail.id || goodsId }}</span>
<span v-if="isDeleted" class="meta-chip status-deleted">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
已删除
</span>
<div class="identity">
<div class="name-row">
<h1 class="name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
<el-button size="small" type="primary" plain @click="openEdit">编辑</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">删除</el-button>
</div>
<div class="id-row">
<span class="label">ID:</span>
<span class="value">{{ detail.id || goodsId }}</span>
<el-divider direction="vertical" />
<span class="label">用户ID:</span>
<span class="value">{{ detail.userId || detail.user_id || '-' }}</span>
<el-divider direction="vertical" />
<span class="label">到期:</span>
<span class="value">{{ formatExpireTime(detail.expireTime || detail.expire_time) }}</span>
<el-divider direction="vertical" />
<span class="label">续费价:</span>
<span class="value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</span>
</div>
</div>
</div>
<div class="hero-right">
<div class="price-block">
<div class="price-label">续费价格</div>
<div class="price-value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</div>
<div class="profile-stats">
<div class="stat-item">
<div class="stat-label">套餐ID</div>
<div class="stat-value">{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
</div>
<div class="price-divider"></div>
<div class="price-block">
<div class="price-label">基础价格</div>
<div class="price-value secondary">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</div>
<div class="stat-item">
<div class="stat-label">备注</div>
<div class="stat-value note-value">{{ detail.note || '-' }}</div>
</div>
</div>
</div>
<el-divider style="margin: 16px 0 12px" />
<el-descriptions :column="3" border size="small" style="width:100%">
<el-descriptions-item label="商品ID">{{ detail.goodId || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单ID">{{ detail.orderId || '-' }}</el-descriptions-item>
<el-descriptions-item label="归属项ID">{{ detail.itemId || '-' }}</el-descriptions-item>
<el-descriptions-item label="基础价格">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(detail.UpdatedAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 数据指标卡片行 -->
<div class="metrics-row">
<div class="metric-card is-link" @click="goToUser">
<div class="metric-icon" style="background:#eef3ff;color:#4f6ef7">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><circle cx="10" cy="7" r="3.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 17c0-2.76 3.13-5 7-5s7 2.24 7 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">所属用户</div>
<div class="metric-value">#{{ detail.userId || detail.user_id || '-' }}</div>
<div class="metric-name">{{ detail.user?.userName || '-' }}</div>
</div>
<div class="metric-arrow">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="metric-card is-link" @click="goToProduct">
<div class="metric-icon" style="background:#f0faf0;color:#52c41a">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M17 7l-7-4-7 4v6l7 4 7-4V7z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M10 11v7M3 7l7 4 7-4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">关联商品</div>
<div class="metric-value">#{{ detail.goodId || '-' }}</div>
<div class="metric-name">{{ detail.good?.name || '-' }}</div>
</div>
<div class="metric-arrow">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="metric-card is-link" @click="goToOrder">
<div class="metric-icon" style="background:#fff7ed;color:#f5a623">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><rect x="3" y="3" width="14" height="14" rx="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5.5M6 13h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">关联订单</div>
<div class="metric-value">#{{ detail.orderId || '-' }}</div>
<div class="metric-name">{{ detail.order?.name || '-' }}</div>
</div>
<div class="metric-arrow">
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="metric-card">
<div class="metric-icon" style="background:#fef0f0;color:#f56c6c">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M10 2l2.47 5.01L18 7.75l-4 3.9.94 5.51L10 14.51l-4.94 2.65.94-5.51-4-3.9 5.53-.74z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">套餐ID</div>
<div class="metric-value">#{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
</div>
</div>
<div class="metric-card">
<div class="metric-icon" style="background:#f5f0ff;color:#722ed1">
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M7 3h6l4 4v9a1 1 0 01-1 1H4a1 1 0 01-1-1V7l4-4z" stroke="currentColor" stroke-width="1.5"/><path d="M7 3v4H3M10 9v5M8 12h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</div>
<div class="metric-body">
<div class="metric-label">归属项ID</div>
<div class="metric-value">#{{ detail.itemId || '-' }}</div>
</div>
</div>
</div>
<!-- 详细信息折叠区 -->
<div class="detail-section">
<div class="section-header" @click="detailExpanded = !detailExpanded">
<div class="section-header-left">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span class="section-title">详细信息</span>
</div>
<el-icon :class="{ 'is-expanded': detailExpanded }"><ArrowDown /></el-icon>
</div>
<transition name="slide">
<div v-show="detailExpanded" class="section-body">
<div class="info-grid">
<div class="info-item">
<span class="info-label">商品标签</span>
<span class="info-value"><el-tag size="small" type="info">{{ detail.good?.tag || detail.tag || '-' }}</el-tag></span>
</div>
<div class="info-item">
<span class="info-label">商品Table</span>
<span class="info-value">{{ detail.good?.table || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">备注</span>
<span class="info-value">{{ detail.note || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">订单状态</span>
<span class="info-value">
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
</el-tag>
<span v-else>-</span>
</span>
</div>
<div class="info-item">
<span class="info-label">创建时间</span>
<span class="info-value">{{ formatTime(detail.CreatedAt) }}</span>
</div>
<div class="info-item">
<span class="info-label">更新时间</span>
<span class="info-value">{{ formatTime(detail.UpdatedAt) }}</span>
</div>
</div>
</div>
</transition>
</div>
<!-- 已删除展示 itemArg 上游快照信息不再请求虚拟机详情 -->
<el-card v-if="isDeleted" shadow="hover" class="related-card">
<template #header>
<div class="card-header-row">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span class="card-title">上游快照信息 (itemArg)</span>
</div>
</template>
<el-descriptions v-if="itemArgEntries.length" :column="2" border size="small">
<el-descriptions-item v-for="item in itemArgEntries" :key="item.key" :label="item.label">
{{ item.value }}
</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="该商品无 itemArg 快照信息" :image-size="100" />
</el-card>
<!-- 类型适配栏目区域 -->
<UserVmDetail v-else-if="currentTag === '云服务器'" embedded :goods-id="goodsId" />
<el-card v-else-if="detail.good" shadow="hover" class="related-card">
<template #header>
<div class="card-header-row">
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><path d="M10 2l2.47 5.01L18 7.75l-4 3.9.94 5.51L10 14.51l-4.94 2.65.94-5.51-4-3.9 5.53-.74z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
<span class="card-title">关联信息</span>
</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
<el-card shadow="hover" v-if="detail" class="related-card">
<template #header>
<span class="card-title">关联信息</span>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
<el-form :model="editForm" label-width="110px">
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
@@ -285,15 +142,14 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onActivated, watch } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, ArrowDown, Refresh, Edit, Delete } from '@element-plus/icons-vue'
import { ArrowLeft, Refresh, Monitor } from '@element-plus/icons-vue'
import { getUserGoodsDetail, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import UserVmDetail from '@/views/user-vm/UserVmDetail.vue'
import dayjs from 'dayjs'
const route = useRoute()
@@ -303,7 +159,6 @@ const goodsId = computed(() => parseInt(route.params.id) || 0)
const loading = ref(false)
const submitLoading = ref(false)
const detail = ref(null)
const detailExpanded = ref(false)
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const formatExpireTime = (t) => {
@@ -313,82 +168,8 @@ const formatExpireTime = (t) => {
return d.format('YYYY-MM-DD HH:mm:ss')
}
const expireLabel = computed(() => formatExpireTime(detail.value?.expireTime || detail.value?.expire_time))
const expireStatusText = computed(() => {
const t = detail.value?.expireTime || detail.value?.expire_time
if (!t) return '未知'
const d = dayjs(t)
if (d.year() < 2000) return '永久有效'
const diff = d.diff(dayjs(), 'day')
if (diff < 0) return '已到期'
if (diff <= 7) return `即将到期 (${diff}天)`
return '正常'
})
const expireStatusClass = computed(() => {
const t = detail.value?.expireTime || detail.value?.expire_time
if (!t) return ''
const d = dayjs(t)
if (d.year() < 2000) return 'status-forever'
const diff = d.diff(dayjs(), 'day')
if (diff < 0) return 'status-expired'
if (diff <= 7) return 'status-warning'
return 'status-ok'
})
const goBack = () => router.push('/user-goods/list')
const currentTag = computed(() => (detail.value?.tag || detail.value?.good?.tag || '').toLowerCase())
// 是否已删除(后端返回 deleteAt 字段,有值即已删除)
const isDeleted = computed(() => !!(detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at))
const deletedTime = computed(() => {
const t = detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at
return t ? formatTime(t) : '-'
})
// 解析 itemArg(可能是 JSON 字符串或对象),用于已删除商品展示上游快照信息
const parsedItemArg = computed(() => {
const raw = detail.value?.itemArg ?? detail.value?.ItemArg ?? detail.value?.item_arg
if (!raw) return null
if (typeof raw === 'string') {
try { return JSON.parse(raw) } catch { return null }
}
if (typeof raw === 'object') return raw
return null
})
// itemArg 字段中文标签映射
const itemArgLabelMap = {
id: '实例ID',
name: '实例名称',
vcpu: 'CPU核数',
memory: '内存',
status: '运行状态',
state: '运行状态',
ips: 'IP地址',
ip: 'IP地址',
disk: '磁盘',
bandwidth: '带宽',
image: '镜像',
image_name: '镜像名称',
os_type: '系统类型'
}
// 将 itemArg 转换为可展示的键值对列表
const itemArgEntries = computed(() => {
const obj = parsedItemArg.value
if (!obj || typeof obj !== 'object') return []
return Object.entries(obj)
.filter(([, v]) => v !== null && v !== undefined && v !== '' && typeof v !== 'object')
.map(([k, v]) => ({
key: k,
label: itemArgLabelMap[k] || k,
value: String(v)
}))
})
const loadDetail = async () => {
if (!goodsId.value) return
loading.value = true
@@ -401,22 +182,6 @@ const loadDetail = async () => {
finally { loading.value = false }
}
const goToUser = () => {
const uid = detail.value?.userId || detail.value?.user_id
if (!uid) return
router.push({ path: '/user/detail', query: { user_id: uid } })
}
const goToProduct = () => {
const gid = detail.value?.goodId
if (!gid) return
router.push({ path: '/product/manage', query: { good_id: gid } })
}
const goToOrder = () => {
const oid = detail.value?.orderId
if (!oid) return
router.push({ path: '/order/list', query: { order_id: oid } })
}
const editVisible = ref(false)
const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' })
const showVmSelector = ref(false)
@@ -437,6 +202,7 @@ const openEdit = () => {
_serviceName: '',
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
})
if (detail.value?.good?.table === 'kvm_service') { /* 通过选择器弹窗选择,无需预加载 */ }
editVisible.value = true
}
@@ -467,126 +233,186 @@ const handleDelete = () => {
}).catch(() => {})
}
let isInitialMount = true
onMounted(loadDetail)
onActivated(() => {
if (isInitialMount) {
isInitialMount = false
return
}
detail.value = null
loadDetail()
})
watch(goodsId, (newId, oldId) => {
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
})
</script>
<style scoped>
.goods-detail-page { padding: 0; }
/* ---- Page Header ---- */
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
.header-left { display: flex; align-items: center; gap: 0; }
.back-btn { font-size: 14px; color: #606266; }
.back-btn:hover { color: #409eff; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; gap: 8px; }
/* ---- Main Content ---- */
.main-content { padding: 20px; min-height: 300px; }
/* ---- Hero Section ---- */
.hero-section {
display: flex; justify-content: space-between; align-items: center;
background: linear-gradient(135deg, #f0f5ff 0%, #f8faff 60%, #fff 100%);
border: 1px solid #e1e8f0; border-radius: 10px;
padding: 24px 28px; gap: 24px;
.goods-detail-page {
padding: 0;
}
.hero-left { display: flex; align-items: center; gap: 20px; flex: 1; min-width: 0; }
.hero-icon {
width: 72px; height: 72px; border-radius: 16px;
background: linear-gradient(135deg, #409eff 0%, #6366f1 100%);
color: #fff; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; box-shadow: 0 6px 16px rgba(64, 158, 255, 0.25);
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #fff;
border-bottom: 1px solid #e1e8ed;
}
.hero-info { min-width: 0; flex: 1; }
.hero-name { font-size: 20px; font-weight: 700; color: #1a1a2e; margin: 0 0 10px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hero-meta { display: flex; gap: 10px; flex-wrap: wrap; }
.meta-chip {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; color: #606266; background: #fff; border: 1px solid #e8e8e8;
padding: 3px 10px; border-radius: 20px; white-space: nowrap;
.header-left {
display: flex;
align-items: center;
gap: 0;
}
.meta-chip.id-chip { font-family: 'SF Mono', 'Consolas', monospace; letter-spacing: 0.3px; }
.meta-chip.status-ok { color: #52c41a; border-color: #b7eb8f; background: #f6ffed; }
.meta-chip.status-warning { color: #fa8c16; border-color: #ffd591; background: #fff7e6; }
.meta-chip.status-expired { color: #f5222d; border-color: #ffa39e; background: #fff1f0; }
.meta-chip.status-forever { color: #722ed1; border-color: #d3adf7; background: #f9f0ff; }
.meta-chip.status-deleted { color: #f5222d; border-color: #ffa39e; background: #fff1f0; }
.deleted-alert { margin-bottom: 16px; }
.hero-right { display: flex; align-items: center; gap: 0; flex-shrink: 0; }
.price-block { text-align: center; padding: 0 20px; }
.price-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
.price-value { font-size: 22px; font-weight: 700; color: #1a1a2e; }
.price-value.secondary { color: #606266; font-weight: 600; }
.price-divider { width: 1px; height: 36px; background: #e0e0e0; }
/* ---- Metrics Row ---- */
.metrics-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 16px; }
.metric-card {
display: flex; align-items: center; gap: 12px;
background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
padding: 14px 16px; transition: all 0.2s; position: relative;
.back-btn {
font-size: 14px;
color: #606266;
}
.metric-card:hover { border-color: #d0d5dd; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.metric-card.is-link { cursor: pointer; }
.metric-card.is-link:hover { border-color: #409eff; background: #f8fbff; box-shadow: 0 2px 12px rgba(64, 158, 255, 0.08); }
.metric-card.is-link:hover .metric-arrow { color: #409eff; transform: translateX(2px); }
.metric-card.is-link:hover .metric-value { color: #409eff; }
.metric-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.metric-body { min-width: 0; flex: 1; }
.metric-label { font-size: 12px; color: #909399; margin-bottom: 2px; }
.metric-value { font-size: 15px; font-weight: 600; color: #303133; font-family: 'SF Mono', 'Consolas', monospace; transition: color 0.2s; }
.metric-name { font-size: 12px; color: #909399; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.metric-arrow { color: #c0c4cc; transition: all 0.2s; flex-shrink: 0; }
/* ---- Detail Section (collapsible) ---- */
.detail-section {
margin-top: 16px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
.back-btn:hover {
color: #409eff;
}
.page-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.header-right {
display: flex;
gap: 8px;
}
.main-content {
padding: 20px;
min-height: 300px;
}
.profile-card {
margin-bottom: 0;
border: 1px solid #e1e8ed;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 16px;
}
.profile-basic {
display: flex;
align-items: center;
gap: 20px;
}
.icon-wrapper {
width: 80px;
height: 80px;
border-radius: 12px;
background: linear-gradient(135deg, #e8f4fd 0%, #d6eaff 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
}
.identity {
display: flex;
flex-direction: column;
gap: 8px;
}
.name-row {
display: flex;
align-items: center;
gap: 10px;
}
.name {
font-size: 22px;
font-weight: 600;
color: #303133;
margin: 0;
}
.id-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #909399;
flex-wrap: wrap;
}
.id-row .label {
color: #909399;
}
.id-row .value {
color: #606266;
font-weight: 500;
}
.profile-stats {
display: flex;
gap: 24px;
flex-shrink: 0;
}
.stat-item {
text-align: center;
min-width: 80px;
padding: 10px 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #ebeef5;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
}
.stat-value {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.note-value {
font-weight: 400;
font-size: 13px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 18px; cursor: pointer; user-select: none;
transition: background 0.15s;
.related-card {
margin-top: 20px;
border: 1px solid #e1e8ed;
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; }
.slide-enter-active, .slide-leave-active { transition: all 0.25s ease; overflow: hidden; }
.slide-enter-from, .slide-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
.slide-enter-to, .slide-leave-from { opacity: 1; max-height: 300px; }
.card-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
/* ---- Related Card (fallback for non-VM) ---- */
.related-card { margin-top: 16px; border: 1px solid #ebeef5; border-radius: 8px; }
.card-header-row { display: flex; align-items: center; gap: 8px; color: #303133; }
.card-title { font-size: 15px; font-weight: 600; color: #303133; }
.selector-row {
display: flex;
align-items: center;
width: 100%;
}
: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-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
:deep(.el-descriptions__label) { font-weight: 500; color: #606266; }
</style>
+17 -167
View File
@@ -1,45 +1,6 @@
<template>
<div class="user-goods-list">
<el-card class="main-container" shadow="never">
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card" :class="{ active: query.status === 'all' }" @click="handleStatusCard('all')">
<div class="stat-icon stat-icon-total"><el-icon><Box /></el-icon></div>
<div class="stat-body">
<div class="stat-label">商品总数</div>
<div class="stat-value">{{ countTotal }}</div>
</div>
</div>
<div class="stat-card" :class="{ active: query.status === 'normal' }" @click="handleStatusCard('normal')">
<div class="stat-icon stat-icon-normal"><el-icon><CircleCheck /></el-icon></div>
<div class="stat-body">
<div class="stat-label">正常</div>
<div class="stat-value">{{ counts.normal }}</div>
</div>
</div>
<div class="stat-card" :class="{ active: query.status === 'expired' }" @click="handleStatusCard('expired')">
<div class="stat-icon stat-icon-expired"><el-icon><Clock /></el-icon></div>
<div class="stat-body">
<div class="stat-label">已到期</div>
<div class="stat-value">{{ counts.expired }}</div>
</div>
</div>
<div class="stat-card" :class="{ active: query.status === '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-content">
@@ -62,20 +23,11 @@
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" style="width:120px" @change="handleSearch">
<el-option label="全部" value="all" />
<el-option label="正常" value="normal" />
<el-option label="已到期" value="expired" />
<el-option label="待开通" value="pending" />
<el-option label="已删除" value="deleted" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.status = 'all'; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
@@ -135,22 +87,12 @@
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tooltip v-if="isDeleted(row)" :content="`删除于 ${formatTime(row.deleteAt || row.DeleteAt || row.deleted_at)}`" placement="top">
<el-tag size="small" type="danger">已删除</el-tag>
</el-tooltip>
<el-tag v-else-if="isExpired(row)" size="small" type="warning">已到期</el-tag>
<el-tag v-else-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">
<template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template>
</el-table-column>
<el-table-column label="订单" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link v-if="row.orderId" type="primary" :underline="false" @click.stop="router.push({ path: '/order/list', query: { order_id: 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: { key: row.orderId } })">{{ row.order?.name || `订单 #${row.orderId}` }}</el-link>
<span v-else>-</span>
</template>
</el-table-column>
@@ -522,8 +464,8 @@
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowDown, Box, CircleCheck, Clock, Delete, Timer } from '@element-plus/icons-vue'
import { getUserGoodsList, getUserGoodsCount, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
import { Plus, Refresh, Search, ArrowDown } from '@element-plus/icons-vue'
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { formatToApiTime } from '@/utils/tool'
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
@@ -538,16 +480,12 @@ const router = useRouter()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '', status: 'all' })
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' })
const filterUserName = ref('')
const filterGoodName = ref('')
const showFilterUserSelector = 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 formatExpireTime = (t) => {
@@ -557,17 +495,6 @@ const formatExpireTime = (t) => {
return d.format('YYYY-MM-DD HH:mm:ss')
}
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
const isDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
// 判断用户商品是否已到期(永久商品除外)
const isExpired = (row) => {
const t = row?.expireTime || row?.expire_time
if (!t) return false
const d = dayjs(t)
return d.year() >= 2000 && d.isBefore(dayjs())
}
const formatMemory = (kb) => {
if (!kb) return '-'
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
@@ -593,20 +520,13 @@ const getStatusText = (status) => {
}
}
// 组装筛选参数(count 与 list 共用)
const buildFilterParams = () => {
const params = {}
if (query.key) params.key = query.key
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
return params
}
const loadList = async () => {
loading.value = true
try {
const params = { page: query.page, count: query.count, ...buildFilterParams() }
if (query.status && query.status !== 'all') params.status = query.status
const params = { page: query.page, count: query.count }
if (query.key) params.key = query.key
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
const res = await getUserGoodsList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
@@ -616,27 +536,7 @@ const loadList = async () => {
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
// 加载数量统计(透传筛选条件,与列表联动)
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 handleSearch = () => { query.page = 1; loadList() }
// ---- 订单参数配置 ----
const argsSpecList = ref([])
@@ -941,7 +841,12 @@ const submitEdit = async () => {
// ---- 详情 / 删除 ----
const handleDetail = (row) => {
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
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 } })
}
}
const handleMoreCmd = (cmd, row) => {
@@ -1006,7 +911,7 @@ const handleSendRemind = (row) => {
}).catch(() => {})
}
onMounted(() => { loadList(); loadCount() })
onMounted(loadList)
</script>
<style scoped>
@@ -1019,61 +924,6 @@ onMounted(() => { loadList(); loadCount() })
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) {
padding: 0;
}
@@ -47,9 +47,6 @@
<span class="price-symbol">¥</span>
<span class="price-amount">{{ formatPlanPrice(row) }}</span>
</div>
<div v-if="isFixedPrice(row) && getRenewPrice(row) > 0" class="plan-renew-price">
续费 ¥{{ formatRenewPrice(row) }}
</div>
</div>
<div class="plan-stat plan-stat-inventory" :class="getInventoryClass(row)">
<div class="plan-stat-label">
@@ -277,13 +274,6 @@
<span class="unit-text"></span>
</div>
</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-input-number v-model="planForm.index" :min="0" style="width: 100%" />
</el-form-item>
@@ -396,8 +386,7 @@ const planForm = reactive({
disable: false,
show_home: false,
can_update: false,
max_per_user: 0,
renew_fixed_price: 0
max_per_user: 0
})
const planFormRules = {
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }]
@@ -474,14 +463,6 @@ const formatPlanPrice = (row) => {
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) => {
return Number(row.inventory ?? 0) || 0
}
@@ -813,8 +794,7 @@ const handleEditPlan = async (row) => {
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price),
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home),
can_update: !!(data.canUpdate || data.can_update),
max_per_user: data.maxPerUser ?? data.max_per_user ?? 0,
renew_fixed_price: ((data.renewFixedPrice ?? data.renew_fixed_price ?? 0) / 100).toFixed(2) * 1
max_per_user: data.maxPerUser ?? data.max_per_user ?? 0
})
initSelectedArgsFromJson(data.args, extraArgIdsArray)
planFormDialogVisible.value = true
@@ -872,8 +852,7 @@ const submitPlanForm = () => {
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,
can_update: planForm.can_update === true,
max_per_user: Number(planForm.max_per_user) || 0,
renew_fixed_price: Math.round(Number(planForm.renew_fixed_price) * 100) || 0
max_per_user: Number(planForm.max_per_user) || 0
}
if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true
let res
@@ -1029,14 +1008,6 @@ watch(() => props.visible, (val) => {
color: #f56c6c;
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 {
background: linear-gradient(135deg, #f0f7ff 0%, #e6f2ff 100%);
border-color: #c6e2ff;
+7 -1
View File
@@ -1130,7 +1130,13 @@ const goToUserDetail = () => {
const goToUserGoods = () => {
const goods = ticketInfo.value?.userGoods
if (!goods) return
const href = router.resolve({ name: 'UserGoodsDetail', params: { id: goods.id } }).href
const tag = (goods.tag || goods.good?.tag || '').toLowerCase()
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')
}
+9 -21
View File
@@ -1,6 +1,6 @@
<template>
<div class="uvm-detail" :class="{ 'is-embedded': embedded }">
<div class="page-header" v-if="!embedded">
<div class="uvm-detail">
<div class="page-header">
<div class="header-left">
<el-button link @click="goBack"><el-icon><ArrowLeft /></el-icon>返回列表</el-button>
<el-divider direction="vertical" />
@@ -1182,11 +1182,6 @@ import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, onActivated
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
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 {
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
@@ -1216,7 +1211,7 @@ import * as echarts from 'echarts'
const route = useRoute()
const router = useRouter()
const userGoodsId = ref(props.embedded ? props.goodsId : (parseInt(route.query.id) || 0))
const userGoodsId = ref(parseInt(route.query.id) || 0)
const loading = ref(false)
const actionLoading = ref(false)
@@ -1324,7 +1319,7 @@ const vmStatusType = (s) => vmStatusTypeUtil(s)
const vmStatusLabel = (s) => vmStatusLabelUtil(s)
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
const goBack = () => { if (!props.embedded) router.back() }
const goBack = () => router.back()
const loadDetail = async () => {
if (!userGoodsId.value) return
@@ -2678,22 +2673,16 @@ const reloadWithNewId = (newId) => {
loadDetail()
}
if (props.embedded) {
watch(() => props.goodsId, (newVal) => {
reloadWithNewId(newVal || 0)
})
} else {
watch(() => route.query.id, (newVal) => {
watch(() => route.query.id, (newVal) => {
if (route.path === '/user-goods/vm-detail') {
reloadWithNewId(parseInt(newVal) || 0)
})
}
}
})
onMounted(() => { loadDetail() })
onActivated(() => {
if (!props.embedded) {
reloadWithNewId(parseInt(route.query.id) || 0)
}
reloadWithNewId(parseInt(route.query.id) || 0)
})
onBeforeUnmount(() => { disposeCharts() })
@@ -2701,7 +2690,6 @@ onBeforeUnmount(() => { disposeCharts() })
<style scoped>
.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; }
.header-left { display: flex; align-items: center; gap: 0; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
+3 -29
View File
@@ -28,20 +28,11 @@
<el-option label="未绑定" :value="false" />
</el-select>
</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-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>查询
</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-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.bound = null; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
@@ -82,15 +73,6 @@
</el-tag>
</template>
</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">
<template #default="{ row }">
<span v-if="row.renewPrice">¥{{ (row.renewPrice / 100).toFixed(2) }}</span>
@@ -957,7 +939,7 @@ const router = useRouter()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({ page: 1, count: 10, bound: null, user_id: '', good_id: '', key: '', status: 'all' })
const query = reactive({ page: 1, count: 10, bound: null, user_id: '', good_id: '', key: '' })
const filterUserName = ref('')
const filterGoodName = ref('')
const showFilterUserSelector = ref(false)
@@ -979,13 +961,6 @@ const formatExpireTime = (t) => {
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 () => {
//
loadList()
@@ -1001,7 +976,6 @@ const loadList = async () => {
if (query.user_id) params.user_id = query.user_id
if (query.good_id) params.good_id = query.good_id
if (query.key) params.key = query.key
if (query.status && query.status !== 'all') params.status = query.status
const res = await getUserGoodsList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
@@ -1027,7 +1001,7 @@ const handleSearch = () => { query.page = 1; loadList() }
const goDetail = (row) => {
if (!row.id) return
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
}
// ---- ----
+18 -363
View File
@@ -181,29 +181,18 @@
</el-tab-pane>
<el-tab-pane label="订单列表" name="3">
<el-table :data="userOrderList" v-loading="orderListLoading" stripe style="width: 100%">
<el-table-column prop="id" label="订单ID" width="80" />
<el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" width="80">
<el-table-column prop="id" label="订单ID" width="100" />
<el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="price" label="金额" width="100">
<template #default="{row}">¥{{ (row.price / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{row}">
<el-tag :type="getOrderTypeTag(row.type)" size="small">{{ getOrderTypeText(row.type) }}</el-tag>
<el-tag :type="getOrderStatusType(row.status)" size="small">{{ getOrderStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="金额" width="120">
<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 prop="created_at" label="创建时间" width="160">
<template #default="{row}">{{ formatDate(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="scope">
@@ -267,13 +256,6 @@
<span v-else>-</span>
</template>
</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">
<template #default="{row}">{{ formatDate(row.expireTime) }}</template>
</el-table-column>
@@ -294,64 +276,6 @@
</div>
<el-empty v-else description="暂无已购商品" :image-size="100" />
</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-card>
</div>
@@ -527,7 +451,7 @@
<el-option label="正式环境" value="production">
<div class="env-option">
<span>正式环境</span>
<span class="env-url">console.007yjs.com</span>
<span class="env-url">www.007yjs.com</span>
</div>
</el-option>
<el-option label="测试环境" value="test">
@@ -554,49 +478,6 @@
</template>
</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-form :model="adminForm" label-width="100px">
@@ -637,7 +518,7 @@ import UserListSelector from '@/components/admin/UserListSelector.vue'
import {
ArrowLeft, Refresh, Edit as EditIcon, Delete, Wallet, Avatar, Lock,
UserFilled, Document, Clock, List, Switch, User, Camera, Upload,
UploadFilled, Key, Monitor, Setting, Close, Plus
UploadFilled, Key, Monitor, Setting, Close
} from '@element-plus/icons-vue'
import { getUserGroupList, getUserBalanceCount } from '@/api/admin/user'
import { getFileDetail } from '@/api/admin/file'
@@ -650,13 +531,6 @@ import { getAdminGroupList } from '@/api/admin/group'
import { getOrderList } from '@/api/admin/order'
import { getTickerList } from '@/api/ticket'
import { getUserGoodsList } from '@/api/admin/product'
import {
getUserVoucherList,
allocateVoucher,
updateUserVoucher,
deleteUserVoucher,
getDiscountCodeList
} from '@/api/admin/discount'
const Edit = EditIcon
const route = useRoute()
@@ -704,41 +578,11 @@ const ticketListPageSize = ref(10)
//
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 goodsListTotal = ref(0)
const goodsListPage = ref(1)
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({
balance: 0,
@@ -836,7 +680,7 @@ const selectedEnvironment = ref('')
//
const environments = {
production: 'https://console.007yjs.com',
production: 'https://www.007yjs.com',
test: 'https://apiserver.s1f.ren',
local: 'http://localhost:5173'
}
@@ -863,8 +707,6 @@ const handleTabClick = (tab) => {
fetchUserTicketList()
} else if (tab.props.name === '5') {
fetchUserGoodsList()
} else if (tab.props.name === '6') {
fetchUserVoucherListData()
}
};
@@ -1175,6 +1017,7 @@ const fetchUserOrderList = async () => {
page: orderListPage.value,
count: orderListPageSize.value
})
console.log('111',res)
if (res.data.code === 200) {
userOrderList.value = res.data.data.list || []
orderListTotal.value = res.data.data.all_count || 0
@@ -1257,169 +1100,6 @@ const handleGoodsListPageChange = (page) => {
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 () => {
if (!route.query.user_id) return
@@ -1439,25 +1119,14 @@ const fetchUserBalance = async () => {
}
//
const getOrderStatusText = (state) => {
const getOrderStatusText = (status) => {
const map = { 0: '待支付', 1: '已支付', 2: '已取消', 3: '已退款', 4: '已完成' }
return map[state] || '未知'
return map[status] || '未知'
}
const getOrderStatusType = (state) => {
const getOrderStatusType = (status) => {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger', 4: 'success' }
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'
return map[status] || 'info'
}
//
@@ -1475,7 +1144,7 @@ const getTicketStatusType = (status) => {
const handleViewOrder = (row) => {
router.push({
path: '/order/list',
query: { order_id: row.id }
query: { order_id: row.order_id }
})
}
@@ -1744,7 +1413,6 @@ const fetchActiveTabData = () => {
else if (tab === '3') fetchUserOrderList()
else if (tab === '4') fetchUserTicketList()
else if (tab === '5') fetchUserGoodsList()
else if (tab === '6') fetchUserVoucherListData()
}
const loadUserData = async () => {
@@ -2163,17 +1831,4 @@ onActivated(() => {
min-width: 60px;
}
}
/* 代金券操作栏 */
.voucher-action-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.voucher-amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
</style>
+3 -541
View File
@@ -85,7 +85,7 @@
{{ row.create_time || row.CreateTime || row.CreatedAt || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="340" fixed="right">
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button type="primary" link @click="handleEdit(row)">
@@ -94,9 +94,6 @@
<el-button type="success" link @click="handleViewMembers(row)">
<el-icon><User /></el-icon>成员
</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-icon><Delete /></el-icon>删除
</el-button>
@@ -291,181 +288,13 @@
</div>
</template>
</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>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue'
import { Plus, Refresh, Edit, User, Delete, Connection, Close } from '@element-plus/icons-vue'
import {
getUserGroupList,
getUserGroupMemberList,
@@ -474,13 +303,6 @@ import {
deleteUserGroup,
addUserGroupMember
} 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 UserGroupSelector from '@/components/admin/UserGroupSelector.vue'
@@ -802,301 +624,6 @@ const clearHigherGroup = () => {
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(() => {
fetchGroupList()
@@ -1169,71 +696,6 @@ onMounted(() => {
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) {
border: none;
+12 -2
View File
@@ -25,11 +25,14 @@
<div class="search-group">
<span class="search-label">用户ID</span>
<el-input
v-model="jumpUserId"
:model-value="jumpUserName || (jumpUserId ? jumpUserId : '')"
placeholder="输入ID跳转"
readonly
clearable
class="search-input-small"
@keyup.enter="handleJumpToUser"
style="cursor:pointer"
@click="showJumpUserSelector = true"
@clear="jumpUserId = ''; jumpUserName = ''"
/>
</div>
<div class="search-buttons">
@@ -338,6 +341,11 @@
:current-user-id="userForm.recommend_id"
@confirm="handleRecommendUserConfirm"
/>
<!-- 筛选用户ID选择器 -->
<UserListSelector
v-model="showJumpUserSelector"
@confirm="u => { jumpUserId = String(u.user_id); jumpUserName = u.user_name || `用户 #${u.user_id}` }"
/>
<!-- 修改头像对话框 -->
<el-dialog
@@ -662,6 +670,8 @@ const router = useRouter()
// ID
const jumpUserId = ref('')
const jumpUserName = ref('')
const showJumpUserSelector = ref(false)
//
const queryParams = reactive({
+5 -13
View File
@@ -556,9 +556,6 @@
<el-tab-pane label="备份管理" name="backup">
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
</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">
<VmMonitor v-if="hostTabLoaded['vmMonitor']" ref="vmMonitorRef" />
</el-tab-pane>
@@ -957,7 +954,6 @@ import VolumeManage from '@/views/virtualization/VolumeManage.vue'
import VmManage from '@/views/virtualization/VmManage.vue'
import SnapshotManage from '@/views/virtualization/SnapshotManage.vue'
import BackupManage from '@/views/virtualization/BackupManage.vue'
import RecycleBinManage from '@/views/virtualization/RecycleBinManage.vue'
import VmMonitor from '@/views/virtualization/VmMonitor.vue'
import { useTagsViewStore } from '@/store/tagsViewStore'
import UserListSelector from '@/components/admin/UserListSelector.vue'
@@ -973,7 +969,7 @@ const serviceName = computed(() => route.query.service_name || '')
const hostId = computed(() => parseInt(route.query.id) || 0)
const activeTab = ref('info')
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, recycleBin: false, vmMonitor: false, networking: false })
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, vmMonitor: false, networking: false })
const imageManageRef = ref(null)
const networkManageRef = ref(null)
@@ -981,9 +977,8 @@ const volumeManageRef = ref(null)
const vmManageRef = ref(null)
const snapshotManageRef = ref(null)
const backupManageRef = ref(null)
const recycleBinManageRef = ref(null)
const vmMonitorRef = ref(null)
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef, recycleBin: recycleBinManageRef, vmMonitor: vmMonitorRef }
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef, vmMonitor: vmMonitorRef }
watch(activeTab, (tab) => {
if (!['info', 'monitor', 'networking'].includes(tab)) {
@@ -1945,13 +1940,10 @@ const initPage = () => {
if (activeTab.value === 'monitor') loadHistoricalMetrics()
}
const isCurrentRoute = () => route.name === 'VirtHostDetail'
watch(hostId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => {
watch(hostId, () => { if (isPageActive) initPage() })
onActivated(() => {
isPageActive = true
await nextTick()
if (isCurrentRoute() && loadedHostId !== hostId.value) initPage()
if (loadedHostId !== hostId.value) initPage()
})
onMounted(() => { isPageActive = true; initPage() })
onDeactivated(() => { isPageActive = false })
+6 -18
View File
@@ -32,13 +32,8 @@
<el-descriptions-item label="状态">
<el-tag :type="statusType(detail.status)" size="small">{{ statusLabel(detail.status) }}</el-tag>
</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="路径" :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="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
@@ -84,11 +79,6 @@
<el-option label="系统镜像" value="system" /><el-option label="数据镜像" value="data" />
</el-select>
</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-select v-model="formData.status" style="width: 100%">
<el-option label="等待中" value="pending" /><el-option label="下载中" value="downloading" />
@@ -137,7 +127,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
@@ -170,7 +160,7 @@ const syncHostId = ref('')
const reloadHostId = ref('')
const formRef = ref(null)
const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', mode: 'local' })
const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '' })
const formRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
path: [{ required: true, message: '请输入路径', trigger: 'blur' }]
@@ -242,7 +232,7 @@ const loadHostStatus = async () => {
const handleEdit = () => {
if (!detail.value) return
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 || '', mode: d.mode || 'local' })
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 || '' })
editDialogVisible.value = true
}
@@ -251,7 +241,7 @@ const handleSubmitEdit = () => {
if (!valid) return
submitLoading.value = true
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, mode: formData.mode }
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 }
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
const res = await updateImage(payload)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
@@ -329,10 +319,8 @@ const initPage = async () => {
loadHostStatus()
}
const isCurrentRoute = () => route.name === 'VirtImageDetail'
watch(imageId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedImageId !== imageId.value) initPage() })
watch(imageId, () => { if (isPageActive) initPage() })
onActivated(() => { isPageActive = true; if (loadedImageId !== imageId.value) initPage() })
onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() })
</script>
+5 -29
View File
@@ -54,11 +54,6 @@
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
</template>
</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">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
@@ -114,13 +109,6 @@
<el-option label="数据镜像" value="data" />
</el-select>
</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-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍(可选)" />
</el-form-item>
@@ -160,19 +148,10 @@
<el-descriptions-item label="状态">
<el-tag :type="statusType(currentDetail.status)" size="small">{{ statusLabel(currentDetail.status) }}</el-tag>
</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="路径" :span="2">
<span class="mono-text">{{ currentDetail.path || '-' }}</span>
</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="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
@@ -312,7 +291,7 @@ const currentHostLabel = computed(() => {
const formData = reactive({
image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system',
description: '', status: '', size: 0, image_name: '', mode: 'local'
description: '', status: '', size: 0, image_name: ''
})
const formRules = {
@@ -421,7 +400,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
dialogType.value = 'add'
Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0, mode: 'local' })
Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0 })
dialogVisible.value = true
}
@@ -430,8 +409,7 @@ const handleEdit = (row) => {
Object.assign(formData, {
image_id: row.id, name: row.name, image_name: row.name, path: row.path || '',
os_type: row.os_type || 'linux', type: row.type || 'system',
description: row.description || '', status: row.status || '', size: row.size || 0,
mode: row.mode || 'local'
description: row.description || '', status: row.status || '', size: row.size || 0
})
dialogVisible.value = true
}
@@ -445,8 +423,7 @@ const handleSubmit = () => {
if (dialogType.value === 'add') {
res = await createImage({
service_id: serviceId.value, name: formData.name, path: formData.path,
os_type: formData.os_type, type: formData.type, description: formData.description || undefined,
mode: formData.mode
os_type: formData.os_type, type: formData.type, description: formData.description || undefined
})
} else {
const payload = {
@@ -454,8 +431,7 @@ const handleSubmit = () => {
image_name: formData.name, path: formData.path,
os_type: formData.os_type, type: formData.type,
description: formData.description || undefined,
status: formData.status || undefined, size: formData.size || undefined,
mode: formData.mode
status: formData.status || undefined, size: formData.size || undefined
}
// undefined
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
+4 -93
View File
@@ -33,14 +33,6 @@
<el-option label="IPv4" value="ipv4" />
<el-option label="IPv6" value="ipv6" />
</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>
<!-- 网络列表 -->
@@ -62,34 +54,10 @@
<el-table-column label="宿主机" width="140">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column>
<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">
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewDetail(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>
</template>
</el-table-column>
@@ -145,10 +113,6 @@
<el-form-item label="逻辑端口名">
<el-input v-model="formData.ls_name" placeholder="不填使用默认" />
</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>
</el-form>
<template #footer>
@@ -178,27 +142,6 @@
<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.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>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog>
@@ -278,15 +221,6 @@ const total = ref(0)
const keyword = ref('')
const filterType = 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 hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 })
@@ -320,8 +254,7 @@ const currentDetail = ref(null)
const formData = reactive({
id: undefined, name: '', address: '', gateway: '', nameservers: '',
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0,
disable: false
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0
})
const formRules = {
@@ -342,8 +275,6 @@ const loadList = async () => {
if (keyword.value) params.key = keyword.value
if (filterType.value) params.type = filterType.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 body = res?.data
if (body?.code === 200 && body?.data) {
@@ -364,7 +295,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
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, disable: false })
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 })
dialogVisible.value = true
}
@@ -374,7 +305,7 @@ const handleEdit = (row) => {
id: row.id, name: row.name, address: row.address, gateway: row.gateway,
nameservers: row.nameservers || '', type: row.type, mac_address: row.mac_address || '',
bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '',
ls_name: row.ls_name || '', host_id: row.host_id, disable: !!row.disable
ls_name: row.ls_name || '', host_id: row.host_id
})
dialogVisible.value = true
}
@@ -401,7 +332,6 @@ const handleSubmit = () => {
res = await createNetwork(fd)
} else {
fd.append('id', formData.id)
fd.append('disable', formData.disable)
res = await updateNetwork(fd)
}
if (res?.data?.code === 200) {
@@ -429,25 +359,6 @@ const handleViewDetail = async (row) => {
} 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) => {
ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
@@ -1,374 +0,0 @@
<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>
+3 -6
View File
@@ -3956,14 +3956,11 @@ const loadSgLockInfo = async () => {
}
}
const isCurrentRoute = () => route.name === 'VirtVmDetail'
watch(vmId, () => { if (isPageActive && isCurrentRoute()) initPage() })
watch(vmId, () => { if (isPageActive) initPage() })
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
onActivated(async () => {
onActivated(() => {
isPageActive = true
await nextTick()
if (isCurrentRoute() && loadedVmId !== vmId.value) initPage()
if (loadedVmId !== vmId.value) initPage()
})
onDeactivated(() => { isPageActive = false; stopMigratePolling() })
onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() })
+2 -106
View File
@@ -28,24 +28,8 @@
</el-select>
</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 ref="vmTableRef" :data="vmList" v-loading="loading" stripe @selection-change="handleSelectionChange">
<el-table-column type="selection" width="45" />
<el-table :data="vmList" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="配置" min-width="200">
@@ -449,7 +433,7 @@
import { ref, reactive, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled, VideoPlay, SwitchButton, Delete } from '@element-plus/icons-vue'
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue'
import {
getRemoteHostList, getVmList, getVmDetail, getVmStatus,
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
@@ -482,7 +466,6 @@ const serviceName = computed(() => injectedServiceName?.value || route.query.ser
const loading = ref(false)
const submitLoading = ref(false)
const detailLoading = ref(false)
const batchLoading = ref(false)
const vmList = ref([])
const total = ref(0)
const keyword = ref('')
@@ -490,12 +473,6 @@ const filterStatus = ref('')
const hostOptions = ref([])
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 showRebuildImageSelector = ref(false)
@@ -1012,72 +989,6 @@ const handleDelete = (row) => {
}).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') }
onMounted(async () => {
@@ -1096,19 +1007,4 @@ defineExpose({ loadList })
.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-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>
+3 -5
View File
@@ -110,7 +110,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
@@ -273,10 +273,8 @@ const initPage = () => {
loadDetail()
}
const isCurrentRoute = () => route.name === 'VirtVolumeDetail'
watch(volumeId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedVolumeId !== volumeId.value) initPage() })
watch(volumeId, () => { if (isPageActive) initPage() })
onActivated(() => { isPageActive = true; if (loadedVolumeId !== volumeId.value) initPage() })
onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() })
</script>