Compare commits
2 Commits
5c2fef6c9d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cae5551107 | |||
| 4bf7c4857b |
@@ -36,13 +36,18 @@
|
||||
<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"
|
||||
@@ -118,6 +123,7 @@ 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
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
</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>查询
|
||||
@@ -203,15 +200,14 @@ const router = useRouter()
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
user_id: undefined,
|
||||
code_id: props.codeId || undefined,
|
||||
id: '',
|
||||
id: props.codeId || undefined,
|
||||
page: 1,
|
||||
count: 10
|
||||
})
|
||||
|
||||
watch(() => props.codeId, (newVal) => {
|
||||
if (newVal) {
|
||||
queryParams.code_id = newVal
|
||||
queryParams.id = newVal
|
||||
fetchHistoryList()
|
||||
}
|
||||
})
|
||||
@@ -285,8 +281,8 @@ const statistics = computed(() => {
|
||||
})
|
||||
// 获取查询用户名称
|
||||
const getQueryUserName = () => {
|
||||
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id)
|
||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}`
|
||||
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}`
|
||||
}
|
||||
|
||||
// 获取使用记录列表
|
||||
@@ -347,15 +343,13 @@ const confirmUserSelection = (user) => {
|
||||
}
|
||||
|
||||
if (selectorType.value === 'query') {
|
||||
// 查询表单选择
|
||||
queryParams.user_id = user.UserId
|
||||
queryParams.user_id = user.user_id
|
||||
} else {
|
||||
// 编辑表单选择
|
||||
editForm.user_id = user.UserId
|
||||
editForm.user_id = user.user_id
|
||||
}
|
||||
|
||||
// 将选中的用户添加到 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)
|
||||
}
|
||||
|
||||
@@ -372,8 +366,7 @@ const handleQuery = () => {
|
||||
// 重置查询
|
||||
const resetQuery = () => {
|
||||
queryParams.user_id = undefined
|
||||
queryParams.code_id = undefined
|
||||
queryParams.id = ''
|
||||
queryParams.id = undefined
|
||||
queryParams.page = 1
|
||||
fetchHistoryList()
|
||||
}
|
||||
|
||||
@@ -423,15 +423,13 @@ const confirmUserSelection = (user) => {
|
||||
}
|
||||
|
||||
if (selectorType.value === 'query') {
|
||||
// 查询表单选择
|
||||
queryParams.user_id = user.UserId
|
||||
queryParams.user_id = user.user_id
|
||||
} else {
|
||||
// 编辑表单选择
|
||||
editForm.user_id = user.UserId
|
||||
editForm.user_id = user.user_id
|
||||
}
|
||||
|
||||
// 将选中的用户添加到 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)
|
||||
}
|
||||
|
||||
@@ -451,14 +449,14 @@ const clearEditUser = () => {
|
||||
|
||||
// 获取查询用户名称
|
||||
const getQueryUserName = () => {
|
||||
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id)
|
||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}`
|
||||
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 getEditUserName = () => {
|
||||
const user = UserOptions.value.find(u => u.UserId === editForm.user_id)
|
||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${editForm.user_id}`
|
||||
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}`
|
||||
}
|
||||
|
||||
// 获取代金券列表
|
||||
|
||||
@@ -122,6 +122,12 @@
|
||||
<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>
|
||||
@@ -207,6 +213,7 @@
|
||||
<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>
|
||||
@@ -393,6 +400,11 @@
|
||||
</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
|
||||
@@ -761,6 +773,9 @@
|
||||
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"
|
||||
@@ -893,7 +908,8 @@ const groupForm = reactive({
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
tag_id: undefined,
|
||||
index: 0
|
||||
index: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
|
||||
const groupRules = {
|
||||
@@ -949,7 +965,8 @@ const productForm = reactive({
|
||||
max_first_purchase_duration: 0,
|
||||
send_notice: false,
|
||||
renew_price: 0,
|
||||
renew_recommend_rebate: 0
|
||||
renew_recommend_rebate: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
|
||||
const productRules = {
|
||||
@@ -1496,7 +1513,8 @@ const handleAdd = (parentRow) => {
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
tag_id: parentTagId || undefined,
|
||||
index: 0
|
||||
index: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag)
|
||||
} else {
|
||||
@@ -1511,7 +1529,8 @@ const handleAdd = (parentRow) => {
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
tag_id: undefined,
|
||||
index: 0
|
||||
index: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1532,7 +1551,8 @@ const handleEdit = (row) => {
|
||||
cover_id: row.coverId || undefined,
|
||||
cover_url: row.cover || '',
|
||||
tag_id: row.tag?.id || row.tagId || undefined,
|
||||
index: row.index || 0
|
||||
index: row.index || 0,
|
||||
recommend_words: row.recommendWords || ''
|
||||
})
|
||||
|
||||
dialogVisible.value = true
|
||||
@@ -1602,7 +1622,8 @@ const handleAddProduct = () => {
|
||||
arg_type: 'all',
|
||||
require_real_name: false,
|
||||
max_per_user: 0,
|
||||
max_first_purchase_duration: 0
|
||||
max_first_purchase_duration: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
|
||||
selectedProductGroup.value = null
|
||||
@@ -1642,7 +1663,8 @@ const handleEditProduct = (product, parentGroupId) => {
|
||||
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
|
||||
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0,
|
||||
recommend_words: product.recommendWords ?? product.recommend_words ?? ''
|
||||
})
|
||||
|
||||
productDialogVisible.value = true
|
||||
@@ -1674,7 +1696,8 @@ const submitProductForm = () => {
|
||||
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
|
||||
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0,
|
||||
recommend_words: productForm.recommend_words || ''
|
||||
}
|
||||
|
||||
let res
|
||||
@@ -1813,7 +1836,8 @@ const submitForm = () => {
|
||||
name: groupForm.name.trim(),
|
||||
note: groupForm.note || '',
|
||||
disable: groupForm.disable,
|
||||
index: Number(groupForm.index) || 0
|
||||
index: Number(groupForm.index) || 0,
|
||||
recommend_words: groupForm.recommend_words || ''
|
||||
}
|
||||
|
||||
if (groupForm.parent_id) {
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
<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">
|
||||
@@ -60,6 +67,7 @@
|
||||
<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>
|
||||
@@ -133,6 +141,7 @@
|
||||
<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>
|
||||
@@ -513,7 +522,7 @@
|
||||
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 } 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 { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { formatToApiTime } from '@/utils/tool'
|
||||
@@ -536,8 +545,8 @@ const showFilterUserSelector = ref(false)
|
||||
const showFilterProductSelector = ref(false)
|
||||
|
||||
// 用户商品数量统计
|
||||
const counts = reactive({ normal: 0, deleted: 0, expired: 0 })
|
||||
const countTotal = computed(() => (counts.normal || 0) + (counts.deleted || 0) + (counts.expired || 0))
|
||||
const 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') : '-'
|
||||
|
||||
@@ -616,6 +625,7 @@ const loadCount = async () => {
|
||||
counts.normal = d.normal ?? 0
|
||||
counts.deleted = d.deleted ?? 0
|
||||
counts.expired = d.expired ?? 0
|
||||
counts.pending = d.pending ?? 0
|
||||
}
|
||||
} catch { /* 统计失败不阻断列表 */ }
|
||||
}
|
||||
@@ -1054,6 +1064,7 @@ onMounted(() => { loadList(); loadCount() })
|
||||
.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; }
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
<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">
|
||||
@@ -471,6 +474,14 @@ 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
|
||||
}
|
||||
@@ -1018,6 +1029,14 @@ 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;
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<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>
|
||||
@@ -86,6 +87,7 @@
|
||||
<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>
|
||||
|
||||
@@ -294,6 +294,64 @@
|
||||
</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>
|
||||
@@ -496,6 +554,49 @@
|
||||
</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">
|
||||
@@ -536,7 +637,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
|
||||
UploadFilled, Key, Monitor, Setting, Close, Plus
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getUserGroupList, getUserBalanceCount } from '@/api/admin/user'
|
||||
import { getFileDetail } from '@/api/admin/file'
|
||||
@@ -549,6 +650,13 @@ 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()
|
||||
@@ -610,6 +718,27 @@ 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,
|
||||
@@ -734,6 +863,8 @@ const handleTabClick = (tab) => {
|
||||
fetchUserTicketList()
|
||||
} else if (tab.props.name === '5') {
|
||||
fetchUserGoodsList()
|
||||
} else if (tab.props.name === '6') {
|
||||
fetchUserVoucherListData()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1126,6 +1257,169 @@ 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
|
||||
@@ -1450,6 +1744,7 @@ 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 () => {
|
||||
@@ -1868,4 +2163,17 @@ 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>
|
||||
|
||||
+283
-41
@@ -317,10 +317,10 @@
|
||||
<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" type="primary" effect="plain">
|
||||
<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" 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 }})
|
||||
</el-tag>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
@@ -348,13 +348,25 @@
|
||||
<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="560px"
|
||||
width="620px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form
|
||||
@@ -363,32 +375,72 @@
|
||||
:rules="discountFormRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<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 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" />
|
||||
@@ -411,7 +463,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -755,6 +807,9 @@ 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)
|
||||
@@ -777,12 +832,99 @@ const discountFormRules = {
|
||||
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: 1000 }),
|
||||
getProductGroupList({ page: 1, count: 1000 })
|
||||
getProductList({ page: 1, count: 100 }),
|
||||
getProductGroupList({ page: 1, count: 100 })
|
||||
])
|
||||
if (pRes.data.code === 200) {
|
||||
productOptions.value = pRes.data.data?.data || pRes.data.data || []
|
||||
@@ -800,9 +942,20 @@ const fetchDiscountList = async () => {
|
||||
if (!currentGroupId.value) return
|
||||
discountLoading.value = true
|
||||
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) {
|
||||
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) {
|
||||
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) => {
|
||||
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()
|
||||
}
|
||||
@@ -839,22 +1004,27 @@ const handleAddDiscount = () => {
|
||||
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
|
||||
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 : undefined,
|
||||
good_group_id: row.goodGroup ? row.goodGroup.id : undefined,
|
||||
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()
|
||||
}
|
||||
|
||||
// 删除优惠绑定
|
||||
@@ -880,15 +1050,32 @@ const handleDeleteDiscount = (row) => {
|
||||
// 提交优惠绑定表单
|
||||
const submitDiscountForm = () => {
|
||||
discountFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
if (!valid && discountFormType.value === 'edit') return
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
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 {
|
||||
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('percentage', Math.round((discountForm.percentage || 0) * 100))
|
||||
|
||||
@@ -896,7 +1083,6 @@ const submitDiscountForm = () => {
|
||||
if (discountFormType.value === 'add') {
|
||||
res = await addUserGroupDiscount(params)
|
||||
} else {
|
||||
params.append('id', discountForm.id)
|
||||
res = await updateUserGroupDiscount(params)
|
||||
}
|
||||
if (res.data.code === 200) {
|
||||
@@ -989,6 +1175,62 @@ onMounted(() => {
|
||||
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; }
|
||||
|
||||
|
||||
@@ -1945,10 +1945,13 @@ const initPage = () => {
|
||||
if (activeTab.value === 'monitor') loadHistoricalMetrics()
|
||||
}
|
||||
|
||||
watch(hostId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => {
|
||||
const isCurrentRoute = () => route.name === 'VirtHostDetail'
|
||||
|
||||
watch(hostId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
onActivated(async () => {
|
||||
isPageActive = true
|
||||
if (loadedHostId !== hostId.value) initPage()
|
||||
await nextTick()
|
||||
if (isCurrentRoute() && loadedHostId !== hostId.value) initPage()
|
||||
})
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
|
||||
@@ -32,8 +32,13 @@
|
||||
<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>
|
||||
@@ -79,6 +84,11 @@
|
||||
<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" />
|
||||
@@ -127,7 +137,7 @@
|
||||
</template>
|
||||
|
||||
<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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
||||
@@ -160,7 +170,7 @@ const syncHostId = ref('')
|
||||
const reloadHostId = ref('')
|
||||
const formRef = ref(null)
|
||||
|
||||
const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '' })
|
||||
const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', mode: 'local' })
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
path: [{ required: true, message: '请输入路径', trigger: 'blur' }]
|
||||
@@ -232,7 +242,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 || '' })
|
||||
Object.assign(formData, { name: d.name || '', path: d.path || '', os_type: d.os_type || 'linux', type: d.type || 'system', description: d.description || '', status: d.status || '', mode: d.mode || 'local' })
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -241,7 +251,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 }
|
||||
const payload = { image_id: imageId.value, service_id: serviceId.value, image_name: formData.name, path: formData.path, os_type: formData.os_type, type: formData.type, description: formData.description || undefined, status: formData.status || undefined, mode: formData.mode }
|
||||
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
|
||||
const res = await updateImage(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||
@@ -319,8 +329,10 @@ const initPage = async () => {
|
||||
loadHostStatus()
|
||||
}
|
||||
|
||||
watch(imageId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => { isPageActive = true; if (loadedImageId !== imageId.value) initPage() })
|
||||
const isCurrentRoute = () => route.name === 'VirtImageDetail'
|
||||
|
||||
watch(imageId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedImageId !== imageId.value) initPage() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
</script>
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
<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>
|
||||
@@ -109,6 +114,13 @@
|
||||
<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>
|
||||
@@ -148,10 +160,19 @@
|
||||
<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>
|
||||
@@ -291,7 +312,7 @@ const currentHostLabel = computed(() => {
|
||||
|
||||
const formData = reactive({
|
||||
image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system',
|
||||
description: '', status: '', size: 0, image_name: ''
|
||||
description: '', status: '', size: 0, image_name: '', mode: 'local'
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
@@ -400,7 +421,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 })
|
||||
Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0, mode: 'local' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -409,7 +430,8 @@ 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
|
||||
description: row.description || '', status: row.status || '', size: row.size || 0,
|
||||
mode: row.mode || 'local'
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -423,7 +445,8 @@ 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
|
||||
os_type: formData.os_type, type: formData.type, description: formData.description || undefined,
|
||||
mode: formData.mode
|
||||
})
|
||||
} else {
|
||||
const payload = {
|
||||
@@ -431,7 +454,8 @@ 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
|
||||
status: formData.status || undefined, size: formData.size || undefined,
|
||||
mode: formData.mode
|
||||
}
|
||||
// 清除 undefined
|
||||
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
<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>
|
||||
|
||||
<!-- 网络列表 -->
|
||||
@@ -54,10 +62,34 @@
|
||||
<el-table-column label="宿主机" width="140">
|
||||
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
|
||||
</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 }">
|
||||
<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>
|
||||
@@ -113,6 +145,10 @@
|
||||
<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>
|
||||
@@ -142,6 +178,27 @@
|
||||
<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>
|
||||
@@ -221,6 +278,15 @@ 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 })
|
||||
@@ -254,7 +320,8 @@ 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
|
||||
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0,
|
||||
disable: false
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
@@ -275,6 +342,8 @@ 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) {
|
||||
@@ -295,7 +364,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 })
|
||||
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
|
||||
}
|
||||
|
||||
@@ -305,7 +374,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
|
||||
ls_name: row.ls_name || '', host_id: row.host_id, disable: !!row.disable
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -332,6 +401,7 @@ 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) {
|
||||
@@ -359,6 +429,25 @@ 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'
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
|
||||
<el-button link type="success" size="small" @click="handleRestore(row)"
|
||||
:disabled="row.status !== 'archived'">恢复</el-button>
|
||||
: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>
|
||||
@@ -99,16 +99,33 @@
|
||||
</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-item label="虚拟机名称">
|
||||
<span>{{ restoreRow?.vm_name }} (ID: {{ restoreRow?.vm_id }})</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="内网网络ID">
|
||||
<el-input v-model="restoreForm.network_ids" placeholder="可选,多个用逗号分隔" />
|
||||
<el-form-item 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="外网网络ID">
|
||||
<el-input v-model="restoreForm.internet_network_id" placeholder="可选" />
|
||||
<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>
|
||||
@@ -116,6 +133,17 @@
|
||||
<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>
|
||||
|
||||
@@ -128,6 +156,7 @@ import {
|
||||
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')
|
||||
@@ -143,21 +172,25 @@ 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: '归档中', archived: '已归档',
|
||||
pending: '等待归档', archiving: '归档中', ready: '就绪', archived: '已归档',
|
||||
restoring: '恢复中', purging: '清理中', failed: '失败', error: '错误'
|
||||
}
|
||||
const statusLabel = (s) => statusLabelMap[s] || 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'
|
||||
}[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')
|
||||
@@ -236,14 +269,30 @@ const handleDetail = async (row) => {
|
||||
const restoreVisible = ref(false)
|
||||
const restoreLoading = ref(false)
|
||||
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) => {
|
||||
restoreRow.value = row
|
||||
Object.assign(restoreForm, { network_ids: '', internet_network_id: '' })
|
||||
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 {
|
||||
@@ -251,13 +300,11 @@ const submitRestore = async () => {
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('host_id', hostId.value)
|
||||
fd.append('recycle_id', restoreRow.value.id)
|
||||
if (restoreForm.network_ids) {
|
||||
restoreForm.network_ids.split(',').map(s => s.trim()).filter(Boolean).forEach(id => {
|
||||
fd.append('network_ids', id)
|
||||
})
|
||||
if (selectedNatNetworks.value.length) {
|
||||
selectedNatNetworks.value.forEach(n => fd.append('network_ids', n.id))
|
||||
}
|
||||
if (restoreForm.internet_network_id) {
|
||||
fd.append('internet_network_id', restoreForm.internet_network_id)
|
||||
if (selectedBridgeNetwork.value) {
|
||||
fd.append('internet_network_id', selectedBridgeNetwork.value.id)
|
||||
}
|
||||
const res = await restoreRecycleBin(fd)
|
||||
if (res?.data?.code === 200) {
|
||||
@@ -322,4 +369,6 @@ defineExpose({ loadList })
|
||||
.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>
|
||||
|
||||
@@ -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) })
|
||||
onActivated(() => {
|
||||
onActivated(async () => {
|
||||
isPageActive = true
|
||||
if (loadedVmId !== vmId.value) initPage()
|
||||
await nextTick()
|
||||
if (isCurrentRoute() && loadedVmId !== vmId.value) initPage()
|
||||
})
|
||||
onDeactivated(() => { isPageActive = false; stopMigratePolling() })
|
||||
onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() })
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
||||
@@ -273,8 +273,10 @@ const initPage = () => {
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
watch(volumeId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => { isPageActive = true; if (loadedVolumeId !== volumeId.value) initPage() })
|
||||
const isCurrentRoute = () => route.name === 'VirtVolumeDetail'
|
||||
|
||||
watch(volumeId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedVolumeId !== volumeId.value) initPage() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user