fix: Optimize code and extract duplicated code into components.

This commit is contained in:
dalaolala committed 2026-03-05 10:31:32 +08:00
1 parent 2babd40d74
commit 1171adb41b
4 files changed
+767 -1204

No files matched your search

+662
View File
@@ -0,0 +1,662 @@
<template>
<div class="xterm-terminal-container">
<div
ref="terminalRef"
class="xterm-inner-container"
@click="focus"
@contextmenu.prevent="showContextMenu"
></div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@mouseleave="hideContextMenu"
>
<div
class="menu-item"
@click="copyFromContextMenu"
:class="{ disabled: !localHasSelection }"
>
<el-icon><DocumentCopy /></el-icon>
<span>复制</span>
</div>
<div class="menu-divider"></div>
<div
class="menu-item"
@click="pasteFromContextMenu"
:class="{ disabled: !isConnected }"
>
<el-icon><DocumentAdd /></el-icon>
<span>粘贴</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="selectAllFromContextMenu">
<el-icon><Select /></el-icon>
<span>全选</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="clearTerminal">
<el-icon><Delete /></el-icon>
<span>清屏</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { WebLinksAddon } from 'xterm-addon-web-links'
import 'xterm/css/xterm.css'
import { DocumentCopy, DocumentAdd, Select, Delete } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
isConnected: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['data', 'resize', 'selection-change'])
const terminalRef = ref(null)
let term = null
let fitAddon = null
let isComposing = false
let wheelHandler = null
let viewportWheelHandler = null
let initialScrollTop = 0
// Context Menu State
const contextMenu = ref({
visible: false,
x: 0,
y: 0
})
const localHasSelection = ref(false)
// Initialize terminal logic
const initTerminal = () => {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace",
fontWeight: '400',
fontWeightBold: '600',
letterSpacing: 0,
lineHeight: 1.15,
theme: {
background: '#1c1c1e',
foreground: '#f5f5f5',
cursor: '#a3a3a6', // Soft cursor
cursorAccent: '#1c1c1e',
selectionBackground: 'rgba(255, 255, 255, 0.25)', // Mac style subtle white selection
black: '#000000',
red: '#ff3b30', // Apple Red
green: '#34c759', // Apple Green
yellow: '#ffcc00', // Apple Yellow
blue: '#0a84ff', // Apple Blue
magenta: '#ff375f',// Apple Magenta/Pink
cyan: '#5ac8fa', // Apple Cyan
white: '#ffffff',
brightBlack: '#686868',
brightRed: '#ff6961',
brightGreen: '#30d158',
brightYellow: '#ffd60a',
brightBlue: '#409cff',
brightMagenta: '#ff6482',
brightCyan: '#64d2ff',
brightWhite: '#ffffff',
},
disableStdin: false,
scrollback: 10000,
convertEol: true,
allowTransparency: false,
tabStopWidth: 8,
cursorStyle: 'block',
bellStyle: 'sound',
allowProposedApi: true,
windowsMode: true,
screenReaderMode: false,
scrollOnUserInput: true,
scrollOnOutput: false,
scrollSensitivity: 3,
smoothScrollDuration: 0,
fastScrollSensitivity: 5,
fastScrollModifier: 'alt',
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
rendererType: 'canvas',
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
const webLinksAddon = new WebLinksAddon()
term.loadAddon(webLinksAddon)
if (terminalRef.value) {
term.open(terminalRef.value)
fitAddon.fit()
term.focus()
// IME Fix for composing
setupHelperTextarea()
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
setupHelperTextarea()
}
})
})
observer.observe(terminalRef.value, { childList: true, subtree: true })
// Terminal input handler
term.onData((data) => {
if (isComposing) return
emit('data', data)
})
term.onSelectionChange(() => {
localHasSelection.value = hasSelection()
emit('selection-change', localHasSelection.value)
})
// Optimize wheel scroll
enableWheelScroll()
const wheelObserver = new MutationObserver(() => enableWheelScroll())
wheelObserver.observe(terminalRef.value, { childList: true, subtree: true })
wheelHandler = (e) => {
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
e.preventDefault()
viewport.scrollTop += e.deltaY
}
}
terminalRef.value.addEventListener('wheel', wheelHandler, { passive: false })
// Custom key handlers
term.attachCustomKeyEventHandler((event) => {
// Prevent browser default on Ctrl+ keys except common text manipulation
if ((event.ctrlKey || event.metaKey) &&
!['c', 'v', 'a', 'z', 'x', 'y'].includes(event.key.toLowerCase())) {
event.preventDefault()
}
return true
})
// Space bar behavior
term.attachCustomKeyEventHandler((event) => {
if (event.code === 'Space' || event.key === ' ') {
event.preventDefault()
if (props.isConnected) {
emit('data', ' ')
term.scrollToBottom()
}
return false
}
return true
})
// Paste support
term.attachCustomKeyEventHandler((event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') {
event.preventDefault()
navigator.clipboard.readText().then(text => {
if (props.isConnected) {
emit('data', text)
}
}).catch(err => console.warn('Paste failed:', err))
return false
}
return true
})
// Copy support
term.attachCustomKeyEventHandler((event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') {
const selection = term.getSelection()
if (selection) navigator.clipboard.writeText(selection)
}
return true
})
}
}
// Fixed IME for multi-character inputs (e.g. Chinese)
const setupHelperTextarea = () => {
const textarea = terminalRef.value?.querySelector('.xterm-helper-textarea')
if (textarea && !textarea._imeFixed) {
textarea._imeFixed = true
textarea.style.opacity = '0'
textarea.style.color = 'transparent'
textarea.style.position = 'absolute'
textarea.style.bottom = '4px'
textarea.style.left = '4px'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.zIndex = '1'
textarea.style.caretColor = 'transparent'
textarea.addEventListener('compositionstart', (e) => {
isComposing = true
e.stopImmediatePropagation()
}, true)
textarea.addEventListener('compositionupdate', (e) => {
e.stopImmediatePropagation()
}, true)
textarea.addEventListener('compositionend', (e) => {
e.stopImmediatePropagation()
isComposing = false
const text = e.data
if (text && props.isConnected) {
emit('data', text)
}
Promise.resolve().then(() => { textarea.value = '' })
}, true)
}
}
// Enhance viewport wheel scrolling
const enableWheelScroll = () => {
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
if (viewportWheelHandler) viewport.removeEventListener('wheel', viewportWheelHandler)
viewportWheelHandler = (event) => {
event.preventDefault()
const deltaY = event.deltaY
const deltaMode = event.deltaMode
if (deltaMode === 0) {
const scrollAmount = Math.sign(deltaY) * Math.ceil(Math.abs(deltaY) / 8)
viewport.scrollTop += scrollAmount * 20
} else if (deltaMode === 1) {
viewport.scrollTop += deltaY * 20
} else {
viewport.scrollTop += deltaY * viewport.clientHeight
}
}
viewport.addEventListener('wheel', viewportWheelHandler, { passive: false })
viewport.style.overflowY = 'auto'
viewport.style.overflowX = 'hidden'
}
}
const handleResize = () => {
if (fitAddon && term) {
fitAddon.fit()
const dimensions = fitAddon.proposeDimensions()
if (dimensions) {
const adjustedRows = Math.max(1, dimensions.rows - 1)
term.resize(dimensions.cols, adjustedRows)
emit('resize', {
rows: adjustedRows,
cols: dimensions.cols,
height: dimensions.height,
width: dimensions.width
})
}
}
}
// Component methods to expose via defineExpose
const write = (data) => {
if (term) term.write(data)
}
const writeError = (msg) => {
if (term) term.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`)
}
const writeWarning = (msg) => {
if (term) term.write(`\r\n\x1b[33m${msg}\x1b[0m\r\n`)
}
const focus = () => {
if (term) term.focus()
}
const clear = () => {
if (term) term.clear()
}
const getSelection = () => {
return term ? term.getSelection() : ''
}
const selectAll = () => {
if (term) term.selectAll()
}
const hasSelection = () => {
return term && term.getSelection() && term.getSelection().length > 0
}
const scrollToTop = () => {
if (term && typeof term.scrollToTop === 'function') term.scrollToTop()
}
const scrollToBottom = () => {
if (term && typeof term.scrollToBottom === 'function') term.scrollToBottom()
}
// ------ Context Menu Methods ------
const showContextMenu = (event) => {
event.preventDefault()
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY
}
}
const hideContextMenu = () => {
contextMenu.value.visible = false
}
const copyFromContextMenu = async () => {
if (hasSelection()) {
const selection = getSelection()
try {
await navigator.clipboard.writeText(selection)
ElMessage.success(`已复制 ${selection.length} 个字符`)
hideContextMenu()
} catch (error) {
console.warn('复制失败:', error)
ElMessage.error('复制失败,请重试')
}
}
}
const pasteFromContextMenu = async () => {
if (props.isConnected) {
try {
const text = await navigator.clipboard.readText()
emit('data', text) // Send data back up to the parent to handle input
hideContextMenu()
} catch (error) {
console.warn('粘贴失败:', error)
ElMessage.error('粘贴失败,请重试')
}
}
}
const selectAllFromContextMenu = () => {
selectAll()
hideContextMenu()
}
const clearTerminal = () => {
reset()
hideContextMenu()
}
const onDocumentClick = () => {
if (contextMenu.value.visible) {
hideContextMenu()
}
}
// ------ End Context Menu Methods ------
const reset = () => {
if (term) {
term.reset()
term.focus()
term.write('\x1b[?25h')
scrollToTop()
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) viewport.scrollTop = initialScrollTop || 0
}
}
const fit = () => {
handleResize()
}
defineExpose({
write,
writeError,
writeWarning,
focus,
clear,
getSelection,
selectAll,
hasSelection,
scrollToTop,
scrollToBottom,
reset,
fit
})
onMounted(async () => {
await nextTick()
initTerminal()
window.addEventListener('resize', handleResize)
document.addEventListener('click', onDocumentClick)
})
onUnmounted(() => {
if (term) {
term.dispose()
term = null
}
if (terminalRef.value && wheelHandler) {
terminalRef.value.removeEventListener('wheel', wheelHandler)
wheelHandler = null
}
const viewportEl = terminalRef.value?.querySelector('.xterm-viewport')
if (viewportEl && viewportWheelHandler) {
viewportEl.removeEventListener('wheel', viewportWheelHandler)
viewportWheelHandler = null
}
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', onDocumentClick)
})
</script>
<style scoped>
.xterm-terminal-container {
flex: 1;
width: 100%;
padding: 8px 12px; /* Apple-style internal padding for the terminal component */
padding-bottom: 12px;
cursor: text;
overflow: hidden;
position: relative;
min-height: 400px;
display: flex;
flex-direction: column;
box-sizing: border-box;
background-color: #1c1c1e; /* Apple Dark Mode Background */
}
.xterm-inner-container {
flex: 1;
width: 100%;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.xterm-inner-container:hover {
outline: 1px solid rgba(255, 255, 255, 0.1); /* Subtle hover outline */
}
/* 右键菜单样式 - Apple MacOS 风格 */
.context-menu {
position: fixed;
background: rgba(30, 30, 30, 0.75);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 1000;
min-width: 140px;
padding: 6px 0;
animation: fadeIn 0.15s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95) translateY(-2px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.menu-item {
display: flex;
align-items: center;
padding: 6px 12px;
margin: 2px 6px;
cursor: pointer;
color: #f5f5f5;
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
border-radius: 6px;
transition: all 0.1s ease;
gap: 10px;
}
.menu-item:hover {
background-color: #0a84ff; /* Apple Blue */
color: #ffffff;
}
.menu-item.disabled {
color: rgba(255, 255, 255, 0.3);
cursor: not-allowed;
}
.menu-item.disabled:hover {
background-color: transparent;
}
.menu-divider {
height: 1px;
background-color: rgba(255, 255, 255, 0.1);
margin: 6px 0;
}
.menu-item .el-icon {
font-size: 14px;
width: 16px;
opacity: 0.9;
}
/* XTerm.js CSS overrides */
:deep(.xterm) {
padding: 0 !important;
overflow: hidden !important;
height: 100% !important;
width: 100% !important;
display: flex !important;
flex-direction: column !important;
}
:deep(.xterm-viewport) {
background-color: transparent !important;
overflow-y: auto !important;
overflow-x: hidden !important;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.1);
position: relative !important;
direction: ltr !important;
width: 100% !important;
height: 100% !important;
pointer-events: auto !important;
}
:deep(.xterm-viewport::-webkit-scrollbar) {
width: 12px;
}
:deep(.xterm-viewport::-webkit-scrollbar-track) {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
margin: 2px;
}
:deep(.xterm-viewport::-webkit-scrollbar-thumb) {
background: rgba(255, 255, 255, 0.3);
border-radius: 6px;
border: 2px solid transparent;
background-clip: content-box;
transition: background 0.2s ease;
}
:deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background: rgba(255, 255, 255, 0.5);
}
:deep(.xterm-viewport::-webkit-scrollbar-thumb:active) {
background: rgba(255, 255, 255, 0.7);
}
:deep(.xterm-screen) {
background-color: transparent !important;
width: 100% !important;
height: 100% !important;
display: flex !important;
flex-direction: column !important;
overflow: visible !important;
}
/* 强制在 xterm-screen 后方追加一段空白,确保滚动到底部时有足够间距 */
:deep(.xterm-screen::after) {
content: "";
display: block;
height: 32px;
width: 100%;
}
:deep(.xterm-helper-textarea) {
opacity: 0 !important;
color: transparent !important;
caret-color: transparent !important;
resize: none !important;
outline: none !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
:deep(.xterm .xterm-selection) {
background-color: rgba(255, 255, 255, 0.25) !important;
border-radius: 4px; /* Slightly rounded selection resembling macOS */
mix-blend-mode: normal;
}
:deep(.xterm-scroll-area) {
overflow: visible !important;
}
:deep(.xterm-char-measure-element) {
position: absolute !important;
left: -9999px !important;
top: -9999px !important;
visibility: hidden !important;
width: auto !important;
height: auto !important;
opacity: 0 !important;
pointer-events: none !important;
}
:deep(.xterm-rows), :deep(.xterm-row) {
color: #f5f5f5 !important;
background-color: transparent !important;
}
:deep(.xterm-selection-layer) {
background-color: rgba(255, 255, 255, 0.25) !important;
}
</style>
+41 -647
View File
@@ -36,51 +36,14 @@
<!-- 终端内容区域 -->
<el-main class="terminal-main" :style="{ width: showSftp ? `calc(100% - ${sftpPanelWidth}px)` : '100%' }">
<div
<!-- 终端显示组件 (带有内置的右键菜单) -->
<XtermTerminal
ref="terminalRef"
class="terminal"
@click="focusTerminal"
@contextmenu.prevent="showContextMenu"
></div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{
left: contextMenu.x + 'px',
top: contextMenu.y + 'px'
}"
@mouseleave="hideContextMenu"
>
<div
class="menu-item"
@click="copyFromContextMenu"
:class="{ disabled: !hasSelection }"
>
<el-icon><DocumentCopy /></el-icon>
<span>复制</span>
</div>
<div class="menu-divider"></div>
<div
class="menu-item"
@click="pasteFromContextMenu"
:class="{ disabled: !isConnected }"
>
<el-icon><DocumentAdd /></el-icon>
<span>粘贴</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="selectAllFromContextMenu">
<el-icon><Select /></el-icon>
<span>全选</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="clearTerminal">
<el-icon><Delete /></el-icon>
<span>清屏</span>
</div>
</div>
:is-connected="isConnected"
@data="handleTerminalData"
@resize="handleTerminalResize"
@selection-change="hasSelection = $event"
/>
<!-- 连接提示 -->
<div v-if="isConnecting" class="connection-status-overlay">
@@ -132,17 +95,14 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { WebLinksAddon } from 'xterm-addon-web-links'
import 'xterm/css/xterm.css'
import { ArrowLeft, Connection, Close, Delete, Monitor, Folder, Document, Edit, DocumentCopy, DocumentAdd, Select, Tickets, Loading } from '@element-plus/icons-vue'
import { ArrowLeft, Connection, Close, Delete, Monitor, Folder, Document, Edit, Select, Tickets, Loading } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useQuickSftp } from '@/composables/useQuickSftp'
import { ElMessage } from 'element-plus'
import QuickSftpFileManager from '@/components/QuickSftpFileManager.vue'
import CommandLibrary from '@/components/CommandLibrary.vue'
import TerminalStatusBar from '@/components/TerminalStatusBar.vue'
import XtermTerminal from '@/components/XtermTerminal.vue'
import { io } from 'socket.io-client'
const props = defineProps({
@@ -167,6 +127,7 @@ const sftpPanelWidth = ref(350)
// 常用命令显示控制
const showCommands = ref(false)
const hasSelection = ref(false)
// 向终端注入并执行命令
const injectCommand = (cmd) => {
@@ -178,26 +139,13 @@ const injectCommand = (cmd) => {
}
}
// 右键菜单相关
const contextMenu = ref({
visible: false,
x: 0,
y: 0
})
let term = null
let fitAddon = null
let initialScrollTop = 0
let wheelHandler = null
let viewportWheelHandler = null
let isComposing = false
let pingInterval = null // Added pingInterval as per instruction diff
let socket = null
let pingInterval = null
// 本地连接状态
const isConnected = ref(false)
const isConnecting = ref(false)
const connectionError = ref(null)
let socket = null
// 连接显示信息
const connectionDisplay = computed(() => {
@@ -217,218 +165,22 @@ const goBack = () => {
emit('close')
}
// 初始化终端
const initTerminal = () => {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Courier New, monospace',
theme: {
background: '#1e1e1e',
foreground: '#ffffff',
cursor: '#ffffff',
cursorAccent: '#ffffff',
selection: 'rgba(58, 134, 255, 0.3)',
selectionForeground: undefined
},
disableStdin: false,
scrollback: 10000,
convertEol: true,
allowTransparency: false,
tabStopWidth: 8,
cursorStyle: 'block',
bellStyle: 'sound',
allowProposedApi: true,
windowsMode: true,
screenReaderMode: false,
scrollOnUserInput: true,
scrollOnOutput: false,
scrollSensitivity: 3,
smoothScrollDuration: 0,
fastScrollSensitivity: 5,
fastScrollModifier: 'alt',
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
rendererType: 'canvas',
})
const handleTerminalData = (data) => {
if (isConnected.value && socket) {
socket.emit('ssh-input', data)
}
}
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
const webLinksAddon = new WebLinksAddon()
term.loadAddon(webLinksAddon)
if (terminalRef.value) {
term.open(terminalRef.value)
fitAddon.fit()
term.focus()
// IME 修复(与 Terminal.vue 相同)
const setupHelperTextarea = () => {
const textarea = terminalRef.value?.querySelector('.xterm-helper-textarea')
if (textarea && !textarea._imeFixed) {
textarea._imeFixed = true
textarea.style.opacity = '0'
textarea.style.color = 'transparent'
textarea.style.position = 'absolute'
textarea.style.bottom = '4px'
textarea.style.left = '4px'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.zIndex = '1'
textarea.style.caretColor = 'transparent'
textarea.addEventListener('compositionstart', (e) => {
isComposing = true
e.stopImmediatePropagation()
}, true)
textarea.addEventListener('compositionupdate', (e) => {
e.stopImmediatePropagation()
}, true)
textarea.addEventListener('compositionend', (e) => {
e.stopImmediatePropagation()
isComposing = false
const text = e.data
if (text && isConnected.value && socket) {
socket.emit('ssh-input', text)
}
Promise.resolve().then(() => { textarea.value = '' })
}, true)
}
}
setupHelperTextarea()
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
setupHelperTextarea()
}
})
})
observer.observe(terminalRef.value, { childList: true, subtree: true })
// 处理终端输入
term.onData((data) => {
if (isComposing) {
return
}
if (isConnected.value && socket) {
socket.emit('ssh-input', data)
} else {
term.write('\r\n\x1b[31m未连接到服务器\x1b[0m\r\n')
}
})
// 优化鼠标滚轮事件处理
const enableWheelScroll = () => {
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
if (viewportWheelHandler) {
viewport.removeEventListener('wheel', viewportWheelHandler)
}
viewportWheelHandler = (event) => {
event.preventDefault()
const deltaY = event.deltaY
const deltaMode = event.deltaMode
if (deltaMode === 0) {
const scrollAmount = Math.sign(deltaY) * Math.ceil(Math.abs(deltaY) / 8)
viewport.scrollTop += scrollAmount * 20
} else if (deltaMode === 1) {
viewport.scrollTop += deltaY * 20
} else {
viewport.scrollTop += deltaY * viewport.clientHeight
}
}
viewport.addEventListener('wheel', viewportWheelHandler, { passive: false })
viewport.style.overflowY = 'auto'
viewport.style.overflowX = 'hidden'
}
}
enableWheelScroll()
const wheelObserver = new MutationObserver(() => {
enableWheelScroll()
})
wheelObserver.observe(terminalRef.value, { childList: true, subtree: true })
wheelHandler = (e) => {
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
e.preventDefault()
viewport.scrollTop += e.deltaY
}
}
terminalRef.value.addEventListener('wheel', wheelHandler, { passive: false })
// 处理终端按键事件
term.attachCustomKeyEventHandler((event) => {
if ((event.ctrlKey || event.metaKey) &&
!['c', 'v', 'a', 'z', 'x', 'y'].includes(event.key.toLowerCase())) {
event.preventDefault()
}
return true
})
// 防止空格触发容器或页面滚动
term.attachCustomKeyEventHandler((event) => {
if (event.code === 'Space' || event.key === ' ') {
event.preventDefault()
if (isConnected.value && socket) {
socket.emit('ssh-input', ' ')
term.scrollToBottom()
}
return false
}
return true
})
// 添加粘贴支持
term.attachCustomKeyEventHandler((event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') {
event.preventDefault()
navigator.clipboard.readText().then(text => {
if (isConnected.value && socket) {
socket.emit('ssh-input', text)
}
}).catch(err => {
console.warn('粘贴失败:', err)
})
return false
}
return true
})
// 添加复制支持
term.attachCustomKeyEventHandler((event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') {
const selection = term.getSelection()
if (selection) {
navigator.clipboard.writeText(selection).catch(err => {
console.warn('复制失败:', err)
})
}
}
return true
})
const handleTerminalResize = (dimensions) => {
if (isConnected.value && socket) {
socket.emit('resize', dimensions)
}
}
// 聚焦终端
const focusTerminal = () => {
if (term) {
term.focus()
if (terminalRef.value) {
terminalRef.value.focus()
}
}
@@ -460,120 +212,6 @@ const connectSftp = async () => {
}
}
// 右键菜单相关功能
const showContextMenu = (event) => {
event.preventDefault()
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY
}
}
const hideContextMenu = () => {
contextMenu.value.visible = false
}
// 全局点击关闭右键菜单
const onDocumentClick = () => {
if (contextMenu.value.visible) {
hideContextMenu()
}
}
const hasSelection = () => {
return term && term.getSelection() && term.getSelection().length > 0
}
const copyFromContextMenu = async () => {
if (term && hasSelection()) {
const selection = term.getSelection()
try {
await navigator.clipboard.writeText(selection)
ElMessage.success(`已复制 ${selection.length} 个字符`)
hideContextMenu()
} catch (error) {
console.warn('复制失败:', error)
ElMessage.error('复制失败,请重试')
}
}
}
const pasteFromContextMenu = async () => {
if (term && isConnected.value && socket) {
try {
const text = await navigator.clipboard.readText()
socket.emit('ssh-input', text)
hideContextMenu()
} catch (error) {
console.warn('粘贴失败:', error)
ElMessage.error('粘贴失败,请重试')
}
}
}
const selectAllFromContextMenu = () => {
if (term) {
term.selectAll()
hideContextMenu()
}
}
const clearTerminal = () => {
resetConnectedState()
}
const copySelected = async () => {
if (term) {
const selection = term.getSelection()
if (selection) {
try {
await navigator.clipboard.writeText(selection)
const copyButton = document.querySelector('.copy-button')
if (copyButton) {
copyButton.classList.add('copied')
setTimeout(() => {
copyButton.classList.remove('copied')
}, 1000)
}
ElMessage.success(`已复制 ${selection.length} 个字符`)
} catch (error) {
console.warn('复制失败:', error)
ElMessage.error('复制失败,请重试')
}
} else {
ElMessage.warning('请先选中要复制的文本')
}
}
}
const pasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText()
if (text && isConnected.value && socket) {
socket.emit('ssh-input', text)
}
} catch (e) {}
}
const resetConnectedState = () => {
if (term) {
term.reset()
term.focus()
term.write('\x1b[?25h')
if (typeof term.scrollToTop === 'function') {
term.scrollToTop()
}
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
viewport.scrollTop = initialScrollTop || 0
}
}
}
// 连接到服务器的新逻辑
const connectToServer = () => {
isConnecting.value = true
@@ -602,9 +240,9 @@ const connectToServer = () => {
socket.on('ssh-connected', () => {
isConnected.value = true
isConnecting.value = false
if (term) {
term.clear()
term.focus()
if (terminalRef.value) {
terminalRef.value.clear()
terminalRef.value.focus()
setTimeout(() => {
handleResize()
}, 200)
@@ -612,8 +250,8 @@ const connectToServer = () => {
})
socket.on('ssh-data', (data) => {
if (term) {
term.write(typeof data === 'string' ? data : String(data))
if (terminalRef.value) {
terminalRef.value.write(typeof data === 'string' ? data : String(data))
}
})
@@ -626,8 +264,8 @@ const connectToServer = () => {
isConnecting.value = false
quickSftp.disconnectSftp() // Changed from sftpStore.disconnectSftp()
showSftp.value = false
if (term) {
term.write('\r\n\x1b[31mSSH连接已断开\x1b[0m\r\n')
if (terminalRef.value) {
terminalRef.value.writeError('SSH连接已断开')
}
})
@@ -640,44 +278,24 @@ const connectToServer = () => {
const handleError = (msg) => {
isConnecting.value = false
connectionError.value = msg
if (term) {
term.write(`\r\n\x1b[31m连接错误: ${msg}\x1b[0m\r\n`)
if (terminalRef.value) {
terminalRef.value.writeError(`连接错误: ${msg}`)
}
}
// 监听窗口大小变化
const handleResize = () => {
if (fitAddon && term) {
fitAddon.fit()
const dimensions = fitAddon.proposeDimensions()
if (dimensions) {
// 故意减少 1 行,在底部留出真实的换行空间,避免贴底
const adjustedRows = Math.max(1, dimensions.rows - 1)
if (isConnected.value && socket) {
socket.emit('resize', {
rows: adjustedRows,
cols: dimensions.cols,
height: dimensions.height,
width: dimensions.width
})
}
term.resize(dimensions.cols, adjustedRows)
}
if (terminalRef.value) {
terminalRef.value.fit()
}
}
onMounted(async () => {
await nextTick()
initTerminal()
window.addEventListener('resize', handleResize)
document.addEventListener('click', onDocumentClick)
if (term) {
term.write('\x1b[33m正在初始化连接...\x1b[0m\r\n')
if (terminalRef.value) {
terminalRef.value.writeWarning('正在初始化连接...')
}
connectToServer()
@@ -688,12 +306,6 @@ onUnmounted(() => {
clearInterval(pingInterval)
}
// 销毁终端
if (term) {
term.dispose()
term = null
}
// 销毁组件内部的 Socket 连接
if (socket) {
socket.disconnect()
@@ -704,17 +316,7 @@ onUnmounted(() => {
if (quickSftp.isConnected.value) {
quickSftp.disconnectSftp()
}
if (terminalRef.value && wheelHandler) {
terminalRef.value.removeEventListener('wheel', wheelHandler)
wheelHandler = null
}
const viewportEl = terminalRef.value?.querySelector('.xterm-viewport')
if (viewportEl && viewportWheelHandler) {
viewportEl.removeEventListener('wheel', viewportWheelHandler)
viewportWheelHandler = null
}
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', onDocumentClick)
})
</script>
@@ -722,7 +324,7 @@ onUnmounted(() => {
.terminal-page {
height: 100%;
overflow: hidden;
background-color: #1e1e1e;
background-color: #1c1c1e;
display: flex;
flex-direction: column;
}
@@ -731,7 +333,7 @@ onUnmounted(() => {
height: 100%;
display: flex;
flex-direction: column;
background-color: #1e1e1e;
background-color: #1c1c1e;
}
.terminal-header {
@@ -930,49 +532,6 @@ onUnmounted(() => {
font-weight: normal;
}
/* 右键菜单样式 */
.context-menu {
position: fixed;
background: #2d2d30;
border: 1px solid #3e3e42;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
min-width: 120px;
padding: 4px 0;
animation: fadeIn 0.15s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.menu-item {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
color: #ffffff;
font-size: 13px;
transition: background-color 0.2s ease;
gap: 8px;
}
.menu-item:hover {
background-color: rgba(58, 134, 255, 0.2);
}
.menu-item.disabled {
color: #6c6c6c;
cursor: not-allowed;
}
.menu-item.clickable:hover {
background-color: #f0f2f5;
color: var(--el-color-primary);
}
/* 常用命令抽屉样式 */
.command-list-container {
display: flex;
@@ -1090,176 +649,11 @@ onUnmounted(() => {
color: #303133;
}
.menu-divider {
height: 1px;
background-color: #3e3e42;
margin: 4px 0;
}
.menu-item .el-icon {
.cmd-name {
font-size: 14px;
width: 16px;
font-weight: 500;
color: #303133;
}
/* XTerm.js 样式调整 */
:deep(.xterm) {
padding: 10px;
position: relative;
}
:deep(.xterm-viewport) {
background-color: #1e1e1e !important;
}
:deep(.xterm-screen) {
background-color: #1e1e1e !important;
}
/* 强制在 xterm-screen 后方追加一段空白,确保滚动到底部时有足够间距 */
:deep(.xterm-screen::after) {
content: "";
display: block;
height: 32px;
width: 100%;
}
:deep(.xterm-helper-textarea) {
opacity: 0 !important;
color: transparent !important;
caret-color: transparent !important;
resize: none !important;
outline: none !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.copy-button {
transition: all 0.3s ease;
}
.copy-button.copied {
background-color: rgba(58, 134, 255, 0.3) !important;
border-color: rgba(58, 134, 255, 0.8) !important;
color: #ffffff !important;
transform: scale(1.05);
box-shadow: 0 0 15px rgba(58, 134, 255, 0.4);
}
.copy-button:hover {
background-color: rgba(58, 134, 255, 0.1);
border-color: rgba(58, 134, 255, 0.6);
}
:deep(.xterm .xterm-selection) {
background-color: rgba(58, 134, 255, 0.2) !important;
border-radius: 2px;
mix-blend-mode: screen;
}
:deep(.xterm) {
padding: 0 !important;
overflow: hidden !important;
position: relative !important;
height: 100% !important;
width: 100% !important;
display: flex !important;
flex-direction: column !important;
}
:deep(.xterm-screen) {
background-color: #1e1e1e !important;
width: 100% !important;
height: 100% !important;
display: flex !important;
flex-direction: column !important;
}
:deep(.xterm-viewport) {
background-color: #1e1e1e !important;
overflow-y: auto !important;
overflow-x: hidden !important;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.1);
position: relative !important;
direction: ltr !important;
width: 100% !important;
height: 100% !important;
pointer-events: auto !important;
}
:deep(.xterm-viewport::-webkit-scrollbar) {
width: 12px;
}
:deep(.xterm-viewport::-webkit-scrollbar-track) {
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
margin: 2px;
}
:deep(.xterm-viewport::-webkit-scrollbar-thumb) {
background: rgba(255, 255, 255, 0.3);
border-radius: 6px;
border: 2px solid transparent;
background-clip: content-box;
transition: background 0.2s ease;
}
:deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background: rgba(255, 255, 255, 0.5);
}
:deep(.xterm-viewport::-webkit-scrollbar-thumb:active) {
background: rgba(255, 255, 255, 0.7);
}
:deep(.xterm-screen) {
overflow: visible !important;
width: 100% !important;
height: 100% !important;
background-color: #1e1e1e !important;
}
:deep(.xterm-scroll-area) {
overflow: visible !important;
}
:deep(.xterm-char-measure-element) {
position: absolute !important;
left: -9999px !important;
top: -9999px !important;
visibility: hidden !important;
width: auto !important;
height: auto !important;
opacity: 0 !important;
pointer-events: none !important;
}
.terminal {
position: relative;
overflow: hidden;
pointer-events: auto !important;
height: 100%;
width: 100%;
background-color: #1e1e1e !important;
display: flex;
flex-direction: column;
}
:deep(.xterm-rows) {
color: #ffffff !important;
background-color: #1e1e1e !important;
}
:deep(.xterm-row) {
color: #ffffff !important;
background-color: #1e1e1e !important;
}
:deep(.xterm-selection-layer) {
background-color: rgba(54,99,181,0.35) !important;
}
/* Command list and other specific QuickConnect styles */
</style>
+40 -547
View File
@@ -41,51 +41,13 @@
<!-- 终端内容区域 -->
<el-main class="terminal-main" :style="{ width: showSftp ? `calc(100% - ${sftpPanelWidth}px)` : '100%' }">
<div
<XtermTerminal
ref="terminalRef"
class="terminal"
@click="focusTerminal"
@contextmenu.prevent="showContextMenu"
></div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{
left: contextMenu.x + 'px',
top: contextMenu.y + 'px'
}"
@mouseleave="hideContextMenu"
>
<div
class="menu-item"
@click="copyFromContextMenu"
:class="{ disabled: !hasSelection }"
>
<el-icon><DocumentCopy /></el-icon>
<span>复制</span>
</div>
<div class="menu-divider"></div>
<div
class="menu-item"
@click="pasteFromContextMenu"
:class="{ disabled: !terminalStore.isConnected }"
>
<el-icon><DocumentAdd /></el-icon>
<span>粘贴</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="selectAllFromContextMenu">
<el-icon><Select /></el-icon>
<span>全选</span>
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="clearTerminal">
<el-icon><Delete /></el-icon>
<span>清屏</span>
</div>
</div>
:is-connected="terminalStore.isConnected"
@data="handleTerminalData"
@resize="handleTerminalResize"
@selection-change="hasSelection = $event"
/>
<!-- 连接提示 -->
<div v-if="terminalStore.isConnecting" class="connection-status">
@@ -136,11 +98,7 @@
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { WebLinksAddon } from 'xterm-addon-web-links'
import 'xterm/css/xterm.css'
import { ArrowLeft, Connection, Close, Delete, Monitor, Folder, User, Document, Edit, DocumentCopy, DocumentAdd, Select } from '@element-plus/icons-vue'
import { ArrowLeft, Connection, Close, Delete, Monitor, Folder, User, Document, Edit, Select } from '@element-plus/icons-vue'
import { useTerminalStore } from '@/stores/terminal'
import { useServersStore } from '@/stores/servers'
import { useAuthStore } from '@/stores/auth'
@@ -148,6 +106,7 @@ import { useSftpStore } from '@/stores/sftp'
import SftpFileManager from '@/components/SftpFileManager.vue'
import TerminalStatusBar from '@/components/TerminalStatusBar.vue'
import CommandLibrary from '@/components/CommandLibrary.vue'
import XtermTerminal from '@/components/XtermTerminal.vue'
import { ElMessage } from 'element-plus'
const route = useRoute()
@@ -161,284 +120,36 @@ const currentServer = ref(null)
const showSftp = ref(false)
const sftpPanelWidth = ref(350)
const showCommands = ref(false)
const hasSelection = ref(false)
// 注入命令(发送给终端)
const injectCommand = (cmd) => {
if (terminalStore.isConnected) {
terminalStore.sendInput(cmd + '\n')
if (term) term.focus()
if (terminalRef.value) terminalRef.value.focus()
} else {
ElMessage.warning('当前没用连通的终端')
}
}
// 右键菜单相关
const contextMenu = ref({
visible: false,
x: 0,
y: 0
})
let term = null
let fitAddon = null
let initialScrollTop = 0
let wheelHandler = null
let viewportWheelHandler = null
let isComposing = false // 标记 IME 输入法是否正在组合输入中
// 初始化终端
const initTerminal = () => {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Courier New, monospace',
theme: {
background: '#1e1e1e',
foreground: '#ffffff',
cursor: '#ffffff',
cursorAccent: '#ffffff',
selection: 'rgba(58, 134, 255, 0.3)',
selectionForeground: undefined
},
disableStdin: false, // 启用终端输入
scrollback: 10000, // 滚动历史缓冲区
convertEol: true, // 转换行结束符
allowTransparency: false,
tabStopWidth: 8,
cursorStyle: 'block', // 光标样式
bellStyle: 'sound', // 铃声样式
allowProposedApi: true,
windowsMode: true, // Windows兼容模式
screenReaderMode: false, // 禁用屏幕阅读器模式,避免辅助输入框问题
// 滚动条配置
scrollOnUserInput: true, // 用户输入时自动滚动到底部
scrollOnOutput: false, // 输出时不自动滚动,让用户手动控制
scrollSensitivity: 3, // 滚动灵敏度
smoothScrollDuration: 0, // 平滑滚动持续时间(0为禁用)
fastScrollSensitivity: 5, // 快速滚动灵敏度
fastScrollModifier: 'alt', // 快速滚动修饰键
// 其他配置
macOptionIsMeta: false, // Windows环境下使用标准行为
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
rendererType: 'canvas', // 使用canvas渲染器以获得更好的性能
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
// 添加Web链接支持
const webLinksAddon = new WebLinksAddon()
term.loadAddon(webLinksAddon)
if (terminalRef.value) {
term.open(terminalRef.value)
fitAddon.fit()
// 聚焦终端
term.focus()
// ================================================================
// 修复中文输入法(IME)渲染到终端顶部的 Bug
// ----------------------------------------------------------------
// 根本原因:xterm.js 内部 compositionHelper 监听了 helper textarea
// 的 compositionupdate 事件,在 IME 选字过程中会直接向 xterm 的内部
// buffer 写入候选字并渲染到 canvas 第0行(终端顶部)。
//
// 解决方案:
// 1. 在「捕获阶段」(useCapture=true)监听 compositionupdate/
// compositionstart,调用 stopImmediatePropagation() 阻止 xterm
// 内部监听器收到事件 → 候选字不会被写入 canvas。
// 2. compositionend 时从 e.data 取出最终文字直接发给服务器,
// 并清空 textarea,防止 xterm 的 onData 再次重复发送。
// ================================================================
const setupHelperTextarea = () => {
const textarea = terminalRef.value?.querySelector('.xterm-helper-textarea')
if (textarea && !textarea._imeFixed) {
textarea._imeFixed = true // 防止重复绑定
// 保留 textarea 在终端容器内(确保 IME 候选框能在屏幕上弹出)
// 仅隐藏其视觉显示
textarea.style.opacity = '0'
textarea.style.color = 'transparent'
textarea.style.position = 'absolute'
textarea.style.bottom = '4px'
textarea.style.left = '4px'
textarea.style.width = '1px'
textarea.style.height = '1px'
textarea.style.zIndex = '1'
textarea.style.caretColor = 'transparent'
// 【核心修复】捕获阶段拦截 compositionstart,阻止 xterm 内部处理
textarea.addEventListener('compositionstart', (e) => {
isComposing = true
e.stopImmediatePropagation() // 阻止 xterm 内部 compositionHelper 收到此事件
}, true)
// 【核心修复】捕获阶段拦截 compositionupdate,阻止 xterm 将候选字写入 canvas
textarea.addEventListener('compositionupdate', (e) => {
e.stopImmediatePropagation() // 阻止 xterm 内部 compositionHelper 渲染候选字
}, true)
// compositionend:取最终确认文字发给服务器,清空 textarea
textarea.addEventListener('compositionend', (e) => {
e.stopImmediatePropagation()
isComposing = false
const text = e.data
if (text && terminalStore.isConnected) {
terminalStore.sendInput(text)
}
// 清空 textarea 防止 xterm onData 再次重复发送
Promise.resolve().then(() => { textarea.value = '' })
}, true)
}
}
// 初始设置
setupHelperTextarea()
// 监听DOM变化,确保 helper textarea 创建后及时处理
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
setupHelperTextarea()
}
})
})
observer.observe(terminalRef.value, { childList: true, subtree: true })
// 处理终端输入 - 直接发送到服务器
// IME 组合期间(isComposing === true)忽略 onData,最终文字由 compositionend 处理
term.onData((data) => {
if (isComposing) {
return // IME 组合进行中,跳过,避免发送候选字
}
if (terminalStore.isConnected) {
terminalStore.sendInput(data)
} else {
term.write('\r\n\x1b[31m未连接到服务器,请先连接\x1b[0m\r\n')
}
})
// 优化鼠标滚轮事件处理
const enableWheelScroll = () => {
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
// 移除之前的事件监听器
if (viewportWheelHandler) {
viewport.removeEventListener('wheel', viewportWheelHandler)
}
// 添加新的滚轮事件监听器 - 直接操作滚动条
viewportWheelHandler = (event) => {
event.preventDefault()
const deltaY = event.deltaY
const deltaMode = event.deltaMode
// 根据滚轮方向和速度滚动终端
if (deltaMode === 0) {
// 像素模式 - 使用更平滑的滚动
const scrollAmount = Math.sign(deltaY) * Math.ceil(Math.abs(deltaY) / 8)
viewport.scrollTop += scrollAmount * 20 // 增加滚动距离
} else if (deltaMode === 1) {
// 行模式
viewport.scrollTop += deltaY * 20
} else {
// 页面模式
viewport.scrollTop += deltaY * viewport.clientHeight
}
}
viewport.addEventListener('wheel', viewportWheelHandler, { passive: false })
// 确保滚动条可以正常拖动
viewport.style.overflowY = 'auto'
viewport.style.overflowX = 'hidden'
}
}
// 初始启用滚轮滚动
enableWheelScroll()
// 监听DOM变化,确保滚轮支持始终生效
const wheelObserver = new MutationObserver(() => {
enableWheelScroll()
})
wheelObserver.observe(terminalRef.value, { childList: true, subtree: true })
// 添加容器级别的滚轮事件作为备用
wheelHandler = (e) => {
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
e.preventDefault()
// 直接操作滚动条位置
viewport.scrollTop += e.deltaY
}
}
terminalRef.value.addEventListener('wheel', wheelHandler, { passive: false })
// 处理终端按键事件
term.attachCustomKeyEventHandler((event) => {
// 防止浏览器默认行为,但允许常用快捷键
if ((event.ctrlKey || event.metaKey) &&
!['c', 'v', 'a', 'z', 'x', 'y'].includes(event.key.toLowerCase())) {
event.preventDefault()
}
return true
})
// 防止空格触发容器或页面滚动,保持滚动条跟随光标
term.attachCustomKeyEventHandler((event) => {
if (event.code === 'Space' || event.key === ' ') {
event.preventDefault()
if (terminalStore.isConnected) {
terminalStore.sendInput(' ')
term.scrollToBottom()
}
return false
}
return true
})
// 添加粘贴支持
term.attachCustomKeyEventHandler((event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') {
event.preventDefault()
navigator.clipboard.readText().then(text => {
if (terminalStore.isConnected) {
terminalStore.sendInput(text)
}
}).catch(err => {
console.warn('粘贴失败:', err)
// 备用方案:允许浏览器默认粘贴行为
})
return false
}
return true
})
// 添加复制支持
term.attachCustomKeyEventHandler((event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') {
const selection = term.getSelection()
if (selection) {
navigator.clipboard.writeText(selection).catch(err => {
console.warn('复制失败:', err)
})
}
}
return true
})
const handleTerminalData = (data) => {
if (terminalStore.isConnected) {
terminalStore.sendInput(data)
}
}
const handleTerminalResize = (dimensions) => {
if (terminalStore.isConnected) {
terminalStore.resizeTerminal(dimensions)
}
}
// 连接到服务器
const connectToServer = async () => {
if (!authStore.token) {
// 显示用户友好的认证错误提示
if (term) {
term.write('\r\n\x1b[31m未认证,请重新登录后重试\x1b[0m\r\n')
if (terminalRef.value) {
terminalRef.value.writeError('未认证,请重新登录后重试')
}
ElMessage.error('未认证,请重新登录')
console.error('未认证')
@@ -460,8 +171,8 @@ const connectToServer = async () => {
console.log('Socket连接和认证成功')
} catch (error) {
console.error('Socket连接或认证失败:', error)
if (term) {
term.write(`\r\n\x1b[31mSocket连接失败: ${error.message}\x1b[0m\r\n`)
if (terminalRef.value) {
terminalRef.value.writeError(`Socket连接失败: ${error.message}`)
}
ElMessage.error('Socket连接失败')
return
@@ -474,20 +185,11 @@ const connectToServer = async () => {
}
}
// 聚焦终端
const focusTerminal = () => {
if (term) {
term.focus()
}
}
// 切换SFTP面板显示
const toggleSftpPanel = () => {
showSftp.value = !showSftp.value
// 延迟调整终端大小
setTimeout(() => {
handleResize()
if (terminalRef.value) terminalRef.value.fit()
}, 100)
}
@@ -504,78 +206,10 @@ const connectSftp = async () => {
}
}
// 右键菜单相关功能
const showContextMenu = (event) => {
event.preventDefault()
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY
}
}
const hideContextMenu = () => {
contextMenu.value.visible = false
}
const onDocumentClick = () => {
if (contextMenu.value.visible) {
hideContextMenu()
}
}
// 检查是否有选中的文本
const hasSelection = () => {
return term && term.getSelection() && term.getSelection().length > 0
}
// 从右键菜单复制
const copyFromContextMenu = async () => {
if (term && hasSelection()) {
const selection = term.getSelection()
try {
await navigator.clipboard.writeText(selection)
ElMessage.success(`已复制 ${selection.length} 个字符`)
hideContextMenu()
} catch (error) {
console.warn('复制失败:', error)
ElMessage.error('复制失败,请重试')
}
}
}
// 从右键菜单粘贴
const pasteFromContextMenu = async () => {
if (term && terminalStore.isConnected) {
try {
const text = await navigator.clipboard.readText()
terminalStore.sendInput(text)
hideContextMenu()
} catch (error) {
console.warn('粘贴失败:', error)
ElMessage.error('粘贴失败,请重试')
}
}
}
// 从右键菜单全选
const selectAllFromContextMenu = () => {
if (term) {
term.selectAll()
hideContextMenu()
}
}
// 清屏功能
const clearTerminal = () => {
// 清空终端状态
resetConnectedState()
}
const copySelected = async () => {
if (term) {
const selection = term.getSelection()
if (terminalRef.value) {
const selection = terminalRef.value.getSelection()
if (selection) {
try {
await navigator.clipboard.writeText(selection)
@@ -612,33 +246,22 @@ const pasteFromClipboard = async () => {
const resetConnectedState = () => {
terminalStore.clearOutput()
if (term) {
term.reset()
term.focus()
// 连接提示
term.write('\x1b[?25h')
if (typeof term.scrollToTop === 'function') {
term.scrollToTop()
}
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
viewport.scrollTop = initialScrollTop || 0
}
if (terminalRef.value) {
terminalRef.value.reset()
}
}
// 监听终端输出变化,让滚动条跟随光标到底部
watch(() => terminalStore.terminalOutput, (newOutput, oldOutput) => {
if (term && terminalStore.isConnected && newOutput !== oldOutput) {
if (terminalRef.value && terminalStore.isConnected && newOutput !== oldOutput) {
const newData = newOutput.slice(oldOutput.length)
if (newData) {
term.write(newData)
terminalRef.value.write(newData)
if (newData.includes('\x1b[H') && newData.includes('\x1b[2J')) {
resetConnectedState()
} else {
// 仅在用户输入时滚动到底部,其他情况下保持当前位置
if (terminalStore.scrollOnUserInput) {
term.scrollToBottom()
terminalRef.value.scrollToBottom()
}
}
}
@@ -647,79 +270,32 @@ watch(() => terminalStore.terminalOutput, (newOutput, oldOutput) => {
// 监听服务器连接状态变化
watch(() => terminalStore.isConnected, (isConnected, wasConnected) => {
if (term) {
if (terminalRef.value) {
if (isConnected && !wasConnected) {
// 连接成功时清空终端并聚焦
term.clear()
term.focus()
// 延迟写入欢迎信息,确保终端准备好
terminalRef.value.clear()
terminalRef.value.focus()
setTimeout(() => {
// 确保光标可见,并将滚动条保持在顶部
term.write('\x1b[?25h') // 显示光标
// 强制将滚动条设置为顶部
if (typeof term.scrollToTop === 'function') {
term.scrollToTop()
}
// 直接操作DOM滚动条确保位置正确
const viewport = terminalRef.value?.querySelector('.xterm-viewport')
if (viewport) {
initialScrollTop = 0 // 初始滚动位置设为顶部
viewport.scrollTop = 0
// 添加延迟确保滚动条位置设置生效
setTimeout(() => {
viewport.scrollTop = 0
}, 50)
}
terminalRef.value.reset()
}, 100)
} else if (!isConnected && wasConnected) {
// 断开时显示断开信息
term.write('\r\n\x1b[31mSSH连接已断开\x1b[0m\r\n')
// 断开SFTP连接
terminalRef.value.writeError('SSH连接已断开')
sftpStore.disconnectSftp()
showSftp.value = false
}
// 无论连接还是断开,重新计算终端尺寸以适应底部内边距
setTimeout(() => { handleResize() }, 150)
setTimeout(() => { terminalRef.value.fit() }, 150)
}
})
// 监听连接错误
watch(() => terminalStore.connectionError, (error) => {
if (term && error) {
term.write(`\r\n\x1b[31m连接错误: ${error}\x1b[0m\r\n`)
if (terminalRef.value && error) {
terminalRef.value.writeError(`连接错误: ${error}`)
}
})
// 监听窗口大小变化
const handleResize = () => {
if (fitAddon) {
fitAddon.fit()
// 发送终端大小到服务器
const dimensions = fitAddon.proposeDimensions()
if (dimensions) {
terminalStore.resizeTerminal({
rows: dimensions.rows,
cols: dimensions.cols,
height: dimensions.height,
width: dimensions.width
})
}
}
}
onMounted(async () => {
await nextTick()
initTerminal()
// 监听窗口大小变化
window.addEventListener('resize', handleResize)
document.addEventListener('click', onDocumentClick)
// 如果是服务器连接,自动连接
if (route.params.serverId) {
@@ -728,23 +304,8 @@ onMounted(async () => {
})
onUnmounted(() => {
// 清理资源
terminalStore.disconnectSocket()
sftpStore.disconnectSftp()
if (term) {
term.dispose()
}
if (terminalRef.value && wheelHandler) {
terminalRef.value.removeEventListener('wheel', wheelHandler)
wheelHandler = null
}
const viewportEl = terminalRef.value?.querySelector('.xterm-viewport')
if (viewportEl && viewportWheelHandler) {
viewportEl.removeEventListener('wheel', viewportWheelHandler)
viewportWheelHandler = null
}
window.removeEventListener('resize', handleResize)
document.removeEventListener('click', onDocumentClick)
})
</script>
@@ -937,74 +498,6 @@ onUnmounted(() => {
font-weight: normal;
}
/* 右键菜单样式 */
.context-menu {
position: fixed;
background: #2d2d30;
border: 1px solid #3e3e42;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
min-width: 120px;
padding: 4px 0;
animation: fadeIn 0.15s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.menu-item {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
color: #ffffff;
font-size: 13px;
transition: background-color 0.2s ease;
gap: 8px;
}
.menu-item:hover {
background-color: rgba(58, 134, 255, 0.2);
}
.menu-item.disabled {
color: #6c6c6c;
cursor: not-allowed;
}
.menu-item.disabled:hover {
background-color: transparent;
}
.menu-divider {
height: 1px;
background-color: #3e3e42;
margin: 4px 0;
}
.menu-item .el-icon {
font-size: 14px;
width: 16px;
}
/* XTerm.js 样式调整 */
:deep(.xterm) {
padding: 10px;
position: relative;
}
:deep(.xterm-viewport) {
background-color: #1e1e1e !important;
}
:deep(.xterm-screen) {
background-color: #1e1e1e !important;
}
/* xterm-helper-textarea: 仅隐藏视觉,保留事件监听能力(IME 修复需要) */
:deep(.xterm-helper-textarea) {
+24 -10
View File
@@ -1392,33 +1392,47 @@ onUnmounted(() => {
background: rgba(255, 255, 255, 0.5);
}
/* 右键菜单样式 - Apple MacOS 风格 */
.context-menu {
position: fixed;
background: #2d2d30;
border: 1px solid #3e3e42;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
background: rgba(30, 30, 30, 0.75);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 1000;
min-width: 160px;
min-width: 140px;
padding: 6px 0;
animation: fadeIn 0.15s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.95) translateY(-2px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.menu-item {
display: flex;
align-items: center;
padding: 8px 16px;
padding: 6px 12px;
margin: 2px 6px;
cursor: pointer;
color: #ffffff;
color: #f5f5f5;
font-size: 13px;
transition: background-color 0.2s ease;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
border-radius: 6px;
transition: all 0.1s ease;
gap: 10px;
}
.menu-item:hover {
background-color: rgba(58, 134, 255, 0.2);
background-color: #0a84ff; /* Apple Blue */
color: #ffffff;
}
.menu-item.disabled {
color: #6c6c6c;
color: rgba(255, 255, 255, 0.3);
cursor: not-allowed;
}