feat: 对接宿主机组映射管理
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
<template>
|
||||
<div class="kvm-service-container">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<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 class="toolbar-right">
|
||||
<el-input
|
||||
v-model="searchKey"
|
||||
placeholder="搜索服务名称/地址"
|
||||
style="width: 260px"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务列表 -->
|
||||
<el-table :data="serviceList" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="Name" label="服务名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="服务地址" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="host-addr">{{ row.Host }}:{{ row.Port }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="认证Token" min-width="160" 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 prop="Note" label="备注" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.Note || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.CreatedAt || row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="goHostGroupMapping(row)">主机组</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 > queryParams.count">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.count"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogType === 'add' ? '新建主控服务' : '编辑主控服务'"
|
||||
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="请输入服务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址" prop="host">
|
||||
<el-input v-model="formData.host" placeholder="请输入服务地址,如 192.168.1.100 或域名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务端口" prop="port">
|
||||
<el-input v-model="formData.port" placeholder="请输入服务端口,如 8080" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证Token" prop="token">
|
||||
<el-input v-model="formData.token" placeholder="请输入认证Token(可选)" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="note">
|
||||
<el-input v-model="formData.note" type="textarea" :rows="3" placeholder="备注说明(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailDialogVisible" title="主控服务详情" width="580px" destroy-on-close>
|
||||
<el-descriptions :column="2" border v-if="currentDetail" v-loading="detailLoading">
|
||||
<el-descriptions-item label="ID">{{ currentDetail.Id ?? currentDetail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务名称">{{ currentDetail.Name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务地址">{{ currentDetail.Host }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务端口">{{ currentDetail.Port }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认证Token" :span="2">
|
||||
<el-input v-if="currentDetail.Token" :model-value="currentDetail.Token" readonly show-password style="max-width: 300px" />
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ currentDetail.Note || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">{{ formatTime(currentDetail.CreatedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</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 {
|
||||
getKvmServiceList,
|
||||
getKvmServiceDetail,
|
||||
createKvmService,
|
||||
updateKvmService,
|
||||
deleteKvmService
|
||||
} from '@/api/admin/kvmService'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const serviceList = ref([])
|
||||
const total = ref(0)
|
||||
const searchKey = ref('')
|
||||
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
count: 10,
|
||||
key: ''
|
||||
})
|
||||
|
||||
// 弹窗控制
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const formRef = ref(null)
|
||||
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
id: undefined,
|
||||
name: '',
|
||||
host: '',
|
||||
port: '',
|
||||
token: '',
|
||||
note: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
||||
host: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
|
||||
port: [{ required: true, message: '请输入服务端口', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 规范化后端 PascalCase 字段为前端 camelCase
|
||||
// 同时保留原始字段以便在需要时直接访问
|
||||
const normalizeService = (item) => {
|
||||
if (!item) return item
|
||||
return {
|
||||
...item, // 保留原始字段(如 Id、Name 等)
|
||||
id: item.Id ?? item.id,
|
||||
name: item.Name ?? item.name,
|
||||
host: item.Host ?? item.host,
|
||||
port: item.Port ?? item.port,
|
||||
token: item.Token ?? item.token,
|
||||
note: item.Note ?? item.note,
|
||||
CreatedAt: item.CreatedAt ?? item.created_at,
|
||||
UpdatedAt: item.UpdatedAt ?? item.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
// 加载列表
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getKvmServiceList(queryParams)
|
||||
// http2 返回完整 axios 响应,res.data 为 JSON body
|
||||
const body = res?.data
|
||||
console.debug('[KvmService] list response body:', JSON.stringify(body))
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data // { all_count, data: [...] } 或直接是数组
|
||||
const items = Array.isArray(inner) ? inner : (inner.data || inner.list || [])
|
||||
serviceList.value = items.map(normalizeService)
|
||||
total.value = inner.all_count ?? inner.total ?? items.length
|
||||
console.debug('[KvmService] normalized list:', serviceList.value)
|
||||
} else {
|
||||
serviceList.value = []
|
||||
total.value = 0
|
||||
if (body?.message) {
|
||||
ElMessage.warning(body.message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取主控服务列表失败:', error)
|
||||
ElMessage.error('获取主控服务列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
queryParams.key = searchKey.value
|
||||
loadList()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
queryParams.count = size
|
||||
queryParams.page = 1
|
||||
loadList()
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
queryParams.page = page
|
||||
loadList()
|
||||
}
|
||||
|
||||
// 遮掩Token
|
||||
const maskToken = (token) => {
|
||||
if (!token) return ''
|
||||
if (token.length <= 8) return '****'
|
||||
return token.substring(0, 4) + '****' + token.substring(token.length - 4)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
return dayjs(t).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: undefined,
|
||||
name: '',
|
||||
host: '',
|
||||
port: '',
|
||||
token: '',
|
||||
note: ''
|
||||
})
|
||||
}
|
||||
|
||||
// 新建
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
Object.assign(formData, {
|
||||
id: Number(row.Id ?? row.id),
|
||||
name: row.Name ?? row.name,
|
||||
host: row.Host ?? row.host,
|
||||
port: row.Port ?? row.port,
|
||||
token: row.Token ?? row.token ?? '',
|
||||
note: row.Note ?? row.note ?? ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
host: formData.host,
|
||||
port: formData.port,
|
||||
token: formData.token,
|
||||
note: formData.note
|
||||
}
|
||||
let res
|
||||
if (dialogType.value === 'add') {
|
||||
res = await createKvmService(payload)
|
||||
} else {
|
||||
const editId = Number(formData.id)
|
||||
if (!editId) {
|
||||
ElMessage.error('无法获取服务ID')
|
||||
submitLoading.value = false
|
||||
return
|
||||
}
|
||||
res = await updateKvmService(editId, payload)
|
||||
}
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '更新成功')
|
||||
dialogVisible.value = false
|
||||
loadList()
|
||||
} else {
|
||||
ElMessage.error(body?.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error('操作失败: ' + (error?.response?.data?.message || error.message))
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = (row) => {
|
||||
const rawId = row.Id ?? row.id
|
||||
if (!rawId) {
|
||||
ElMessage.error('无法获取服务ID')
|
||||
return
|
||||
}
|
||||
ElMessageBox.confirm(`确定要删除主控服务「${row.Name}」吗?删除后不可恢复。`, '删除确认', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteKvmService({ id: Number(rawId) })
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
loadList()
|
||||
} else {
|
||||
ElMessage.error(body?.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败: ' + (error?.response?.data?.message || error.message))
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = async (row) => {
|
||||
// 优先使用原始 Id(PascalCase),回退到规范化后的 id
|
||||
const rawId = row.Id ?? row.id
|
||||
console.debug('[KvmService] handleViewDetail rawId:', rawId, 'row:', row)
|
||||
if (rawId === undefined || rawId === null || rawId === '') {
|
||||
ElMessage.error('无法获取服务ID,请刷新列表后重试')
|
||||
return
|
||||
}
|
||||
detailDialogVisible.value = true
|
||||
detailLoading.value = true
|
||||
currentDetail.value = null
|
||||
try {
|
||||
const res = await getKvmServiceDetail({ id: Number(rawId) })
|
||||
const body = res?.data
|
||||
console.debug('[KvmService] detail response body:', JSON.stringify(body))
|
||||
if (body?.code === 200 && body?.data) {
|
||||
currentDetail.value = normalizeService(body.data)
|
||||
} else {
|
||||
// 接口返回非200,显示错误但仍展示列表行数据
|
||||
ElMessage.error(body?.message || '获取详情失败')
|
||||
currentDetail.value = normalizeService(row)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
const errMsg = error?.response?.data?.message || error?.message || '未知错误'
|
||||
ElMessage.error('获取详情失败: ' + errMsg)
|
||||
currentDetail.value = normalizeService(row)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到宿主机组映射管理
|
||||
const goHostGroupMapping = (row) => {
|
||||
const id = Number(row.Id ?? row.id)
|
||||
const name = row.Name ?? row.name
|
||||
router.push({
|
||||
path: '/virtualization/host-group-mapping',
|
||||
query: { service_id: id, service_name: name }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kvm-service-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.host-addr {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.token-mask {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
--el-table-border-color: #ebeef5;
|
||||
--el-table-header-bg-color: #fafafa;
|
||||
--el-table-row-hover-bg-color: #f5f7fa;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user