- 用户列表新增通过IDC代理商uid查询用户信息 - 用户商品详情新增续费功能(admin/good/user_goods/renew) - VNC节点管理新增access_mode和use_ssl参数 - 用户详情左栏新增IDC平台信息展示 - 新购订单选择商品后所属表不再被覆盖,固定为good Co-authored-by: Cursor <cursoragent@cursor.com>
391 lines
17 KiB
Vue
391 lines
17 KiB
Vue
<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>
|