fix: Optimize code and extract duplicated code into components.
This commit is contained in:
1 parent
2babd40d74
commit
1171adb41b
4 files changed
+767
-1204
No files matched your search
@@ -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>
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user