f7c3be1d30
- Created ImageForm.vue as standalone page for add/edit image functionality - Removed dialog-based image form from VmImages.vue - Implemented tagsViewStore for global tab state management - Added automatic tab closing on form cancel/back - Fixed data persistence issue when switching between image edits - Removed quick actions section from ImageForm - Updated router configuration for new image form route
727 lines
18 KiB
Vue
727 lines
18 KiB
Vue
<template>
|
||
<div class="guacamole-container">
|
||
<!-- 统计卡片 -->
|
||
<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>
|
||
|
||
<el-card class="main-container" shadow="never">
|
||
<!-- 搜索和筛选 -->
|
||
<div class="filter-section">
|
||
<div class="filter-content">
|
||
<el-form :inline="true" :model="filterForm" class="search-form">
|
||
<el-form-item>
|
||
<el-input
|
||
v-model="filterForm.url"
|
||
placeholder="搜索 Guacamole URL"
|
||
prefix-icon="Search"
|
||
clearable
|
||
@keyup.enter="handleSearch"
|
||
style="width: 300px"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="handleSearch">
|
||
<el-icon><search /></el-icon>搜索
|
||
</el-button>
|
||
<el-button @click="resetFilter">
|
||
<el-icon><refresh /></el-icon>重置
|
||
</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div class="action-bar">
|
||
<el-button type="primary" @click="handleAdd">
|
||
<el-icon><plus /></el-icon>添加配置
|
||
</el-button>
|
||
<el-button @click="handleRefresh">
|
||
<el-icon><refresh /></el-icon>刷新
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Guacamole 配置列表 -->
|
||
<div class="table-section">
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="guacamoleData"
|
||
style="width: 100%"
|
||
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||
>
|
||
<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="primary"
|
||
link
|
||
size="small"
|
||
@click="togglePasswordVisibility(scope.row)"
|
||
>
|
||
<el-icon style="margin-right: 4px"><component :is="scope.row.showPassword ? View : Hide" /></el-icon>
|
||
{{ 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">
|
||
<el-button
|
||
type="primary"
|
||
link
|
||
@click="handleEdit(scope.row)"
|
||
>
|
||
<el-icon><edit /></el-icon>编辑
|
||
</el-button>
|
||
<el-button
|
||
type="danger"
|
||
link
|
||
@click="handleDelete(scope.row)"
|
||
>
|
||
<el-icon><delete /></el-icon>删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 分页 -->
|
||
<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
|
||
class="pagination"
|
||
/>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 添加/编辑配置对话框 -->
|
||
<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: 0;
|
||
}
|
||
|
||
/* 统计卡片 */
|
||
.stats-panel {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: white;
|
||
border-radius: 4px;
|
||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||
padding: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
transition: all 0.3s;
|
||
border: 1px solid #e1e8ed;
|
||
}
|
||
|
||
.stat-card:hover {
|
||
transform: translateY(-3px);
|
||
box-shadow: 0 4px 16px 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;
|
||
color: #303133;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 14px;
|
||
color: #909399;
|
||
}
|
||
|
||
.main-container {
|
||
border: 1px solid #e1e8ed;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.filter-section {
|
||
padding: 0;
|
||
border-bottom: 1px solid #e1e8ed;
|
||
background: #fafbfc;
|
||
}
|
||
|
||
.filter-content {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
gap: 20px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.search-form {
|
||
margin: 0;
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.search-form :deep(.el-form-item) {
|
||
margin-bottom: 0;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.action-bar {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.table-section {
|
||
padding: 0;
|
||
}
|
||
|
||
.pagination {
|
||
margin-top: 20px;
|
||
padding: 16px 20px;
|
||
border-top: 1px solid #e1e8ed;
|
||
background: #fafbfc;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
/* 表单样式 */
|
||
.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;
|
||
}
|
||
|
||
/* 对话框底部 */
|
||
.dialog-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
}
|
||
|
||
.left-actions, .right-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* 表格样式优化 */
|
||
:deep(.el-table) {
|
||
border: none;
|
||
color: #2c3e50;
|
||
}
|
||
|
||
:deep(.el-table__header) {
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
:deep(.el-table th) {
|
||
background: #f8f9fa !important;
|
||
border-bottom: 2px solid #e1e8ed;
|
||
color: #2c3e50;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
|
||
:deep(.el-table td) {
|
||
border-bottom: 1px solid #f0f2f5;
|
||
color: #34495e;
|
||
}
|
||
|
||
:deep(.el-table tr:hover > td) {
|
||
background-color: #f8f9fa !important;
|
||
}
|
||
|
||
:deep(.el-card__body) {
|
||
padding: 0;
|
||
}
|
||
|
||
/* 响应式设计 */
|
||
@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) {
|
||
.stats-panel {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.stat-card:last-child {
|
||
grid-column: auto;
|
||
}
|
||
|
||
.filter-content {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.search-form {
|
||
width: 100%;
|
||
}
|
||
|
||
.action-bar {
|
||
width: 100%;
|
||
justify-content: flex-start;
|
||
}
|
||
}
|
||
</style>
|