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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shiran
2026-06-26 16:46:57 +08:00
co-authored by Cursor
parent 6f82e5e79d
commit 4bf7c4857b
15 changed files with 862 additions and 111 deletions
+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 })
+5 -3
View File
@@ -127,7 +127,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'
@@ -319,8 +319,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>
+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>