feat:add guacamole
Build and Deploy Vue3 / build (push) Successful in 1m8s
Build and Deploy Vue3 / deploy (push) Successful in 4m43s

This commit is contained in:
2025-09-28 23:29:18 +08:00
parent 2b4083c2f1
commit 4fc9a43fd2
5 changed files with 829 additions and 2 deletions
+5
View File
@@ -35,6 +35,11 @@ export const menus = [
}, },
{ {
path: '/acs/nodes', title: '节点管理' path: '/acs/nodes', title: '节点管理'
},
{
path: '/acs/guacamole',
title: '远程桌面网关管理',
icon: 'Monitor'
},{ },{
path: '/audit', path: '/audit',
title: '站点审计', title: '站点审计',
+7
View File
@@ -130,6 +130,13 @@ const routes = [
meta: { meta: {
title: '节点管理' title: '节点管理'
} }
},{
path: 'guacamole',
name: 'Guacamole',
component: () => import('../views/acs/guacamole/Guacamole.vue'),
meta: {
title: 'Guacamole管理'
}
} }
] ]
}, },
+34
View File
@@ -0,0 +1,34 @@
import {http2} from "@/utils/request.js";
/**获取 guacamole 列表 */
export const getGuacamoleList = data => {
return http2.get(`/v1/admin/server/get_guacamole_list`);
};
/**获取服务器 guacamole 信息 */
export const getGuacamoleInfo = data => {
return http2.get(`/v1/admin/server/get_server_guacamole?server_id=${data}`);
};
/**新增 guacamole 参数 url:string,username:string,password:string*/
export const addGuacamoleInfo = data => {
return http2.post(`/v1/admin/server/add_guacamole`, data,{
headers:{
'Content-Type': 'multipart/form-data'
}
});
};
/**修改guacamole 参数 id:string,url:string,username:string,password:string*/
export const updateGuacamoleInfo = data => {
return http2.post(`/v1/admin/server/edit_guacamole`, data,{
headers:{
'Content-Type': 'multipart/form-data'
}
});
};
/**删除guacamole 参数 id:string */
export const deleteGuacamoleInfo = data => {
return http2.post(`/v1/admin/server/delete_guacamole`, data,{
headers:{
'Content-Type': 'multipart/form-data'
}
});
};
+739
View File
@@ -0,0 +1,739 @@
<template>
<div class="guacamole-container">
<!-- 页面标题和操作按钮 -->
<div class="page-header">
<div class="left">
<h2 class="title">远程桌面网关管理</h2>
<el-tag type="info" effect="plain" class="count-tag"> {{ guacamoleStats.total }} 个配置</el-tag>
</div>
<div class="actions">
<el-button type="primary" @click="handleAdd" :icon="Plus" class="action-btn">添加配置</el-button>
<el-button @click="handleRefresh" :icon="Refresh" class="action-btn">刷新</el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-panel">
<div class="stat-card total-card">
<div class="stat-icon"><el-icon><Monitor /></el-icon></div>
<div class="stat-content">
<div class="stat-value">{{ guacamoleStats.total }}</div>
<div class="stat-label">总配置数</div>
</div>
</div>
<div class="stat-card active-card">
<div class="stat-icon"><el-icon><CircleCheck /></el-icon></div>
<div class="stat-content">
<div class="stat-value">{{ guacamoleStats.active }}</div>
<div class="stat-label">活跃配置</div>
</div>
</div>
<div class="stat-card error-card">
<div class="stat-icon"><el-icon><CircleClose /></el-icon></div>
<div class="stat-content">
<div class="stat-value">{{ guacamoleStats.error }}</div>
<div class="stat-label">异常配置</div>
</div>
</div>
</div>
<!-- 搜索和筛选 -->
<div class="filter-section">
<el-input
v-model="filterForm.url"
placeholder="搜索 Guacamole URL"
prefix-icon="Search"
clearable
@keyup.enter="handleSearch"
class="search-input"
/>
<div class="filter-actions">
<el-button type="primary" @click="handleSearch" :icon="Search">搜索</el-button>
<el-button @click="resetFilter" :icon="Delete">重置</el-button>
</div>
</div>
<!-- Guacamole 配置列表 -->
<div class="table-container">
<el-table
v-loading="loading"
:data="guacamoleData"
border
stripe
style="width: 100%"
table-layout="auto"
class="guacamole-table"
>
<el-table-column prop="id" label="ID" width="80" show-overflow-tooltip />
<el-table-column prop="url" label="Guacamole URL" min-width="200" show-overflow-tooltip />
<el-table-column prop="username" label="用户名" min-width="120" show-overflow-tooltip />
<el-table-column label="密码" width="120" align="center">
<template #default="scope">
<el-button
type="text"
size="small"
@click="togglePasswordVisibility(scope.row)"
:icon="scope.row.showPassword ? View : Hide"
>
{{ scope.row.showPassword ? scope.row.password : '••••••••' }}
</el-button>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180" align="center">
<template #default="scope">
{{ formatDate(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="scope">
<div class="action-buttons">
<el-tooltip content="编辑配置" placement="top" :hide-after="1500">
<el-button
type="warning"
:icon="Edit"
circle
@click="handleEdit(scope.row)"
/>
</el-tooltip>
<el-tooltip content="删除配置" placement="top" :hide-after="1500">
<el-button
type="danger"
:icon="Delete"
circle
@click="handleDelete(scope.row)"
/>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
/>
</div>
</div>
<!-- 添加/编辑配置对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '添加 Guacamole 配置' : '编辑 Guacamole 配置'"
:width="dialogWidth"
destroy-on-close
:close-on-click-modal="false"
:before-close="handleDialogClose"
class="guacamole-dialog"
>
<el-form :model="guacamoleForm" label-width="120px" :rules="rules" ref="guacamoleFormRef">
<el-form-item label="Guacamole URL" prop="url">
<el-input
v-model="guacamoleForm.url"
placeholder="请输入 Guacamole 服务器地址 (如: http://192.168.1.100:8080/guacamole)"
/>
<div class="form-item-tip">请确保 URL 格式正确包含协议IP/域名和端口</div>
</el-form-item>
<el-form-item label="管理员用户名" prop="username">
<el-input v-model="guacamoleForm.username" placeholder="请输入管理员用户名" />
<div class="form-item-tip">用于连接 Guacamole 服务的管理员账号</div>
</el-form-item>
<el-form-item label="管理员密码" prop="password">
<el-input
v-model="guacamoleForm.password"
placeholder="请输入管理员密码"
type="password"
show-password
/>
<div class="form-item-tip">用于连接 Guacamole 服务的管理员密码</div>
</el-form-item>
<el-form-item label="连接测试">
<el-button
@click="testConnection"
:loading="testingConnection"
:icon="Connection"
>
{{ testingConnection ? '测试中...' : '测试连接' }}
</el-button>
<div class="form-item-tip">建议在保存前测试连接是否正常</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<div class="left-actions">
<!-- 预留空间给可能的其他操作 -->
</div>
<div class="right-actions">
<el-button @click="handleDialogClose">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">
{{ submitting ? '保存中...' : '确认' }}
</el-button>
</div>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import {
Plus, Refresh, Edit, Delete, Monitor, CircleCheck, CircleClose,
View, Hide, Connection, Search
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import {
getGuacamoleList,
addGuacamoleInfo,
updateGuacamoleInfo,
deleteGuacamoleInfo
} from '@/utils/acs/guacamole'
// 表格数据
const loading = ref(false)
const guacamoleData = ref([])
// 筛选表单
const filterForm = reactive({
url: ''
})
// 重置筛选
const resetFilter = () => {
filterForm.url = ''
handleSearch()
}
// 统计数据
const guacamoleStats = reactive({
total: 0,
active: 0,
error: 0
})
// 分页
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 处理页码变化
const handleCurrentChange = (val) => {
pagination.currentPage = val
fetchData()
}
// 处理每页条数变化
const handleSizeChange = (val) => {
pagination.pageSize = val
fetchData()
}
// 对话框相关
const dialogVisible = ref(false)
const dialogType = ref('add') // 'add', 'edit'
const submitting = ref(false)
// 表单对象和规则
const guacamoleFormRef = ref(null)
const guacamoleForm = reactive({
id: '',
url: '',
username: '',
password: ''
})
const rules = {
url: [
{ required: true, message: '请输入 Guacamole URL', trigger: 'blur' },
{
pattern: /^https?:\/\/.+/,
message: '请输入有效的URL地址 (以http://或https://开头)',
trigger: 'blur'
}
],
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '用户名长度应为2-50个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度至少为6个字符', trigger: 'blur' }
]
}
// 连接测试
const testingConnection = ref(false)
const testConnection = async () => {
if (!guacamoleForm.url || !guacamoleForm.username || !guacamoleForm.password) {
ElMessage.warning('请先填写完整的连接信息')
return
}
testingConnection.value = true
try {
// 这里可以添加实际的连接测试逻辑
// 暂时模拟测试过程
await new Promise(resolve => setTimeout(resolve, 2000))
ElMessage.success('连接测试成功!')
} catch (error) {
console.error('连接测试失败:', error)
ElMessage.error('连接测试失败,请检查配置信息')
} finally {
testingConnection.value = false
}
}
// 刷新数据
const handleRefresh = () => {
ElNotification({
title: '刷新中',
message: '正在重新获取 Guacamole 配置数据',
type: 'info',
duration: 2000
})
fetchData()
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const res = await getGuacamoleList()
if (res && res.data && res.data.code === 200) {
const data = res.data.data || []
// 为每个项目添加密码显示状态
guacamoleData.value = data.map(item => ({
...item,
showPassword: false
}))
// 更新统计数据
updateStats()
// 更新分页信息
pagination.total = data.length
} else {
ElMessage.error('获取 Guacamole 配置失败')
}
} catch (error) {
console.error('获取 Guacamole 配置失败:', error)
ElMessage.error('获取 Guacamole 配置失败')
} finally {
loading.value = false
}
}
// 处理搜索
const handleSearch = () => {
pagination.currentPage = 1
fetchData()
}
// 更新统计数据
const updateStats = () => {
guacamoleStats.total = guacamoleData.value.length
guacamoleStats.active = guacamoleData.value.filter(item => item.status !== 'error').length
guacamoleStats.error = guacamoleData.value.filter(item => item.status === 'error').length
}
// 切换密码显示状态
const togglePasswordVisibility = (row) => {
row.showPassword = !row.showPassword
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
// 添加配置
const handleAdd = () => {
dialogType.value = 'add'
dialogVisible.value = true
// 重置表单
Object.keys(guacamoleForm).forEach(key => {
guacamoleForm[key] = ''
})
// 确保在下一个 tick 重置表单验证状态
nextTick(() => {
if (guacamoleFormRef.value) {
guacamoleFormRef.value.resetFields()
}
})
}
// 编辑配置
const handleEdit = (row) => {
dialogType.value = 'edit'
dialogVisible.value = true
// 填充表单
Object.keys(guacamoleForm).forEach(key => {
if (key in row) {
guacamoleForm[key] = row[key]
}
})
nextTick(() => {
if (guacamoleFormRef.value) {
guacamoleFormRef.value.clearValidate()
}
})
}
// 删除配置
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除配置"${row.url}"吗?`,
'删除确认',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'error',
draggable: true,
distinguishCancelAndClose: true,
closeOnClickModal: false
}
).then(async () => {
try {
const res = await deleteGuacamoleInfo({ id: row.id })
if (res && res.data && res.data.code === 200) {
ElNotification({
title: '删除成功',
message: `配置"${row.url}"已被成功删除`,
type: 'success',
duration: 3000
})
fetchData()
} else {
ElMessage.error('删除失败!')
}
} catch (error) {
console.error('删除配置失败:', error)
ElMessage.error('删除配置失败')
}
}).catch(() => {})
}
// 关闭对话框
const handleDialogClose = () => {
dialogVisible.value = false
if (guacamoleFormRef.value) {
guacamoleFormRef.value.resetFields()
}
}
// 提交表单
const submitForm = async () => {
if (!guacamoleFormRef.value) return
await guacamoleFormRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
const formData = { ...guacamoleForm }
let res
if (dialogType.value === 'add') {
let data = {
url:formData.url,
username:formData.username,
password:formData.password
}
res = await addGuacamoleInfo(data)
} else {
res = await updateGuacamoleInfo(formData)
}
if (res && res.data && res.data.code === 200) {
ElNotification({
title: dialogType.value === 'add' ? '添加成功' : '更新成功',
message: `Guacamole 配置已${dialogType.value === 'add' ? '添加' : '更新'}成功`,
type: 'success',
duration: 3000
})
dialogVisible.value = false
fetchData()
} else {
const errorMsg = res?.data?.msg || '操作失败'
ElMessage.error(errorMsg)
}
} catch (error) {
console.error('提交表单失败:', error)
ElMessage.error('提交失败')
} finally {
submitting.value = false
}
} else {
ElMessage.warning('请完善表单信息')
}
})
}
// 计算对话框宽度
const dialogWidth = computed(() => {
const width = window.innerWidth
if (width < 768) return '95%'
if (width < 992) return '80%'
return '50%'
})
// 初始加载
onMounted(async () => {
try {
await fetchData()
} catch (error) {
console.error('初始化失败:', error)
ElMessage.error('页面初始化失败')
}
})
</script>
<style scoped>
.guacamole-container {
padding: 20px;
min-height: calc(100vh - 120px);
background-color: #f5f7fa;
}
/* 页面标题样式 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #ebeef5;
}
.page-header .left {
display: flex;
align-items: center;
gap: 12px;
}
.page-header .title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.count-tag {
font-size: 13px;
}
.page-header .actions {
display: flex;
gap: 12px;
align-items: center;
}
/* 统计卡片 */
.stats-panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
padding: 20px;
display: flex;
align-items: center;
transition: all 0.3s;
border: 1px solid #ebeef5;
}
.stat-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin-right: 16px;
flex-shrink: 0;
}
.total-card .stat-icon {
background-color: rgba(64, 158, 255, 0.1);
color: #409EFF;
}
.active-card .stat-icon {
background-color: rgba(103, 194, 58, 0.1);
color: #67C23A;
}
.error-card .stat-icon {
background-color: rgba(245, 108, 108, 0.1);
color: #F56C6C;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: 600;
margin-bottom: 4px;
line-height: 1.1;
}
.stat-label {
font-size: 14px;
color: #606266;
}
/* 搜索和筛选部分 */
.filter-section {
display: flex;
gap: 16px;
margin-bottom: 24px;
align-items: center;
background: white;
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.search-input {
flex: 1;
max-width: 400px;
}
.filter-actions {
display: flex;
gap: 8px;
}
/* 表格容器 */
.table-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
padding: 16px;
margin-bottom: 24px;
}
.guacamole-table {
margin-bottom: 16px;
}
/* 操作按钮 */
.action-buttons {
display: flex;
justify-content: center;
gap: 8px;
}
/* 分页 */
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
/* 表单样式 */
.guacamole-dialog :deep(.el-form-item__label) {
font-weight: 500;
}
.form-item-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.2;
}
.form-section-title {
font-weight: 600;
margin: 16px 0 8px;
padding-bottom: 8px;
border-bottom: 1px dashed #ebeef5;
color: #409EFF;
}
/* 对话框底部 */
.dialog-footer {
display: flex;
justify-content: space-between;
width: 100%;
}
.left-actions, .right-actions {
display: flex;
gap: 8px;
}
/* 响应式设计 */
@media screen and (max-width: 992px) {
.stats-panel {
grid-template-columns: repeat(2, 1fr);
}
.stat-card:last-child {
grid-column: span 2;
}
}
@media screen and (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.page-header .actions {
width: 100%;
flex-wrap: wrap;
}
.stats-panel {
grid-template-columns: 1fr;
}
.stat-card:last-child {
grid-column: auto;
}
.filter-section {
flex-direction: column;
align-items: stretch;
}
.search-input {
max-width: none;
}
}
</style>
+44 -2
View File
@@ -201,7 +201,31 @@
<div class="form-section-title">认证与展示</div> <div class="form-section-title">认证与展示</div>
<el-form-item v-if="serverForm.server_type === 'dockerContainer'" label="Auth-ID"> <el-form-item v-if="serverForm.server_type === 'dockerContainer'" label="Auth-ID">
<el-input v-model="serverForm.auth_id" placeholder="节点服务器管理员的auth_id" /> <el-input v-model="serverForm.auth_id" placeholder="服务器管理id" />
</el-form-item>
<el-form-item v-if="serverForm.server_type === 'hyperV'" label="Guacamole-ID">
<el-input v-model="serverForm.guacamole_id" placeholder="guacamole服务id" />
</el-form-item>
<el-form-item v-if="serverForm.server_type === 'hyperV'" label="登录用户名">
<el-input v-model="serverForm.username" placeholder="服务器登录用户名" />
</el-form-item>
<el-form-item v-if="serverForm.server_type === 'hyperV'" label="登录密码">
<el-input
v-model="serverForm.password"
placeholder="服务器登录密码"
type="password"
show-password
/>
</el-form-item>
<el-form-item v-if="serverForm.server_type === 'hyperV'" label="端口映射">
<el-switch
v-model="serverForm.allow_port_forward"
:active-value="1"
:inactive-value="0"
active-text="开启"
inactive-text="关闭"
/>
<div class="form-item-description">服务器是否开放端口映射</div>
</el-form-item> </el-form-item>
<el-form-item label="Token"> <el-form-item label="Token">
<el-input <el-input
@@ -335,6 +359,15 @@ const rules = {
server_ip: [ server_ip: [
{ required: true, message: '请输入IP地址', trigger: 'blur' }, { required: true, message: '请输入IP地址', trigger: 'blur' },
{ pattern: /^(\d{1,3}\.){3}\d{1,3}$/, message: '请输入有效的IP地址', trigger: 'blur' } { pattern: /^(\d{1,3}\.){3}\d{1,3}$/, message: '请输入有效的IP地址', trigger: 'blur' }
],
guacamole_id: [
{ required: false, message: '请输入Guacamole服务ID', trigger: 'blur' }
],
username: [
{ required: false, message: '请输入登录用户名', trigger: 'blur' }
],
password: [
{ required: false, message: '请输入登录密码', trigger: 'blur' }
] ]
} }
@@ -418,6 +451,8 @@ const handleAdd = () => {
Object.keys(serverForm).forEach(key => { Object.keys(serverForm).forEach(key => {
if (key === 'hide') { if (key === 'hide') {
serverForm[key] = 0 serverForm[key] = 0
} else if (key === 'allow_port_forward') {
serverForm[key] = 0
} else if (key === 'server_type') { } else if (key === 'server_type') {
serverForm[key] = serverType.value serverForm[key] = serverType.value
} else { } else {
@@ -534,7 +569,7 @@ const submitForm = async () => {
const formData = { ...serverForm } const formData = { ...serverForm }
// 转换可能的数字字段 // 转换可能的数字字段
const numericFields = ['bandwidth', 'disk', 'memory', 'cpu', 'hide']; const numericFields = ['bandwidth', 'disk', 'memory', 'cpu', 'hide', 'allow_port_forward'];
numericFields.forEach(field => { numericFields.forEach(field => {
if (formData[field] !== '' && formData[field] !== null && formData[field] !== undefined) { if (formData[field] !== '' && formData[field] !== null && formData[field] !== undefined) {
formData[field] = Number(formData[field]) formData[field] = Number(formData[field])
@@ -985,6 +1020,13 @@ onMounted(async () => {
color: #409EFF; color: #409EFF;
} }
.form-item-description {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.2;
}
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);