Files
ssh-manager/frontend/src/components/TerminalWidget.vue
liumangmang 7b7399912b Fix: 修复终端标签切换时重连问题
将终端工作区提升为主布局常驻层,离开终端路由时只隐藏不卸载组件。
新增活动终端显隐状态跟踪,页面恢复时自动重新适配尺寸和聚焦。

改动范围:
- frontend/src/layouts/MainLayout.vue
- frontend/src/views/TerminalWorkspaceView.vue
- frontend/src/components/TerminalWidget.vue
2026-03-20 15:36:47 +08:00

168 lines
3.8 KiB
Vue

<script setup lang="ts">
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
import { Terminal } from 'xterm'
import { AttachAddon } from '@xterm/addon-attach'
import { FitAddon } from '@xterm/addon-fit'
import { useAuthStore } from '../stores/auth'
import 'xterm/css/xterm.css'
const props = defineProps<{
connectionId: number
active?: boolean
}>()
const containerRef = ref<HTMLElement | null>(null)
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
const errorMessage = ref('')
let term: Terminal | null = null
let fitAddon: FitAddon | null = null
let ws: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null
function fitTerminal() {
fitAddon?.fit()
}
function focusTerminal() {
term?.focus()
}
function refreshTerminalLayout() {
nextTick(() => {
fitTerminal()
focusTerminal()
})
}
function getWsUrl(): string {
const authStore = useAuthStore()
const token = authStore.token
if (!token) {
throw new Error('Not authenticated')
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
}
function cleanup() {
if (resizeObserver && containerRef.value) {
resizeObserver.unobserve(containerRef.value)
}
resizeObserver = null
if (ws) {
ws.close()
ws = null
}
if (term) {
term.dispose()
term = null
}
fitAddon = null
}
onMounted(() => {
const authStore = useAuthStore()
if (!authStore.token) {
status.value = 'error'
errorMessage.value = '未登录'
return
}
if (!containerRef.value) return
term = new Terminal({
cursorBlink: true,
theme: {
background: '#0f172a',
foreground: '#f1f5f9',
cursor: '#22d3ee',
cursorAccent: '#0f172a',
selectionBackground: 'rgba(34, 211, 238, 0.3)',
},
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(containerRef.value)
fitTerminal()
try {
ws = new WebSocket(getWsUrl())
} catch (e) {
status.value = 'error'
errorMessage.value = e instanceof Error ? e.message : '连接失败'
return
}
ws.onopen = () => {
status.value = 'connected'
const attachAddon = new AttachAddon(ws!)
term!.loadAddon(attachAddon)
refreshTerminalLayout()
}
ws.onerror = () => {
if (status.value === 'connecting') {
status.value = 'error'
errorMessage.value = 'WebSocket 连接失败'
}
}
ws.onclose = () => {
if (status.value === 'connected') {
term?.writeln('\r\n[连接已关闭]')
}
}
resizeObserver = new ResizeObserver(() => {
fitTerminal()
})
resizeObserver.observe(containerRef.value)
if (props.active !== false) {
refreshTerminalLayout()
}
})
watch(() => props.active, (active) => {
if (active) {
refreshTerminalLayout()
}
})
onUnmounted(() => {
cleanup()
})
</script>
<template>
<div class="h-full flex flex-col bg-slate-950 rounded-lg border border-slate-700 overflow-hidden">
<div
ref="containerRef"
class="flex-1 min-h-0 p-4 xterm-container"
:class="{ 'flex items-center justify-center': status !== 'connected' }"
style="min-height: 300px"
>
<div v-if="status === 'connecting'" class="text-slate-400">
正在连接 SSH...
</div>
<div v-else-if="status === 'error'" class="text-red-400">
{{ errorMessage }}
</div>
</div>
</div>
</template>
<style>
.xterm-container .xterm {
padding: 0.5rem;
}
.xterm-container .xterm-viewport {
overflow-y: auto !important;
}
</style>