feat: implement quick SSH connect more terminals

This commit is contained in:
dalaolala committed 2026-02-28 16:17:57 +08:00
1 parent de1cf2bbc7
commit 0d83c9a8b1
3 files changed
+289 -148

No files matched your search

+4 -15
View File
@@ -203,9 +203,10 @@ const HISTORY_KEY = 'webssh_quick_connect_history'
const MAX_HISTORY = 20
const router = useRouter()
const terminalStore = useTerminalStore()
const authStore = useAuthStore()
const emit = defineEmits(['connect'])
const connectForm = ref()
const fileInput = ref()
const connecting = ref(false)
@@ -487,16 +488,6 @@ const handleConnect = async () => {
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 = {
@@ -512,14 +503,12 @@ const handleConnect = async () => {
connectionInfo.privateKey = form.privateKey
}
terminalStore.quickConnect(connectionInfo)
emit('connect', connectionInfo)
connecting.value = false
router.push('/quick-connect/terminal')
} catch (error) {
connecting.value = false
ElMessage.error('连接失败,请重试')
ElMessage.error('连接准备失败,请重试')
}
}
+160 -24
View File
@@ -1,46 +1,110 @@
<template>
<div class="quick-connect-layout">
<!-- 固定顶部栏 -->
<div class="layout-header" :class="{ dark: isTerminal }">
<el-button @click="$router.push('/dashboard')" size="small" text :style="{ color: isTerminal ? '#aaa' : '#888' }">
<div class="layout-header" :class="{ dark: activeTab && activeTab.type === 'terminal' }">
<el-button @click="$router.push('/dashboard')" size="small" text :style="{ color: activeTab && activeTab.type === 'terminal' ? '#aaa' : '#888' }">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<span class="header-divider"></span>
<span class="page-title" v-if="!isTerminal">快速连接</span>
<!-- 终端 Tab -->
<div v-if="isTerminal" class="terminal-tab">
<span class="tab-dot"></span>
<span class="tab-label">{{ tabLabel }}</span>
<!-- 动态 Tab 列表 -->
<div class="tabs-list">
<div
v-for="tab in tabs"
:key="tab.id"
class="terminal-tab"
:class="{ active: activeTabId === tab.id }"
@click="activeTabId = tab.id"
>
<span class="tab-dot" v-if="tab.type === 'terminal'"></span>
<span class="tab-label">{{ getTabLabel(tab) }}</span>
<el-icon class="tab-close" @click.stop="closeTab(tab.id)" v-if="tabs.length > 1"><Close /></el-icon>
</div>
<el-tooltip content="新建会话" placement="bottom">
<el-button
class="new-tab-btn"
text
circle
size="small"
@click="createNewTab"
>
<el-icon><Plus /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
<!-- 子页面内容 -->
<div class="layout-content">
<router-view />
<div v-for="tab in tabs" :key="tab.id" v-show="activeTabId === tab.id" class="tab-content-wrapper">
<QuickConnect
v-if="tab.type === 'form'"
@connect="handleConnect(tab.id, $event)"
/>
<QuickConnectTerminal
v-else-if="tab.type === 'terminal'"
:tab-id="tab.id"
:connection-info="tab.connectionInfo"
@close="closeTab(tab.id)"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { useTerminalStore } from '@/stores/terminal'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, Plus, Close } from '@element-plus/icons-vue'
import QuickConnect from './QuickConnect.vue'
import QuickConnectTerminal from './QuickConnectTerminal.vue'
const route = useRoute()
const terminalStore = useTerminalStore()
const router = useRouter()
const isTerminal = computed(() => route.path.includes('/terminal'))
const generateId = () => 'tab_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
const tabLabel = computed(() => {
const conn = terminalStore.currentConnection
const tabs = ref([
{ id: generateId(), type: 'form', connectionInfo: null }
])
const activeTabId = ref(tabs.value[0].id)
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value))
const getTabLabel = (tab) => {
if (tab.type === 'form') return '快速连接'
const conn = tab.connectionInfo
if (!conn) return '终端'
const name = conn.name || ''
const userHost = `${conn.username || ''}@${conn.host || ''}:${conn.port || 22}`
return name ? `${name} (${userHost})` : userHost
})
}
const createNewTab = () => {
const newTab = { id: generateId(), type: 'form', connectionInfo: null }
tabs.value.push(newTab)
activeTabId.value = newTab.id
}
const closeTab = (id) => {
const index = tabs.value.findIndex(t => t.id === id)
if (index !== -1) {
tabs.value.splice(index, 1)
if (activeTabId.value === id) {
const newActive = tabs.value[index - 1] || tabs.value[index]
if (newActive) activeTabId.value = newActive.id
}
}
}
const handleConnect = (tabId, connectionInfo) => {
const tab = tabs.value.find(t => t.id === tabId)
if (tab) {
tab.type = 'terminal'
tab.connectionInfo = connectionInfo
}
}
</script>
<style scoped>
@@ -73,16 +137,25 @@ const tabLabel = computed(() => {
width: 1px;
height: 14px;
background: #dcdfe6;
margin-right: 8px;
}
.layout-header.dark .header-divider {
background: #555;
}
.page-title {
font-size: 13px;
font-weight: 500;
color: #606266;
/* Tabs List */
.tabs-list {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
height: 100%;
}
.tabs-list::-webkit-scrollbar {
display: none;
}
/* Terminal Tab */
@@ -91,11 +164,25 @@ const tabLabel = computed(() => {
align-items: center;
gap: 8px;
padding: 4px 12px;
background: #1e1e1e;
background: transparent;
border-radius: 4px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.terminal-tab.active {
background: #1e1e1e;
border: 1px solid #3e3e42;
}
.layout-header:not(.dark) .terminal-tab.active {
background: #fff;
border: 1px solid #dcdfe6;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.tab-dot {
width: 7px;
height: 7px;
@@ -110,12 +197,61 @@ const tabLabel = computed(() => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
max-width: 200px;
}
.layout-header:not(.dark) .tab-label {
color: #606266;
}
.tab-close {
font-size: 14px;
color: #999;
border-radius: 50%;
padding: 2px;
margin-left: 4px;
}
.tab-close:hover {
background-color: rgba(255,255,255,0.2);
color: #fff;
}
.layout-header:not(.dark) .tab-close:hover {
background-color: rgba(0,0,0,0.1);
color: #333;
}
.new-tab-btn {
color: #aaa;
transition: all 0.2s;
flex-shrink: 0;
}
.new-tab-btn:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
}
.layout-header:not(.dark) .new-tab-btn {
color: #888;
}
.layout-header:not(.dark) .new-tab-btn:hover {
color: #333;
background-color: rgba(0, 0, 0, 0.05);
}
.layout-content {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
.tab-content-wrapper {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
}
</style>
+125 -109
View File
@@ -5,7 +5,7 @@
<el-container class="main-content">
<!-- SFTP文件管理器侧边栏 -->
<el-aside
v-if="showSftp && terminalStore.isConnected"
v-if="showSftp && isConnected"
class="sftp-sidebar"
:width="sftpPanelWidth + 'px'"
>
@@ -56,7 +56,7 @@
<div
class="menu-item"
@click="pasteFromContextMenu"
:class="{ disabled: !terminalStore.isConnected }"
:class="{ disabled: !isConnected }"
>
<el-icon><DocumentAdd /></el-icon>
<span>粘贴</span>
@@ -74,7 +74,7 @@
</div>
<!-- 连接提示 -->
<div v-if="terminalStore.isConnecting" class="connection-status">
<div v-if="isConnecting" class="connection-status">
<el-alert
title="正在连接服务器..."
type="info"
@@ -85,16 +85,16 @@
</div>
<!-- 连接状态提示 -->
<div v-if="terminalStore.connectionError" class="connection-error">
<div v-if="connectionError" class="connection-error">
<el-alert
:title="terminalStore.connectionError"
:title="connectionError"
type="error"
show-icon
:closable="false"
/>
</div>
<div v-else-if="!terminalStore.isConnected && !terminalStore.isConnecting" class="connection-prompt">
<div v-else-if="!isConnected && !isConnecting" class="connection-prompt">
<div class="prompt-content">
<el-icon size="48" color="#909399"><Monitor /></el-icon>
<h3>准备连接</h3>
@@ -105,7 +105,7 @@
</el-container>
<!-- 状态栏 -->
<el-footer class="terminal-footer" v-if="terminalStore.isConnected">
<el-footer class="terminal-footer" v-if="isConnected">
<div class="status-bar">
<span class="status-item">
<el-icon><Connection /></el-icon>
@@ -128,14 +128,25 @@ import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { WebLinksAddon } from 'xterm-addon-web-links'
import { ArrowLeft, Connection, Close, Delete, Monitor, Folder, Document, Edit, DocumentCopy, DocumentAdd, Select } from '@element-plus/icons-vue'
import { useTerminalStore } from '@/stores/terminal'
import { useAuthStore } from '@/stores/auth'
import { useSftpStore } from '@/stores/sftp'
import { ElMessage } from 'element-plus'
import SftpFileManager from '@/components/SftpFileManager.vue'
import { io } from 'socket.io-client'
const props = defineProps({
tabId: {
type: String,
required: true
},
connectionInfo: {
type: Object,
required: true
}
})
const emit = defineEmits(['close'])
const router = useRouter()
const terminalStore = useTerminalStore()
const authStore = useAuthStore()
const sftpStore = useSftpStore()
@@ -157,9 +168,15 @@ let wheelHandler = null
let viewportWheelHandler = null
let isComposing = false
// 连接显示信息(从 terminalStore.currentConnection 获取)
// 本地连接状态
const isConnected = ref(false)
const isConnecting = ref(false)
const connectionError = ref(null)
let socket = null
// 连接显示信息
const connectionDisplay = computed(() => {
const conn = terminalStore.currentConnection
const conn = props.connectionInfo
if (conn) {
return `${conn.username || ''}@${conn.host || ''}:${conn.port || 22}`
}
@@ -167,14 +184,12 @@ const connectionDisplay = computed(() => {
})
const serverTitle = computed(() => {
const conn = terminalStore.currentConnection
return conn?.name || '快速连接'
return props.connectionInfo?.name || '快速连接'
})
// 返回快速连接页面
// 返回/关闭Tab
const goBack = () => {
terminalStore.disconnect()
router.push('/quick-connect')
emit('close')
}
// 初始化终端
@@ -254,8 +269,8 @@ const initTerminal = () => {
e.stopImmediatePropagation()
isComposing = false
const text = e.data
if (text && terminalStore.isConnected) {
terminalStore.sendInput(text)
if (text && isConnected.value && socket) {
socket.emit('ssh-input', text)
}
Promise.resolve().then(() => { textarea.value = '' })
}, true)
@@ -279,10 +294,10 @@ const initTerminal = () => {
if (isComposing) {
return
}
if (terminalStore.isConnected) {
terminalStore.sendInput(data)
if (isConnected.value && socket) {
socket.emit('ssh-input', data)
} else {
term.write('\r\n\x1b[31m未连接到服务器,请先连接\x1b[0m\r\n')
term.write('\r\n\x1b[31m未连接到服务器\x1b[0m\r\n')
}
})
@@ -345,8 +360,8 @@ const initTerminal = () => {
term.attachCustomKeyEventHandler((event) => {
if (event.code === 'Space' || event.key === ' ') {
event.preventDefault()
if (terminalStore.isConnected) {
terminalStore.sendInput(' ')
if (isConnected.value && socket) {
socket.emit('ssh-input', ' ')
term.scrollToBottom()
}
return false
@@ -359,8 +374,8 @@ const initTerminal = () => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') {
event.preventDefault()
navigator.clipboard.readText().then(text => {
if (terminalStore.isConnected) {
terminalStore.sendInput(text)
if (isConnected.value && socket) {
socket.emit('ssh-input', text)
}
}).catch(err => {
console.warn('粘贴失败:', err)
@@ -403,7 +418,7 @@ const toggleSftpPanel = () => {
// 连接SFTP
const connectSftp = async () => {
const conn = terminalStore.currentConnection
const conn = props.connectionInfo
if (!conn) {
console.error('没有可用的连接信息')
return
@@ -456,10 +471,10 @@ const copyFromContextMenu = async () => {
}
const pasteFromContextMenu = async () => {
if (term && terminalStore.isConnected) {
if (term && isConnected.value && socket) {
try {
const text = await navigator.clipboard.readText()
terminalStore.sendInput(text)
socket.emit('ssh-input', text)
hideContextMenu()
} catch (error) {
console.warn('粘贴失败:', error)
@@ -508,14 +523,13 @@ const copySelected = async () => {
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
if (text) {
terminalStore.sendInput(text)
if (text && isConnected.value && socket) {
socket.emit('ssh-input', text)
}
} catch (e) {}
}
const resetConnectedState = () => {
terminalStore.clearOutput()
if (term) {
term.reset()
term.focus()
@@ -530,63 +544,76 @@ const resetConnectedState = () => {
}
}
// 监听终端输出变化
watch(() => terminalStore.terminalOutput, (newOutput, oldOutput) => {
if (term && terminalStore.isConnected && newOutput !== oldOutput) {
const newData = newOutput.slice(oldOutput.length)
if (newData) {
term.write(newData)
if (newData.includes('\x1b[H') && newData.includes('\x1b[2J')) {
resetConnectedState()
} else {
if (terminalStore.scrollOnUserInput) {
term.scrollToBottom()
}
}
// 连接到服务器的新逻辑
const connectToServer = () => {
isConnecting.value = true
connectionError.value = null
socket = io({
auth: { token: authStore.token }
})
socket.on('connect', () => {
socket.emit('authenticate', authStore.token)
})
socket.on('authenticated', (data) => {
if (data.success) {
socket.emit('quick-connect', props.connectionInfo)
} else {
handleError('认证失败')
}
}
})
// 监听服务器连接状态变化
watch(() => terminalStore.isConnected, (isConnected, wasConnected) => {
if (term) {
if (isConnected && !wasConnected) {
})
socket.on('connect_error', (err) => {
handleError(err.message || '连接失败')
})
socket.on('ssh-connected', () => {
isConnected.value = true
isConnecting.value = false
if (term) {
term.clear()
term.focus()
setTimeout(() => {
term.write('\x1b[?25h')
if (typeof term.scrollToTop === 'function') {
term.scrollToTop()
}
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
initialScrollTop = 0
viewport.scrollTop = 0
setTimeout(() => {
viewport.scrollTop = 0
}, 50)
}
}, 100)
} else if (!isConnected && wasConnected) {
term.write('\r\n\x1b[31mSSH连接已断开\x1b[0m\r\n')
sftpStore.disconnectSftp()
showSftp.value = false
handleResize()
}, 200)
}
setTimeout(() => { handleResize() }, 150)
}
})
})
socket.on('ssh-data', (data) => {
if (term) {
term.write(typeof data === 'string' ? data : String(data))
}
})
socket.on('ssh-error', (data) => {
handleError(data.error)
})
socket.on('ssh-closed', () => {
isConnected.value = false
isConnecting.value = false
sftpStore.disconnectSftp()
showSftp.value = false
if (term) {
term.write('\r\n\x1b[31mSSH连接已断开\x1b[0m\r\n')
}
})
socket.on('disconnect', () => {
isConnected.value = false
isConnecting.value = false
})
}
// 监听连接错误
watch(() => terminalStore.connectionError, (error) => {
if (term && error) {
term.write(`\r\n\x1b[31m连接错误: ${error}\x1b[0m\r\n`)
const handleError = (msg) => {
isConnecting.value = false
connectionError.value = msg
if (term) {
term.write(`\r\n\x1b[31m连接错误: ${msg}\x1b[0m\r\n`)
}
})
}
// 监听窗口大小变化
const handleResize = () => {
@@ -598,12 +625,14 @@ const handleResize = () => {
// 故意减少 1 行,在底部留出真实的换行空间,避免贴底
const adjustedRows = Math.max(1, dimensions.rows - 1)
terminalStore.resizeTerminal({
rows: adjustedRows,
cols: dimensions.cols,
height: dimensions.height,
width: dimensions.width
})
if (isConnected.value && socket) {
socket.emit('resize', {
rows: adjustedRows,
cols: dimensions.cols,
height: dimensions.height,
width: dimensions.width
})
}
term.resize(dimensions.cols, adjustedRows)
}
@@ -617,31 +646,18 @@ onMounted(async () => {
window.addEventListener('resize', handleResize)
document.addEventListener('click', onDocumentClick)
// 快速连接模式:连接已由 QuickConnect.vue 发起
// 如果连接已建立且有缓冲输出,立即写入终端
await nextTick()
if (terminalStore.isConnected && term) {
// 已连接:显示欢迎信息
term.clear()
// 写入已缓冲的输出数据
if (terminalStore.terminalOutput) {
term.write(terminalStore.terminalOutput)
}
// 发送 resize 触发服务器重新绘制(关键:让远程 shell 发送完整的提示符)
setTimeout(() => {
handleResize()
term.focus()
}, 200)
} else if (terminalStore.isConnecting && term) {
term.write('\x1b[33m正在连接服务器...\x1b[0m\r\n')
if (term) {
term.write('\x1b[33m正在初始化连接...\x1b[0m\r\n')
}
connectToServer()
})
onUnmounted(() => {
terminalStore.disconnectSocket()
if (socket) {
socket.disconnect()
socket = null
}
sftpStore.disconnectSftp()
if (term) {
term.dispose()