3 Commits

Author SHA1 Message Date
shiran f62b4db0e1 Merge pull request 'feat: 用户详情代金券管理与优惠模块修复' (#26) from master into deploy
Build and Deploy Vue3 / build (push) Successful in 1m44s
Build and Deploy Vue3 / deploy (push) Successful in 40s
Reviewed-on: #26
2026-06-26 17:12:38 +08:00
shiran cae5551107 feat: 镜像管理新增mode模式字段支持(local/remote)
Build and Deploy Vue3 / build (push) Successful in 1m40s
Build and Deploy Vue3 / deploy (push) Successful in 40s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 17:08:01 +08:00
shiran 4bf7c4857b feat: 用户详情代金券管理与优惠模块修复
Build and Deploy Vue3 / build (push) Successful in 1m39s
Build and Deploy Vue3 / deploy (push) Successful in 42s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 16:46:57 +08:00
16 changed files with 904 additions and 119 deletions
@@ -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
+8 -15
View File
@@ -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()
}
+7 -9
View File
@@ -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}`
}
// 获取代金券列表
+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>
<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) {
+14 -3
View File
@@ -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;
+2
View File
@@ -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>
+309 -1
View File
@@ -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
View File
@@ -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; }
+6 -3
View File
@@ -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 })
+18 -6
View File
@@ -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>
+29 -5
View File
@@ -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] })
+93 -4
View File
@@ -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'
+65 -16
View File
@@ -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>
+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) })
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() })
+5 -3
View File
@@ -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>