feat: 添加用户虚拟机商品管理
Build and Deploy Vue3 / build (push) Successful in 1m40s
Build and Deploy Vue3 / deploy (push) Successful in 1m8s

This commit is contained in:
2026-03-31 15:13:04 +08:00
parent 71d3605f4f
commit c07e09c151
28 changed files with 7143 additions and 621 deletions
+103
View File
@@ -0,0 +1,103 @@
import { http2 } from '@/utils/request.js'
const BASE = '/api/v1/admin/good/user_vm'
const GOODS_BASE = '/api/v1/admin/good/user_goods'
const fd = (data) => {
const f = new FormData()
Object.entries(data).forEach(([k, v]) => {
if (v === undefined || v === null || v === '') return
// 数组类型逐个 append(如 network_ids
if (Array.isArray(v)) {
v.forEach(item => f.append(k, item))
} else {
f.append(k, v)
}
})
return f
}
// ========== 用户虚拟机 ==========
export const getUserVmList = (params) => http2.get(`${BASE}/list`, { params })
export const getUserVmDetail = (params) => http2.get(`${BASE}/detail`, { params })
export const getUserVmVnc = (params) => http2.get(`${BASE}/vnc`, { params })
export const getUserVmHostImages = (params) => http2.get(`${BASE}/host_images`, { params })
export const createUserVm = (data) => http2.post(`${BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const bindUserVm = (data) => http2.post(`${BASE}/bind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const transferUserVm = (data) => http2.post(`${BASE}/transfer`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const migrateUserVm = (data) => http2.post(`${BASE}/migrate`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserVmTraffic = (data) => http2.post(`${BASE}/update_traffic`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserVm = (data) => http2.post(`${BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const refactorUserVm = (data) => http2.post(`${BASE}/refactor`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const startUserVm = (data) => http2.post(`${BASE}/start`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const stopUserVm = (data) => http2.post(`${BASE}/stop`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const rebootUserVm = (data) => http2.post(`${BASE}/reboot`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const suspendUserVm = (data) => http2.post(`${BASE}/suspend`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const resumeUserVm = (data) => http2.post(`${BASE}/resume`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const rescueUserVm = (data) => http2.post(`${BASE}/rescue`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const exitRescueUserVm = (data) => http2.post(`${BASE}/exit_rescue`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const rebuildUserVm = (data) => http2.post(`${BASE}/rebuild`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVm = (params) => http2.delete(`${BASE}/delete`, { params })
// ========== 数据卷 ==========
export const getUserVmVolumeList = (params) => http2.get(`${BASE}/volume/list`, { params })
export const getUserVmVolumeDetail = (params) => http2.get(`${BASE}/volume/detail`, { params })
export const createUserVmVolume = (data) => http2.post(`${BASE}/volume/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const resizeUserVmVolume = (data) => http2.post(`${BASE}/volume/resize`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const mountUserVmVolume = (data) => http2.post(`${BASE}/volume/mount`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const unmountUserVmVolume = (data) => http2.post(`${BASE}/volume/unmount`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmVolume = (params) => http2.delete(`${BASE}/volume/delete`, { params })
// ========== 快照 ==========
export const getUserVmSnapshotList = (params) => http2.get(`${BASE}/snapshot/list`, { params })
export const getUserVmSnapshotProgress = (params) => http2.get(`${BASE}/snapshot/progress`, { params })
export const getUserVmSnapshotCount = (params) => http2.get(`${BASE}/snapshot/count`, { params })
export const createUserVmSnapshot = (data) => http2.post(`${BASE}/snapshot/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const restoreUserVmSnapshot = (data) => http2.post(`${BASE}/snapshot/restore`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmSnapshot = (data) => http2.post(`${BASE}/snapshot/delete`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const setUserVmSnapshotLimit = (data) => http2.post(`${BASE}/snapshot/set_limit`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
// ========== 备份 ==========
export const getUserVmBackupList = (params) => http2.get(`${BASE}/backup/list`, { params })
export const getUserVmBackupProgress = (params) => http2.get(`${BASE}/backup/progress`, { params })
export const getUserVmBackupCount = (params) => http2.get(`${BASE}/backup/count`, { params })
export const createUserVmBackup = (data) => http2.post(`${BASE}/backup/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const restoreUserVmBackup = (data) => http2.post(`${BASE}/backup/restore`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmBackup = (data) => http2.post(`${BASE}/backup/delete`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const setUserVmBackupLimit = (data) => http2.post(`${BASE}/backup/set_limit`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
// ========== 安全组 ==========
export const getUserVmPostGroupList = (params) => http2.get(`${BASE}/post_group/list`, { params })
export const getUserVmPostGroupDetail = (params) => http2.get(`${BASE}/post_group/detail`, { params })
export const getUserVmPostGroupUserList = (params) => http2.get(`${BASE}/post_group/user_list`, { params })
export const createUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const bindUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/bind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const unbindUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/unbind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const applyUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/apply`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const setSharedUserVmPostGroup = (data) => http2.post(`${BASE}/post_group/set_shared`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmPostGroup = (params) => http2.delete(`${BASE}/post_group/delete`, { params })
export const enableUserVmPostGroupWhitelist = (data) => http2.post(`${BASE}/post_group/enable_whitelist`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const disableUserVmPostGroupWhitelist = (data) => http2.post(`${BASE}/post_group/disable_whitelist`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const createUserVmPostGroupRule = (data) => http2.post(`${BASE}/post_group/create_rule`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserVmPostGroupRule = (data) => http2.post(`${BASE}/post_group/update_rule`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmPostGroupRule = (params) => http2.delete(`${BASE}/post_group/delete_rule`, { params })
// ========== 网络 ==========
export const getUserVmNetworkList = (params) => http2.get(`${BASE}/network/list`, { params })
export const getUserVmNetworkDetail = (params) => http2.get(`${BASE}/network/detail`, { params })
// ========== 组网 ==========
export const getUserVmNetworkingList = (params) => http2.get(`${BASE}/networking/list`, { params })
export const getUserVmNetworkingDetail = (params) => http2.get(`${BASE}/networking/detail`, { params })
export const createUserVmNetworking = (data) => http2.post(`${BASE}/networking/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const assignUserVmNetworking = (data) => http2.post(`${BASE}/networking/assign`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const removeUserVmNetworkingNetwork = (data) => http2.post(`${BASE}/networking/remove_network`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserVmNetworking = (params) => http2.delete(`${BASE}/networking/delete`, { params })
// ========== 用户商品 ==========
export const getUserGoodsList = (params) => http2.get(`${GOODS_BASE}/list`, { params })
export const getUserGoodsDetail = (params) => http2.get(`${GOODS_BASE}/detail`, { params })
export const createUserGoods = (data) => http2.post(`${GOODS_BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
export const deleteUserGoods = (params) => http2.delete(`${GOODS_BASE}/delete`, { params })
+1 -1
View File
@@ -46,7 +46,7 @@
<el-table-column prop="user_name" label="用户名" min-width="130">
<template #default="{ row }">
<div class="user-name-cell">
<el-avatar v-if="row.avatar" :src="row.avatar" :size="28" />
<el-avatar v-if="row.cover" :src="row.cover" :size="28" />
<el-avatar v-else :size="28">
{{ row.user_name?.charAt(0)?.toUpperCase() || 'U' }}
</el-avatar>
+36 -10
View File
@@ -1,13 +1,21 @@
<template>
<el-dialog v-model="visible" title="选择宿主机组" width="600px" append-to-body @close="handleClose">
<el-dialog v-model="visible" title="选择宿主机组" width="650px" append-to-body @close="handleClose">
<div class="selector-container">
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索宿主机组名称" clearable style="width:200px" @keyup.enter="handleSearch" @clear="handleSearch" />
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<el-table v-loading="loading" :data="filteredList" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || row.Note || '-' }}</template>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column prop="serviceId" label="服务ID" width="80" />
</el-table>
<div class="pagination-wrapper" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" small @current-change="loadList" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
@@ -17,7 +25,8 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getHostGroupList } from '@/api/admin/kvmService'
const props = defineProps({
@@ -32,25 +41,40 @@ const visible = ref(false)
const loading = ref(false)
const list = ref([])
const selectedItem = ref(null)
const keyword = ref('')
const page = ref(1)
const pageSize = 10
const total = ref(0)
const filteredList = computed(() => {
if (!keyword.value) return list.value
const kw = keyword.value.toLowerCase()
return list.value.filter(i => (i.name || '').toLowerCase().includes(kw))
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) loadList()
if (val) { page.value = 1; loadList() }
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
loading.value = true
try {
const res = await getHostGroupList({ service_id: props.serviceId })
const res = await getHostGroupList({ service_id: props.serviceId, page: page.value, count: pageSize })
const body = res?.data
if (body?.code === 200 && body?.data) {
const items = Array.isArray(body.data) ? body.data : (body.data.data || body.data.list || [])
list.value = items.map(i => ({
id: i.Id ?? i.id,
name: i.Name ?? i.name,
note: i.Note ?? i.note
id: i.id,
name: i.name ?? i.Name,
note: i.note ?? i.Note,
serviceId: i.serviceId ?? i.service_id ?? 0,
serviceHostGroupId: i.serviceHostGroupId ?? 0
}))
total.value = body.data.total ?? body.data.all_count ?? list.value.length
}
} catch { /* ignore */ }
finally { loading.value = false }
@@ -69,6 +93,8 @@ const handleClose = () => { selectedItem.value = null }
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 8px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
@@ -0,0 +1,98 @@
<template>
<el-dialog v-model="visible" title="选择宿主机" width="700px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索宿主机名称/IP" clearable style="width:200px" @keyup.enter="loadList" @clear="loadList" />
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<el-table v-loading="loading" :data="filteredList" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="ip" label="IP" min-width="130" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" small @current-change="loadList" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getRemoteHostList } from '@/api/admin/kvmService'
const props = defineProps({
modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 },
hostGroupId: { type: Number, default: 0 },
currentId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const selectedItem = ref(null)
const keyword = ref('')
const page = ref(1)
const pageSize = 10
const total = ref(0)
const filteredList = computed(() => {
if (!keyword.value) return list.value
const kw = keyword.value.toLowerCase()
return list.value.filter(i => (i.name || '').toLowerCase().includes(kw) || (i.ip || '').includes(kw))
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) { page.value = 1; loadList() }
})
watch(visible, (val) => emit('update:modelValue', val))
const loadList = async () => {
loading.value = true
try {
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
if (props.hostGroupId) params.host_group_id = props.hostGroupId
const res = await getRemoteHostList(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
const hosts = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
list.value = hosts.map(i => ({
id: i.id, name: i.name, ip: i.ip, is_active: i.is_active ?? true,
host_group_id: i.host_group_id
}))
total.value = inner.total ?? list.value.length
}
} catch { /* ignore */ }
finally { loading.value = false }
}
const rowClassName = ({ row }) => row.id === props.currentId ? 'current-row' : ''
const handleCurrentChange = (row) => { selectedItem.value = row }
const handleConfirm = () => {
if (selectedItem.value) { emit('confirm', selectedItem.value); visible.value = false }
}
const handleClose = () => { selectedItem.value = null }
</script>
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 8px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
+21 -17
View File
@@ -2,35 +2,32 @@
<el-dialog v-model="visible" title="选择镜像" width="700px" append-to-body @close="handleClose">
<div class="selector-container">
<div class="filter-bar">
<el-input v-model="keyword" placeholder="搜索镜像名称" clearable style="width: 200px" @keyup.enter="loadList" @clear="loadList">
<el-input v-model="keyword" placeholder="搜索镜像名称" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 120px" @change="loadList">
<el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 120px" @change="handleSearch">
<el-option label="Linux" value="linux" />
<el-option label="Windows" value="windows" />
</el-select>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange" :height="300" :row-class-name="rowClassName">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="系统" width="80">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" min-width="200" show-overflow-tooltip />
<el-table-column label="系统" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ row.os_type }}</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="80">
<el-table-column label="类型" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="{ ready: 'success', error: 'danger', downloading: 'warning' }[row.status] || 'info'" size="small">
{{ { ready: '就绪', error: '错误', downloading: '下载中', pending: '等待中' }[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" small @current-change="loadList" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
@@ -41,7 +38,7 @@
<script setup>
import { ref, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getImageList } from '@/api/admin/kvmService'
const props = defineProps({
@@ -58,24 +55,30 @@ const list = ref([])
const selectedItem = ref(null)
const keyword = ref('')
const filterOsType = ref('')
const page = ref(1)
const pageSize = 10
const total = ref(0)
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) loadList()
if (val) { page.value = 1; loadList() }
})
watch(visible, (val) => emit('update:modelValue', val))
const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => {
loading.value = true
try {
const params = { service_id: props.serviceId, page: 1, count: 10 }
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
if (keyword.value) params.keyword = keyword.value
if (filterOsType.value) params.os_type = filterOsType.value
const res = await getImageList(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
list.value = inner.data || (Array.isArray(inner) ? inner : [])
list.value = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
total.value = inner.total ?? inner.all_count ?? list.value.length
}
} catch { /* ignore */ }
finally { loading.value = false }
@@ -95,6 +98,7 @@ const handleClose = () => { selectedItem.value = null }
<style scoped>
.selector-container { min-height: 200px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 8px; }
:deep(.current-row) { background-color: #ecf5ff !important; }
:deep(.el-table__body tr) { cursor: pointer; }
</style>
@@ -0,0 +1,83 @@
<template>
<el-dialog v-model="visible" title="选择主控服务" width="640px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-input v-model="keyword" placeholder="搜索服务名称/地址" clearable style="width:220px"
@keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="handleRefresh" :loading="loading">刷新</el-button>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row
@current-change="row => selected = row" :height="320" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="服务名称" min-width="160" show-overflow-tooltip />
<el-table-column label="地址" min-width="180">
<template #default="{ row }">
<span style="font-family:monospace;color:#409eff">{{ row.host }}:{{ row.port }}</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无主控服务" />
<div class="selector-footer-bar">
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getKvmServiceList } from '@/api/admin/kvmService'
const props = defineProps({ modelValue: { type: Boolean, default: false } })
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const selected = ref(null)
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
loading.value = true
try {
const params = { page: page.value, count: pageSize.value }
if (keyword.value) params.key = keyword.value
const res = await getKvmServiceList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
list.value = raw.map(s => ({ id: s.id ?? s.Id, name: s.name ?? s.Name, host: s.host ?? s.Host, port: s.port ?? s.Port, note: s.note ?? s.Note }))
total.value = inner.all_count ?? inner.total ?? list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; loadList() }
const handleRefresh = () => { keyword.value = ''; page.value = 1; loadList() }
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
</style>
+90
View File
@@ -0,0 +1,90 @@
<template>
<el-dialog v-model="visible" title="选择订单" width="800px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-input v-model="keyword" placeholder="搜索订单名称/ID" clearable style="width:220px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="handleRefresh" :loading="loading">刷新</el-button>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row @current-change="row => selected = row" :height="360" stripe size="small">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="订单名称" min-width="200" show-overflow-tooltip />
<el-table-column label="价格" width="100">
<template #default="{ row }">¥{{ ((row.price || 0) / 100).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.state === 1 ? 'success' : row.state === 0 ? 'warning' : 'info'" size="small">
{{ row.state === 1 ? '已支付' : row.state === 0 ? '待支付' : '已失效' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" width="160">
<template #default="{ row }">{{ formatTime(row.expireTime || row.expire_time) }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无订单" />
<div class="selector-selected" v-if="selected">
<el-tag type="primary" size="large" closable @close="selected = null">已选{{ selected.name }} (ID: {{ selected.id }})</el-tag>
</div>
<div class="selector-footer-bar">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }" @current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getOrderList } from '@/api/admin/order'
import dayjs from 'dayjs'
const props = defineProps({ modelValue: { type: Boolean, default: false } })
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const selected = ref(null)
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '-'
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
loading.value = true
try {
const params = { page: page.value, count: pageSize.value }
if (keyword.value) params.key = keyword.value
const res = await getOrderList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.list || d.data || (Array.isArray(d) ? d : [])
total.value = d.all_count ?? d.total ?? list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const handleSearch = () => { page.value = 1; loadList() }
const handleRefresh = () => { keyword.value = ''; page.value = 1; loadList() }
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-selected { margin-top: 12px; }
.selector-footer-bar { display: flex; justify-content: flex-end; align-items: center; margin-top: 10px; }
</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<el-dialog v-model="visible" title="选择套餐" width="700px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
<span style="color:#909399;font-size:13px" v-if="goodId">商品 ID: {{ goodId }}</span>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row @current-change="row => selected = row" :height="360" stripe size="small">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="套餐名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="note" label="说明" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.disable ? 'danger' : 'success'" size="small">{{ row.disable ? '禁用' : '启用' }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无套餐" />
<div class="selector-footer-bar">
<span v-if="selected" style="color:#606266;font-size:13px">已选{{ selected.name }} (ID: {{ selected.id }})</span>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }" @current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getProductPlanList } from '@/api/admin/product'
const props = defineProps({
modelValue: { type: Boolean, default: false },
goodId: { type: [Number, String], default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const selected = ref(null)
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
loading.value = true
try {
const params = { page: page.value, count: pageSize.value }
if (props.goodId) params.good_id = props.goodId
const res = await getProductPlanList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.data || (Array.isArray(d) ? d : [])
total.value = d.all_count ?? d.total ?? list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
</style>
@@ -46,12 +46,12 @@
<el-table-column prop="name" label="商品组名称" min-width="180" show-overflow-tooltip />
<el-table-column label="父级ID" width="80" align="center">
<template #default="{ row }">
{{ row.parent_id || '-' }}
{{ row.parentId || '-' }}
</template>
</el-table-column>
<el-table-column label="标签" min-width="120">
<template #default="{ row }">
<el-tag v-if="row.tag" size="small" type="info">{{ row.tag }}</el-tag>
<el-tag v-if="row.tag" size="small" type="info">{{ row.tag?.name || row.tag }}</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
+8 -18
View File
@@ -117,7 +117,6 @@ import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue'
import { getProductList, getProductGroupList } from '@/api/admin/product'
import { getFileDetail } from '@/api/admin/file'
// Props
const props = defineProps({
@@ -172,7 +171,7 @@ watch(visible, (newVal) => {
// 获取商品分组列表
const fetchGroupList = async () => {
try {
const res = await getProductGroupList({ page: 1, count: 100 })
const res = await getProductGroupList({ page: 1, count: 10 })
if (res.data.code === 200) {
groupOptions.value = res.data.data.data || []
}
@@ -199,23 +198,14 @@ const fetchProductList = async () => {
if (res.data.code === 200) {
const allData = res.data.data.data || []
// 过滤掉已删除的数据
productList.value = allData.filter(item => item.delete == false)
total.value = productList.value.length
// 过滤掉已删除的数据(兼容 delete 字段不存在的情况)
productList.value = allData.filter(item => item.delete !== true)
total.value = res.data.data.all_count ?? allData.length
// 加载商品图片
for (let i = 0; i < productList.value.length; i++) {
if (productList.value[i].coverId) {
try {
const res2 = await getFileDetail({ file_id: productList.value[i].coverId })
if (res2.data.code === 200) {
productList.value[i].image = res2.data.data.url
}
} catch (error) {
console.error('获取商品图片失败:', error)
}
}
}
// cover 字段直接是图片 URL,无需再请求 file detail
productList.value.forEach(item => {
if (item.cover) item.image = item.cover
})
// 如果有当前选中的商品ID,自动选中
if (props.currentProductId) {
@@ -0,0 +1,126 @@
<template>
<el-dialog v-model="visible" title="选择公网网络(网桥)" width="680px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="showCreate = true">创建组网</el-button>
<span style="color:#909399;font-size:13px">仅显示网桥(bridge)类型网络</span>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row
@current-change="row => selected = row" :height="280" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="address" label="地址(CIDR)" min-width="150" show-overflow-tooltip />
<el-table-column prop="gateway" label="网关" min-width="120" />
<el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip />
<el-table-column label="类型" width="80">
<template #default>
<el-tag type="success" size="small">网桥</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无网桥网络" />
<div class="selector-footer-bar">
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
</template>
</el-dialog>
<!-- 创建组网弹窗 -->
<el-dialog v-model="showCreate" title="创建组网" width="440px" append-to-body destroy-on-close>
<el-form :model="createForm" label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="createForm.name" placeholder="组网名称" />
</el-form-item>
<el-form-item label="网桥名称" required>
<el-input v-model="createForm.bridge_name" placeholder="网桥名称" />
</el-form-item>
<el-form-item label="网关">
<el-input v-model="createForm.gateway" placeholder="可选 10.0.0.1/24" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getUserVmNetworkList, createUserVmNetworking } from '@/api/admin/userVm'
const props = defineProps({
modelValue: { type: Boolean, default: false },
userGoodsId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const selected = ref(null)
const showCreate = ref(false)
const createLoading = ref(false)
const createForm = reactive({ name: '', bridge_name: '', gateway: '', description: '' })
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
if (!props.userGoodsId) return
loading.value = true
try {
const res = await getUserVmNetworkList({ user_goods_id: props.userGoodsId, page: page.value, count: pageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
const all = d.data || (Array.isArray(d) ? d : [])
list.value = all.filter(n => n.type === 'bridge')
total.value = list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const submitCreate = async () => {
if (!createForm.name || !createForm.bridge_name) { ElMessage.warning('请填写名称和网桥名称'); return }
createLoading.value = true
try {
const res = await createUserVmNetworking({
user_goods_id: props.userGoodsId,
name: createForm.name,
bridge_name: createForm.bridge_name,
gateway: createForm.gateway,
description: createForm.description
})
if (res?.data?.code === 200) {
ElMessage.success('创建成功')
showCreate.value = false
Object.assign(createForm, { name: '', bridge_name: '', gateway: '', description: '' })
loadList()
} else ElMessage.error(res?.data?.message || '创建失败')
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
}
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
</style>
@@ -0,0 +1,143 @@
<template>
<el-dialog v-model="visible" title="选择安全组" width="640px" append-to-body @close="handleClose">
<div class="selector-toolbar">
<el-input v-model="keyword" placeholder="搜索安全组名称" clearable style="width:200px"
@keyup.enter="loadList" @clear="loadList">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="showCreate = true">新增安全组</el-button>
</div>
<el-table :data="list" v-loading="loading" highlight-current-row
@current-change="row => selected = row" :height="280" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="方向" width="80">
<template #default="{ row }">
<el-tag :type="row.direction === 'in' ? 'success' : 'warning'" size="small">
{{ row.direction === 'in' ? '入站' : row.direction === 'out' ? '出站' : (row.direction || '-') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="白名单" width="80">
<template #default="{ row }">
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="共享" width="70">
<template #default="{ row }">
<el-tag :type="row.shared ? 'success' : 'info'" size="small">{{ row.shared ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无安全组" />
<div class="selector-footer-bar">
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
layout="total,sizes,prev,pager,next" small background
@size-change="s => { pageSize = s; page = 1; loadList() }"
@current-change="p => { page = p; loadList() }" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
</template>
</el-dialog>
<!-- 新增安全组弹窗 -->
<el-dialog v-model="showCreate" title="新增安全组" width="440px" append-to-body destroy-on-close>
<el-form :model="createForm" label-width="90px">
<el-form-item label="名称" required>
<el-input v-model="createForm.name" placeholder="安全组名称" />
</el-form-item>
<el-form-item label="方向">
<el-select v-model="createForm.direction" style="width:100%">
<el-option label="入站 (in)" value="in" />
<el-option label="出站 (out)" value="out" />
</el-select>
</el-form-item>
<el-form-item label="锁定">
<el-switch v-model="createForm.lock" />
</el-form-item>
<el-form-item label="白名单">
<el-switch v-model="createForm.drop_all" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getUserVmPostGroupUserList, createUserVmPostGroup } from '@/api/admin/userVm'
const props = defineProps({
modelValue: { type: Boolean, default: false },
userGoodsId: { type: Number, default: 0 }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const keyword = ref('')
const selected = ref(null)
const showCreate = ref(false)
const createLoading = ref(false)
const createForm = reactive({ name: '', direction: 'in', lock: false, drop_all: false })
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => {
if (!props.userGoodsId) return
loading.value = true
try {
const params = { user_goods_id: props.userGoodsId, page: page.value, page_size: pageSize.value }
if (keyword.value) params.keyword = keyword.value
const res = await getUserVmPostGroupUserList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.groups || d.data || (Array.isArray(d) ? d : [])
total.value = d.total ?? list.value.length
}
} catch { /* */ } finally { loading.value = false }
}
const submitCreate = async () => {
if (!createForm.name) { ElMessage.warning('请输入名称'); return }
createLoading.value = true
try {
const res = await createUserVmPostGroup({
user_goods_id: props.userGoodsId,
name: createForm.name,
direction: createForm.direction,
lock: createForm.lock,
drop_all: createForm.drop_all
})
if (res?.data?.code === 200) {
ElMessage.success('创建成功')
showCreate.value = false
Object.assign(createForm, { name: '', direction: 'in', lock: false, drop_all: false })
loadList()
} else ElMessage.error(res?.data?.message || '创建失败')
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
}
const handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script>
<style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
</style>
+9 -4
View File
@@ -39,10 +39,16 @@ export const menus = [
title: '商品管理',
icon: 'Goods',
children: [
{ path: '/product/manage', title: '商品管理' }
]
},
{
path: '/product/manage',
title: '商品管理'
}
path: '/user-goods',
title: '用户商品管理',
icon: 'ShoppingCart',
children: [
{ path: '/user-goods/list', title: '所有商品' },
{ path: '/user-goods/vm-list', title: '云计算平台' }
]
},
{
@@ -136,7 +142,6 @@ export const menus = [
]
},
{
path: '/virtualization',
title: '虚拟化平台管理',
icon: 'Platform',
children: [
+33 -13
View File
@@ -224,32 +224,53 @@ const routes = [
}
]
},
// 商品管理路由(已合并商品列表和商品分组到统一树形视图)
// 商品管理路由
{
path: 'product',
name: 'Product',
meta: {
title: '商品管理',
icon: 'Goods'
},
meta: { title: '商品管理', icon: 'Goods' },
redirect: '/product/manage',
children: [
{
path: 'manage',
name: 'ProductManage',
component: () => import('../views/product/ProductGroup.vue'),
meta: {
title: '商品管理'
}
meta: { title: '商品管理' }
},
{ path: 'list', redirect: '/product/manage' },
{ path: 'group', redirect: '/product/manage' }
]
},
// 用户商品管理路由
{
path: 'user-goods',
name: 'UserGoods',
meta: { title: '用户商品管理', icon: 'ShoppingCart' },
redirect: '/user-goods/list',
children: [
{
// 保留旧路由兼容,重定向到新路由
path: 'list',
redirect: '/product/manage'
name: 'UserGoodsList',
component: () => import('../views/product/UserGoodsList.vue'),
meta: { title: '所有商品' }
},
{
path: 'group',
redirect: '/product/manage'
path: 'detail',
name: 'UserGoodsDetail',
component: () => import('../views/product/UserGoodsDetail.vue'),
meta: { title: '用户商品详情', hidden: true, activeMenu: '/user-goods/list' }
},
{
path: 'vm-list',
name: 'UserVmList',
component: () => import('../views/user-vm/UserVmList.vue'),
meta: { title: '云计算平台' }
},
{
path: 'vm-detail',
name: 'UserVmDetail',
component: () => import('../views/user-vm/UserVmDetail.vue'),
meta: { title: '用户虚拟机详情', hidden: true, activeMenu: '/user-goods/vm-list' }
}
]
},
@@ -389,7 +410,6 @@ const routes = [
}
]
},
// 虚拟化平台管理路由
{
path: 'virtualization',
name: 'Virtualization',
+7
View File
@@ -138,6 +138,13 @@ function parseRpcError(err) {
export function extractApiError(body, fallback = '操作失败') {
if (!body) return fallback
// 识别数据库唯一约束冲突
if (body.error && body.error.includes('duplicate key value violates unique constraint')) {
const nameMatch = body.error.match(/create \w+ \[(.+?)\] error/)
const hint = nameMatch ? `${nameMatch[1]}」已存在,请勿重复生成` : '数据已存在,请勿重复操作'
return hint
}
const rpcMsg = parseRpcError(body.error)
if (rpcMsg) return rpcMsg
+13
View File
@@ -109,3 +109,16 @@ export function isoToMilliseconds(time) {
return null
}
/**
* 格式化时间为 "YYYY-MM-DD HH:mm:ss" 格式(用于接口提交)
* @param {string|Date|number} time
* @returns {string} 格式化后的时间字符串,无效时返回 ''
*/
export function formatToApiTime(time) {
if (!time) return ''
const d = time instanceof Date ? time : new Date(time)
if (isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
+21 -24
View File
@@ -108,7 +108,7 @@
<el-avatar v-else-if="row.isGroup" :size="32" :style="{ background: getLevelColor(row.level) }">
<el-icon><Folder /></el-icon>
</el-avatar>
<el-avatar v-else-if="row.isProduct && row.data?.coverId" :size="32" :src="getFileUrl(row.data.coverId)" />
<el-avatar v-else-if="row.isProduct && row.data?.cover" :size="32" :src="row.data.cover" />
<el-avatar v-else-if="row.isProduct" :size="32" :style="{ background: '#409eff' }">
<el-icon><Document /></el-icon>
</el-avatar>
@@ -760,6 +760,19 @@
<el-form-item label="是否必选" prop="must">
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
</el-form-item>
<el-divider content-position="left">权限控制</el-divider>
<el-form-item label="允许单独购买">
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
</el-form-item>
<el-form-item label="用户组优惠">
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
</el-form-item>
<el-form-item label="用户优惠">
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠代金券与优惠码</div>
</el-form-item>
<template v-if="paramForm.arg_type === 'number'">
<el-divider content-position="left">数值参数配置</el-divider>
<el-form-item label="步进值" prop="arg_step">
@@ -1089,7 +1102,6 @@ import {
disablePlanFixedPrice,
enablePlanFixedPrice
} from '@/api/admin/product'
import { getFileDetail } from '@/api/admin/file'
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
// Tab切换
@@ -1161,24 +1173,6 @@ const expandedGroups = ref(new Set()) // 记录展开的分组ID
const loadedProductGroups = ref(new Set()) // 记录已加载商品的分组ID,避免重复请求
const groupProductsMap = ref(new Map()) // 按分组ID存储商品列表 Map<groupId, product[]>
// 文件URL缓存 Map<fileId, url>
const fileUrlCache = ref(new Map())
// 获取文件URL(带缓存)
const getFileUrl = (fileId) => {
if (!fileId) return ''
const cached = fileUrlCache.value.get(fileId)
if (cached) return cached
// 异步加载
getFileDetail({ file_id: fileId }).then(res => {
if (res.data.code === 200 && res.data.data?.url) {
const newCache = new Map(fileUrlCache.value)
newCache.set(fileId, res.data.data.url)
fileUrlCache.value = newCache
}
}).catch(() => {})
return '' // 先返回空,加载完成后会响应式更新
}
// 商品表单
const productForm = reactive({
@@ -2317,7 +2311,10 @@ const paramForm = reactive({
must: false,
arg_step: 1,
arg_min: 0,
arg_max: 100
arg_max: 100,
user_add: false,
use_user_group_discount: false,
use_user_discount: false
})
const paramRules = {
arg_name: [{ required: true, message: '请输入参数名称', trigger: 'blur' }],
@@ -2384,14 +2381,14 @@ const getRangeTypeText = (type) => {
const handleAddParameter = () => {
paramFormType.value = 'add'
paramFormDialogVisible.value = true
Object.assign(paramForm, { arg_id: undefined, arg_name: '', arg_type: 'string', must: false, arg_step: 1, arg_min: 0, arg_max: 100 })
Object.assign(paramForm, { arg_id: undefined, arg_name: '', arg_type: 'string', must: false, arg_step: 1, arg_min: 0, arg_max: 100, user_add: false, use_user_group_discount: false, use_user_discount: false })
nextTick(() => { paramFormRef.value?.resetFields() })
}
const handleEditParameter = (row) => {
paramFormType.value = 'edit'
paramFormDialogVisible.value = true
Object.assign(paramForm, { arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false, arg_step: row.step || 1, arg_min: row.min || 0, arg_max: row.max || 100 })
Object.assign(paramForm, { arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false, arg_step: row.step || 1, arg_min: row.min || 0, arg_max: row.max || 100, user_add: row.userAdd ?? row.user_add ?? false, use_user_group_discount: row.useUserGroupDiscount ?? row.use_user_group_discount ?? false, use_user_discount: row.useUserDiscount ?? row.use_user_discount ?? false })
}
const handleDeleteParameter = (row) => {
@@ -2409,7 +2406,7 @@ const submitParamForm = () => {
paramFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = { good_id: Number(currentProductId.value), arg_name: paramForm.arg_name, arg_type: paramForm.arg_type, must: paramForm.must === true }
const submitData = { good_id: Number(currentProductId.value), arg_name: paramForm.arg_name, arg_type: paramForm.arg_type, must: paramForm.must === true, user_add: paramForm.user_add === true, use_user_group_discount: paramForm.use_user_group_discount === true, use_user_discount: paramForm.use_user_discount === true }
if (paramForm.arg_type === 'number') {
submitData.arg_step = Number(paramForm.arg_step)
submitData.arg_min = Number(paramForm.arg_min)
+35 -39
View File
@@ -334,6 +334,19 @@
inactive-text="可选"
/>
</el-form-item>
<el-divider content-position="left">权限控制</el-divider>
<el-form-item label="允许单独购买">
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
</el-form-item>
<el-form-item label="用户组优惠">
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
</el-form-item>
<el-form-item label="用户优惠">
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠代金券与优惠码</div>
</el-form-item>
<!-- number 类型参数的额外配置 -->
<template v-if="paramForm.arg_type === 'number'">
<el-divider content-position="left">数值参数配置</el-divider>
@@ -816,7 +829,6 @@
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { getFileDetail } from '@/api/admin/file'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search, Refresh, Picture, ArrowRight, Loading, View } from '@element-plus/icons-vue'
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
@@ -1033,24 +1045,9 @@ const fetchProductList = async () => {
// 使用后端返回的总数
total.value = res.data.data.all_count || allData.length
// 异步获取所有商品的封面图片
const imagePromises = productList.value.map(async (item) => {
if (item.coverId) {
try {
const fileRes = await getFileDetail({ file_id: item.coverId })
item.image = fileRes.data?.data?.url || ''
} catch (error) {
console.error('获取商品图片失败:', error)
item.image = ''
}
} else {
item.image = ''
}
return item
productList.value.forEach(item => {
item.image = item.cover || ''
})
// 等待所有图片加载完成
await Promise.all(imagePromises)
}
} catch (error) {
ElMessage.error('获取商品列表失败')
@@ -1184,8 +1181,7 @@ const handleEdit = (row) => {
recommend_rebate: row.recommendRebate,
arg_type: row.argType || 'all'
})
// 加载封面预览
loadCoverPreview(row.coverId)
coverPreviewUrl.value = row.cover || ''
}
// 规格管理
@@ -1318,21 +1314,9 @@ const clearCover = () => {
coverPreviewUrl.value = ''
}
// 加载封面预览
const loadCoverPreview = async (coverId) => {
if (!coverId) {
coverPreviewUrl.value = ''
return
}
try {
const res = await getFileDetail({ file_id: coverId })
if (res.data.code === 200) {
coverPreviewUrl.value = res.data.data.url
}
} catch (error) {
console.error('加载封面预览失败:', error)
coverPreviewUrl.value = ''
}
const loadCoverPreview = (coverId) => {
const item = productList.value.find(p => p.coverId === coverId)
coverPreviewUrl.value = item?.cover || ''
}
// 初始化
@@ -1362,7 +1346,10 @@ const paramForm = reactive({
must: false,
arg_step: 1,
arg_min: 0,
arg_max: 100
arg_max: 100,
user_add: false,
use_user_group_discount: false,
use_user_discount: false
})
const paramRules = {
@@ -1443,7 +1430,10 @@ const handleAddParameter = () => {
must: false,
arg_step: 1,
arg_min: 0,
arg_max: 100
arg_max: 100,
user_add: false,
use_user_group_discount: false,
use_user_discount: false
})
nextTick(() => {
paramFormRef.value?.resetFields()
@@ -1461,7 +1451,10 @@ const handleEditParameter = (row) => {
must: row.must || false,
arg_step: row.step || 1,
arg_min: row.min || 0,
arg_max: row.max || 100
arg_max: row.max || 100,
user_add: row.userAdd ?? row.user_add ?? false,
use_user_group_discount: row.useUserGroupDiscount ?? row.use_user_group_discount ?? false,
use_user_discount: row.useUserDiscount ?? row.use_user_discount ?? false
})
}
@@ -1491,7 +1484,10 @@ const submitParamForm = () => {
good_id: Number(currentProductId.value),
arg_name: paramForm.arg_name,
arg_type: paramForm.arg_type,
must: paramForm.must === true
must: paramForm.must === true,
user_add: paramForm.user_add === true,
use_user_group_discount: paramForm.use_user_group_discount === true,
use_user_discount: paramForm.use_user_discount === true
}
// number 类型添加额外参数
if (paramForm.arg_type === 'number') {
+251
View File
@@ -0,0 +1,251 @@
<template>
<div class="goods-detail-page">
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" link class="back-btn">
<el-icon><ArrowLeft /></el-icon> 返回所有商品列表
</el-button>
<el-divider direction="vertical" />
<span class="page-title">所有商品详情</span>
</div>
<div class="header-right">
<el-button type="primary" plain @click="loadDetail" :loading="loading">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</div>
</div>
<div class="main-content" v-loading="loading">
<el-card class="profile-card" shadow="never" v-if="detail">
<div class="profile-header">
<div class="profile-basic">
<div class="icon-wrapper">
<el-icon :size="48" color="#409eff"><Monitor /></el-icon>
</div>
<div class="identity">
<div class="name-row">
<h1 class="name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
<el-button size="small" type="primary" plain @click="openEdit">编辑</el-button>
<el-button size="small" type="danger" plain @click="handleDelete">删除</el-button>
</div>
<div class="id-row">
<span class="label">ID:</span>
<span class="value">{{ detail.id || goodsId }}</span>
<el-divider direction="vertical" />
<span class="label">用户ID:</span>
<span class="value">{{ detail.userId || detail.user_id || '-' }}</span>
<el-divider direction="vertical" />
<span class="label">到期:</span>
<span class="value">{{ formatExpireTime(detail.expireTime || detail.expire_time) }}</span>
<el-divider direction="vertical" />
<span class="label">续费价:</span>
<span class="value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</span>
</div>
</div>
</div>
<div class="profile-stats">
<div class="stat-item">
<div class="stat-label">套餐ID</div>
<div class="stat-value">{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
</div>
<div class="stat-item">
<div class="stat-label">备注</div>
<div class="stat-value note-value">{{ detail.note || '-' }}</div>
</div>
</div>
</div>
<el-divider style="margin: 16px 0 12px" />
<el-descriptions :column="3" border size="small" style="width:100%">
<el-descriptions-item label="商品ID">{{ detail.goodId || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单ID">{{ detail.orderId || '-' }}</el-descriptions-item>
<el-descriptions-item label="归属项ID">{{ detail.itemId || '-' }}</el-descriptions-item>
<el-descriptions-item label="基础价格">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(detail.UpdatedAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" v-if="detail" style="margin-top:20px">
<h3 style="margin:0 0 16px;font-size:16px;font-weight:600;color:#303133">关联信息</h3>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
</el-tag>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
<el-form :model="editForm" label-width="110px">
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
<el-form-item label="续费价格(元)">
<el-input-number v-model="editForm.renew_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="基础价格(元)"><el-input-number v-model="editForm.base_price" :min="0" :precision="2" controls-position="right" style="width:100%" /></el-form-item>
<el-form-item label="到期时间"><el-date-picker v-model="editForm.expire_time" type="datetime" placeholder="选择到期时间" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /></el-form-item>
<el-form-item label="归属项">
<div style="width:100%">
<template v-if="detail?.good?.table === 'kvm_service'">
<div class="selector-row" style="margin-bottom:8px">
<el-input :model-value="editForm._serviceName || (editForm._serviceId ? `主控服务 #${editForm._serviceId}` : '')"
readonly placeholder="1. 选择主控服务" style="flex:1" />
<el-button type="primary" @click="showServiceSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm._serviceId" @click="editForm._serviceId = 0; editForm._serviceName = ''; editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
</div>
<div class="selector-row">
<el-input :model-value="editForm._itemName || (editForm.item_id ? `虚拟机 #${editForm.item_id}` : '')"
readonly placeholder="2. 选择虚拟机" style="flex:1" />
<el-button type="primary" @click="showVmSelector = true" :disabled="!editForm._serviceId" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm.item_id" @click="editForm.item_id = 0; editForm._itemName = ''" style="margin-left:4px">清除</el-button>
</div>
<div style="font-size:12px;color:#909399;margin-top:4px">归属项为虚拟机ID需先选择主控服务</div>
</template>
<el-input-number v-else v-model="editForm.item_id" :min="0" controls-position="right" style="width:100%" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitEdit">确定</el-button>
</template>
</el-dialog>
<VmSelectorPopup v-model="showVmSelector" :service-id="editForm._serviceId || 0"
@confirm="vm => { editForm.item_id = vm.id; editForm._itemName = vm.name }" />
<KvmServiceSelector v-model="showServiceSelector"
@confirm="s => { editForm._serviceId = s.id; editForm._serviceName = s.name; editForm.item_id = 0; editForm._itemName = '' }" />
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, Monitor } from '@element-plus/icons-vue'
import { getUserGoodsDetail, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const goodsId = computed(() => parseInt(route.query.id) || 0)
const loading = ref(false)
const submitLoading = ref(false)
const detail = ref(null)
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const formatExpireTime = (t) => {
if (!t) return '-'
const d = dayjs(t)
if (d.year() < 2000) return '永久'
return d.format('YYYY-MM-DD HH:mm:ss')
}
const goBack = () => router.push('/user-goods/list')
const loadDetail = async () => {
if (!goodsId.value) return
loading.value = true
try {
const res = await getUserGoodsDetail({ id: goodsId.value })
if (res?.data?.code === 200 && res?.data?.data) {
detail.value = res.data.data.data ?? res.data.data
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) }
finally { loading.value = false }
}
const editVisible = ref(false)
const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' })
const showVmSelector = ref(false)
const showServiceSelector = ref(false)
const openEdit = () => {
const rawRenew = detail.value?.renewPrice || detail.value?.renew_price || 0
const rawBase = detail.value?.basePrice || detail.value?.base_price || 0
Object.assign(editForm, {
note: detail.value?.note || '',
renew_price: rawRenew / 100,
base_price: rawBase / 100,
expire_time: detail.value?.expireTime || detail.value?.expire_time
? dayjs(detail.value?.expireTime || detail.value?.expire_time).format('YYYY-MM-DD HH:mm:ss')
: '',
item_id: detail.value?.itemId || detail.value?.item_id || 0,
_serviceId: 0,
_serviceName: '',
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
})
if (detail.value?.good?.table === 'kvm_service') { /* 通过选择器弹窗选择,无需预加载 */ }
editVisible.value = true
}
const submitEdit = async () => {
submitLoading.value = true
try {
const data = { id: goodsId.value }
if (editForm.note !== undefined) data.note = editForm.note
if (editForm.renew_price) data.renew_price = Math.round(editForm.renew_price * 100)
if (editForm.base_price) data.base_price = Math.round(editForm.base_price * 100)
if (editForm.expire_time) data.expire_time = editForm.expire_time
if (editForm.item_id) data.item_id = editForm.item_id
const res = await updateUserGoods(data)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '修改失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) }
finally { submitLoading.value = false }
}
const handleDelete = () => {
ElMessageBox.confirm('确定删除该用户商品吗?', '删除确认', { type: 'warning' })
.then(async () => {
try {
const res = await deleteUserGoods({ id: goodsId.value })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
onMounted(loadDetail)
watch(goodsId, (newId, oldId) => {
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
})
</script>
<style scoped>
.goods-detail-page { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
.header-left { display: flex; align-items: center; gap: 0; }
.back-btn { font-size: 14px; color: #606266; }
.back-btn:hover { color: #409eff; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; gap: 8px; }
.main-content { padding: 20px; }
.profile-card { margin-bottom: 0; }
.profile-header { display: flex; justify-content: space-between; align-items: flex-start; }
.profile-basic { display: flex; align-items: center; gap: 20px; }
.icon-wrapper { width: 80px; height: 80px; border-radius: 12px; background: linear-gradient(135deg, #e8f4fd, #d6eaff); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.identity { display: flex; flex-direction: column; gap: 8px; }
.name-row { display: flex; align-items: center; gap: 10px; }
.name { font-size: 22px; font-weight: 600; color: #303133; margin: 0; }
.id-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #909399; flex-wrap: wrap; }
.id-row .label { color: #909399; }
.id-row .value { color: #606266; font-weight: 500; }
.profile-stats { display: flex; gap: 32px; flex-shrink: 0; }
.stat-item { text-align: center; min-width: 80px; }
.stat-label { font-size: 12px; color: #909399; margin-bottom: 4px; }
.stat-value { font-size: 14px; font-weight: 600; color: #303133; }
.note-value { font-weight: 400; font-size: 13px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.selector-row { display: flex; align-items: center; width: 100%; }
</style>
+291
View File
@@ -0,0 +1,291 @@
<template>
<div class="user-goods-list">
<div class="toolbar">
<div class="toolbar-left">
<el-button type="primary" :icon="Plus" @click="handleCreate">新增用户商品</el-button>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<div class="toolbar-right">
<el-input v-model="query.key" placeholder="搜索商品名称" clearable style="width:200px"
@keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
</div>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户" min-width="140">
<template #default="{ row }">
<span>{{ row.user?.UserName || row.user?.username || '-' }}</span>
<span style="color:#909399;font-size:12px"> ({{ row.userId || row.user_id || '-' }})</span>
</template>
</el-table-column>
<el-table-column label="商品" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.good?.name || '-' }}</template>
</el-table-column>
<el-table-column label="套餐ID" width="90">
<template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template>
</el-table-column>
<el-table-column label="订单" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ row.order?.name || (row.orderId ? `订单 #${row.orderId}` : '-') }}</template>
</el-table-column>
<el-table-column label="续费价格" width="110">
<template #default="{ row }">
<span v-if="row.renewPrice || row.renew_price">¥{{ ((row.renewPrice || row.renew_price) / 100).toFixed(2) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="到期时间" width="170">
<template #default="{ row }">{{ formatExpireTime(row.expireTime || row.expire_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="query.page" v-model:page-size="query.count"
:page-sizes="[10,20,50]" :total="total" layout="total,sizes,prev,pager,next"
@size-change="s => { query.count = s; query.page = 1; loadList() }"
@current-change="p => { query.page = p; loadList() }" />
</div>
<!-- 新增弹窗 -->
<el-dialog v-model="createVisible" title="新增用户商品" width="560px" destroy-on-close>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px">
<el-form-item label="商品" prop="good_id">
<div class="selector-row">
<el-input :model-value="createForm._goodName || (createForm.good_id ? `商品 #${createForm.good_id}` : '')"
readonly placeholder="请选择商品" style="flex:1" />
<el-button type="primary" @click="showProductSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.good_id" @click="createForm.good_id = 0; createForm._goodName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="用户" prop="user_id">
<div class="selector-row">
<el-input :model-value="createForm._userName || (createForm.user_id ? `用户 #${createForm.user_id}` : '')"
readonly placeholder="请选择用户" style="flex:1" />
<el-button type="primary" @click="showUserSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.user_id" @click="createForm.user_id = 0; createForm._userName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="订单">
<div class="selector-row">
<el-input :model-value="createForm._orderName || (createForm.order_id ? `订单 #${createForm.order_id}` : '')"
readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="showOrderSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.order_id" @click="createForm.order_id = 0; createForm._orderName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="套餐">
<div class="selector-row">
<el-input :model-value="createForm._planName || (createForm.good_plan_id ? `套餐 #${createForm.good_plan_id}` : '')"
readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="showPlanSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.good_plan_id" @click="createForm.good_plan_id = 0; createForm._planName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="续费价格()">
<el-input-number v-model="createForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="基础价格()">
<el-input-number v-model="createForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="到期时间">
<el-date-picker v-model="createForm.expire_time" type="datetime"
format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createForm.note" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">确定</el-button>
</template>
</el-dialog>
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑用户商品" width="480px" destroy-on-close>
<el-form :model="editForm" label-width="110px">
<el-form-item label="续费价格()">
<el-input-number v-model="editForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="基础价格()">
<el-input-number v-model="editForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="到期时间">
<el-date-picker v-model="editForm.expire_time" type="datetime"
format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.note" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<ProductSelector v-model="showProductSelector" @confirm="p => { createForm.good_id = p.id; createForm._goodName = p.name }" />
<UserSelector v-model:visible="showUserSelector" @select="u => { createForm.user_id = u.user_id; createForm._userName = u.user_name }" />
<OrderSelector v-model="showOrderSelector" @confirm="o => { createForm.order_id = o.id; createForm._orderName = o.name }" />
<PlanSelector v-model="showPlanSelector" :good-id="createForm.good_id" @confirm="p => { createForm.good_plan_id = p.id; createForm._planName = p.name }" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { formatToApiTime } from '@/utils/tool'
import ProductSelector from '@/components/admin/ProductSelector.vue'
import UserSelector from '@/components/UserSelector/index.vue'
import OrderSelector from '@/components/admin/OrderSelector.vue'
import PlanSelector from '@/components/admin/PlanSelector.vue'
import dayjs from 'dayjs'
const router = useRouter()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({ page: 1, count: 10, key: '' })
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
// 过期时间为 0001-01-01 时视为无到期时间
const formatExpireTime = (t) => {
if (!t) return '-'
const d = dayjs(t)
if (d.year() < 2000) return '永久'
return d.format('YYYY-MM-DD HH:mm:ss')
}
const loadList = async () => {
loading.value = true
try {
const params = { page: query.page, count: query.count }
if (query.key) params.key = query.key
const res = await getUserGoodsList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.data || (Array.isArray(d) ? d : [])
console.log("用户商品列表",list.value)
total.value = d.all_count ?? d.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const handleSearch = () => { query.page = 1; loadList() }
// ---- 详情 ----
const handleDetail = (row) => {
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
}
// ---- 新增 ----
const createVisible = ref(false)
const submitLoading = ref(false)
const createFormRef = ref(null)
const showProductSelector = ref(false)
const showUserSelector = ref(false)
const showOrderSelector = ref(false)
const showPlanSelector = ref(false)
const createForm = reactive({
good_id: 0, _goodName: '', user_id: 0, _userName: '',
order_id: 0, _orderName: '', good_plan_id: 0, _planName: '',
_renewYuan: 0, _baseYuan: 0, note: '', expire_time: ''
})
const createRules = {
good_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择商品')), trigger: 'change' }],
user_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择用户')), trigger: 'change' }]
}
const handleCreate = () => {
Object.assign(createForm, { good_id: 0, _goodName: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', good_plan_id: 0, _planName: '', _renewYuan: 0, _baseYuan: 0, note: '', expire_time: '' })
createVisible.value = true
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const payload = {
good_id: createForm.good_id, user_id: createForm.user_id,
order_id: createForm.order_id, good_plan_id: createForm.good_plan_id,
note: createForm.note,
renew_price: Math.round((createForm._renewYuan || 0) * 100),
base_price: Math.round((createForm._baseYuan || 0) * 100)
}
if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time)
const res = await createUserGoods(payload)
if (res?.data?.code === 200) { ElMessage.success('新增成功'); createVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '新增失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '新增失败')) } finally { submitLoading.value = false }
})
}
// ---- 编辑 ----
const editVisible = ref(false)
const editForm = reactive({ id: 0, note: '', _renewYuan: 0, _baseYuan: 0, expire_time: '' })
const handleEdit = (row) => {
Object.assign(editForm, {
id: row.id,
note: row.note || '',
_renewYuan: ((row.renewPrice || row.renew_price || 0) / 100),
_baseYuan: ((row.basePrice || row.base_price || 0) / 100),
expire_time: row.expireTime || row.expire_time || ''
})
editVisible.value = true
}
const submitEdit = async () => {
submitLoading.value = true
try {
const payload = {
id: editForm.id,
note: editForm.note,
renew_price: Math.round((editForm._renewYuan || 0) * 100),
base_price: Math.round((editForm._baseYuan || 0) * 100)
}
if (editForm.expire_time) payload.expire_time = formatToApiTime(editForm.expire_time)
const res = await updateUserGoods(payload)
if (res?.data?.code === 200) { ElMessage.success('保存成功'); editVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '保存失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { submitLoading.value = false }
}
// ---- 删除 ----
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除该用户商品吗?`, '删除确认', { type: 'warning' })
.then(async () => {
try {
const res = await deleteUserGoods({ id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
onMounted(loadList)
</script>
<style scoped>
.user-goods-list { padding: 20px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
.toolbar-left, .toolbar-right { display: flex; gap: 8px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.selector-row { display: flex; align-items: center; width: 100%; }
</style>
+948
View File
@@ -0,0 +1,948 @@
<template>
<div class="uvm-detail">
<div class="page-header">
<div class="header-left">
<el-button link @click="goBack"><el-icon><ArrowLeft /></el-icon>返回列表</el-button>
<el-divider direction="vertical" />
<span class="page-title">用户虚拟机详情</span>
</div>
<el-button :icon="Refresh" plain @click="loadDetail" :loading="loading">刷新</el-button>
</div>
<div class="main-content" v-loading="loading">
<!-- 概览卡片 -->
<el-card shadow="never" class="overview-card" v-if="userGoods">
<div class="overview-header">
<div class="overview-left">
<el-icon :size="44" color="#409eff"><Monitor /></el-icon>
<div class="overview-info">
<div class="name-row">
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2>
<el-tag v-if="vm?.status" :type="vmStatusType(vm.status)" size="small" style="margin-left:8px">{{ vmStatusLabel(vm.status) }}</el-tag>
<el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
</div>
<div class="meta-row">
<span>用户商品ID: <b>{{ userGoods.id }}</b></span>
<el-divider direction="vertical" />
<span>虚拟机ID: <b>{{ userGoods.itemId || '-' }}</b></span>
<el-divider direction="vertical" />
<span>用户ID: <b>{{ userGoods.userId }}</b></span>
<el-divider direction="vertical" />
<span>到期: <b>{{ formatExpireTime(userGoods.expireTime) }}</b></span>
</div>
</div>
</div>
<div class="overview-actions">
<el-button size="small" type="primary" @click="handleVnc">VNC</el-button>
<el-button size="small" type="success" @click="handlePower('start')" :disabled="vm?.status === 'running'">启动</el-button>
<el-button size="small" type="warning" @click="handlePower('reboot')">重启</el-button>
<el-button size="small" type="danger" @click="handlePower('stop')" :disabled="vm?.status === 'stopped' || vm?.status === 'stop'">关机</el-button>
<el-dropdown trigger="click" @command="handleMoreCmd">
<el-button size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
<el-dropdown-item command="resume">恢复</el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
<el-dropdown-item divided command="rebuild">重装系统</el-dropdown-item>
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
<el-dropdown-item command="updateTraffic">修改带宽</el-dropdown-item>
<el-dropdown-item divided command="migrate">迁移</el-dropdown-item>
<el-dropdown-item command="transfer">转移用户</el-dropdown-item>
<el-dropdown-item divided command="editGoods">编辑商品信息</el-dropdown-item>
<el-dropdown-item divided command="delete" style="color:#f56c6c">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<el-divider style="margin:12px 0 8px" />
<!-- VM 配置信息 -->
<el-descriptions :column="4" border size="small" v-if="vm">
<el-descriptions-item label="vCPU">{{ vm.vcpu || '-' }}</el-descriptions-item>
<el-descriptions-item label="内存">{{ formatMemory(vm.memory) }}</el-descriptions-item>
<el-descriptions-item label="下行带宽">{{ vm.rx_bandwidth || 0 }} Mbps</el-descriptions-item>
<el-descriptions-item label="上行带宽">{{ vm.tx_bandwidth || 0 }} Mbps</el-descriptions-item>
<el-descriptions-item label="SSH端口">{{ vm.ssh_port || 22 }}</el-descriptions-item>
<el-descriptions-item label="流量上限">{{ formatTraffic(vm.traffic_max) }}</el-descriptions-item>
<el-descriptions-item label="IP">{{ vm.ips || '-' }}</el-descriptions-item>
<!-- <el-descriptions-item label="快照/备份上限">{{ vm.snapshot_num || 0 }} / {{ vm.backup_num || 0 }}</el-descriptions-item> -->
<el-descriptions-item label="续费价格">¥{{ (userGoods.renewPrice / 100 ).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="基础价格">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="商品">{{ userGoods.good?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ userGoods.note || '-' }}</el-descriptions-item>
<!-- <el-descriptions-item label="UUID" :span="2"><span style="font-family:monospace;font-size:12px">{{ vm.uuid || '-' }}</span></el-descriptions-item> -->
<el-descriptions-item label="入站安全组" v-if="inPortGroup">
<el-tag size="small" type="success">{{ inPortGroup.name }} (ID:{{ inPortGroup.id }})</el-tag>
</el-descriptions-item>
<el-descriptions-item label="镜像" v-if="vmImage">{{ vmImage.name }} ({{ vmImage.os_type }})</el-descriptions-item>
</el-descriptions>
<el-descriptions :column="3" border size="small" v-else>
<el-descriptions-item label="商品">{{ userGoods.good?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="续费价格">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="基础价格">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ userGoods.note || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 标签页 -->
<el-card shadow="never" class="tabs-card" v-if="userGoods">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<!-- 数据卷 -->
<el-tab-pane label="数据卷" name="volume">
<div class="tab-toolbar">
<el-button size="small" type="primary" @click="handleCreateVolume">创建数据卷</el-button>
<el-button size="small" :icon="Refresh" @click="loadVolumes">刷新</el-button>
</div>
<el-table :data="volumes" v-loading="volumeLoading" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="大小" width="80"><template #default="{ row }">{{ row.size }} GB</template></el-table-column>
<el-table-column label="类型" width="80"><template #default="{ row }"><el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="80"><template #default="{ row }"><el-tag :type="row.status === 'ready' ? 'success' : 'info'" size="small">{{ row.status || '-' }}</el-tag></template></el-table-column>
<el-table-column label="挂载" width="80"><template #default="{ row }"><el-tag :type="row.is_mount ? 'success' : 'info'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag></template></el-table-column>
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip><template #default="{ row }"><span style="font-family:monospace;font-size:12px">{{ row.path || '-' }}</span></template></el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleResizeVolume(row)">扩容</el-button>
<el-button link type="success" size="small" @click="handleMountVolume(row)" v-if="!row.is_mount">挂载</el-button>
<el-button link type="warning" size="small" @click="handleUnmountVolume(row)" v-if="row.is_mount">卸载</el-button>
<el-button link type="danger" size="small" @click="handleDeleteVolume(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!volumes.length && !volumeLoading" :image-size="60" description="暂无数据卷" />
<div class="pagination-wrapper" v-if="volumeTotal > 0">
<el-pagination v-model:current-page="volumePage" v-model:page-size="volumePageSize" :page-sizes="[10,20]" :total="volumeTotal" layout="total,sizes,prev,pager,next" small
@size-change="s => { volumePageSize = s; volumePage = 1; loadVolumes() }" @current-change="p => { volumePage = p; loadVolumes() }" />
</div>
</el-tab-pane>
<!-- 快照 -->
<el-tab-pane label="快照" name="snapshot">
<div class="tab-toolbar">
<el-tag v-if="snapshotQuota" size="small" effect="plain">{{ snapshotQuota.count || 0}} / {{ snapshotQuota.limit }}</el-tag>
<el-button size="small" @click="handleSetSnapshotLimit">设置上限</el-button>
<el-button size="small" type="primary" @click="handleCreateSnapshot">创建快照</el-button>
<el-button size="small" :icon="Refresh" @click="loadSnapshots">刷新</el-button>
</div>
<el-table :data="snapshots" v-loading="snapshotLoading" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="taskStatusType(row.status)" size="small">{{ row.status || '-' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestoreSnapshot(row)">恢复</el-button>
<el-button link type="info" size="small" @click="handleSnapshotProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
<el-button link type="danger" size="small" @click="handleDeleteSnapshot(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!snapshots.length && !snapshotLoading" :image-size="60" description="暂无快照" />
</el-tab-pane>
<!-- 备份 -->
<el-tab-pane label="备份" name="backup">
<div class="tab-toolbar">
<el-tag v-if="backupQuota" size="small" effect="plain">{{ backupQuota.count || 0 }} / {{ backupQuota.limit }}</el-tag>
<el-button size="small" @click="handleSetBackupLimit">设置上限</el-button>
<el-button size="small" type="primary" @click="handleCreateBackup">创建备份</el-button>
<el-button size="small" :icon="Refresh" @click="loadBackups">刷新</el-button>
</div>
<el-table :data="backups" v-loading="backupLoading" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="taskStatusType(row.status)" size="small">{{ row.status || '-' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleRestoreBackup(row)">恢复</el-button>
<el-button link type="info" size="small" @click="handleBackupProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
<el-button link type="danger" size="small" @click="handleDeleteBackup(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!backups.length && !backupLoading" :image-size="60" description="暂无备份" />
</el-tab-pane>
<!-- 安全组 -->
<el-tab-pane label="安全组" name="security">
<div class="tab-toolbar">
<el-button size="small" type="primary" @click="showSgBindSelector = true">绑定安全组</el-button>
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
</div>
<!-- 只展示详情接口返回的入站安全组 -->
<el-table :data="inPortGroupList" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="方向" width="80">
<template #default="{ row }"><el-tag :type="row.direction === 'in' ? 'success' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : '出站' }}</el-tag></template>
</el-table-column>
<el-table-column label="白名单" width="80">
<template #default="{ row }"><el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag></template>
</el-table-column>
<el-table-column label="锁定" width="70">
<template #default="{ row }"><el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '是' : '否' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="warning" size="small" @click="handleApplySg(row)">应用</el-button>
<el-button link type="danger" size="small" @click="handleUnbindSg(row)">解绑</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!inPortGroupList.length" :image-size="60" description="暂无绑定的安全组" />
</el-tab-pane>
<!-- 网络 -->
<el-tab-pane label="网络" name="network">
<div class="tab-toolbar">
<el-button size="small" :icon="Refresh" @click="loadNetworks">刷新</el-button>
</div>
<el-table :data="networks" v-loading="networkLoading" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="address" label="地址(CIDR)" min-width="150" />
<el-table-column prop="gateway" label="网关" min-width="120" />
<el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip />
<el-table-column label="类型" width="80"><template #default="{ row }"><el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag></template></el-table-column>
</el-table>
<el-empty v-if="!networks.length && !networkLoading" :image-size="60" description="暂无网络" />
<div class="pagination-wrapper" v-if="networkTotal > 0">
<el-pagination v-model:current-page="networkPage" v-model:page-size="networkPageSize" :page-sizes="[10,20]" :total="networkTotal" layout="total,sizes,prev,pager,next" small
@size-change="s => { networkPageSize = s; networkPage = 1; loadNetworks() }" @current-change="p => { networkPage = p; loadNetworks() }" />
</div>
</el-tab-pane>
<!-- 组网 -->
<el-tab-pane label="组网" name="networking">
<div class="tab-toolbar">
<el-button size="small" type="primary" @click="handleCreateNetworking">创建组网</el-button>
<el-button size="small" :icon="Refresh" @click="loadNetworkings">刷新</el-button>
</div>
<el-table :data="networkings" v-loading="networkingLoading" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="bridge_name" label="网桥" min-width="100" />
<el-table-column prop="gateway" label="网关" min-width="120" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="success" size="small" @click="handleAssignNetworking(row)">分配IP</el-button>
<el-button link type="danger" size="small" @click="handleDeleteNetworking(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!networkings.length && !networkingLoading" :image-size="60" description="暂无组网" />
<div class="pagination-wrapper" v-if="networkingTotal > 0">
<el-pagination v-model:current-page="networkingPage" v-model:page-size="networkingPageSize" :page-sizes="[10,20]" :total="networkingTotal" layout="total,sizes,prev,pager,next" small
@size-change="s => { networkingPageSize = s; networkingPage = 1; loadNetworkings() }" @current-change="p => { networkingPage = p; loadNetworkings() }" />
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<!-- 弹窗 -->
<!-- VNC -->
<el-dialog v-model="vncVisible" title="VNC连接" width="480px" destroy-on-close>
<div v-loading="vncLoading">
<el-descriptions :column="1" border v-if="vncResult">
<el-descriptions-item label="VNC地址"><el-link type="primary" :href="vncResult.url" target="_blank">{{ vncResult.url }}</el-link></el-descriptions-item>
<el-descriptions-item label="过期时间">{{ formatTime(vncResult.expire_at) }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="!vncLoading" description="获取失败" :image-size="60" />
</div>
<template #footer><el-button @click="vncVisible = false">关闭</el-button></template>
</el-dialog>
<!-- 电源操作 -->
<el-dialog v-model="powerVisible" :title="`${powerLabels[powerAction]}虚拟机`" width="380px" destroy-on-close>
<div style="padding:8px 0">确定要{{ powerLabels[powerAction] }}吗?</div>
<template #footer>
<el-button @click="powerVisible = false">取消</el-button>
<el-button :type="powerAction === 'stop' ? 'danger' : 'primary'" :loading="actionLoading" @click="submitPower">确定</el-button>
</template>
</el-dialog>
<!-- 创建数据卷 -->
<el-dialog v-model="volCreateVisible" title="创建数据卷" width="440px" destroy-on-close>
<el-form :model="volCreateForm" label-width="100px">
<el-form-item label="名称" required><el-input v-model="volCreateForm.name" /></el-form-item>
<el-form-item label="大小(GB)"><el-input-number v-model="volCreateForm.size" :min="1" controls-position="right" style="width:100%" /></el-form-item>
<el-form-item label="目标设备名"><el-input v-model="volCreateForm.target_device" placeholder="不填自动生成" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="volCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateVolume">确定</el-button>
</template>
</el-dialog>
<!-- 扩容数据卷 -->
<el-dialog v-model="volResizeVisible" title="扩容数据卷" width="400px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="当前大小">{{ volResizeTarget?.size || 0 }} GB</el-form-item>
<el-form-item label="新大小(GB)"><el-input-number v-model="volNewSize" :min="volResizeTarget?.size || 1" controls-position="right" style="width:100%" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="volResizeVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitResizeVolume">确定</el-button>
</template>
</el-dialog>
<!-- 创建快照 -->
<el-dialog v-model="snapshotCreateVisible" title="创建快照" width="400px" destroy-on-close>
<el-form label-width="90px">
<el-form-item label="快照名称" required><el-input v-model="snapshotForm.name" /></el-form-item>
<el-form-item label="描述"><el-input v-model="snapshotForm.description" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="snapshotCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateSnapshot">创建</el-button>
</template>
</el-dialog>
<!-- 创建备份 -->
<el-dialog v-model="backupCreateVisible" title="创建备份" width="400px" destroy-on-close>
<el-form label-width="90px">
<el-form-item label="备份名称" required><el-input v-model="backupForm.name" /></el-form-item>
<el-form-item label="描述"><el-input v-model="backupForm.description" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="backupCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateBackup">创建</el-button>
</template>
</el-dialog>
<!-- 创建安全组 -->
<el-dialog v-model="sgCreateVisible" title="创建安全组" width="440px" destroy-on-close>
<el-form :model="sgCreateForm" label-width="90px">
<el-form-item label="名称" required><el-input v-model="sgCreateForm.name" /></el-form-item>
<el-form-item label="方向">
<el-select v-model="sgCreateForm.direction" style="width:100%">
<el-option label="入站 (in)" value="in" /><el-option label="出站 (out)" value="out" />
</el-select>
</el-form-item>
<el-form-item label="锁定"><el-switch v-model="sgCreateForm.lock" /></el-form-item>
<el-form-item label="白名单"><el-switch v-model="sgCreateForm.drop_all" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="sgCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateSg">创建</el-button>
</template>
</el-dialog>
<!-- 创建组网 -->
<el-dialog v-model="networkingCreateVisible" title="创建组网" width="440px" destroy-on-close>
<el-form :model="networkingCreateForm" label-width="90px">
<el-form-item label="名称" required><el-input v-model="networkingCreateForm.name" /></el-form-item>
<el-form-item label="网桥名称" required><el-input v-model="networkingCreateForm.bridge_name" /></el-form-item>
<el-form-item label="网关"><el-input v-model="networkingCreateForm.gateway" placeholder="可选" /></el-form-item>
<el-form-item label="描述"><el-input v-model="networkingCreateForm.description" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="networkingCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitCreateNetworking">创建</el-button>
</template>
</el-dialog>
<!-- 分配IP -->
<el-dialog v-model="assignVisible" title="分配组网IP" width="400px" destroy-on-close>
<el-form label-width="90px">
<el-form-item label="组网">{{ assignTarget?.name }}</el-form-item>
<el-form-item label="指定IP"><el-input v-model="assignIp" placeholder="留空自动分配" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="assignVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitAssign">分配</el-button>
</template>
</el-dialog>
<!-- 重装系统 -->
<el-dialog v-model="rebuildVisible" title="重装系统" width="440px" destroy-on-close>
<el-alert type="warning" :closable="false" style="margin-bottom:12px">重装会清除当前系统数据!</el-alert>
<el-form label-width="80px">
<el-form-item label="镜像ID" required><el-input-number v-model="rebuildImageId" :min="1" controls-position="right" style="width:100%" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="rebuildVisible = false">取消</el-button>
<el-button type="danger" :loading="actionLoading" @click="submitRebuild">确定重装</el-button>
</template>
</el-dialog>
<!-- 修改带宽 -->
<el-dialog v-model="trafficVisible" title="修改带宽" width="440px" destroy-on-close>
<el-form :model="trafficForm" label-width="130px">
<el-form-item label="下行带宽(Mbps)"><el-input-number v-model="trafficForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" /></el-form-item>
<el-form-item label="上行带宽(Mbps)"><el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" /></el-form-item>
<el-form-item label="流量上限(MB)"><el-input-number v-model="trafficForm.traffic_max" :min="0" controls-position="right" style="width:100%" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="trafficVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitUpdateTraffic">确定</el-button>
</template>
</el-dialog>
<!-- 转移用户 -->
<el-dialog v-model="transferVisible" title="转移虚拟机" width="440px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="目标用户">
<div class="selector-row">
<el-input :model-value="transferForm._userName || (transferForm.target_user_id ? `用户 #${transferForm.target_user_id}` : '')" readonly placeholder="请选择目标用户" style="flex:1" />
<el-button type="primary" @click="showTransferUserSelector = true" style="margin-left:8px">选择</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitTransfer">确定转移</el-button>
</template>
</el-dialog>
<UserSelector v-model:visible="showTransferUserSelector" @select="u => { transferForm.target_user_id = u.user_id; transferForm._userName = u.user_name }" />
<!-- 绑定安全组选择器 -->
<UserVmSecurityGroupSelector v-model="showSgBindSelector" :user-goods-id="userGoodsId"
@confirm="sg => handleBindSg(sg)" />
<!-- 编辑商品信息弹窗 -->
<el-dialog v-model="editGoodsVisible" title="编辑商品信息" width="480px" destroy-on-close>
<el-form :model="editGoodsForm" label-width="110px">
<el-form-item label="备注"><el-input v-model="editGoodsForm.note" /></el-form-item>
<el-form-item label="续费价格()">
<el-input-number v-model="editGoodsForm.renew_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="基础价格()">
<el-input-number v-model="editGoodsForm.base_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
<el-form-item label="到期时间">
<el-date-picker v-model="editGoodsForm.expire_time" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editGoodsVisible = false">取消</el-button>
<el-button type="primary" :loading="actionLoading" @click="submitEditGoods">保存</el-button>
</template>
</el-dialog>
<!-- 任务进度弹窗 -->
<el-dialog v-model="progressVisible" :title="progressTitle" width="480px" destroy-on-close>
<div v-loading="progressLoading">
<el-descriptions :column="1" border size="small" v-if="progressData">
<el-descriptions-item label="任务ID">
<span style="font-family:monospace;font-size:12px">{{ progressData.task_id || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="taskStatusType(progressData.status)" size="small">{{ progressData.status || '-' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="元数据" v-if="progressData.meta && progressData.meta.length > 2">
<span style="word-break:break-all;font-size:12px">{{ progressData.meta }}</span>
</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="!progressLoading" description="暂无进度信息" :image-size="60" />
</div>
<template #footer><el-button @click="progressVisible = false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh, ArrowDown, Monitor } from '@element-plus/icons-vue'
import {
getUserVmDetail, getUserVmVnc,
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
transferUserVm, updateUserVmTraffic,
getUserVmVolumeList, createUserVmVolume, resizeUserVmVolume, mountUserVmVolume, unmountUserVmVolume, deleteUserVmVolume,
getUserVmSnapshotList, getUserVmSnapshotCount, createUserVmSnapshot, restoreUserVmSnapshot, deleteUserVmSnapshot, getUserVmSnapshotProgress, setUserVmSnapshotLimit,
getUserVmBackupList, getUserVmBackupCount, createUserVmBackup, restoreUserVmBackup, deleteUserVmBackup, getUserVmBackupProgress, setUserVmBackupLimit,
getUserVmPostGroupList, createUserVmPostGroup, bindUserVmPostGroup, unbindUserVmPostGroup, applyUserVmPostGroup, deleteUserVmPostGroup, enableUserVmPostGroupWhitelist, disableUserVmPostGroupWhitelist,
getUserVmNetworkList, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, deleteUserVmNetworking
} from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil'
import UserSelector from '@/components/UserSelector/index.vue'
import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const userGoodsId = computed(() => parseInt(route.query.id) || 0)
const loading = ref(false)
const actionLoading = ref(false)
const activeTab = ref('volume')
// 详情数据
const userGoods = ref(null)
const vm = ref(null)
const vmNetworks = ref([])
const vmVolumes = ref([])
const vmImage = ref(null)
const inPortGroup = ref(null)
// 入站安全组列表(来自详情接口)
const inPortGroupList = computed(() => inPortGroup.value ? [inPortGroup.value] : [])
const showSgBindSelector = ref(false)
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const formatExpireTime = (t) => { if (!t) return '-'; const d = dayjs(t); return d.year() < 2000 ? '永久' : d.format('YYYY-MM-DD HH:mm') }
const formatMemory = (kb) => { if (!kb) return '-'; const n = Number(kb); if (n >= 1048576) return (n / 1048576).toFixed(1) + ' GB'; if (n >= 1024) return (n / 1024).toFixed(0) + ' MB'; return n + ' KB' }
const formatTraffic = (kb) => { if (!kb) return '-'; const n = Number(kb); if (n >= 1048576) return (n / 1048576).toFixed(1) + ' GB'; if (n >= 1024) return (n / 1024).toFixed(0) + ' MB'; return n + ' KB' }
const vmStatusType = (s) => ({ running: 'success', stopped: 'danger', stop: 'danger', shutoff: 'danger', paused: 'warning', error: 'danger' }[s] || 'info')
const vmStatusLabel = (s) => ({ running: '运行中', stopped: '已停止', stop: '已停止', shutoff: '已关闭', paused: '已暂停', error: '错误' }[s] || s || '-')
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
const goBack = () => router.push('/user-goods/vm-list')
const loadDetail = async () => {
if (!userGoodsId.value) return
loading.value = true
try {
const res = await getUserVmDetail({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
userGoods.value = d.user_goods
const vmData = d.vm
if (vmData) {
vm.value = vmData.data
vmNetworks.value = vmData.networks || []
vmVolumes.value = vmData.volumes || []
vmImage.value = vmData.image || null
inPortGroup.value = vmData.in_port_group || null
}
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
}
const handleTabChange = (tab) => {
if (tab === 'volume') loadVolumes()
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
if (tab === 'security') loadSecurityGroups()
if (tab === 'network') loadNetworks()
if (tab === 'networking') loadNetworkings()
}
// ---- VNC ----
const vncVisible = ref(false)
const vncLoading = ref(false)
const vncResult = ref(null)
const handleVnc = async () => {
vncVisible.value = true; vncLoading.value = true; vncResult.value = null
try {
const res = await getUserVmVnc({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) vncResult.value = res.data.data
else ElMessage.error(extractApiError(res?.data, '获取VNC失败'))
} catch { /* */ } finally { vncLoading.value = false }
}
// ---- 电源 ----
const powerVisible = ref(false)
const powerAction = ref('')
const powerLabels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复', rescue: '救援', exitRescue: '退出救援' }
const powerApis = { start: startUserVm, stop: stopUserVm, reboot: rebootUserVm, suspend: suspendUserVm, resume: resumeUserVm, rescue: rescueUserVm, exitRescue: exitRescueUserVm }
const handlePower = (action) => { powerAction.value = action; powerVisible.value = true }
const submitPower = async () => {
actionLoading.value = true
try {
const res = await powerApis[powerAction.value]({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success(`${powerLabels[powerAction.value]}成功`); powerVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
}
const handleMoreCmd = (cmd) => {
if (powerLabels[cmd]) { handlePower(cmd); return }
if (cmd === 'rebuild') { rebuildImageId.value = 0; rebuildVisible.value = true }
if (cmd === 'updateTraffic') { Object.assign(trafficForm, { rx_bandwidth: vm.value?.rx_bandwidth || 0, tx_bandwidth: vm.value?.tx_bandwidth || 0, traffic_max: 0 }); trafficVisible.value = true }
if (cmd === 'transfer') { Object.assign(transferForm, { target_user_id: 0, _userName: '' }); transferVisible.value = true }
if (cmd === 'updateVm') router.push({ path: '/user-goods/vm-list' })
if (cmd === 'migrate') ElMessage.info('迁移请在列表页操作')
if (cmd === 'editGoods') openEditGoods()
if (cmd === 'delete') {
ElMessageBox.confirm('确定删除该用户虚拟机吗?', '删除确认', { type: 'error' }).then(async () => {
try {
const res = await deleteUserVm({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); goBack() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch { /* */ }
}).catch(() => {})
}
}
// ---- 数据卷 ----
const volumes = ref([])
const volumeLoading = ref(false)
const volumePage = ref(1)
const volumePageSize = ref(10)
const volumeTotal = ref(0)
const volCreateVisible = ref(false)
const volResizeVisible = ref(false)
const volResizeTarget = ref(null)
const volNewSize = ref(1)
const volCreateForm = reactive({ name: '', size: 10, target_device: '' })
const loadVolumes = async () => {
volumeLoading.value = true
try {
const res = await getUserVmVolumeList({ user_goods_id: userGoodsId.value, page: volumePage.value, count: volumePageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data; volumes.value = d.data || (Array.isArray(d) ? d : [])
volumeTotal.value = d.all_count ?? d.total ?? volumes.value.length
}
} catch { /* */ } finally { volumeLoading.value = false }
}
const handleCreateVolume = () => { Object.assign(volCreateForm, { name: '', size: 10, target_device: '' }); volCreateVisible.value = true }
const submitCreateVolume = async () => {
if (!volCreateForm.name) { ElMessage.warning('请输入名称'); return }
actionLoading.value = true
try {
const res = await createUserVmVolume({ user_goods_id: userGoodsId.value, ...volCreateForm })
if (res?.data?.code === 200) { ElMessage.success('创建成功'); volCreateVisible.value = false; loadVolumes() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
}
const handleResizeVolume = (row) => { volResizeTarget.value = row; volNewSize.value = row.size || 1; volResizeVisible.value = true }
const submitResizeVolume = async () => {
actionLoading.value = true
try {
const res = await resizeUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: volResizeTarget.value.id, size: volNewSize.value })
if (res?.data?.code === 200) { ElMessage.success('扩容成功'); volResizeVisible.value = false; loadVolumes() }
else ElMessage.error(extractApiError(res?.data, '扩容失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '扩容失败')) } finally { actionLoading.value = false }
}
const handleMountVolume = (row) => {
ElMessageBox.confirm('确定挂载该数据卷吗?', '挂载', { type: 'info' }).then(async () => {
try { const res = await mountUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('挂载成功'); loadVolumes() } else ElMessage.error(extractApiError(res?.data, '挂载失败')) } catch { /* */ }
}).catch(() => {})
}
const handleUnmountVolume = (row) => {
ElMessageBox.confirm('确定卸载该数据卷吗?', '卸载', { type: 'warning' }).then(async () => {
try { const res = await unmountUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadVolumes() } else ElMessage.error(extractApiError(res?.data, '卸载失败')) } catch { /* */ }
}).catch(() => {})
}
const handleDeleteVolume = (row) => {
ElMessageBox.confirm('确定删除该数据卷吗?', '删除', { type: 'error' }).then(async () => {
try { const res = await deleteUserVmVolume({ user_goods_id: userGoodsId.value, volume_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadVolumes() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
}).catch(() => {})
}
// ---- 任务进度 ----
const progressVisible = ref(false)
const progressLoading = ref(false)
const progressTitle = ref('')
const progressData = ref(null)
// ---- 快照 ----
const snapshots = ref([])
const snapshotLoading = ref(false)
const snapshotQuota = ref(null)
const snapshotCreateVisible = ref(false)
const snapshotForm = reactive({ name: '', description: '' })
const loadSnapshots = async () => {
snapshotLoading.value = true
try {
const res = await getUserVmSnapshotList({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200 && res?.data?.data) { const d = res.data.data; snapshots.value = d.data || (Array.isArray(d) ? d : []) }
} catch { /* */ } finally { snapshotLoading.value = false }
}
const loadSnapshotQuota = async () => {
try { const res = await getUserVmSnapshotCount({ user_goods_id: userGoodsId.value }); if (res?.data?.code === 200) snapshotQuota.value = res.data.data } catch { /* */ }
}
const handleCreateSnapshot = () => { Object.assign(snapshotForm, { name: '', description: '' }); snapshotCreateVisible.value = true }
const submitCreateSnapshot = async () => {
if (!snapshotForm.name) { ElMessage.warning('请输入名称'); return }
actionLoading.value = true
try {
const res = await createUserVmSnapshot({ user_goods_id: userGoodsId.value, ...snapshotForm })
if (res?.data?.code === 200) { ElMessage.success('创建成功'); snapshotCreateVisible.value = false; loadSnapshots(); loadSnapshotQuota() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
}
const handleRestoreSnapshot = (row) => {
ElMessageBox.confirm(`确定恢复快照「${row.name}」吗?`, '恢复', { type: 'warning' }).then(async () => {
try { const res = await restoreUserVmSnapshot({ user_goods_id: userGoodsId.value, snapshot_id: row.id }); if (res?.data?.code === 200) ElMessage.success('恢复操作已提交'); else ElMessage.error(extractApiError(res?.data, '恢复失败')) } catch { /* */ }
}).catch(() => {})
}
const handleDeleteSnapshot = (row) => {
ElMessageBox.confirm(`确定删除快照「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
try { const res = await deleteUserVmSnapshot({ user_goods_id: userGoodsId.value, snapshot_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSnapshots(); loadSnapshotQuota() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
}).catch(() => {})
}
const handleSnapshotProgress = async (row) => {
progressTitle.value = '快照任务进度'
progressData.value = null
progressVisible.value = true
progressLoading.value = true
try {
const res = await getUserVmSnapshotProgress({ user_goods_id: userGoodsId.value, task_id: String(row.task_id || row.id) })
if (res?.data?.code === 200) progressData.value = res.data.data?.data ?? res.data.data
else ElMessage.warning('暂无进度信息')
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
}
const handleSetSnapshotLimit = () => { ElMessageBox.prompt('请输入快照上限', '设置快照上限', { inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数', inputValue: String(snapshotQuota.value?.limit || 10) })
.then(async ({ value }) => { try { const res = await setUserVmSnapshotLimit({ user_goods_id: userGoodsId.value, limit: value }); if (res?.data?.code === 200) { ElMessage.success('设置成功'); loadSnapshotQuota() } else ElMessage.error(extractApiError(res?.data, '设置失败')) } catch { /* */ } }).catch(() => {})
}
// ---- 备份 ----
const backups = ref([])
const backupLoading = ref(false)
const backupQuota = ref(null)
const backupCreateVisible = ref(false)
const backupForm = reactive({ name: '', description: '' })
const loadBackups = async () => {
backupLoading.value = true
try {
const res = await getUserVmBackupList({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200 && res?.data?.data) { const d = res.data.data; backups.value = d.data || (Array.isArray(d) ? d : []) }
} catch { /* */ } finally { backupLoading.value = false }
}
const loadBackupQuota = async () => {
try { const res = await getUserVmBackupCount({ user_goods_id: userGoodsId.value }); if (res?.data?.code === 200) backupQuota.value = res.data.data } catch { /* */ }
}
const handleCreateBackup = () => { Object.assign(backupForm, { name: '', description: '' }); backupCreateVisible.value = true }
const submitCreateBackup = async () => {
if (!backupForm.name) { ElMessage.warning('请输入名称'); return }
actionLoading.value = true
try {
const res = await createUserVmBackup({ user_goods_id: userGoodsId.value, ...backupForm })
if (res?.data?.code === 200) { ElMessage.success('创建成功'); backupCreateVisible.value = false; loadBackups(); loadBackupQuota() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
}
const handleRestoreBackup = (row) => {
ElMessageBox.confirm(`确定恢复备份「${row.name}」吗?`, '恢复', { type: 'warning' }).then(async () => {
try { const res = await restoreUserVmBackup({ user_goods_id: userGoodsId.value, backup_id: row.id }); if (res?.data?.code === 200) ElMessage.success('恢复操作已提交'); else ElMessage.error(extractApiError(res?.data, '恢复失败')) } catch { /* */ }
}).catch(() => {})
}
const handleDeleteBackup = (row) => {
ElMessageBox.confirm(`确定删除备份「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
try { const res = await deleteUserVmBackup({ user_goods_id: userGoodsId.value, backup_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadBackups(); loadBackupQuota() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
}).catch(() => {})
}
const handleBackupProgress = async (row) => {
progressTitle.value = '备份任务进度'
progressData.value = null
progressVisible.value = true
progressLoading.value = true
try {
const res = await getUserVmBackupProgress({ user_goods_id: userGoodsId.value, task_id: String(row.task_id || row.id) })
if (res?.data?.code === 200) progressData.value = res.data.data?.data ?? res.data.data
else ElMessage.warning('暂无进度信息')
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
}
const handleSetBackupLimit = () => {
ElMessageBox.prompt('请输入备份上限', '设置备份上限', { inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数', inputValue: String(backupQuota.value?.limit || 10) })
.then(async ({ value }) => { try { const res = await setUserVmBackupLimit({ user_goods_id: userGoodsId.value, limit: value }); if (res?.data?.code === 200) { ElMessage.success('设置成功'); loadBackupQuota() } else ElMessage.error(extractApiError(res?.data, '设置失败')) } catch { /* */ } }).catch(() => {})
}
// ---- 安全组 ----
const sgs = ref([])
const sgLoading = ref(false)
const sgPage = ref(1)
const sgPageSize = ref(10)
const sgTotal = ref(0)
const sgCreateVisible = ref(false)
const sgCreateForm = reactive({ name: '', direction: 'in', lock: false, drop_all: false })
const loadSecurityGroups = async () => {
sgLoading.value = true
try {
const res = await getUserVmPostGroupList({ user_goods_id: userGoodsId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data; sgs.value = d.groups || d.data || (Array.isArray(d) ? d : [])
sgTotal.value = d.total ?? sgs.value.length
}
} catch { /* */ } finally { sgLoading.value = false }
}
const handleCreateSg = () => { Object.assign(sgCreateForm, { name: '', direction: 'in', lock: false, drop_all: false }); sgCreateVisible.value = true }
const submitCreateSg = async () => {
if (!sgCreateForm.name) { ElMessage.warning('请输入名称'); return }
actionLoading.value = true
try {
const res = await createUserVmPostGroup({ user_goods_id: userGoodsId.value, ...sgCreateForm })
if (res?.data?.code === 200) { ElMessage.success('创建成功'); sgCreateVisible.value = false; loadSecurityGroups() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
}
const handleBindSg = async (row) => { try { const res = await bindUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadDetail() } else ElMessage.error(extractApiError(res?.data, '绑定失败')) } catch { /* */ } }
const handleUnbindSg = async (row) => { try { const res = await unbindUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('解绑成功'); loadDetail() } else ElMessage.error(extractApiError(res?.data, '解绑失败')) } catch { /* */ } }
const handleApplySg = async (row) => { try { const res = await applyUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) ElMessage.success('应用成功'); else ElMessage.error(extractApiError(res?.data, '应用失败')) } catch { /* */ } }
const handleSgWhitelist = async (row) => {
const api = row.drop_all ? disableUserVmPostGroupWhitelist : enableUserVmPostGroupWhitelist
try { const res = await api({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('操作成功'); loadSecurityGroups() } else ElMessage.error(extractApiError(res?.data, '操作失败')) } catch { /* */ }
}
const handleDeleteSg = (row) => {
ElMessageBox.confirm(`确定删除安全组「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
try { const res = await deleteUserVmPostGroup({ user_goods_id: userGoodsId.value, id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSecurityGroups() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
}).catch(() => {})
}
// ---- 网络 ----
const networks = ref([])
const networkLoading = ref(false)
const networkPage = ref(1)
const networkPageSize = ref(10)
const networkTotal = ref(0)
const loadNetworks = async () => {
networkLoading.value = true
try {
const res = await getUserVmNetworkList({ user_goods_id: userGoodsId.value, page: networkPage.value, count: networkPageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data; networks.value = d.data || (Array.isArray(d) ? d : [])
networkTotal.value = d.all_count ?? d.total ?? networks.value.length
}
} catch { /* */ } finally { networkLoading.value = false }
}
// ---- 组网 ----
const networkings = ref([])
const networkingLoading = ref(false)
const networkingPage = ref(1)
const networkingPageSize = ref(10)
const networkingTotal = ref(0)
const networkingCreateVisible = ref(false)
const networkingCreateForm = reactive({ name: '', bridge_name: '', gateway: '', description: '' })
const assignVisible = ref(false)
const assignTarget = ref(null)
const assignIp = ref('')
const loadNetworkings = async () => {
networkingLoading.value = true
try {
const res = await getUserVmNetworkingList({ user_goods_id: userGoodsId.value, page: networkingPage.value, count: networkingPageSize.value })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data; networkings.value = d.data || (Array.isArray(d) ? d : [])
networkingTotal.value = d.all_count ?? d.total ?? networkings.value.length
}
} catch { /* */ } finally { networkingLoading.value = false }
}
const handleCreateNetworking = () => { Object.assign(networkingCreateForm, { name: '', bridge_name: '', gateway: '', description: '' }); networkingCreateVisible.value = true }
const submitCreateNetworking = async () => {
if (!networkingCreateForm.name || !networkingCreateForm.bridge_name) { ElMessage.warning('请填写名称和网桥名称'); return }
actionLoading.value = true
try {
const res = await createUserVmNetworking({ user_goods_id: userGoodsId.value, ...networkingCreateForm })
if (res?.data?.code === 200) { ElMessage.success('创建成功'); networkingCreateVisible.value = false; loadNetworkings() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
}
const handleAssignNetworking = (row) => { assignTarget.value = row; assignIp.value = ''; assignVisible.value = true }
const submitAssign = async () => {
actionLoading.value = true
try {
const payload = { user_goods_id: userGoodsId.value, networking_id: assignTarget.value.id }
if (assignIp.value.trim()) payload.ip = assignIp.value.trim()
const res = await assignUserVmNetworking(payload)
if (res?.data?.code === 200) { ElMessage.success('分配成功'); assignVisible.value = false; loadNetworkings() }
else ElMessage.error(extractApiError(res?.data, '分配失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '分配失败')) } finally { actionLoading.value = false }
}
const handleDeleteNetworking = (row) => {
ElMessageBox.confirm(`确定删除组网「${row.name}」吗?`, '删除', { type: 'warning' }).then(async () => {
try { const res = await deleteUserVmNetworking({ user_goods_id: userGoodsId.value, networking_id: row.id }); if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadNetworkings() } else ElMessage.error(extractApiError(res?.data, '删除失败')) } catch { /* */ }
}).catch(() => {})
}
// ---- 重装 ----
const rebuildVisible = ref(false)
const rebuildImageId = ref(0)
const submitRebuild = async () => {
if (!rebuildImageId.value) { ElMessage.warning('请填写镜像ID'); return }
actionLoading.value = true
try {
const res = await rebuildUserVm({ user_goods_id: userGoodsId.value, image_id: rebuildImageId.value })
if (res?.data?.code === 200) { ElMessage.success('重装成功'); rebuildVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '重装失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重装失败')) } finally { actionLoading.value = false }
}
// ---- 修改带宽 ----
const trafficVisible = ref(false)
const trafficForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, traffic_max: 0 })
const submitUpdateTraffic = async () => {
actionLoading.value = true
try {
const res = await updateUserVmTraffic({ user_goods_id: userGoodsId.value, ...trafficForm })
if (res?.data?.code === 200) { ElMessage.success('修改成功'); trafficVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '修改失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
}
// ---- 转移 ----
const transferVisible = ref(false)
const showTransferUserSelector = ref(false)
const transferForm = reactive({ target_user_id: 0, _userName: '' })
const submitTransfer = async () => {
if (!transferForm.target_user_id) { ElMessage.warning('请选择目标用户'); return }
actionLoading.value = true
try {
const res = await transferUserVm({ user_goods_id: userGoodsId.value, target_user_id: transferForm.target_user_id })
if (res?.data?.code === 200) { ElMessage.success('转移成功'); transferVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '转移失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '转移失败')) } finally { actionLoading.value = false }
}
// ---- 编辑商品信息 ----
const editGoodsVisible = ref(false)
const editGoodsForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '' })
const openEditGoods = () => {
Object.assign(editGoodsForm, {
note: userGoods.value?.note || '',
renew_price: (userGoods.value?.renewPrice || 0) / 100,
base_price: (userGoods.value?.basePrice || 0) / 100,
expire_time: (() => {
const t = userGoods.value?.expireTime
if (!t) return ''
const d = dayjs(t)
return d.year() < 2000 ? '' : d.format('YYYY-MM-DD HH:mm:ss')
})()
})
editGoodsVisible.value = true
}
const submitEditGoods = async () => {
actionLoading.value = true
try {
const { updateUserGoods } = await import('@/api/admin/userVm')
const payload = { id: userGoodsId.value }
if (editGoodsForm.note !== undefined) payload.note = editGoodsForm.note
if (editGoodsForm.renew_price) payload.renew_price = Math.round(editGoodsForm.renew_price * 100)
if (editGoodsForm.base_price) payload.base_price = Math.round(editGoodsForm.base_price * 100)
if (editGoodsForm.expire_time) payload.expire_time = editGoodsForm.expire_time
const res = await updateUserGoods(payload)
if (res?.data?.code === 200) { ElMessage.success('保存成功'); editGoodsVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '保存失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { actionLoading.value = false }
}
onMounted(async () => {
await loadDetail()
loadVolumes()
})
</script>
<style scoped>
.uvm-detail { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
.header-left { display: flex; align-items: center; gap: 0; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.main-content { padding: 16px 20px; }
.overview-card { margin-bottom: 16px; }
.overview-header { display: flex; justify-content: space-between; align-items: flex-start; }
.overview-left { display: flex; align-items: center; gap: 16px; }
.overview-info { display: flex; flex-direction: column; gap: 8px; }
.name-row { display: flex; align-items: center; }
.vm-name { margin: 0; font-size: 20px; font-weight: 600; color: #303133; }
.meta-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #606266; flex-wrap: wrap; }
.overview-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.tabs-card { }
.tab-toolbar { display: flex; gap: 8px; align-items: center; margin: 12px 0; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
.selector-row { display: flex; align-items: center; width: 100%; }
</style>
+517
View File
@@ -0,0 +1,517 @@
<template>
<div class="user-vm-list">
<div class="toolbar">
<div class="toolbar-left">
<el-button type="primary" :icon="Plus" @click="handleCreate">新建虚拟机</el-button>
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
</div>
<div class="toolbar-right">
<el-input v-model="query.key" placeholder="搜索商品名称" clearable style="width:200px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="query.bound" placeholder="绑定状态" clearable style="width:120px" @change="handleSearch">
<el-option label="已绑定" :value="true" />
<el-option label="未绑定" :value="false" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
</div>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column label="用户商品ID" width="100">
<template #default="{ row }">{{ row.id }}</template>
</el-table-column>
<el-table-column label="虚拟机ID" width="90">
<template #default="{ row }">{{ row.itemId || row.item_id || '-' }}</template>
</el-table-column>
<el-table-column label="用户" min-width="130">
<template #default="{ row }">
<span>{{ row.user?.UserName || '-' }}</span>
<span style="color:#909399;font-size:12px"> ({{ row.userId || row.user_id }})</span>
</template>
</el-table-column>
<el-table-column label="商品" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.good?.name || '-' }}</span>
<el-tag v-if="row.tag" size="small" type="info" style="margin-left:4px">{{ row.tag }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定状态" width="90">
<template #default="{ row }">
<el-tag :type="row.itemId || row.item_id ? 'success' : 'info'" size="small">
{{ row.itemId || row.item_id ? '已绑定' : '未绑定' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="到期时间" width="160">
<template #default="{ row }">{{ formatExpireTime(row.expireTime || row.expire_time) }}</template>
</el-table-column>
<el-table-column label="续费价" width="90">
<template #default="{ row }">
<span v-if="row.renewPrice">¥{{ (row.renewPrice).toFixed(2) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="基础价" width="90">
<template #default="{ row }">
<span v-if="row.basePrice">¥{{ (row.basePrice ).toFixed(2) }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.note || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="goDetail(row)">详情</el-button>
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="query.page" v-model:page-size="query.count"
:page-sizes="[10,20,50]" :total="total" layout="total,sizes,prev,pager,next"
@size-change="s => { query.count = s; query.page = 1; loadList() }"
@current-change="p => { query.page = p; loadList() }" />
</div>
<!-- 新建虚拟机弹窗 -->
<el-dialog v-model="createVisible" title="新建用户虚拟机" width="940px" destroy-on-close class="scrollable-dialog">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px" v-loading="createLoading">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="商品" prop="good_id">
<div class="selector-row">
<el-input :model-value="createForm._goodName || (createForm.good_id ? `商品 #${createForm.good_id}` : '')" readonly placeholder="请选择商品" style="flex:1" />
<el-button type="primary" @click="showProductSelector = true" style="margin-left:8px">选择</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="用户" prop="user_id">
<div class="selector-row">
<el-input :model-value="createForm._userName || (createForm.user_id ? `用户 #${createForm.user_id}` : '')" readonly placeholder="请选择用户" style="flex:1" />
<el-button type="primary" @click="showUserSelector = true" style="margin-left:8px">选择</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="虚拟机名称" prop="name">
<el-input v-model="createForm.name" placeholder="虚拟机名称" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="内存(MB)" prop="memory">
<el-input-number v-model="createForm._memoryMB" :min="0" controls-position="right" style="width:100%" placeholder="MB" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="vCPU" prop="vcpu">
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="系统盘(GB)" prop="system_size">
<el-input-number v-model="createForm.system_size" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="镜像ID" prop="image_id">
<div style="display:flex;flex-direction:column;gap:6px;width:100%">
<div class="selector-row">
<el-input :model-value="createForm._serviceName || (createForm._serviceId ? `主控 #${createForm._serviceId}` : '')"
readonly placeholder="1. 选择主控服务" style="flex:1" />
<el-button type="primary" @click="showServiceSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm._serviceId" @click="createForm._serviceId = 0; createForm._serviceName = ''; createForm.image_id = 0; createForm._imageName = ''" style="margin-left:4px">清除</el-button>
</div>
<div class="selector-row">
<el-input :model-value="createForm._imageName || (createForm.image_id ? `镜像 #${createForm.image_id}` : '')"
readonly placeholder="2. 选择镜像" style="flex:1" />
<el-button type="primary" @click="showImageSelector = true" :disabled="!createForm._serviceId" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left:4px">清除</el-button>
</div>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="下行带宽(Mbps)" width="220px">
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上行带宽(Mbps)" width="220px">
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="IPv4数量" label-width="90px">
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="IPv6数量" label-width="90px">
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="快照上限">
<el-input-number v-model="createForm.snapshot_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="备份上限">
<el-input-number v-model="createForm.backup_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="续费价格()">
<el-input-number v-model="createForm._renewPriceYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="基础价格()">
<el-input-number v-model="createForm._basePriceYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="订单">
<div class="selector-row">
<el-input :model-value="createForm._orderName || (createForm.order_id ? `订单 #${createForm.order_id}` : '')" readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="showOrderSelector = true" style="margin-left:8px">选择</el-button>
<el-button v-if="createForm.order_id" @click="createForm.order_id = 0; createForm._orderName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="到期时间">
<el-date-picker v-model="createForm.expire_time" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注">
<el-input v-model="createForm.note" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">确定创建</el-button>
</template>
</el-dialog>
<!-- 编辑虚拟机弹窗(对接 /user_vm/update -->
<el-dialog v-model="editVisible" title="编辑虚拟机配置" width="560px" destroy-on-close class="scrollable-dialog">
<el-form :model="editForm" label-width="130px" v-loading="editLoading">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="下行带宽(Mbps)">
<el-input-number v-model="editForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="editForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Root密码">
<el-input v-model="editForm.root_password" placeholder="留空则不修改" show-password />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="SSH端口">
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="快照上限">
<el-input-number v-model="editForm.snapshot_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="备份上限">
<el-input-number v-model="editForm.backup_num" :min="0" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="安全组">
<div class="selector-row">
<el-input :model-value="editForm._sgName || (editForm.port_group_id ? `安全组 #${editForm.port_group_id}` : '')"
readonly placeholder="可选" style="flex:1" />
<el-button type="primary" @click="showSgSelector = true" :disabled="!editForm.id" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm.port_group_id" @click="editForm.port_group_id = 0; editForm._sgName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="公网网络">
<div class="selector-row">
<el-input :model-value="editForm._networkName || (editForm.internet_network_id ? `网络 #${editForm.internet_network_id}` : '')"
readonly placeholder="可选仅网桥类型" style="flex:1" />
<el-button type="primary" @click="showNetworkSelector = true" :disabled="!editForm.id" style="margin-left:8px">选择</el-button>
<el-button v-if="editForm.internet_network_id" @click="editForm.internet_network_id = 0; editForm._networkName = ''" style="margin-left:4px">清除</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<!-- 商品选择器 -->
<ProductSelector v-model="showProductSelector" @confirm="p => { createForm.good_id = p.id; createForm._goodName = p.name }" />
<!-- 用户选择器 -->
<UserSelector v-model:visible="showUserSelector" @select="u => { createForm.user_id = u.user_id; createForm._userName = u.user_name }" />
<!-- 订单选择器 -->
<OrderSelector v-model="showOrderSelector" @confirm="o => { createForm.order_id = o.id; createForm._orderName = o.name }" />
<!-- 主控服务选择器(镜像用) -->
<KvmServiceSelector v-model="showServiceSelector" @confirm="s => { createForm._serviceId = s.id; createForm._serviceName = s.name; createForm.image_id = 0; createForm._imageName = '' }" />
<!-- 镜像选择器 -->
<ImageSelectorPopup v-model="showImageSelector" :service-id="createForm._serviceId || 0" @confirm="img => { createForm.image_id = img.id; createForm._imageName = img.name }" />
<!-- 编辑用安全组选择器 -->
<UserVmSecurityGroupSelector v-model="showSgSelector" :user-goods-id="editForm.id"
@confirm="sg => { editForm.port_group_id = sg.id; editForm._sgName = sg.name }" />
<!-- 编辑用公网网络选择器 -->
<UserVmNetworkSelector v-model="showNetworkSelector" :user-goods-id="editForm.id"
@confirm="net => { editForm.internet_network_id = net.id; editForm._networkName = net.name }" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { getUserVmList, getUserVmDetail, createUserVm, updateUserVm, deleteUserVm } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { formatToApiTime } from '@/utils/tool'
import ProductSelector from '@/components/admin/ProductSelector.vue'
import UserSelector from '@/components/UserSelector/index.vue'
import OrderSelector from '@/components/admin/OrderSelector.vue'
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue'
import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue'
import dayjs from 'dayjs'
const router = useRouter()
const loading = ref(false)
const list = ref([])
const total = ref(0)
const query = reactive({ page: 1, count: 10, key: '', bound: null })
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
const formatExpireTime = (t) => {
if (!t) return '-'
const d = dayjs(t)
if (d.year() < 2000) return '永久'
return d.format('YYYY-MM-DD HH:mm')
}
const loadList = async () => {
loading.value = true
try {
const params = { page: query.page, count: query.count }
if (query.key) params.key = query.key
if (query.bound !== null && query.bound !== undefined && query.bound !== '') params.bound = query.bound
const res = await getUserVmList(params)
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
list.value = d.data || (Array.isArray(d) ? d : [])
total.value = d.all_count ?? d.total ?? list.value.length
} else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
const handleSearch = () => { query.page = 1; loadList() }
const goDetail = (row) => {
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
}
// ---- 新建 ----
const createVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref(null)
const showProductSelector = ref(false)
const showUserSelector = ref(false)
const showOrderSelector = ref(false)
const showServiceSelector = ref(false)
const showImageSelector = ref(false)
const createForm = reactive({
good_id: 0, _goodName: '', user_id: 0, _userName: '',
order_id: 0, _orderName: '',
name: '',
_memoryMB: 0,
vcpu: 0, system_size: 0,
rx_bandwidth: 0, tx_bandwidth: 0,
_serviceId: 0, _serviceName: '', image_id: 0, _imageName: '',
ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0,
_renewPriceYuan: 0, _basePriceYuan: 0,
note: '', expire_time: ''
})
const createRules = {
good_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择商品')), trigger: 'change' }],
user_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请选择用户')), trigger: 'change' }],
vcpu: [{ required: true, message: '请填写vCPU', trigger: 'blur' }],
system_size: [{ required: true, message: '请填写系统盘大小', trigger: 'blur' }],
image_id: [{ required: true, validator: (r, v, cb) => v > 0 ? cb() : cb(new Error('请填写镜像ID')), trigger: 'blur' }]
}
const handleCreate = () => {
Object.assign(createForm, { good_id: 0, _goodName: '', user_id: 0, _userName: '', order_id: 0, _orderName: '', name: '', _memoryMB: 0, vcpu: 0, system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, _serviceId: 0, _serviceName: '', image_id: 0, _imageName: '', ipv4_num: 0, ipv6_num: 0, snapshot_num: 0, backup_num: 0, _renewPriceYuan: 0, _basePriceYuan: 0, note: '', expire_time: '' })
createVisible.value = true
}
const submitCreate = () => {
createFormRef.value?.validate(async (valid) => {
if (!valid) return
createLoading.value = true
try {
const payload = {
good_id: createForm.good_id,
user_id: createForm.user_id,
name: createForm.name,
memory: Math.round((createForm._memoryMB || 0) * 1024), // MB → KB
vcpu: createForm.vcpu,
system_size: createForm.system_size,
rx_bandwidth: createForm.rx_bandwidth,
tx_bandwidth: createForm.tx_bandwidth,
image_id: createForm.image_id,
ipv4_num: createForm.ipv4_num,
ipv6_num: createForm.ipv6_num,
snapshot_num: createForm.snapshot_num,
backup_num: createForm.backup_num,
renew_price: Math.round(createForm._renewPriceYuan || 0 ),
base_price: Math.round(createForm._basePriceYuan || 0 ),
note: createForm.note
}
if (createForm.order_id) payload.order_id = createForm.order_id
if (createForm.expire_time) payload.expire_time = formatToApiTime(createForm.expire_time)
const res = await createUserVm(payload)
if (res?.data?.code === 200) { ElMessage.success('创建成功'); createVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '创建失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { createLoading.value = false }
})
}
// ---- 编辑 ----
const editVisible = ref(false)
const editLoading = ref(false)
const showSgSelector = ref(false)
const showNetworkSelector = ref(false)
const editForm = reactive({
id: 0,
rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '',
ssh_port: 22,
port_group_id: 0, _sgName: '',
snapshot_num: 0, backup_num: 0,
internet_network_id: 0, _networkName: ''
})
const handleEdit = async (row) => {
// 先重置
Object.assign(editForm, {
id: row.id,
rx_bandwidth: 0, tx_bandwidth: 0,
root_password: '',
ssh_port: 22,
port_group_id: 0, _sgName: '',
snapshot_num: 0, backup_num: 0,
internet_network_id: 0, _networkName: ''
})
editVisible.value = true
editLoading.value = true
try {
const res = await getUserVmDetail({ user_goods_id: row.id })
if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data
const vm = d.vm?.data ?? d.vm
if (vm) {
editForm.rx_bandwidth = vm.rx_bandwidth || 0
editForm.tx_bandwidth = vm.tx_bandwidth || 0
editForm.ssh_port = vm.ssh_port || 22
editForm.snapshot_num = vm.snapshot_num || 0
editForm.backup_num = vm.backup_num || 0
}
// 回填入站安全组
const inSg = d.vm?.in_port_group
if (inSg) {
editForm.port_group_id = inSg.id
editForm._sgName = inSg.name
}
// 回填公网网络(取第一个 bridge 类型)
const bridgeNet = (d.vm?.networks || []).find(n => n.type === 'bridge')
if (bridgeNet) {
editForm.internet_network_id = bridgeNet.id
editForm._networkName = bridgeNet.name || bridgeNet.address
}
}
} catch { /* 回填失败不影响编辑 */ } finally { editLoading.value = false }
}
const submitEdit = async () => {
editLoading.value = true
try {
const payload = { user_goods_id: editForm.id }
if (editForm.rx_bandwidth) payload.rx_bandwidth = editForm.rx_bandwidth
if (editForm.tx_bandwidth) payload.tx_bandwidth = editForm.tx_bandwidth
if (editForm.root_password) payload.root_password = editForm.root_password
if (editForm.ssh_port && editForm.ssh_port !== 22) payload.ssh_port = editForm.ssh_port
if (editForm.port_group_id) payload.port_group_id = editForm.port_group_id
if (editForm.snapshot_num) payload.snapshot_num = editForm.snapshot_num
if (editForm.backup_num) payload.backup_num = editForm.backup_num
if (editForm.internet_network_id) payload.internet_network_id = editForm.internet_network_id
const res = await updateUserVm(payload)
if (res?.data?.code === 200) { ElMessage.success('保存成功'); editVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '保存失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { editLoading.value = false }
}
// ---- 删除 ----
const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除该用户虚拟机吗?此操作会同时删除远程VM和用户商品记录!`, '删除确认', { type: 'error' })
.then(async () => {
try {
const res = await deleteUserVm({ user_goods_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
onMounted(loadList)
</script>
<style scoped>
.user-vm-list { padding: 20px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
.toolbar-left, .toolbar-right { display: flex; gap: 8px; align-items: center; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.selector-row { display: flex; align-items: center; width: 100%; }
:global(.scrollable-dialog .el-dialog__body) {
max-height: 65vh;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
:global(.scrollable-dialog .el-dialog__body::-webkit-scrollbar) {
display: none;
}
</style>
+104 -11
View File
@@ -96,9 +96,10 @@
disabled
style="flex: 1"
/>
<el-button type="primary" @click="showGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button type="primary" @click="showGroupSelector = true" style="margin-left: 8px" :disabled="!!bindForm.good_id">选择</el-button>
<el-button v-if="bindForm.good_group_id" @click="clearBindGroup" style="margin-left: 4px">清除</el-button>
</div>
<div v-if="bindForm.good_id" style="font-size: 12px; color: #e6a23c; margin-top: 4px">已绑定商品请先清除商品后再绑定商品组</div>
</el-form-item>
<el-form-item label="绑定商品">
<div class="bind-selector-row">
@@ -107,9 +108,10 @@
disabled
style="flex: 1"
/>
<el-button type="primary" @click="showProductSelector = true" style="margin-left: 8px">选择</el-button>
<el-button type="primary" @click="showProductSelector = true" style="margin-left: 8px" :disabled="!!bindForm.good_group_id">选择</el-button>
<el-button v-if="bindForm.good_id" @click="clearBindProduct" style="margin-left: 4px">清除</el-button>
</div>
<div v-if="bindForm.good_group_id" style="font-size: 12px; color: #e6a23c; margin-top: 4px">已绑定商品组请先清除商品组后再绑定商品</div>
</el-form-item>
<el-alert type="info" :closable="false" style="margin-bottom: 12px;">
<template #title>
@@ -123,7 +125,34 @@
</template>
</el-dialog>
<!-- 商品组选择器 -->
<!-- 生成商品 - 父级商品组选择器 -->
<ProductGroupSelector
v-model="showGenerateGroupSelector"
:current-group-id="generateForm.parent_group_id"
@confirm="g => { generateForm.parent_group_id = g.id; generateForm._parentGroupName = g.name }"
/>
<!-- 生成商品 - 标签选择器 -->
<el-dialog v-model="showGenerateTagSelector" title="选择标签" width="560px" append-to-body destroy-on-close>
<div style="margin-bottom: 12px; display: flex; gap: 8px">
<el-input v-model="tagKeyword" placeholder="搜索标签名称" clearable style="width: 220px" @input="filterTags">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button :icon="Refresh" @click="() => { tagOptions.value = []; fetchTagOptions() }" :loading="tagLoading">刷新</el-button>
</div>
<el-table :data="filteredTagOptions" v-loading="tagLoading" highlight-current-row
@current-change="row => selectedTagRow = row" :height="300" stripe size="small">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
</el-table>
<el-empty v-if="!filteredTagOptions.length && !tagLoading" description="暂无标签" :image-size="60" />
<template #footer>
<el-button @click="showGenerateTagSelector = false">取消</el-button>
<el-button type="primary" :disabled="!selectedTagRow" @click="confirmTagSelect">确定选择</el-button>
</template>
</el-dialog>
<!-- 绑定弹窗用商品组选择器 -->
<ProductGroupSelector
v-model="showGroupSelector"
:current-group-id="bindForm.good_group_id"
@@ -146,13 +175,29 @@
</el-alert>
<el-form ref="generateFormRef" :model="generateForm" :rules="generateFormRules" label-width="120px">
<el-form-item label="起始主机组ID" prop="id">
<el-input-number v-model="generateForm.id" :min="1" disabled style="width: 100%" />
<el-input :model-value="generateForm.id" disabled style="width: 100%" />
</el-form-item>
<el-form-item label="父级GoodGroup">
<el-input-number v-model="generateForm.parent_group_id" :min="0" placeholder="挂载到已有父级(可选)" style="width: 100%" />
<div class="bind-selector-row">
<el-input
:model-value="generateForm.parent_group_id ? `商品组 #${generateForm.parent_group_id}${generateForm._parentGroupName ? ' - ' + generateForm._parentGroupName : ''}` : '不挂载父级'"
disabled
style="flex: 1"
/>
<el-button type="primary" @click="showGenerateGroupSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="generateForm.parent_group_id" @click="generateForm.parent_group_id = 0; generateForm._parentGroupName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="标签ID">
<el-input-number v-model="generateForm.tag_id" :min="0" placeholder="根节点标签ID(可选)" style="width: 100%" />
<el-form-item label="标签">
<div class="bind-selector-row">
<el-input
:model-value="generateForm.tag_id ? `标签 #${generateForm.tag_id}${generateForm._tagName ? ' - ' + generateForm._tagName : ''}` : '不设置标签'"
disabled
style="flex: 1"
/>
<el-button type="primary" @click="showGenerateTagSelector = true" style="margin-left: 8px">选择</el-button>
<el-button v-if="generateForm.tag_id" @click="generateForm.tag_id = 0; generateForm._tagName = ''" style="margin-left: 4px">清除</el-button>
</div>
</el-form-item>
<el-form-item label="Table标识" prop="table">
<el-input v-model="generateForm.table" placeholder=" kvm_service" />
@@ -167,7 +212,7 @@
</template>
<script setup>
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { ref, reactive, computed, inject, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, RefreshRight, Search, ArrowLeft } from '@element-plus/icons-vue'
@@ -181,6 +226,7 @@ import {
getKvmServiceList
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import { getProductGroupTagList } from '@/api/admin/product'
import ProductGroupSelector from '@/components/admin/ProductGroupSelector.vue'
import ProductSelector from '@/components/admin/ProductSelector.vue'
import dayjs from 'dayjs'
@@ -211,7 +257,7 @@ const normalizeService = (s) => ({
const loadServiceOptions = async () => {
try {
const res = await getKvmServiceList({ page: 1, count: 100, key: '' })
const res = await getKvmServiceList({ page: 1, count: 10, key: '' })
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
@@ -471,7 +517,9 @@ const generateFormRef = ref(null)
const generateForm = reactive({
id: undefined,
parent_group_id: 0,
_parentGroupName: '',
tag_id: 0,
_tagName: '',
table: 'kvm_service'
})
@@ -479,11 +527,56 @@ const generateFormRules = {
id: [{ required: true, message: '主机组ID不能为空', trigger: 'blur' }]
}
// 父级商品组选择器
const showGenerateGroupSelector = ref(false)
// 标签选择器
const showGenerateTagSelector = ref(false)
const tagOptions = ref([])
const tagLoading = ref(false)
const tagKeyword = ref('')
const selectedTagRow = ref(null)
const filteredTagOptions = computed(() =>
tagKeyword.value
? tagOptions.value.filter(t => t.name?.includes(tagKeyword.value))
: tagOptions.value
)
const filterTags = () => { /* computed 自动响应 */ }
const confirmTagSelect = () => {
if (!selectedTagRow.value) return
generateForm.tag_id = selectedTagRow.value.id
generateForm._tagName = selectedTagRow.value.name
showGenerateTagSelector.value = false
selectedTagRow.value = null
tagKeyword.value = ''
}
const loadTagOptions = async () => {
if (tagOptions.value.length) return
await fetchTagOptions()
}
const fetchTagOptions = async () => {
tagLoading.value = true
try {
const res = await getProductGroupTagList()
if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data
tagOptions.value = Array.isArray(inner) ? inner : (inner.data || inner.list || [])
}
} catch { /* */ } finally { tagLoading.value = false }
}
// 监听标签选择器打开时加载数据
watch(showGenerateTagSelector, (val) => { if (val) loadTagOptions() })
const handleGenerateGoods = (row) => {
Object.assign(generateForm, {
id: Number(row.Id ?? row.id),
parent_group_id: 0,
_parentGroupName: '',
tag_id: 0,
_tagName: '',
table: 'kvm_service'
})
generateDialogVisible.value = true
@@ -501,8 +594,8 @@ const submitGenerate = () => {
generateSubmitLoading.value = true
try {
const payload = { id: generateForm.id }
if (generateForm.parent_group_id > 0) payload.parent_group_id = generateForm.parent_group_id
if (generateForm.tag_id > 0) payload.tag_id = generateForm.tag_id
if (generateForm.parent_group_id) payload.parent_group_id = generateForm.parent_group_id
if (generateForm.tag_id) payload.tag_id = generateForm.tag_id
if (generateForm.table) payload.table = generateForm.table
const res = await generateGoodsByHostGroup(payload)
+1 -1
View File
@@ -61,7 +61,7 @@
</el-table-column>
<el-table-column label="同步状态" width="100">
<template #default="{ row }">
<el-tag :type="row.sync_status === 'synced' ? 'success' : 'warning'" size="small">{{ row.sync_status === 'synced' ? '已同步' : '未同步' }}</el-tag>
<el-tag :type="syncStatusType(row.sync_status)" size="small">{{ syncStatusLabel(row.sync_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
+90 -33
View File
@@ -161,7 +161,8 @@
<div class="section-header">
<h3 class="section-title">网络列表</h3>
<div style="display: flex; gap: 8px">
<el-button size="small" type="primary" @click="showNetBindSelector = true">绑定网</el-button>
<el-button size="small" type="primary" @click="showNetBindBridgeSelector = true">绑定</el-button>
<el-button size="small" type="success" @click="showNetBindNatSelector = true">绑定内网</el-button>
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
</div>
</div>
@@ -261,7 +262,7 @@
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
</div>
</div>
<el-table :data="sgTableData" size="small" stripe>
<el-table :data="pagedSecurityGroups" size="small" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column label="锁定" width="80">
@@ -301,6 +302,20 @@
</el-table-column>
</el-table>
<el-empty v-if="!sgTableData.length" description="暂无绑定的安全组" :image-size="60" />
<div class="pagination-wrapper" v-if="sgTableData.length > 0">
<el-pagination
v-model:current-page="sgPage"
v-model:page-size="sgPageSize"
:page-size="[10,20,50]"
:total="sgTableData.length"
layout="total,sizes,prev,pager,next"
small
@size-change="s => {sgPageSize = s; sgPage = 1}"
@current-change="p => {sgPage = p}"
>
</el-pagination>
</div>
</div>
</el-tab-pane>
@@ -826,8 +841,10 @@
</template>
</el-dialog>
<!-- 绑定网选择器 -->
<NetworkSelectorPopup v-model="showNetBindSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleNetBindConfirm" @create="() => handleNetCreate('bind')" />
<!-- 绑定网选择器bridge -->
<NetworkSelectorPopup v-model="showNetBindBridgeSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleNetBindBridgeConfirm" @create="() => handleNetCreate('bindBridge')" />
<!-- 绑定内网选择器(nat -->
<NetworkSelectorPopup v-model="showNetBindNatSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleNetBindNatConfirm" @create="() => handleNetCreate('bindNat')" />
<!-- 创建/编辑网络弹窗 -->
<el-dialog v-model="netDialogVisible" :title="netDialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close>
@@ -1249,8 +1266,8 @@ const handleMoreCommand = (cmd) => {
if (actionMap[cmd]) actionMap[cmd]()
}
const vmStatusType = (s) => ({ running: 'success', ready: 'success', creating: 'warning', pending: 'info', stopped: 'danger', stop: 'danger', error: 'danger', paused: 'warning', reboot: 'warning', poweroff: 'info', unknown: 'info' }[s] || 'info')
const vmStatusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停', reboot: '重启中', poweroff: '已关机', unknown: '未知' }[s] || s || '-')
const vmStatusType = (s) => ({ running: 'success', ready: 'success', creating: 'warning', pending: 'info', stopped: 'danger', stop: 'danger', shutoff: 'danger', error: 'danger', paused: 'warning', reboot: 'warning', poweroff: 'info', unknown: 'info' }[s] || 'info')
const vmStatusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', shutoff: '已关闭', error: '错误', paused: '已暂停', reboot: '重启中', poweroff: '已关机', unknown: '未知' }[s] || s || '-')
const imgStatusType = (s) => ({ ready: 'success', downloading: 'warning', pending: 'info', error: 'danger' }[s] || 'info')
const imgStatusLabel = (s) => ({ ready: '就绪', downloading: '下载中', pending: '等待中', error: '错误' }[s] || s || '-')
@@ -1298,9 +1315,12 @@ const fetchVmStatus = async () => {
try {
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
const sd = res.data.data
detail.value = { ...detail.value, status: sd.status ?? sd }
ElMessage.success('状态已刷新: ' + vmStatusLabel(detail.value.status))
const outer = res.data.data
const inner = outer.data ?? outer
const state = inner.state ?? inner.status ?? inner
const desc = inner.desc || ''
detail.value = { ...detail.value, status: state }
ElMessage.success(`状态已刷新: ${desc || vmStatusLabel(state)}`)
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) } finally { statusLoading.value = false }
}
@@ -1476,7 +1496,11 @@ const submitRebuild = async () => {
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
actionLoading.value = true
try {
const res = await rebuildVm({ service_id: serviceId.value, vm_id: vmId.value, image_id: rebuildImageId.value })
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
fd.append('image_id', rebuildImageId.value)
const res = await rebuildVm(fd)
if (res?.data?.code === 200) { ElMessage.success('重装成功'); rebuildDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '重装失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重装失败')) } finally { actionLoading.value = false }
@@ -1542,7 +1566,7 @@ const handleEditVm = async () => {
Object.assign(editForm, {
rx_bandwidth: d.rx_bandwidth || 0,
tx_bandwidth: d.tx_bandwidth || 0,
root_password: '',
root_password: d.root_password || '',
ssh_port: d.ssh_port || 22,
port_group_id: vmPortGroup.value?.id || ''
})
@@ -1570,7 +1594,7 @@ const submitEditVm = async () => {
if (editForm.root_password) fd.append('root_password', editForm.root_password)
fd.append('ssh_port', editForm.ssh_port)
editSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id))
editSelectedInternalNetworks.value.forEach(n => fd.append('internet_network_id', n.id))
if (editSelectedInternalNetworks.value.length) fd.append('internet_network_id', editSelectedInternalNetworks.value[0].id)
if (editForm.port_group_id) fd.append('port_group_id', editForm.port_group_id)
const res = await updateVm(fd)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
@@ -1662,7 +1686,7 @@ const submitRefactorVm = async () => {
if (refactorForm.vnc_port) fd.append('vnc_port', refactorForm.vnc_port)
if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password)
refactorSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id))
refactorSelectedInternalNetworks.value.forEach(n => fd.append('internet_network_id', n.id))
if (refactorSelectedInternalNetworks.value.length) fd.append('internet_network_id', refactorSelectedInternalNetworks.value[0].id)
if (refactorForm.port_group_id) fd.append('port_group_id', refactorForm.port_group_id)
const res = await refactorVm(fd)
if (res?.data?.code === 200) { ElMessage.success('重构成功'); refactorDialogVisible.value = false; loadDetail() }
@@ -1798,20 +1822,25 @@ const loadSgOptions = async () => {
}
// ---- 绑定网络(通过 updateVm 接口) ----
const showNetBindSelector = ref(false)
const handleNetBindConfirm = async (selectedNetwork) => {
const existingIds = vmNetworks.value.map(n => n.id)
if (existingIds.includes(selectedNetwork.id)) {
ElMessage.warning('该网络已绑定')
return
}
const showNetBindBridgeSelector = ref(false)
const showNetBindNatSelector = ref(false)
const submitNetBind = async (paramName, newId, existingIds) => {
actionLoading.value = true
try {
const allNetworkIds = [...existingIds, selectedNetwork.id]
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', vmId.value)
allNetworkIds.forEach(id => fd.append('network_ids', id))
const bridgeIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
const natNet = vmNetworks.value.find(n => n.type === 'nat')
if (paramName === 'network_ids') {
const allBridge = [...bridgeIds, newId]
allBridge.forEach(id => fd.append('network_ids', id))
if (natNet) fd.append('internet_network_id', natNet.id)
} else {
bridgeIds.forEach(id => fd.append('network_ids', id))
fd.append('internet_network_id', newId)
}
if (detail.value?.rx_bandwidth) fd.append('rx_bandwidth', detail.value.rx_bandwidth)
if (detail.value?.tx_bandwidth) fd.append('tx_bandwidth', detail.value.tx_bandwidth)
if (detail.value?.ssh_port) fd.append('ssh_port', detail.value.ssh_port)
@@ -1821,15 +1850,33 @@ const handleNetBindConfirm = async (selectedNetwork) => {
ElMessage.success('绑定网络成功')
loadDetail()
} else {
ElMessage.error(extractApiError(res, '绑定网络失败'))
ElMessage.error(extractApiError(res?.data, '绑定网络失败'))
}
} catch (e) {
ElMessage.error(extractApiError(e, '绑定网络失败'))
ElMessage.error(extractApiError(e?.response?.data, '绑定网络失败'))
} finally {
actionLoading.value = false
}
}
const handleNetBindBridgeConfirm = (selectedNetwork) => {
const existingIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
if (existingIds.includes(selectedNetwork.id)) {
ElMessage.warning('该网络已绑定')
return
}
submitNetBind('network_ids', selectedNetwork.id)
}
const handleNetBindNatConfirm = (selectedNetwork) => {
const existingNat = vmNetworks.value.find(n => n.type === 'nat')
if (existingNat?.id === selectedNetwork.id) {
ElMessage.warning('该内网已绑定')
return
}
submitNetBind('internet_network_id', selectedNetwork.id)
}
// ---- 网络操作(创建/编辑/删除/详情) ----
const netDialogVisible = ref(false)
const netDialogType = ref('add')
@@ -1859,7 +1906,8 @@ const handleNetDialogCancel = () => {
else if (src === 'editInternal') showEditInternalNetworkSelector.value = true
else if (src === 'refactor') showRefactorNetworkSelector.value = true
else if (src === 'refactorInternal') showRefactorInternalNetworkSelector.value = true
else if (src === 'bind') showNetBindSelector.value = true
else if (src === 'bindBridge') showNetBindBridgeSelector.value = true
else if (src === 'bindNat') showNetBindNatSelector.value = true
}
const handleNetEdit = (row) => {
@@ -2045,6 +2093,13 @@ const sgTableData = computed(() => {
if (vmOutPortGroup.value) list.push(vmOutPortGroup.value)
return list
})
//安全组分页
const sgPage = ref(1)
const sgPageSize = ref(10)
const pagedSecurityGroups = computed(() =>{
const start = (sgPage.value -1) * sgPageSize.value
return sgTableData.value.slice(start,start + sgPageSize.value)
})
const sgSubmitLoading = ref(false)
const sgDetailLoading = ref(false)
const sgHostOptions = ref([])
@@ -2609,7 +2664,6 @@ const vnCreateForm = reactive({ name: '', bridge_name: '', gateway: '' })
const vnCreateHostName = ref('')
const vnCreateUserName = ref('')
const vnCreateRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
bridge_name: [{ required: true, message: '请输入网桥名称', trigger: 'blur' }]
}
@@ -2717,7 +2771,7 @@ const handleLeaveNetworking = (row) => {
}
let loadedVmId = null
const initPage = () => {
const initPage = async () => {
if (!vmId.value || loadedVmId === vmId.value) return
loadedVmId = vmId.value
metricsData.value = null
@@ -2725,18 +2779,21 @@ const initPage = () => {
disposeCharts()
clearHistory()
loadHostOptions()
loadDetail()
if (activeTab.value === 'monitor') startPolling()
// 先加载详情,详情加载完后再触发当前 tab 的数据
await loadDetail()
triggerTabLoad(activeTab.value)
}
watch(vmId, () => { if (isPageActive) initPage() })
watch(activeTab, (tab) => {
if (tab === 'monitor' && detail.value) startPolling()
const triggerTabLoad = (tab) => {
if (tab === 'monitor') startPolling()
else stopPolling()
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
if (tab === 'userNetworking') loadVmNetworkingList()
})
}
watch(vmId, () => { if (isPageActive) initPage() })
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
onActivated(() => {
isPageActive = true
if (loadedVmId !== vmId.value) initPage()
+57 -24
View File
@@ -321,6 +321,26 @@
<HostGroupSelectorPopup v-model="showHostGroupSelector" :service-id="serviceId" :current-id="createForm.host_group_id" @confirm="handleHostGroupSelected" />
<!-- 用户选择器 -->
<UserListSelector v-model="showUserSelector" :current-user-id="createForm.user_id" @confirm="handleUserSelected" />
<!-- 电源操作确认弹窗 -->
<el-dialog v-model="powerDialogVisible" :title="`${powerLabels[powerAction] || ''}虚拟机`" width="400px" destroy-on-close>
<div style="display: flex; align-items: flex-start; gap: 12px; padding: 8px 0">
<el-icon :size="22" :style="{ color: powerAction === 'stop' ? '#F56C6C' : powerAction === 'reboot' ? '#E6A23C' : '#409EFF', flexShrink: 0, marginTop: '2px' }">
<WarningFilled />
</el-icon>
<div>
<div style="font-size: 15px; font-weight: 500; color: #303133; margin-bottom: 12px">
确定要{{ powerLabels[powerAction] }}虚拟机「{{ powerRow?.name }}」吗?
</div>
<el-checkbox v-model="powerForce" style="margin-bottom: 4px">强制执行</el-checkbox>
<div style="font-size: 12px; color: #909399; padding-left: 24px">勾选后将强制{{ powerLabels[powerAction] }},可能导致数据丢失</div>
</div>
</div>
<template #footer>
<el-button @click="powerDialogVisible = false">取消</el-button>
<el-button :type="powerAction === 'stop' ? 'danger' : 'primary'" @click="submitPower">确定{{ powerLabels[powerAction] }}</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -328,7 +348,7 @@
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue'
import {
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getVmMetrics,
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
@@ -438,6 +458,7 @@ const vmStatuses = [
{ label: '等待中', value: 'pending' }, { label: '创建中', value: 'creating' },
{ label: '就绪', value: 'ready' }, { label: '运行中', value: 'running' },
{ label: '已停止', value: 'stopped' }, { label: '已停止', value: 'stop' },
{ label: '已关闭', value: 'shutoff' },
{ label: '错误', value: 'error' }, { label: '已暂停', value: 'paused' },
{ label: '重启中', value: 'reboot' }, { label: '已关机', value: 'poweroff' },
{ label: '未知', value: 'unknown' }
@@ -470,13 +491,13 @@ const createRules = {
const vmStatusType = (s) => ({
running: 'success', ready: 'success', creating: 'warning', pending: 'info',
stopped: 'danger', stop: 'danger', error: 'danger', paused: 'warning',
stopped: 'danger', stop: 'danger', shutoff: 'danger', error: 'danger', paused: 'warning',
reboot: 'warning', poweroff: 'info', unknown: 'info'
}[s] || 'info')
const vmStatusLabel = (s) => ({
running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中',
stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停',
stopped: '已停止', stop: '已停止', shutoff: '已关闭', error: '错误', paused: '已暂停',
reboot: '重启中', poweroff: '已关机', unknown: '未知'
}[s] || s || '-')
@@ -589,29 +610,34 @@ const submitCreate = () => {
})
}
const powerDialogVisible = ref(false)
const powerAction = ref('')
const powerRow = ref(null)
const powerForce = ref(false)
const powerLabels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
const handlePower = (row, action) => {
const labels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
ElMessageBox.confirm(`确定要${labels[action]}虚拟机「${row.name}」吗?`, `${labels[action]}确认`, {
confirmButtonText: '确定', cancelButtonText: '取消',
type: action === 'stop' ? 'warning' : 'info'
}).then(async () => {
powerRow.value = row
powerAction.value = action
powerForce.value = false
powerDialogVisible.value = true
}
const submitPower = async () => {
const action = powerAction.value
const row = powerRow.value
const label = powerLabels[action]
powerDialogVisible.value = false
try {
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
const payload = { service_id: serviceId.value, vm_id: row.id }
// resume uses FormData
let res
if (action === 'resume') {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', row.id)
res = await resumeVm(fd)
} else {
res = await apis[action](payload)
}
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadList() }
else ElMessage.error(extractApiError(res?.data, `${labels[action]}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${labels[action]}失败`)) }
}).catch(() => {})
if (powerForce.value) fd.append('force', true)
const res = await apis[action](fd)
if (res?.data?.code === 200) { ElMessage.success(`${label}成功`); loadList() }
else ElMessage.error(extractApiError(res?.data, `${label}失败`))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${label}失败`)) }
}
const handleMoreAction = (row, command) => {
@@ -635,7 +661,11 @@ const submitRebuild = async () => {
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
submitLoading.value = true
try {
const res = await rebuildVm({ service_id: serviceId.value, vm_id: rebuildTarget.value.id, image_id: rebuildImageId.value })
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('vm_id', rebuildTarget.value.id)
fd.append('image_id', rebuildImageId.value)
const res = await rebuildVm(fd)
if (res?.data?.code === 200) { ElMessage.success('重装成功'); rebuildDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '重装失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重装失败')) } finally { submitLoading.value = false }
@@ -695,9 +725,12 @@ const fetchVmStatus = async (vm) => {
try {
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vm.id })
if (res?.data?.code === 200 && res?.data?.data) {
const statusData = res.data.data
currentDetail.value = { ...currentDetail.value, status: statusData.status ?? statusData }
ElMessage.success('状态已刷新: ' + vmStatusLabel(currentDetail.value.status))
const outer = res.data.data
const inner = outer.data ?? outer
const state = inner.state ?? inner.status ?? inner
const desc = inner.desc || ''
currentDetail.value = { ...currentDetail.value, status: state }
ElMessage.success(`状态已刷新: ${desc || vmStatusLabel(state)}`)
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) }
}
+26 -44
View File
@@ -1,52 +1,34 @@
## 管理员前端控制台 - 问题跟踪
✅已完成、⚠️部分完成、❌未完成这样显示
-----------------------------------------------------------------------------------------------需要解决
### 最新一轮 (图一到图七)
1.新增用户商品点击选择用户,点击确定选择并没有将数据返回到弹窗中,带有例如订单ID,套餐ID的这种都需要变为选择组件选择,里面是列表展示,并且带有分页,和刷新按钮
1. ✅已完成 - 图一:安全组列表 "暂无数据" → 修复 `SecurityGroupManage.vue` 数据映射优先 `inner.groups`
2. ✅已完成 - 图二:安全组详情弹窗字段为空 → 修复 `SecurityGroupManage.vue` 详情映射优先 `res.data.data.group`
3. ✅已完成 - 图三:安全组绑定VM使用 `el-input-number` → 改用 `VmSelectorPopup` 组件
4. ✅已完成 - 图四:安全组解绑VM使用 `el-input-number` → 改用 `VmSelectorPopup` 组件
5. ✅已完成 - 图五:VNC 获取VM连接使用 `el-input-number` → 改用 `VmSelectorPopup` 组件
6. ✅已完成 - 图六:VNC 测试宿主机默认值显示 "0" → 改为 `null` (空选择)
7. ✅已完成 - 图七:宿主机、镜像、虚拟机、安全组四个模块表格操作只保留"编辑"和"删除"按钮
- 创建 `HostDetail.vue` 宿主机独立详情页 (含编辑、指标、详情)
- 创建 `ImageDetail.vue` 镜像独立详情页 (含编辑、同步到宿主机、重下载、宿主机状态)
- 创建 `VmDetail.vue` 虚拟机独立详情页 (含电源操作、重建、救援、指标)
- 创建 `SecurityGroupDetail.vue` 安全组独立详情页 (含同步、绑定/解绑VM、白名单切换、规则管理)
- 添加4个详情页路由 (`host-detail`, `image-detail`, `vm-detail`, `security-group-detail`)
- `HostManage.vue` 表格操作改为 "编辑"→跳转详情页 + "删除"
- `ImageManage.vue` 表格操作改为 "编辑"→跳转详情页 + "删除"
- `VmManage.vue` 表格操作改为 "编辑"→跳转详情页 + "删除",补充 `deleteVm` API
- `SecurityGroupManage.vue` 表格操作改为 "编辑"→跳转详情页 + "删除"
---
### 前一轮 (图一到图五 - 列表数据显示)
-----------------------------------------------------------------------------------------------需要解决
1.请求接口的带有page-size或者是count参数的都只能是10
2.每次解决后的内容写在-需要解决之间,不要写在外面
3.我不要你解释,不是我主动告诉你解释需求,那么你就根据问题开始直接编写代码解决问题或者完善功能,问题都是用-需要解决隔开的
4.涉及到表单,需要查看后端模型是不是JSON字符串或者数组,前端发送的是不是对应的JSON字符串或者数组,如果不同需要改为相同,前端提交的数据需要跟后端对应
5.涉及到表单时,需要检查后端模型字段类型。
6.前端提交的数据格式必须与后端对应:
后端模型为 JSON 字符串 → 前端需要 `JSON.stringify()` 后提交
后端模型为数组 → 前端需要提交 JSON 数组字符串
注意各项目 POST 请求默认 Content-Type 不同,可能需要手动调整
7.内存统一kb单位(展示是以Mb为单位),硬盘统一GB单位,流量统一MB单位(展示是以GB为单位),续费价格统一以分为单位(展示是以元为单位),基础价格统一以分为单位(展示是以元为单位)
8.开发的代码或者文件放在对应的文件模块,不要太乱
9.前端页面有问题或者不美观的也可以修改优化美化等
10.例如订单ID,套餐ID的这种都需要变为选择组件选择,里面是列表展示,并且带有分页,和刷新按钮
11.只需要修改我指定的位置,如果有关联使用的需要提前告知确认后再进行修改
管理员前端控制台:ApiServer-web-admin_dashboard_pc
1. ✅已完成 - 远程宿主机组列表 "暂无数据" → 修复 `RemoteHostGroupManage.vue` 优先 `inner.host_groups`
2. ✅已完成 - 宿主机指标弹窗 emoji 图标 → 改为 Element Plus 图标 (`Monitor`, `Coin`, `Box`, `Connection`)
3. ✅已完成 - 虚拟机管理 "更多" 下拉按钮错位 → 修复 `el-dropdown` 内联样式对齐
4. ✅已完成 - 安全组列表 "暂无数据" → 修复 `SecurityGroupManage.vue` 优先 `inner.groups`
5. ✅已完成 - VNC 节点列表 "暂无数据" → 修复 `VncNodeManage.vue` 优先 `inner.items`
网站首页前端:ApiServer-Web-home
---
用户前端控制台:ApiServer-web-user_dashboard_pc
### 虚拟化平台管理 17项问题 (已全部完成)
移动端前端:ApiServer-Web-user_dashboard_pe---移动前端需要适配兼容微信小程序 H5 APP (安卓/IOS双端),注意分包大小
1. ✅已完成 - 宿主机管理数据无法正常显示
2. ✅已完成 - 宿主机创建/编辑:数字输入框样式优化 + 带宽单位 Mbps
3. ✅已完成 - 宿主机创建:宿主机组ID改为选择器
4. ✅已完成 - 宿主机详情:SSH密码显示 + 指标美化 + 时间戳格式化
5. ✅已完成 - 远程宿主机组管理页面
6. ✅已完成 - 镜像详情:data.image 嵌套映射
7. ✅已完成 - 镜像同步到宿主机 + 重下载功能
8. ✅已完成 - 网络管理:空高级参数不提交 + host_id 改为下拉选择
9. ✅已完成 - 数据卷:host_id 改为下拉选择
10. ✅已完成 - 数据卷:迁移功能 + 选择器组件
11. ✅已完成 - 数据卷详情:data.volume 嵌套映射 + 时间戳
12. ✅已完成 - 虚拟机:带宽显示 Mbps 单位
13. ✅已完成 - 虚拟机:完整中文状态映射
14. ✅已完成 - 虚拟机详情:data.vm 嵌套映射 + 子表格
15. ✅已完成 - 虚拟机:恢复/救援/退出救援操作
16. ✅已完成 - 虚拟机创建:镜像选择器 + 宿主机组选择器
17. ✅已完成 - 虚拟机指标:CPU/内存/磁盘/网络卡片美化
文档统计:全前端项目文档.md
全部项目涉及到硬编码配置的可以单独在一个文件进行配置然后进行调用(每个项目都有一个单独配置的文件)
开始编写代码完善项目
对应完成的部分在当前文档记录并且进行标记✅已完成、⚠️部分完成、❌未完成这样显示
+3925 -352
View File
File diff suppressed because it is too large Load Diff