feat: 用户详情代金券管理与优惠模块修复
Build and Deploy Vue3 / build (push) Successful in 1m39s
Build and Deploy Vue3 / deploy (push) Successful in 42s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shiran
2026-06-26 16:46:57 +08:00
parent 6f82e5e79d
commit 4bf7c4857b
15 changed files with 862 additions and 111 deletions
@@ -36,13 +36,18 @@
<el-table-column prop="gateway" label="网关" width="130" /> <el-table-column prop="gateway" label="网关" width="130" />
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip /> <el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
<el-table-column prop="bridge_name" label="网桥名称" width="100" /> <el-table-column prop="bridge_name" label="网桥名称" width="100" />
<el-table-column label="状态" width="80" align="center"> <el-table-column label="占用" width="80" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag> <el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag>
<el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag> <el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag>
<el-tag v-else type="info" size="small">-</el-tag> <el-tag v-else type="info" size="small">-</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="禁用" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.disable" type="danger" size="small">禁用</el-tag>
</template>
</el-table-column>
</el-table> </el-table>
<div class="pagination-wrapper" v-if="total > 0"> <div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" <el-pagination v-model:current-page="page" v-model:page-size="pageSize"
@@ -118,6 +123,7 @@ const loadList = async () => {
const effectiveUsed = props.filterUsed || usedFilter.value const effectiveUsed = props.filterUsed || usedFilter.value
if (effectiveUsed) params.used = effectiveUsed if (effectiveUsed) params.used = effectiveUsed
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
params.disable = false
const res = await getNetworkList(params) const res = await getNetworkList(params)
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data const inner = res.data.data
+8 -15
View File
@@ -15,9 +15,6 @@
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="关联信息ID">
<el-input v-model="queryParams.id" placeholder="请输入关联信息ID" clearable style="width: 180px" />
</el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="handleQuery"> <el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询 <el-icon><Search /></el-icon>查询
@@ -203,15 +200,14 @@ const router = useRouter()
// 查询参数 // 查询参数
const queryParams = reactive({ const queryParams = reactive({
user_id: undefined, user_id: undefined,
code_id: props.codeId || undefined, id: props.codeId || undefined,
id: '',
page: 1, page: 1,
count: 10 count: 10
}) })
watch(() => props.codeId, (newVal) => { watch(() => props.codeId, (newVal) => {
if (newVal) { if (newVal) {
queryParams.code_id = newVal queryParams.id = newVal
fetchHistoryList() fetchHistoryList()
} }
}) })
@@ -285,8 +281,8 @@ const statistics = computed(() => {
}) })
// 获取查询用户名称 // 获取查询用户名称
const getQueryUserName = () => { const getQueryUserName = () => {
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id) const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}` return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
} }
// 获取使用记录列表 // 获取使用记录列表
@@ -347,15 +343,13 @@ const confirmUserSelection = (user) => {
} }
if (selectorType.value === 'query') { if (selectorType.value === 'query') {
// 查询表单选择 queryParams.user_id = user.user_id
queryParams.user_id = user.UserId
} else { } else {
// 编辑表单选择 editForm.user_id = user.user_id
editForm.user_id = user.UserId
} }
// 将选中的用户添加到 UserOptions 中(如果不存在) // 将选中的用户添加到 UserOptions 中(如果不存在)
if (!UserOptions.value.find(u => u.UserId === user.UserId)) { if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
UserOptions.value.push(user) UserOptions.value.push(user)
} }
@@ -372,8 +366,7 @@ const handleQuery = () => {
// 重置查询 // 重置查询
const resetQuery = () => { const resetQuery = () => {
queryParams.user_id = undefined queryParams.user_id = undefined
queryParams.code_id = undefined queryParams.id = undefined
queryParams.id = ''
queryParams.page = 1 queryParams.page = 1
fetchHistoryList() fetchHistoryList()
} }
+7 -9
View File
@@ -423,15 +423,13 @@ const confirmUserSelection = (user) => {
} }
if (selectorType.value === 'query') { if (selectorType.value === 'query') {
// 查询表单选择 queryParams.user_id = user.user_id
queryParams.user_id = user.UserId
} else { } else {
// 编辑表单选择 editForm.user_id = user.user_id
editForm.user_id = user.UserId
} }
// 将选中的用户添加到 UserOptions 中(如果不存在) // 将选中的用户添加到 UserOptions 中(如果不存在)
if (!UserOptions.value.find(u => u.UserId === user.UserId)) { if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
UserOptions.value.push(user) UserOptions.value.push(user)
} }
@@ -451,14 +449,14 @@ const clearEditUser = () => {
// 获取查询用户名称 // 获取查询用户名称
const getQueryUserName = () => { const getQueryUserName = () => {
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id) const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}` return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
} }
// 获取编辑用户名称 // 获取编辑用户名称
const getEditUserName = () => { const getEditUserName = () => {
const user = UserOptions.value.find(u => u.UserId === editForm.user_id) const user = UserOptions.value.find(u => u.user_id === editForm.user_id)
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${editForm.user_id}` return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${editForm.user_id}`
} }
// 获取代金券列表 // 获取代金券列表
+33 -9
View File
@@ -122,6 +122,12 @@
<el-tag v-else-if="row.isProduct" type="success" size="small" style="margin-left: 8px;">商品</el-tag> <el-tag v-else-if="row.isProduct" type="success" size="small" style="margin-left: 8px;">商品</el-tag>
<span class="group-name">{{ row.name }}</span> <span class="group-name">{{ row.name }}</span>
<el-tag
v-if="(row.isGroup && row.recommendWords) || (row.isProduct && row.data?.recommendWords)"
type="danger"
size="small"
style="margin-left: 6px;"
>{{ row.isGroup ? row.recommendWords : row.data?.recommendWords }}</el-tag>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -207,6 +213,7 @@
<div class="group-name-cell"> <div class="group-name-cell">
<el-avatar v-if="row.cover" :size="32" :src="row.cover" /> <el-avatar v-if="row.cover" :size="32" :src="row.cover" />
<span class="group-name">{{ row.name }}</span> <span class="group-name">{{ row.name }}</span>
<el-tag v-if="row.recommendWords" type="danger" size="small" style="margin-left: 6px;">{{ row.recommendWords }}</el-tag>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@@ -393,6 +400,11 @@
</div> </div>
</div> </div>
<!-- 推荐词 -->
<el-form-item prop="recommend_words" label="推荐词">
<el-input v-model="groupForm.recommend_words" placeholder="推荐词(可选,如:热销、新品)" clearable />
</el-form-item>
<!-- 备注 --> <!-- 备注 -->
<el-form-item prop="note" label="备注" class="note-form-item"> <el-form-item prop="note" label="备注" class="note-form-item">
<el-input <el-input
@@ -761,6 +773,9 @@
style="width: 100%" style="width: 100%"
/> />
</el-form-item> </el-form-item>
<el-form-item label="推荐词" prop="recommend_words">
<el-input v-model="productForm.recommend_words" placeholder="推荐词(可选,如:热销、新品)" clearable style="width: 100%" />
</el-form-item>
<el-form-item label="购买通知" prop="send_notice"> <el-form-item label="购买通知" prop="send_notice">
<el-switch <el-switch
v-model="productForm.send_notice" v-model="productForm.send_notice"
@@ -893,7 +908,8 @@ const groupForm = reactive({
cover_id: undefined, cover_id: undefined,
cover_url: '', cover_url: '',
tag_id: undefined, tag_id: undefined,
index: 0 index: 0,
recommend_words: ''
}) })
const groupRules = { const groupRules = {
@@ -949,7 +965,8 @@ const productForm = reactive({
max_first_purchase_duration: 0, max_first_purchase_duration: 0,
send_notice: false, send_notice: false,
renew_price: 0, renew_price: 0,
renew_recommend_rebate: 0 renew_recommend_rebate: 0,
recommend_words: ''
}) })
const productRules = { const productRules = {
@@ -1496,7 +1513,8 @@ const handleAdd = (parentRow) => {
cover_id: undefined, cover_id: undefined,
cover_url: '', cover_url: '',
tag_id: parentTagId || undefined, tag_id: parentTagId || undefined,
index: 0 index: 0,
recommend_words: ''
}) })
console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag) console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag)
} else { } else {
@@ -1511,7 +1529,8 @@ const handleAdd = (parentRow) => {
cover_id: undefined, cover_id: undefined,
cover_url: '', cover_url: '',
tag_id: undefined, tag_id: undefined,
index: 0 index: 0,
recommend_words: ''
}) })
} }
@@ -1532,7 +1551,8 @@ const handleEdit = (row) => {
cover_id: row.coverId || undefined, cover_id: row.coverId || undefined,
cover_url: row.cover || '', cover_url: row.cover || '',
tag_id: row.tag?.id || row.tagId || undefined, tag_id: row.tag?.id || row.tagId || undefined,
index: row.index || 0 index: row.index || 0,
recommend_words: row.recommendWords || ''
}) })
dialogVisible.value = true dialogVisible.value = true
@@ -1602,7 +1622,8 @@ const handleAddProduct = () => {
arg_type: 'all', arg_type: 'all',
require_real_name: false, require_real_name: false,
max_per_user: 0, max_per_user: 0,
max_first_purchase_duration: 0 max_first_purchase_duration: 0,
recommend_words: ''
}) })
selectedProductGroup.value = null selectedProductGroup.value = null
@@ -1642,7 +1663,8 @@ const handleEditProduct = (product, parentGroupId) => {
max_first_purchase_duration: product.maxFirstPurchaseDuration ?? product.max_first_purchase_duration ?? 0, max_first_purchase_duration: product.maxFirstPurchaseDuration ?? product.max_first_purchase_duration ?? 0,
send_notice: !!product.sendNotice, send_notice: !!product.sendNotice,
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100, renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0 renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0,
recommend_words: product.recommendWords ?? product.recommend_words ?? ''
}) })
productDialogVisible.value = true productDialogVisible.value = true
@@ -1674,7 +1696,8 @@ const submitProductForm = () => {
max_first_purchase_duration: Number(productForm.max_first_purchase_duration) || 0, max_first_purchase_duration: Number(productForm.max_first_purchase_duration) || 0,
send_notice: productForm.send_notice === true, send_notice: productForm.send_notice === true,
renew_price: Number(productForm.renew_price) || 0, renew_price: Number(productForm.renew_price) || 0,
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0 renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0,
recommend_words: productForm.recommend_words || ''
} }
let res let res
@@ -1813,7 +1836,8 @@ const submitForm = () => {
name: groupForm.name.trim(), name: groupForm.name.trim(),
note: groupForm.note || '', note: groupForm.note || '',
disable: groupForm.disable, disable: groupForm.disable,
index: Number(groupForm.index) || 0 index: Number(groupForm.index) || 0,
recommend_words: groupForm.recommend_words || ''
} }
if (groupForm.parent_id) { if (groupForm.parent_id) {
+14 -3
View File
@@ -24,6 +24,13 @@
<div class="stat-value">{{ counts.expired }}</div> <div class="stat-value">{{ counts.expired }}</div>
</div> </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-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-icon stat-icon-deleted"><el-icon><Delete /></el-icon></div>
<div class="stat-body"> <div class="stat-body">
@@ -60,6 +67,7 @@
<el-option label="全部" value="all" /> <el-option label="全部" value="all" />
<el-option label="正常" value="normal" /> <el-option label="正常" value="normal" />
<el-option label="已到期" value="expired" /> <el-option label="已到期" value="expired" />
<el-option label="待开通" value="pending" />
<el-option label="已删除" value="deleted" /> <el-option label="已删除" value="deleted" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -133,6 +141,7 @@
<el-tag size="small" type="danger">已删除</el-tag> <el-tag size="small" type="danger">已删除</el-tag>
</el-tooltip> </el-tooltip>
<el-tag v-else-if="isExpired(row)" size="small" type="warning">已到期</el-tag> <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> <el-tag v-else size="small" type="success">正常</el-tag>
</template> </template>
</el-table-column> </el-table-column>
@@ -513,7 +522,7 @@
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowDown, Box, CircleCheck, Clock, Delete } from '@element-plus/icons-vue' 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 { getUserGoodsList, getUserGoodsCount, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import { formatToApiTime } from '@/utils/tool' import { formatToApiTime } from '@/utils/tool'
@@ -536,8 +545,8 @@ const showFilterUserSelector = ref(false)
const showFilterProductSelector = ref(false) const showFilterProductSelector = ref(false)
// 用户商品数量统计 // 用户商品数量统计
const counts = reactive({ normal: 0, deleted: 0, expired: 0 }) const counts = reactive({ normal: 0, deleted: 0, expired: 0, pending: 0 })
const countTotal = computed(() => (counts.normal || 0) + (counts.deleted || 0) + (counts.expired || 0)) const countTotal = computed(() => (counts.normal || 0) + (counts.deleted || 0) + (counts.expired || 0) + (counts.pending || 0))
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-' const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
@@ -616,6 +625,7 @@ const loadCount = async () => {
counts.normal = d.normal ?? 0 counts.normal = d.normal ?? 0
counts.deleted = d.deleted ?? 0 counts.deleted = d.deleted ?? 0
counts.expired = d.expired ?? 0 counts.expired = d.expired ?? 0
counts.pending = d.pending ?? 0
} }
} catch { /* 统计失败不阻断列表 */ } } catch { /* 统计失败不阻断列表 */ }
} }
@@ -1054,6 +1064,7 @@ onMounted(() => { loadList(); loadCount() })
.stat-icon-normal { background: #f0faf0; color: #52c41a; } .stat-icon-normal { background: #f0faf0; color: #52c41a; }
.stat-icon-expired { background: #fff7e6; color: #fa8c16; } .stat-icon-expired { background: #fff7e6; color: #fa8c16; }
.stat-icon-deleted { background: #fff1f0; color: #f5222d; } .stat-icon-deleted { background: #fff1f0; color: #f5222d; }
.stat-icon-pending { background: #e6f7ff; color: #1890ff; }
.stat-body { display: flex; flex-direction: column; } .stat-body { display: flex; flex-direction: column; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 2px; } .stat-label { font-size: 13px; color: #909399; margin-bottom: 2px; }
@@ -47,6 +47,9 @@
<span class="price-symbol">¥</span> <span class="price-symbol">¥</span>
<span class="price-amount">{{ formatPlanPrice(row) }}</span> <span class="price-amount">{{ formatPlanPrice(row) }}</span>
</div> </div>
<div v-if="isFixedPrice(row) && getRenewPrice(row) > 0" class="plan-renew-price">
续费 ¥{{ formatRenewPrice(row) }}
</div>
</div> </div>
<div class="plan-stat plan-stat-inventory" :class="getInventoryClass(row)"> <div class="plan-stat plan-stat-inventory" :class="getInventoryClass(row)">
<div class="plan-stat-label"> <div class="plan-stat-label">
@@ -471,6 +474,14 @@ const formatPlanPrice = (row) => {
return (Number(raw) / 100).toFixed(2) return (Number(raw) / 100).toFixed(2)
} }
const getRenewPrice = (row) => {
return Number(row.renewFixedPrice ?? row.renew_fixed_price ?? 0)
}
const formatRenewPrice = (row) => {
return (getRenewPrice(row) / 100).toFixed(2)
}
const getInventoryNum = (row) => { const getInventoryNum = (row) => {
return Number(row.inventory ?? 0) || 0 return Number(row.inventory ?? 0) || 0
} }
@@ -1018,6 +1029,14 @@ watch(() => props.visible, (val) => {
color: #f56c6c; color: #f56c6c;
font-weight: 700; font-weight: 700;
} }
.plan-renew-price {
font-size: 11px;
color: #e6a23c;
font-weight: 500;
margin-top: 2px;
line-height: 1;
}
.plan-stat-price.is-dynamic { .plan-stat-price.is-dynamic {
background: linear-gradient(135deg, #f0f7ff 0%, #e6f2ff 100%); background: linear-gradient(135deg, #f0f7ff 0%, #e6f2ff 100%);
border-color: #c6e2ff; border-color: #c6e2ff;
+2
View File
@@ -33,6 +33,7 @@
<el-option label="全部" value="all" /> <el-option label="全部" value="all" />
<el-option label="正常" value="normal" /> <el-option label="正常" value="normal" />
<el-option label="已到期" value="expired" /> <el-option label="已到期" value="expired" />
<el-option label="待开通" value="pending" />
<el-option label="已删除" value="deleted" /> <el-option label="已删除" value="deleted" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -86,6 +87,7 @@
<el-tooltip v-if="isDeleted(row)" :content="`删除于 ${deletedTimeText(row)}`" placement="top"> <el-tooltip v-if="isDeleted(row)" :content="`删除于 ${deletedTimeText(row)}`" placement="top">
<el-tag size="small" type="danger">已删除</el-tag> <el-tag size="small" type="danger">已删除</el-tag>
</el-tooltip> </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> <el-tag v-else size="small" type="success">正常</el-tag>
</template> </template>
</el-table-column> </el-table-column>
+309 -1
View File
@@ -294,6 +294,64 @@
</div> </div>
<el-empty v-else description="暂无已购商品" :image-size="100" /> <el-empty v-else description="暂无已购商品" :image-size="100" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="代金券" name="6">
<div class="voucher-action-bar">
<el-button type="primary" size="small" @click="handleAddVoucher">
<el-icon><Plus /></el-icon>添加代金券
</el-button>
<el-button type="success" size="small" @click="fetchUserVoucherListData">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<el-table :data="userVoucherList" v-loading="voucherListLoading" stripe style="width: 100%">
<el-table-column prop="Id" label="ID" width="70" />
<el-table-column label="代金券名称" min-width="150" show-overflow-tooltip>
<template #default="{row}">{{ row.discount?.name || '-' }}</template>
</el-table-column>
<el-table-column label="面额" min-width="100">
<template #default="{row}">
<span class="voucher-amount">¥{{ row.discount?.amount ? (row.discount.amount / 100).toFixed(2) : '0.00' }}</span>
</template>
</el-table-column>
<el-table-column label="已使用/最大" min-width="100">
<template #default="{row}">
{{ row.useTimes || 0 }} / {{ row.maxUseTimes || '∞' }}
</template>
</el-table-column>
<el-table-column label="状态" min-width="80">
<template #default="{row}">
<el-tag :type="getVoucherStatusType(row)" size="small">
{{ getVoucherStatusText(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="过期时间" min-width="140">
<template #default="{row}">{{ formatVoucherDate(row.expireAt) }}</template>
</el-table-column>
<el-table-column label="创建时间" min-width="140">
<template #default="{row}">{{ formatDate(row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{row}">
<el-button type="warning" link size="small" @click="handleEditVoucher(row)">编辑</el-button>
<el-divider direction="vertical" />
<el-button type="danger" link size="small" @click="handleDeleteVoucher(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="voucherListTotal > 0">
<el-pagination
v-model:current-page="voucherListPage"
v-model:page-size="voucherListPageSize"
:total="voucherListTotal"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next"
@size-change="handleVoucherListSizeChange"
@current-change="handleVoucherListPageChange"
/>
</div>
<el-empty v-if="!voucherListLoading && !userVoucherList.length" description="暂无代金券" :image-size="100" />
</el-tab-pane>
</el-tabs> </el-tabs>
</el-card> </el-card>
</div> </div>
@@ -496,6 +554,49 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 代金券添加/编辑对话框 -->
<el-dialog
v-model="voucherDialogVisible"
:title="voucherDialogType === 'add' ? '添加代金券' : '编辑代金券'"
width="500px"
append-to-body
>
<el-form ref="voucherFormRef" :model="voucherForm" :rules="voucherFormRules" label-width="120px">
<el-form-item label="代金券" prop="discount_id" v-if="voucherDialogType === 'add'">
<el-select v-model="voucherForm.discount_id" placeholder="请选择代金券" filterable style="width: 100%">
<el-option
v-for="item in discountCouponOptions"
:key="item.id"
:label="`${item.name} (¥${(item.amount/100).toFixed(2)})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="已使用次数" prop="use_times" v-if="voucherDialogType === 'edit'">
<el-input-number v-model="voucherForm.use_times" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="最大使用次数" prop="max_use_times" v-if="voucherDialogType === 'edit'">
<el-input-number v-model="voucherForm.max_use_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item>
<el-form-item label="过期时间" prop="expire_at" v-if="voucherDialogType === 'edit'">
<el-date-picker
v-model="voucherForm.expire_at"
type="datetime"
placeholder="选择过期时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="X"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="voucherDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitVoucherForm">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 管理员权限管理对话框 --> <!-- 管理员权限管理对话框 -->
<el-dialog v-model="adminDialogVisible" title="修改管理员权限" width="500px" append-to-body> <el-dialog v-model="adminDialogVisible" title="修改管理员权限" width="500px" append-to-body>
<el-form :model="adminForm" label-width="100px"> <el-form :model="adminForm" label-width="100px">
@@ -536,7 +637,7 @@ import UserListSelector from '@/components/admin/UserListSelector.vue'
import { import {
ArrowLeft, Refresh, Edit as EditIcon, Delete, Wallet, Avatar, Lock, ArrowLeft, Refresh, Edit as EditIcon, Delete, Wallet, Avatar, Lock,
UserFilled, Document, Clock, List, Switch, User, Camera, Upload, UserFilled, Document, Clock, List, Switch, User, Camera, Upload,
UploadFilled, Key, Monitor, Setting, Close UploadFilled, Key, Monitor, Setting, Close, Plus
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { getUserGroupList, getUserBalanceCount } from '@/api/admin/user' import { getUserGroupList, getUserBalanceCount } from '@/api/admin/user'
import { getFileDetail } from '@/api/admin/file' import { getFileDetail } from '@/api/admin/file'
@@ -549,6 +650,13 @@ import { getAdminGroupList } from '@/api/admin/group'
import { getOrderList } from '@/api/admin/order' import { getOrderList } from '@/api/admin/order'
import { getTickerList } from '@/api/ticket' import { getTickerList } from '@/api/ticket'
import { getUserGoodsList } from '@/api/admin/product' import { getUserGoodsList } from '@/api/admin/product'
import {
getUserVoucherList,
allocateVoucher,
updateUserVoucher,
deleteUserVoucher,
getDiscountCodeList
} from '@/api/admin/discount'
const Edit = EditIcon const Edit = EditIcon
const route = useRoute() const route = useRoute()
@@ -610,6 +718,27 @@ const goodsListTotal = ref(0)
const goodsListPage = ref(1) const goodsListPage = ref(1)
const goodsListPageSize = ref(10) const goodsListPageSize = ref(10)
//
const userVoucherList = ref([])
const voucherListLoading = ref(false)
const voucherListTotal = ref(0)
const voucherListPage = ref(1)
const voucherListPageSize = ref(10)
const voucherDialogVisible = ref(false)
const voucherDialogType = ref('add')
const voucherFormRef = ref(null)
const discountCouponOptions = ref([])
const voucherForm = reactive({
discount_id: undefined,
id: undefined,
use_times: 0,
max_use_times: 0,
expire_at: undefined
})
const voucherFormRules = {
discount_id: [{ required: true, message: '请选择代金券', trigger: 'change' }]
}
// //
const userBalance = ref({ const userBalance = ref({
balance: 0, balance: 0,
@@ -734,6 +863,8 @@ const handleTabClick = (tab) => {
fetchUserTicketList() fetchUserTicketList()
} else if (tab.props.name === '5') { } else if (tab.props.name === '5') {
fetchUserGoodsList() fetchUserGoodsList()
} else if (tab.props.name === '6') {
fetchUserVoucherListData()
} }
}; };
@@ -1126,6 +1257,169 @@ const handleGoodsListPageChange = (page) => {
fetchUserGoodsList() fetchUserGoodsList()
} }
//
const fetchUserVoucherListData = async () => {
if (!route.query.user_id) return
voucherListLoading.value = true
try {
const res = await getUserVoucherList({
user_id: route.query.user_id,
page: voucherListPage.value,
count: voucherListPageSize.value
})
if (res.data.code === 200) {
userVoucherList.value = res.data.data?.data || []
voucherListTotal.value = res.data.data?.all_count || 0
}
} catch (error) {
ElMessage.error('获取代金券列表失败')
} finally {
voucherListLoading.value = false
}
}
const handleVoucherListSizeChange = (size) => {
voucherListPageSize.value = size
voucherListPage.value = 1
fetchUserVoucherListData()
}
const handleVoucherListPageChange = (page) => {
voucherListPage.value = page
fetchUserVoucherListData()
}
//
const getVoucherStatusText = (row) => {
if (row.useTimes >= row.maxUseTimes && row.maxUseTimes > 0) return '已用完'
const expireAt = row.expireAt ? new Date(row.expireAt).getTime() : 0
if (expireAt > 0 && new Date(row.expireAt).getFullYear() !== 1970 && expireAt < Date.now()) return '已过期'
return '可使用'
}
const getVoucherStatusType = (row) => {
if (row.useTimes >= row.maxUseTimes && row.maxUseTimes > 0) return 'info'
const expireAt = row.expireAt ? new Date(row.expireAt).getTime() : 0
if (expireAt > 0 && new Date(row.expireAt).getFullYear() !== 1970 && expireAt < Date.now()) return 'warning'
return 'success'
}
//
const formatVoucherDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
if (isNaN(date.getTime()) || date.getFullYear() === 1970 || date.getFullYear() === 1) return '-'
return formatDate(dateStr)
}
//
const fetchDiscountCouponOptions = async () => {
try {
const res = await getDiscountCodeList({ discount_type: 'coupon', page: 1, count: 100 })
if (res.data.code === 200) {
discountCouponOptions.value = res.data.data?.data || []
}
} catch (error) {
console.error('获取代金券选项失败:', error)
}
}
//
const handleAddVoucher = () => {
voucherDialogType.value = 'add'
voucherDialogVisible.value = true
Object.assign(voucherForm, {
discount_id: undefined,
id: undefined,
use_times: 0,
max_use_times: 0,
expire_at: undefined
})
voucherFormRef.value?.resetFields()
fetchDiscountCouponOptions()
}
//
const handleEditVoucher = (row) => {
voucherDialogType.value = 'edit'
voucherDialogVisible.value = true
let expireTime = undefined
if (row.expireAt && row.expireAt !== '0001-01-01T00:00:00Z') {
const date = new Date(row.expireAt)
if (!isNaN(date.getTime()) && date.getFullYear() !== 1970) {
expireTime = Math.floor(date.getTime() / 1000)
}
}
Object.assign(voucherForm, {
discount_id: row.discountId,
id: row.Id,
use_times: row.useTimes || 0,
max_use_times: row.maxUseTimes || 0,
expire_at: expireTime
})
}
//
const handleDeleteVoucher = (row) => {
const name = row.discount?.name || '该代金券'
ElMessageBox.confirm(`确认删除代金券「${name}」吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteUserVoucher({
user_id: route.query.user_id,
id: row.Id
})
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchUserVoucherListData()
} else {
ElMessage.error(res.data.message || '删除失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
//
const submitVoucherForm = () => {
voucherFormRef.value?.validate(async (valid) => {
if (!valid) return
try {
let res
if (voucherDialogType.value === 'add') {
res = await allocateVoucher({
user_id: route.query.user_id,
code_id: voucherForm.discount_id
})
} else {
res = await updateUserVoucher({
user_id: route.query.user_id,
id: voucherForm.id,
discount_id: voucherForm.discount_id,
use_times: voucherForm.use_times,
max_use_times: voucherForm.max_use_times,
expire_at: voucherForm.expire_at
})
}
if (res.data.code === 200) {
ElMessage.success(voucherDialogType.value === 'add' ? '添加成功' : '修改成功')
voucherDialogVisible.value = false
fetchUserVoucherListData()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (error) {
ElMessage.error(error.response?.data?.message || '操作失败')
}
})
}
// //
const fetchUserBalance = async () => { const fetchUserBalance = async () => {
if (!route.query.user_id) return if (!route.query.user_id) return
@@ -1450,6 +1744,7 @@ const fetchActiveTabData = () => {
else if (tab === '3') fetchUserOrderList() else if (tab === '3') fetchUserOrderList()
else if (tab === '4') fetchUserTicketList() else if (tab === '4') fetchUserTicketList()
else if (tab === '5') fetchUserGoodsList() else if (tab === '5') fetchUserGoodsList()
else if (tab === '6') fetchUserVoucherListData()
} }
const loadUserData = async () => { const loadUserData = async () => {
@@ -1868,4 +2163,17 @@ onActivated(() => {
min-width: 60px; min-width: 60px;
} }
} }
/* 代金券操作栏 */
.voucher-action-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.voucher-amount {
color: #f56c6c;
font-weight: bold;
font-size: 14px;
}
</style> </style>
+283 -41
View File
@@ -317,10 +317,10 @@
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column label="绑定对象" min-width="220"> <el-table-column label="绑定对象" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.good" type="primary" effect="plain"> <el-tag v-if="row.good && row.good.id" type="primary" effect="plain">
商品{{ row.good.name }} (ID:{{ row.good.id }}) 商品{{ row.good.name }} (ID:{{ row.good.id }})
</el-tag> </el-tag>
<el-tag v-else-if="row.goodGroup" type="warning" effect="plain"> <el-tag v-else-if="row.goodGroup && row.goodGroup.id" type="warning" effect="plain">
商品组{{ row.goodGroup.name }} (ID:{{ row.goodGroup.id }}) 商品组{{ row.goodGroup.name }} (ID:{{ row.goodGroup.id }})
</el-tag> </el-tag>
<span v-else style="color:#c0c4cc">-</span> <span v-else style="color:#c0c4cc">-</span>
@@ -348,13 +348,25 @@
<el-empty description="暂无优惠绑定" :image-size="80" /> <el-empty description="暂无优惠绑定" :image-size="80" />
</template> </template>
</el-table> </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>
<!-- 用户组优惠 新增/编辑 对话框 --> <!-- 用户组优惠 新增/编辑 对话框 -->
<el-dialog <el-dialog
v-model="discountFormVisible" v-model="discountFormVisible"
:title="discountFormType === 'add' ? '新增优惠绑定' : '编辑优惠绑定'" :title="discountFormType === 'add' ? '新增优惠绑定' : '编辑优惠绑定'"
width="560px" width="620px"
append-to-body append-to-body
> >
<el-form <el-form
@@ -363,32 +375,72 @@
:rules="discountFormRules" :rules="discountFormRules"
label-width="120px" label-width="120px"
> >
<el-form-item label="绑定类型" prop="bind_type"> <!-- 新增模式折叠层级选择器 -->
<el-radio-group v-model="discountForm.bind_type" @change="handleBindTypeChange"> <el-form-item v-if="discountFormType === 'add'" label="选择商品">
<el-radio label="good">商品</el-radio> <div class="goods-tree-wrapper">
<el-radio label="good_group">商品组</el-radio> <div class="goods-tree-toolbar">
</el-radio-group> <span class="tree-tip">勾选商品组或商品仅可选一个</span>
</el-form-item> <div class="tree-summary">
<el-form-item v-if="discountForm.bind_type === 'good'" label="选择商品" prop="good_id"> 已选: <b>{{ discountTreeChecked.name || '无' }}</b>
<el-select v-model="discountForm.good_id" placeholder="请选择商品" filterable style="width: 100%"> </div>
<el-option </div>
v-for="item in productOptions" <el-tree
:key="item.id" ref="discountTreeRef"
:label="`${item.name} (ID:${item.id})`" :props="discountTreeProps"
:value="item.id" :load="loadDiscountTreeNode"
/> lazy
</el-select> show-checkbox
</el-form-item> check-strictly
<el-form-item v-else label="选择商品组" prop="good_group_id"> node-key="key"
<el-select v-model="discountForm.good_group_id" placeholder="请选择商品组" filterable style="width: 100%"> class="goods-tree"
<el-option @check="handleDiscountTreeCheck"
v-for="item in productGroupOptions" >
:key="item.id" <template #default="{ data }">
:label="`${item.name} (ID:${item.id})`" <span class="tree-node">
:value="item.id" <el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain">
/> {{ data.nodeType === 'group' ? '组' : '品' }}
</el-select> </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> </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"> <el-form-item label="固定抵扣额" prop="amount">
<div class="unit-input-row"> <div class="unit-input-row">
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="0表示不使用" style="flex:1" /> <el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="0表示不使用" style="flex:1" />
@@ -411,7 +463,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue' import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue'
import { import {
@@ -755,6 +807,9 @@ const discountDialogVisible = ref(false)
const discountFormVisible = ref(false) const discountFormVisible = ref(false)
const discountLoading = ref(false) const discountLoading = ref(false)
const discountList = ref([]) const discountList = ref([])
const discountTotal = ref(0)
const discountPage = ref(1)
const discountPageSize = ref(10)
const discountFormType = ref('add') const discountFormType = ref('add')
const discountFormRef = ref(null) const discountFormRef = ref(null)
const currentGroupId = ref(undefined) const currentGroupId = ref(undefined)
@@ -777,12 +832,99 @@ const discountFormRules = {
good_group_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 () => { const loadProductOptions = async () => {
try { try {
const [pRes, gRes] = await Promise.all([ const [pRes, gRes] = await Promise.all([
getProductList({ page: 1, count: 1000 }), getProductList({ page: 1, count: 100 }),
getProductGroupList({ page: 1, count: 1000 }) getProductGroupList({ page: 1, count: 100 })
]) ])
if (pRes.data.code === 200) { if (pRes.data.code === 200) {
productOptions.value = pRes.data.data?.data || pRes.data.data || [] productOptions.value = pRes.data.data?.data || pRes.data.data || []
@@ -800,9 +942,20 @@ const fetchDiscountList = async () => {
if (!currentGroupId.value) return if (!currentGroupId.value) return
discountLoading.value = true discountLoading.value = true
try { try {
const res = await getUserGroupDiscountList({ user_group_id: currentGroupId.value, page: 1, count: 1000 }) const res = await getUserGroupDiscountList({
user_group_id: currentGroupId.value,
page: discountPage.value,
count: discountPageSize.value
})
if (res.data.code === 200) { if (res.data.code === 200) {
discountList.value = res.data.data || [] 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) { } catch (error) {
console.error('获取用户组优惠列表失败:', error) console.error('获取用户组优惠列表失败:', error)
@@ -812,11 +965,23 @@ const fetchDiscountList = async () => {
} }
} }
const handleDiscountSizeChange = (size) => {
discountPageSize.value = size
discountPage.value = 1
fetchDiscountList()
}
const handleDiscountPageChange = (page) => {
discountPage.value = page
fetchDiscountList()
}
// //
const handleViewDiscount = (row) => { const handleViewDiscount = (row) => {
currentGroupId.value = row.group_id || row.GroupId || row.id || row.Id currentGroupId.value = row.group_id || row.GroupId || row.id || row.Id
currentGroupName.value = row.group_name || row.name || row.Name || '' currentGroupName.value = row.group_name || row.name || row.Name || ''
discountDialogVisible.value = true discountDialogVisible.value = true
discountPage.value = 1
loadProductOptions() loadProductOptions()
fetchDiscountList() fetchDiscountList()
} }
@@ -839,22 +1004,27 @@ const handleAddDiscount = () => {
amount: 0, amount: 0,
percentage: 0 percentage: 0
}) })
Object.assign(discountTreeChecked, { name: '', nodeType: '', rawId: undefined })
discountFormRef.value?.resetFields() discountFormRef.value?.resetFields()
nextTick(() => {
discountTreeRef.value?.setCheckedKeys([])
})
} }
// //
const handleEditDiscount = (row) => { const handleEditDiscount = (row) => {
discountFormType.value = 'edit' discountFormType.value = 'edit'
discountFormVisible.value = true discountFormVisible.value = true
const isGroup = !!row.goodGroup const isGroup = !!(row.goodGroup && row.goodGroup.id)
Object.assign(discountForm, { Object.assign(discountForm, {
id: row.id, id: row.id,
bind_type: isGroup ? 'good_group' : 'good', bind_type: isGroup ? 'good_group' : 'good',
good_id: row.good ? row.good.id : undefined, good_id: (row.good && row.good.id) ? row.good.id : undefined,
good_group_id: row.goodGroup ? row.goodGroup.id : undefined, good_group_id: (row.goodGroup && row.goodGroup.id) ? row.goodGroup.id : undefined,
amount: row.amount ? row.amount / 100 : 0, amount: row.amount ? row.amount / 100 : 0,
percentage: row.percentage ? row.percentage / 100 : 0 percentage: row.percentage ? row.percentage / 100 : 0
}) })
loadProductOptions()
} }
// //
@@ -880,15 +1050,32 @@ const handleDeleteDiscount = (row) => {
// //
const submitDiscountForm = () => { const submitDiscountForm = () => {
discountFormRef.value?.validate(async (valid) => { discountFormRef.value?.validate(async (valid) => {
if (!valid) return if (!valid && discountFormType.value === 'edit') return
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
params.append('user_group_id', currentGroupId.value) params.append('user_group_id', currentGroupId.value)
if (discountForm.bind_type === 'good') {
params.append('good_id', discountForm.good_id) 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 { } else {
params.append('good_group_id', discountForm.good_group_id) //
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('amount', Math.round((discountForm.amount || 0) * 100))
params.append('percentage', Math.round((discountForm.percentage || 0) * 100)) params.append('percentage', Math.round((discountForm.percentage || 0) * 100))
@@ -896,7 +1083,6 @@ const submitDiscountForm = () => {
if (discountFormType.value === 'add') { if (discountFormType.value === 'add') {
res = await addUserGroupDiscount(params) res = await addUserGroupDiscount(params)
} else { } else {
params.append('id', discountForm.id)
res = await updateUserGroupDiscount(params) res = await updateUserGroupDiscount(params)
} }
if (res.data.code === 200) { if (res.data.code === 200) {
@@ -989,6 +1175,62 @@ onMounted(() => {
margin-bottom: 16px; 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-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; } .unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
+6 -3
View File
@@ -1945,10 +1945,13 @@ const initPage = () => {
if (activeTab.value === 'monitor') loadHistoricalMetrics() if (activeTab.value === 'monitor') loadHistoricalMetrics()
} }
watch(hostId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtHostDetail'
onActivated(() => {
watch(hostId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => {
isPageActive = true isPageActive = true
if (loadedHostId !== hostId.value) initPage() await nextTick()
if (isCurrentRoute() && loadedHostId !== hostId.value) initPage()
}) })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
onDeactivated(() => { isPageActive = false }) onDeactivated(() => { isPageActive = false })
+5 -3
View File
@@ -127,7 +127,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue' import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
@@ -319,8 +319,10 @@ const initPage = async () => {
loadHostStatus() loadHostStatus()
} }
watch(imageId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtImageDetail'
onActivated(() => { isPageActive = true; if (loadedImageId !== imageId.value) initPage() })
watch(imageId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedImageId !== imageId.value) initPage() })
onDeactivated(() => { isPageActive = false }) onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
</script> </script>
+93 -4
View File
@@ -33,6 +33,14 @@
<el-option label="IPv4" value="ipv4" /> <el-option label="IPv4" value="ipv4" />
<el-option label="IPv6" value="ipv6" /> <el-option label="IPv6" value="ipv6" />
</el-select> </el-select>
<el-select v-model="filterUsed" placeholder="使用状态" clearable style="width: 120px" @change="handleSearch">
<el-option label="已用" value="true" />
<el-option label="未用" value="false" />
</el-select>
<el-select v-model="filterDisable" placeholder="禁用状态" clearable style="width: 120px" @change="handleSearch">
<el-option label="已禁用" value="true" />
<el-option label="已启用" value="false" />
</el-select>
</div> </div>
<!-- 网络列表 --> <!-- 网络列表 -->
@@ -54,10 +62,34 @@
<el-table-column label="宿主机" width="140"> <el-table-column label="宿主机" width="140">
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template> <template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180" fixed="right"> <el-table-column label="使用状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="isNetworkUsed(row) ? 'success' : 'info'" size="small">
{{ isNetworkUsed(row) ? '已用' : '未用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="虚拟机" width="120">
<template #default="{ row }">
<el-button v-if="isNetworkUsed(row)" link type="primary" @click="goToVmDetail(row.vm_id)">
{{ row.vm_id }}
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="禁用" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.disable" type="danger" size="small">已禁用</el-tag>
<el-tag v-else type="success" size="small">启用</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button> <el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button> <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link :type="row.disable ? 'success' : 'warning'" @click="handleToggleDisable(row)">
{{ row.disable ? '启用' : '禁用' }}
</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button> <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -113,6 +145,10 @@
<el-form-item label="逻辑端口名"> <el-form-item label="逻辑端口名">
<el-input v-model="formData.ls_name" placeholder="不填使用默认" /> <el-input v-model="formData.ls_name" placeholder="不填使用默认" />
</el-form-item> </el-form-item>
<el-form-item label="禁用网络" v-if="dialogType === 'edit'">
<el-switch v-model="formData.disable" active-text="禁用" inactive-text="启用" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">禁用后该网络不参与自动分配</div>
</el-form-item>
</div> </div>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -142,6 +178,27 @@
<el-descriptions-item label="逻辑网桥">{{ currentDetail.ls_bridge_name || '-' }}</el-descriptions-item> <el-descriptions-item label="逻辑网桥">{{ currentDetail.ls_bridge_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="逻辑端口">{{ currentDetail.ls_name || '-' }}</el-descriptions-item> <el-descriptions-item label="逻辑端口">{{ currentDetail.ls_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标设备">{{ currentDetail.target_device || '-' }}</el-descriptions-item> <el-descriptions-item label="目标设备">{{ currentDetail.target_device || '-' }}</el-descriptions-item>
<el-descriptions-item label="使用状态">
<el-tag :type="isNetworkUsed(currentDetail) ? 'success' : 'info'" size="small">
{{ isNetworkUsed(currentDetail) ? '已用' : '未用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="虚拟机">
<el-button v-if="isNetworkUsed(currentDetail)" link type="primary" @click="goToVmDetail(currentDetail.vm_id)">
{{ currentDetail.vm_id }}
</el-button>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="主网卡">
<el-tag :type="currentDetail.is_primary ? 'warning' : 'info'" size="small">
{{ currentDetail.is_primary ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="禁用状态">
<el-tag :type="currentDetail.disable ? 'danger' : 'success'" size="small">
{{ currentDetail.disable ? '已禁用' : '启用' }}
</el-tag>
</el-descriptions-item>
</el-descriptions> </el-descriptions>
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template> <template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
</el-dialog> </el-dialog>
@@ -221,6 +278,15 @@ const total = ref(0)
const keyword = ref('') const keyword = ref('')
const filterType = ref('') const filterType = ref('')
const filterIpVersion = ref('') const filterIpVersion = ref('')
const filterUsed = ref('')
const filterDisable = ref('')
const isNetworkUsed = (row) => !!row?.vm_id
const goToVmDetail = (vmId) => {
if (!vmId) return
router.push({ path: '/virtualization/vm-detail', query: { service_id: serviceId.value, service_name: serviceName.value, vm_id: vmId } })
}
const hostIdInput = ref(0) const hostIdInput = ref(0)
const hostOptions = ref([]) const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 }) const queryParams = reactive({ page: 1, page_size: 10 })
@@ -254,7 +320,8 @@ const currentDetail = ref(null)
const formData = reactive({ const formData = reactive({
id: undefined, name: '', address: '', gateway: '', nameservers: '', id: undefined, name: '', address: '', gateway: '', nameservers: '',
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0 type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0,
disable: false
}) })
const formRules = { const formRules = {
@@ -275,6 +342,8 @@ const loadList = async () => {
if (keyword.value) params.key = keyword.value if (keyword.value) params.key = keyword.value
if (filterType.value) params.type = filterType.value if (filterType.value) params.type = filterType.value
if (filterIpVersion.value) params.ip_version = filterIpVersion.value if (filterIpVersion.value) params.ip_version = filterIpVersion.value
if (filterUsed.value !== '') params.used = filterUsed.value
if (filterDisable.value !== '') params.disable = filterDisable.value
const res = await getNetworkList(params) const res = await getNetworkList(params)
const body = res?.data const body = res?.data
if (body?.code === 200 && body?.data) { if (body?.code === 200 && body?.data) {
@@ -295,7 +364,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => { const handleAdd = () => {
dialogType.value = 'add' dialogType.value = 'add'
Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0 }) Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0, disable: false })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -305,7 +374,7 @@ const handleEdit = (row) => {
id: row.id, name: row.name, address: row.address, gateway: row.gateway, id: row.id, name: row.name, address: row.address, gateway: row.gateway,
nameservers: row.nameservers || '', type: row.type, mac_address: row.mac_address || '', nameservers: row.nameservers || '', type: row.type, mac_address: row.mac_address || '',
bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '', bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '',
ls_name: row.ls_name || '', host_id: row.host_id ls_name: row.ls_name || '', host_id: row.host_id, disable: !!row.disable
}) })
dialogVisible.value = true dialogVisible.value = true
} }
@@ -332,6 +401,7 @@ const handleSubmit = () => {
res = await createNetwork(fd) res = await createNetwork(fd)
} else { } else {
fd.append('id', formData.id) fd.append('id', formData.id)
fd.append('disable', formData.disable)
res = await updateNetwork(fd) res = await updateNetwork(fd)
} }
if (res?.data?.code === 200) { if (res?.data?.code === 200) {
@@ -359,6 +429,25 @@ const handleViewDetail = async (row) => {
} catch { /* fallback */ } } catch { /* fallback */ }
} }
const handleToggleDisable = (row) => {
const nextState = !row.disable
const actionText = nextState ? '禁用' : '启用'
ElMessageBox.confirm(`确定要${actionText}网络「${row.name}」吗?${nextState ? '禁用后该网络不参与自动分配。' : ''}`, `${actionText}确认`, {
confirmButtonText: `确定${actionText}`, cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('id', row.id)
fd.append('host_id', row.host_id)
fd.append('disable', nextState)
const res = await updateNetwork(fd)
if (res?.data?.code === 200) { ElMessage.success(`${actionText}成功`); loadList() }
else ElMessage.error(extractApiError(res?.data, `${actionText}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${actionText}失败`)) }
}).catch(() => {})
}
const handleDelete = (row) => { const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', { ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
+65 -16
View File
@@ -51,7 +51,7 @@
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button> <el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
<el-button link type="success" size="small" @click="handleRestore(row)" <el-button link type="success" size="small" @click="handleRestore(row)"
:disabled="row.status !== 'archived'">恢复</el-button> :disabled="!canRestore(row.status)">恢复</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)" <el-button link type="danger" size="small" @click="handleDelete(row)"
:disabled="row.status === 'restoring' || row.status === 'purging'">永久删除</el-button> :disabled="row.status === 'restoring' || row.status === 'purging'">永久删除</el-button>
</template> </template>
@@ -99,16 +99,33 @@
</el-dialog> </el-dialog>
<!-- 恢复弹窗可选指定网络 --> <!-- 恢复弹窗可选指定网络 -->
<el-dialog v-model="restoreVisible" title="恢复虚拟机" width="480px" destroy-on-close> <el-dialog v-model="restoreVisible" title="恢复虚拟机" width="520px" destroy-on-close>
<el-form label-width="120px"> <el-form label-width="120px">
<el-form-item label="虚拟机名称"> <el-form-item label="虚拟机名称">
<span>{{ restoreRow?.vm_name }} (ID: {{ restoreRow?.vm_id }})</span> <span>{{ restoreRow?.vm_name }} (ID: {{ restoreRow?.vm_id }})</span>
</el-form-item> </el-form-item>
<el-form-item label="网网络ID"> <el-form-item label="网网络">
<el-input v-model="restoreForm.network_ids" placeholder="可选,多个用逗号分隔" /> <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>
<el-form-item label="网网络ID"> <el-form-item label="网网络">
<el-input v-model="restoreForm.internet_network_id" placeholder="可选" /> <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-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -116,6 +133,17 @@
<el-button type="primary" :loading="restoreLoading" @click="submitRestore">确认恢复</el-button> <el-button type="primary" :loading="restoreLoading" @click="submitRestore">确认恢复</el-button>
</template> </template>
</el-dialog> </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> </div>
</template> </template>
@@ -128,6 +156,7 @@ import {
restoreRecycleBin, deleteRecycleBin, cleanRecycleBin restoreRecycleBin, deleteRecycleBin, cleanRecycleBin
} from '@/api/admin/kvmService' } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
const serviceId = inject('serviceId') const serviceId = inject('serviceId')
const hostId = inject('hostId') const hostId = inject('hostId')
@@ -143,21 +172,25 @@ const filterStatus = ref('')
const statusOptions = [ const statusOptions = [
{ value: 'pending', label: '等待归档' }, { value: 'pending', label: '等待归档' },
{ value: 'archiving', label: '归档中' }, { value: 'archiving', label: '归档中' },
{ value: 'ready', label: '就绪' },
{ value: 'archived', label: '已归档' }, { value: 'archived', label: '已归档' },
{ value: 'restoring', label: '恢复中' }, { value: 'restoring', label: '恢复中' },
{ value: 'purging', label: '清理中' } { value: 'purging', label: '清理中' }
] ]
const statusLabelMap = { const statusLabelMap = {
pending: '等待归档', archiving: '归档中', archived: '已归档', pending: '等待归档', archiving: '归档中', ready: '就绪', archived: '已归档',
restoring: '恢复中', purging: '清理中', failed: '失败', error: '错误' restoring: '恢复中', purging: '清理中', failed: '失败', error: '错误'
} }
const statusLabel = (s) => statusLabelMap[s] || s || '-' const statusLabel = (s) => statusLabelMap[s] || s || '-'
const statusTagType = (s) => ({ const statusTagType = (s) => ({
archived: 'success', pending: 'info', archiving: 'warning', ready: 'success', archived: 'success', pending: 'info', archiving: 'warning',
restoring: 'primary', purging: 'danger', failed: 'danger', error: 'danger' restoring: 'primary', purging: 'danger', failed: 'danger', error: 'danger'
}[s] || 'info') }[s] || 'info')
// ready archived
const canRestore = (status) => status === 'ready' || status === 'archived'
const formatTs = (ts) => { const formatTs = (ts) => {
if (!ts) return '-' if (!ts) return '-'
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN') if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
@@ -236,14 +269,30 @@ const handleDetail = async (row) => {
const restoreVisible = ref(false) const restoreVisible = ref(false)
const restoreLoading = ref(false) const restoreLoading = ref(false)
const restoreRow = ref(null) const restoreRow = ref(null)
const restoreForm = reactive({ network_ids: '', internet_network_id: '' })
//
const showBridgeNetSelector = ref(false)
const showNatNetSelector = ref(false)
const selectedBridgeNetwork = ref(null)
const selectedNatNetworks = ref([])
const handleRestore = (row) => { const handleRestore = (row) => {
restoreRow.value = row restoreRow.value = row
Object.assign(restoreForm, { network_ids: '', internet_network_id: '' }) selectedBridgeNetwork.value = null
selectedNatNetworks.value = []
restoreVisible.value = true 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 () => { const submitRestore = async () => {
restoreLoading.value = true restoreLoading.value = true
try { try {
@@ -251,13 +300,11 @@ const submitRestore = async () => {
fd.append('service_id', serviceId.value) fd.append('service_id', serviceId.value)
fd.append('host_id', hostId.value) fd.append('host_id', hostId.value)
fd.append('recycle_id', restoreRow.value.id) fd.append('recycle_id', restoreRow.value.id)
if (restoreForm.network_ids) { if (selectedNatNetworks.value.length) {
restoreForm.network_ids.split(',').map(s => s.trim()).filter(Boolean).forEach(id => { selectedNatNetworks.value.forEach(n => fd.append('network_ids', n.id))
fd.append('network_ids', id)
})
} }
if (restoreForm.internet_network_id) { if (selectedBridgeNetwork.value) {
fd.append('internet_network_id', restoreForm.internet_network_id) fd.append('internet_network_id', selectedBridgeNetwork.value.id)
} }
const res = await restoreRecycleBin(fd) const res = await restoreRecycleBin(fd)
if (res?.data?.code === 200) { if (res?.data?.code === 200) {
@@ -322,4 +369,6 @@ defineExpose({ loadList })
.mono-text { font-family: Consolas, Monaco, monospace; font-size: 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; } .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; } .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> </style>
+6 -3
View File
@@ -3956,11 +3956,14 @@ const loadSgLockInfo = async () => {
} }
} }
watch(vmId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtVmDetail'
watch(vmId, () => { if (isPageActive && isCurrentRoute()) initPage() })
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) }) watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
onActivated(() => { onActivated(async () => {
isPageActive = true isPageActive = true
if (loadedVmId !== vmId.value) initPage() await nextTick()
if (isCurrentRoute() && loadedVmId !== vmId.value) initPage()
}) })
onDeactivated(() => { isPageActive = false; stopMigratePolling() }) onDeactivated(() => { isPageActive = false; stopMigratePolling() })
onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() }) onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() })
+5 -3
View File
@@ -110,7 +110,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue' import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
@@ -273,8 +273,10 @@ const initPage = () => {
loadDetail() loadDetail()
} }
watch(volumeId, () => { if (isPageActive) initPage() }) const isCurrentRoute = () => route.name === 'VirtVolumeDetail'
onActivated(() => { isPageActive = true; if (loadedVolumeId !== volumeId.value) initPage() })
watch(volumeId, () => { if (isPageActive && isCurrentRoute()) initPage() })
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedVolumeId !== volumeId.value) initPage() })
onDeactivated(() => { isPageActive = false }) onDeactivated(() => { isPageActive = false })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
</script> </script>