Files
webssh/frontend/src/views/QuickConnect.vue
T

752 lines
19 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="quick-connect-page">
<el-container class="quick-connect-container">
<!-- 顶部工具栏 -->
<el-header class="quick-connect-header">
<div class="header-left">
<el-button @click="$router.push('/dashboard')" size="small">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<span class="page-title">快速连接</span>
</div>
</el-header>
<!-- 主内容区域:左侧历史树 + 右侧表单 -->
<el-container class="main-body">
<!-- 左侧:连接历史树 -->
<el-aside class="history-aside" width="300px">
<div class="aside-header">
<span class="aside-title">连接历史</span>
<div class="aside-actions">
<el-tooltip content="导入" placement="top">
<el-button size="small" text @click="triggerImport">
<el-icon><Upload /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="导出" placement="top">
<el-button
size="small"
text
:disabled="historyList.length === 0"
@click="exportHistory"
>
<el-icon><Download /></el-icon>
</el-button>
</el-tooltip>
<el-button
v-if="historyList.length > 0"
type="danger"
size="small"
text
@click="clearAllHistory"
>
<el-icon><Delete /></el-icon>
清空
</el-button>
</div>
<input
ref="fileInput"
type="file"
accept=".json"
style="display: none;"
@change="handleImportFile"
/>
</div>
<div v-if="historyList.length > 0" class="history-tree">
<el-tree
:data="treeData"
:props="treeProps"
node-key="id"
default-expand-all
highlight-current
@node-click="handleTreeNodeClick"
>
<template #default="{ node, data }">
<div class="tree-node" :class="{ 'is-leaf': data.isLeaf }">
<el-icon v-if="!data.isLeaf" class="tree-folder-icon"><Folder /></el-icon>
<el-icon v-else class="tree-server-icon"><Monitor /></el-icon>
<span class="tree-node-label">{{ node.label }}</span>
<div class="tree-node-actions" v-if="data.isLeaf">
<el-tag v-if="data.record?.hasSavedCredential" size="small" type="success" class="tree-tag">
<el-icon style="vertical-align: middle;"><Lock /></el-icon>
</el-tag>
<el-button
type="danger"
size="small"
text
circle
class="tree-delete-btn"
@click.stop="removeHistoryByRecord(data.record)"
>
<el-icon><Close /></el-icon>
</el-button>
</div>
</div>
</template>
</el-tree>
</div>
<!-- 空状态 -->
<div v-else class="history-empty">
<el-empty description="暂无连接历史" :image-size="64" />
</div>
</el-aside>
<!-- 右侧:连接表单 -->
<el-main class="form-main">
<div class="connect-form-container">
<el-card class="connect-card">
<template #header>
<div class="card-header">
<h3>SSH连接信息</h3>
<p>输入服务器信息进行快速连接</p>
</div>
</template>
<el-alert
title="快速连接数据保存在本地浏览器中,更换设备或清除浏览器数据后将会丢失。"
type="warning"
show-icon
:closable="false"
style="margin-bottom: 20px;"
/>
<el-form :model="form" :rules="rules" ref="connectForm" label-width="100px">
<el-form-item label="服务器名称" prop="name">
<el-input v-model="form.name" placeholder="请输入服务器名称(可选)" />
</el-form-item>
<el-form-item label="主机地址" prop="host">
<el-input v-model="form.host" placeholder="请输入IP地址或域名" />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input-number v-model="form.port" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入SSH用户名" />
</el-form-item>
<el-form-item label="认证方式" prop="authType">
<el-radio-group v-model="form.authType">
<el-radio label="password">密码认证</el-radio>
<el-radio label="key">私钥认证</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.authType === 'password'" label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入SSH密码"
show-password
/>
</el-form-item>
<el-form-item v-if="form.authType === 'key'" label="私钥" prop="privateKey">
<el-input
v-model="form.privateKey"
type="textarea"
:rows="6"
placeholder="请输入SSH私钥内容"
/>
</el-form-item>
<!-- 保存密码复选框 -->
<el-form-item label=" ">
<el-checkbox v-model="savePassword">
保存密码到本地(记住凭据)
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="connecting"
@click="handleConnect"
style="width: 100%"
>
<el-icon><Connection /></el-icon>
连接
</el-button>
</el-form-item>
</el-form>
<!-- 连接状态提示 -->
<div v-if="connectionError" class="connection-error">
<el-alert
:title="connectionError"
type="error"
show-icon
:closable="false"
/>
</div>
<div v-if="connecting" class="connecting-status">
<el-alert
title="正在连接..."
type="info"
show-icon
:closable="false"
/>
</div>
</el-card>
</div>
</el-main>
</el-container>
</el-container>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Connection, Delete, Close, Monitor, Lock, Folder, Upload, Download } from '@element-plus/icons-vue'
import { useTerminalStore } from '@/stores/terminal'
import { useAuthStore } from '@/stores/auth'
const HISTORY_KEY = 'webssh_quick_connect_history'
const MAX_HISTORY = 20
const router = useRouter()
const terminalStore = useTerminalStore()
const authStore = useAuthStore()
const connectForm = ref()
const fileInput = ref()
const connecting = ref(false)
const connectionError = ref('')
const savePassword = ref(false)
const historyList = ref([])
const form = reactive({
name: '',
host: '',
port: 22,
username: '',
authType: 'password',
password: '',
privateKey: ''
})
const rules = {
host: [
{ required: true, message: '请输入主机地址', trigger: 'blur' }
],
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{
required: true,
message: '请输入密码',
trigger: 'blur',
validator: (rule, value, callback) => {
if (form.authType === 'password' && !value) {
callback(new Error('请输入密码'))
} else {
callback()
}
}
}
],
privateKey: [
{
required: true,
message: '请输入私钥',
trigger: 'blur',
validator: (rule, value, callback) => {
if (form.authType === 'key' && !value) {
callback(new Error('请输入私钥'))
} else {
callback()
}
}
}
]
}
// ========== 树形数据 ==========
const treeProps = {
children: 'children',
label: 'label'
}
// 将 historyList 转换为树形结构,按主机地址分组
const treeData = computed(() => {
const groups = {}
historyList.value.forEach((item, index) => {
const groupKey = item.host
if (!groups[groupKey]) {
groups[groupKey] = {
id: `group-${groupKey}`,
label: groupKey,
isLeaf: false,
children: []
}
}
const displayName = item.name || `${item.username}@${item.host}:${item.port}`
groups[groupKey].children.push({
id: `item-${index}`,
label: displayName,
isLeaf: true,
record: item,
index: index
})
})
return Object.values(groups)
})
const handleTreeNodeClick = (data) => {
if (data.isLeaf && data.record) {
fillFromHistory(data.record)
}
}
// ========== 历史记录管理 ==========
const loadHistory = () => {
try {
const raw = localStorage.getItem(HISTORY_KEY)
historyList.value = raw ? JSON.parse(raw) : []
} catch {
historyList.value = []
}
}
const persistHistory = () => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(historyList.value))
}
const saveToHistory = () => {
const record = {
name: form.name || '',
host: form.host,
port: form.port,
username: form.username,
authType: form.authType,
connectedAt: Date.now(),
hasSavedCredential: savePassword.value
}
if (savePassword.value) {
if (form.authType === 'password') {
record.password = form.password
} else {
record.privateKey = form.privateKey
}
}
const idx = historyList.value.findIndex(
h => h.host === record.host && h.port === record.port && h.username === record.username
)
if (idx !== -1) {
historyList.value.splice(idx, 1)
}
historyList.value.unshift(record)
if (historyList.value.length > MAX_HISTORY) {
historyList.value = historyList.value.slice(0, MAX_HISTORY)
}
persistHistory()
}
const fillFromHistory = (item) => {
form.name = item.name || ''
form.host = item.host
form.port = item.port
form.username = item.username
form.authType = item.authType
if (item.hasSavedCredential) {
savePassword.value = true
if (item.authType === 'password') {
form.password = item.password || ''
form.privateKey = ''
} else {
form.privateKey = item.privateKey || ''
form.password = ''
}
} else {
savePassword.value = false
form.password = ''
form.privateKey = ''
}
ElMessage.success('已填充连接信息,请检查后点击连接')
}
const removeHistoryByRecord = (record) => {
const idx = historyList.value.findIndex(
h => h.host === record.host && h.port === record.port && h.username === record.username
)
if (idx !== -1) {
historyList.value.splice(idx, 1)
persistHistory()
ElMessage.info('已删除')
}
}
const clearAllHistory = () => {
ElMessageBox.confirm('确定要清空所有连接历史吗?', '清空历史', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
historyList.value = []
persistHistory()
ElMessage.success('已清空所有历史记录')
}).catch(() => {})
}
// ========== 导出 / 导入 ==========
const exportHistory = () => {
const data = JSON.stringify(historyList.value, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `webssh_quick_connect_${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${historyList.value.length} 条连接记录`)
}
const triggerImport = () => {
fileInput.value?.click()
}
const handleImportFile = (event) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const imported = JSON.parse(e.target.result)
if (!Array.isArray(imported)) {
ElMessage.error('文件格式错误:需要 JSON 数组')
return
}
// 验证每条记录必须有 host 和 username
const valid = imported.filter(item => item.host && item.username)
if (valid.length === 0) {
ElMessage.error('未找到有效的连接记录')
return
}
// 合并:按 host+port+username 去重,导入的覆盖旧的
let merged = [...historyList.value]
let addedCount = 0
valid.forEach(item => {
const idx = merged.findIndex(
h => h.host === item.host && h.port === item.port && h.username === item.username
)
if (idx !== -1) {
merged.splice(idx, 1)
}
addedCount++
merged.unshift(item)
})
// 限制最大条数
if (merged.length > MAX_HISTORY) {
merged = merged.slice(0, MAX_HISTORY)
}
historyList.value = merged
persistHistory()
ElMessage.success(`成功导入 ${addedCount} 条连接记录`)
} catch {
ElMessage.error('文件解析失败,请确认是有效的 JSON 文件')
}
}
reader.readAsText(file)
// 清空 input 以便重复选择同一文件
event.target.value = ''
}
// ========== 连接逻辑 ==========
const handleConnect = async () => {
if (!connectForm.value) return
try {
const valid = await connectForm.value.validate()
if (!valid) return
if (!authStore.token) {
ElMessage.error('请先登录')
return
}
connecting.value = true
connectionError.value = ''
if (!terminalStore.socket) {
try {
await terminalStore.connectSocket(authStore.token)
} catch (error) {
connecting.value = false
connectionError.value = 'Socket连接失败: ' + error.message
return
}
}
saveToHistory()
const connectionInfo = {
name: form.name || '',
host: form.host,
port: form.port,
username: form.username
}
if (form.authType === 'password') {
connectionInfo.password = form.password
} else {
connectionInfo.privateKey = form.privateKey
}
terminalStore.quickConnect(connectionInfo)
connecting.value = false
router.push('/quick-connect-terminal')
} catch (error) {
connecting.value = false
ElMessage.error('连接失败,请重试')
}
}
onMounted(() => {
loadHistory()
})
</script>
<style scoped>
.quick-connect-page {
height: 100vh;
background-color: #f0f2f5;
}
.quick-connect-container {
height: 100%;
display: flex;
flex-direction: column;
}
.quick-connect-header {
background: white;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
padding: 0 20px;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
/* 主体布局 */
.main-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* 左侧历史树 */
.history-aside {
background: white;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
overflow: hidden;
}
.aside-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 12px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.aside-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.aside-actions {
display: flex;
align-items: center;
gap: 2px;
}
.history-tree {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.history-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.aside-tip {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid #f0f0f0;
}
/* 树节点样式 */
.tree-node {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 2px 0;
font-size: 13px;
}
.tree-folder-icon {
color: #e6a23c;
font-size: 16px;
flex-shrink: 0;
}
.tree-server-icon {
color: #409eff;
font-size: 14px;
flex-shrink: 0;
}
.tree-node-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #303133;
}
.tree-node.is-leaf .tree-node-label {
color: #606266;
}
.tree-node-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s;
}
.tree-node:hover .tree-node-actions {
opacity: 1;
}
.tree-tag {
transform: scale(0.85);
}
.tree-delete-btn {
width: 20px !important;
height: 20px !important;
}
/* Element Tree 深度覆盖 */
:deep(.el-tree-node__content) {
height: 36px;
padding-right: 8px !important;
}
:deep(.el-tree-node__content:hover) {
background-color: #f5f7fa;
}
:deep(.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content) {
background-color: #ecf5ff;
}
/* 右侧表单 */
.form-main {
padding: 30px 40px;
overflow-y: auto;
display: flex;
justify-content: center;
align-items: flex-start;
}
.connect-form-container {
width: 100%;
max-width: 580px;
}
.connect-card {
margin-bottom: 20px;
}
.card-header h3 {
margin: 0 0 4px 0;
color: #333;
}
.card-header p {
margin: 0;
color: #999;
font-size: 13px;
}
.connection-error,
.connecting-status {
margin-top: 20px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.main-body {
flex-direction: column;
}
.history-aside {
width: 100% !important;
max-height: 200px;
border-right: none;
border-bottom: 1px solid #e4e7ed;
}
.form-main {
padding: 20px;
}
}
</style>