Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/VncNodeManage.vue
T
shiranandCursor 2e3574a182
Build and Deploy Vue3 / build (push) Successful in 1m32s
Build and Deploy Vue3 / deploy (push) Successful in 42s
feat: 用户列表代理商查询、商品续费、VNC节点access_mode、IDC信息展示、新购订单table修复
- 用户列表新增通过IDC代理商uid查询用户信息

- 用户商品详情新增续费功能(admin/good/user_goods/renew)

- VNC节点管理新增access_mode和use_ssl参数

- 用户详情左栏新增IDC平台信息展示

- 新购订单选择商品后所属表不再被覆盖,固定为good

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 18:00:20 +08:00

391 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="vnc-node-container">
<div class="page-header" v-if="!embedded">
<div class="header-left">
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
<div class="header-info">
<h3>VNC 节点管理</h3>
<span class="sub-info" v-if="serviceName">主控服务{{ serviceName }}</span>
</div>
</div>
<div class="header-right">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新增节点</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
</div>
<!-- 嵌入模式下的工具栏 -->
<div class="embedded-toolbar" v-if="embedded">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新增节点</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
<el-input v-model="keyword" placeholder="搜索节点" clearable style="width: 220px; margin-left: auto;" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<!-- 独立模式下的筛选栏 -->
<div class="filter-bar" v-if="!embedded">
<el-input v-model="keyword" placeholder="搜索VNC节点" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
</div>
<!-- 节点列表 -->
<el-table :data="nodeList" v-loading="loading" stripe>
<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="200">
<template #default="{ row }">
<span class="host-addr">{{ row.ip || '-' }}:{{ row.port || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="访问方案" width="110">
<template #default="{ row }">
<el-tag v-if="row.access_mode === 'token'" type="warning" size="small">Token传参</el-tag>
<el-tag v-else size="small">域名解析</el-tag>
</template>
</el-table-column>
<el-table-column label="SSL" width="70">
<template #default="{ row }">
<el-tag v-if="row.use_ssl" type="success" size="small"></el-tag>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="Token" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.token" class="token-mask">{{ maskToken(row.token) }}</span>
<span v-else class="text-muted">未设置</span>
</template>
</el-table-column>
<el-table-column label="延迟" width="100">
<template #default="{ row }">
<span v-if="row._delay != null" :style="{ color: row._delay > 500000000 ? '#F56C6C' : row._delay > 100000000 ? '#E6A23C' : '#67C23A' }">{{ formatDelay(row._delay) }}</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="success" @click="handleTest(row)">测试连接</el-button>
<el-button link type="primary" @click="handleVmVnc(row)">VM VNC</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="formDialogVisible" :title="formType === 'add' ? '新增 VNC 节点' : '编辑 VNC 节点'" width="520px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="VNC节点名称" />
</el-form-item>
<el-form-item label="IP 地址" prop="ip">
<el-input v-model="formData.ip" placeholder=" 192.168.1.100" />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input v-model="formData.port" placeholder=" 6080" />
</el-form-item>
<el-form-item label="Token">
<el-input v-model="formData.token" placeholder="节点鉴权Token可选" show-password />
</el-form-item>
<el-form-item label="访问方案">
<el-select v-model="formData.access_mode" style="width: 100%">
<el-option label="域名解析 (domain)" value="domain" />
<el-option label="Token传参 (token)" value="token" />
</el-select>
</el-form-item>
<el-form-item label="使用SSL">
<el-switch v-model="formData.use_ssl" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 测试连接弹窗 -->
<el-dialog v-model="testDialogVisible" title="测试 VNC 节点连接" width="460px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="VNC节点">{{ testTarget?.name || '-' }}</el-form-item>
<el-form-item label="宿主机">
<el-select v-model="testHostId" placeholder="选择宿主机" filterable style="width: 100%">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
</el-select>
</el-form-item>
</el-form>
<div v-if="testResult" class="test-result" :class="testResult.success ? 'success' : 'error'">
<el-icon v-if="testResult.success"><SuccessFilled /></el-icon>
<el-icon v-else><CircleCloseFilled /></el-icon>
{{ testResult.message }}
</div>
<template #footer>
<el-button @click="testDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="testLoading" @click="submitTest">测试</el-button>
</template>
</el-dialog>
<!-- VM VNC 弹窗 -->
<el-dialog v-model="vmVncDialogVisible" title="获取虚拟机 VNC 连接" width="520px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="VNC节点">{{ vmVncTarget?.name || '-' }}</el-form-item>
<el-form-item label="虚拟机">
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
<el-input :model-value="vmVncVmId ? `${vmVncVmName || ''} (ID: ${vmVncVmId})` : ''" readonly placeholder="请选择虚拟机" style="flex: 1" />
<el-button type="primary" @click="showVmVncSelector = true">选择</el-button>
</div>
</el-form-item>
</el-form>
<div v-if="vmVncResult" class="vnc-result">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="VNC地址">
<el-link type="primary" :href="vmVncResult.url" target="_blank">{{ vmVncResult.url }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="过期时间">{{ formatTimestamp(vmVncResult.expire_at) }}</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="vmVncDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="vmVncLoading" @click="submitVmVnc">获取</el-button>
</template>
</el-dialog>
<!-- 虚拟机选择器 -->
<VmSelectorPopup v-model="showVmVncSelector" :service-id="serviceId" :current-id="vmVncVmId" @confirm="handleVmVncSelected" />
</div>
</template>
<script setup>
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, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import {
getVncNodeList, getVmVnc, addVncNode, testVncNode, updateVncNode, deleteVncNode,
getRemoteHostList
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
const route = useRoute()
const router = useRouter()
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
const submitLoading = ref(false)
const nodeList = ref([])
const total = ref(0)
const keyword = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 })
const maskToken = (token) => {
if (!token) return ''
if (token.length <= 8) return '****'
return token.substring(0, 4) + '****' + token.substring(token.length - 4)
}
const formatTimestamp = (ts) => {
if (!ts) return '-'
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
return '-'
}
const formatDelay = (ns) => {
if (!ns && ns !== 0) return '-'
const ms = ns / 1000000
if (ms >= 1000) return (ms / 1000).toFixed(2) + ' s'
return ms.toFixed(2) + ' ms'
}
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
}
} catch (e) { console.error('加载宿主机列表失败:', e) }
}
const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
if (keyword.value) params.keyword = keyword.value
const res = await getVncNodeList(params)
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
nodeList.value = inner.items || inner.vnc_nodes || inner.nodes || inner.data || (Array.isArray(inner) ? inner : [])
total.value = inner.total ?? inner.all_count ?? nodeList.value.length
} else { nodeList.value = []; total.value = 0 }
} catch (e) {
console.error('获取VNC节点列表失败:', e)
ElMessage.error('获取VNC节点列表失败')
} finally { loading.value = false }
}
const handleSearch = () => { queryParams.page = 1; loadList() }
// 表单
const formDialogVisible = ref(false)
const formType = ref('add')
const formRef = ref(null)
const formData = reactive({ id: undefined, name: '', ip: '', port: '', token: '', access_mode: 'domain', use_ssl: false })
const formRules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
ip: [{ required: true, message: '请输入IP地址', trigger: 'blur' }],
port: [{ required: true, message: '请输入端口', trigger: 'blur' }]
}
const handleAdd = () => {
formType.value = 'add'
Object.assign(formData, { id: undefined, name: '', ip: '', port: '', token: '', access_mode: 'domain', use_ssl: false })
formDialogVisible.value = true
}
const handleEdit = (row) => {
formType.value = 'edit'
Object.assign(formData, {
id: row.id, name: row.name || '', ip: row.ip || '', port: row.port || '', token: row.token || '',
access_mode: row.access_mode || 'domain', use_ssl: !!row.use_ssl
})
formDialogVisible.value = true
}
const handleSubmit = () => {
formRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
let res
const payload = { service_id: serviceId.value, name: formData.name, ip: formData.ip, port: formData.port, token: formData.token, access_mode: formData.access_mode, use_ssl: formData.use_ssl }
if (formType.value === 'add') {
res = await addVncNode(payload)
} else {
payload.id = formData.id
res = await updateVncNode(payload)
}
if (res?.data?.code === 200) {
ElMessage.success(formType.value === 'add' ? '创建成功' : '修改成功')
formDialogVisible.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(`确定要删除VNC节点「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteVncNode({ service_id: serviceId.value, 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(() => {})
}
// 测试连接
const testDialogVisible = ref(false)
const testTarget = ref(null)
const testHostId = ref(null)
const testLoading = ref(false)
const testResult = ref(null)
const handleTest = (row) => {
testTarget.value = row
testHostId.value = null
testResult.value = null
testDialogVisible.value = true
}
const submitTest = async () => {
if (!testHostId.value) { ElMessage.warning('请选择宿主机'); return }
testLoading.value = true
testResult.value = null
try {
const res = await testVncNode({ service_id: serviceId.value, id: testTarget.value.id, host_id: testHostId.value })
if (res?.data?.code === 200) {
const d = res.data.data || {}
const delay = d.delay
if (delay != null && testTarget.value) {
const idx = nodeList.value.findIndex(n => n.id === testTarget.value.id)
if (idx >= 0) nodeList.value[idx]._delay = delay
}
testResult.value = { success: true, message: '连接测试成功' + (delay != null ? `,延迟:${formatDelay(delay)}` : '') }
} else {
testResult.value = { success: false, message: extractApiError(res?.data, '连接测试失败') }
}
} catch (e) {
testResult.value = { success: false, message: '连接测试异常: ' + (e?.message || '未知错误') }
} finally { testLoading.value = false }
}
// VM VNC
const vmVncDialogVisible = ref(false)
const vmVncTarget = ref(null)
const vmVncVmId = ref(0)
const vmVncVmName = ref('')
const vmVncLoading = ref(false)
const vmVncResult = ref(null)
const showVmVncSelector = ref(false)
const handleVmVncSelected = (vm) => { vmVncVmId.value = vm.id; vmVncVmName.value = vm.name || '' }
const handleVmVnc = (row) => {
vmVncTarget.value = row
vmVncVmId.value = 0
vmVncVmName.value = ''
vmVncResult.value = null
vmVncDialogVisible.value = true
}
const submitVmVnc = async () => {
if (!vmVncVmId.value) { ElMessage.warning('请选择虚拟机'); return }
vmVncLoading.value = true
vmVncResult.value = null
try {
const res = await getVmVnc({ service_id: serviceId.value, id: vmVncTarget.value.id, vm_id: vmVncVmId.value })
if (res?.data?.code === 200 && res?.data?.data) {
vmVncResult.value = res.data.data
} else {
ElMessage.error(extractApiError(res?.data, '获取VNC连接信息失败'))
}
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取VNC连接信息失败')) } finally { vmVncLoading.value = false }
}
const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(async () => {
if (serviceId.value) {
await loadHostOptions()
loadList()
}
})
</script>
<style scoped>
.vnc-node-container { padding: 20px; }
.host-addr { font-family: 'Consolas', 'Monaco', monospace; color: #409eff; font-size: 13px; }
.token-mask { font-family: 'Consolas', 'Monaco', monospace; color: #909399; font-size: 13px; }
.test-result { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 4px; margin-top: 12px; font-size: 14px; }
.test-result.success { background: #f0f9eb; color: #67c23a; }
.test-result.error { background: #fef0f0; color: #f56c6c; }
.vnc-result { margin-top: 12px; }
</style>