diff --git a/frontend/src/views/TerminalNew.vue b/frontend/src/views/TerminalNew.vue
index 8d7ba08..18f312f 100644
--- a/frontend/src/views/TerminalNew.vue
+++ b/frontend/src/views/TerminalNew.vue
@@ -120,7 +120,28 @@
:data-tab-id="tab.id"
class="terminal"
@click="focusTerminal(tab.id)"
+ @contextmenu.prevent="onContextMenu($event, tab.id)"
>
+
{
@@ -269,7 +291,9 @@ const createNewTab = () => {
error: null,
terminal: null,
fitAddon: null,
- socket: null
+ socket: null,
+ autoScroll: true,
+ viewportWheelHandler: null
}
tabs.value.push(newTab)
@@ -295,7 +319,7 @@ const initTerminal = (tab) => {
foreground: '#ffffff',
cursor: '#ffffff',
cursorAccent: '#ffffff',
- selection: 'rgba(58, 134, 255, 0.3)',
+ selection: 'rgba(54, 99, 181, 0.35)',
selectionForeground: undefined
},
disableStdin: false,
@@ -335,6 +359,9 @@ const initTerminal = (tab) => {
if (tab.socket) {
tab.socket.emit('ssh-input', data)
}
+ if (tab.autoScroll && tab.terminal.scrollToBottom) {
+ tab.terminal.scrollToBottom()
+ }
} else {
tab.terminal.write('\r\n\x1b[31m未连接到服务器,请先连接\x1b[0m\r\n')
}
@@ -342,6 +369,133 @@ const initTerminal = (tab) => {
// 隐藏辅助输入框
hideHelperTextarea(tab.id)
+
+ tab.terminal.attachCustomKeyEventHandler((event) => {
+ if ((event.ctrlKey || event.metaKey) && !['c', 'v', 'a', 'z', 'x', 'y'].includes(event.key.toLowerCase())) {
+ event.preventDefault()
+ }
+ return true
+ })
+
+ tab.terminal.attachCustomKeyEventHandler((event) => {
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') {
+ event.preventDefault()
+ navigator.clipboard.readText().then(text => {
+ if (tab.connected && tab.socket) {
+ tab.socket.emit('ssh-input', text)
+ if (tab.autoScroll && tab.terminal.scrollToBottom) {
+ tab.terminal.scrollToBottom()
+ }
+ }
+ }).catch(() => {})
+ return false
+ }
+ return true
+ })
+
+ tab.terminal.attachCustomKeyEventHandler((event) => {
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') {
+ const selection = tab.terminal.getSelection()
+ if (selection) {
+ navigator.clipboard.writeText(selection).catch(() => {})
+ }
+ }
+ return true
+ })
+
+ tab.terminal.attachCustomKeyEventHandler((event) => {
+ if (event.code === 'Space' || event.key === ' ') {
+ event.preventDefault()
+ if (tab.connected && tab.socket) {
+ tab.socket.emit('ssh-input', ' ')
+ if (tab.autoScroll && tab.terminal.scrollToBottom) {
+ tab.terminal.scrollToBottom()
+ }
+ }
+ return false
+ }
+ return true
+ })
+
+ const viewportEl = terminalEl.querySelector('.xterm-viewport')
+ if (viewportEl) {
+ const handler = (e) => {
+ const mode = e.deltaMode
+ const unit = mode === 1 ? 40 : (mode === 2 ? viewportEl.clientHeight : 1)
+ const delta = e.deltaY * unit
+ const next = Math.min(
+ viewportEl.scrollHeight - viewportEl.clientHeight,
+ Math.max(0, viewportEl.scrollTop + delta)
+ )
+ e.preventDefault()
+ viewportEl.scrollTop = next
+ const nearBottom = viewportEl.scrollTop >= (viewportEl.scrollHeight - viewportEl.clientHeight - 2)
+ // 用户滚动时,如果不在底部则关闭自动滚动;到达底部后开启自动滚动
+ tab.autoScroll = nearBottom
+ }
+ viewportEl.addEventListener('wheel', handler, { passive: false })
+ tab.viewportWheelHandler = handler
+ }
+}
+
+const onContextMenu = (event, tabId) => {
+ contextMenu.value = { visible: true, x: event.clientX, y: event.clientY, tabId }
+ document.addEventListener('click', hideContextMenuOnce, { once: true })
+}
+
+const hideContextMenuOnce = () => {
+ contextMenu.value.visible = false
+}
+
+const getTabById = (tabId) => tabs.value.find(t => t.id === tabId)
+
+const hasSelection = (tabId) => {
+ const tab = getTabById(tabId)
+ if (!tab || !tab.terminal) return false
+ const sel = tab.terminal.getSelection()
+ return !!sel && sel.length > 0
+}
+
+const copyFromContextMenu = async (tabId) => {
+ const tab = getTabById(tabId)
+ if (!tab || !tab.terminal) return
+ const selection = tab.terminal.getSelection()
+ if (selection) {
+ try {
+ await navigator.clipboard.writeText(selection)
+ } catch {}
+ }
+ contextMenu.value.visible = false
+}
+
+const pasteFromContextMenu = async (tabId) => {
+ const tab = getTabById(tabId)
+ if (!tab || !tab.socket || !tab.connected) return
+ try {
+ const text = await navigator.clipboard.readText()
+ if (text) {
+ tab.socket.emit('ssh-input', text)
+ if (tab.autoScroll && tab.terminal && tab.terminal.scrollToBottom) {
+ tab.terminal.scrollToBottom()
+ }
+ }
+ } catch {}
+ contextMenu.value.visible = false
+}
+
+const clearFromContextMenu = (tabId) => {
+ const tab = getTabById(tabId)
+ if (!tab || !tab.terminal) return
+ tab.terminal.reset()
+ tab.terminal.focus()
+ tab.terminal.write('\x1b[32mSSH连接已建立,可以开始输入命令\x1b[0m\r\n\r\n')
+ tab.terminal.write('\x1b[?25h')
+ const terminalEl = document.querySelector(`[data-tab-id="${tab.id}"]`)
+ const viewportEl = terminalEl?.querySelector('.xterm-viewport')
+ if (viewportEl) {
+ viewportEl.scrollTop = 0
+ }
+ contextMenu.value.visible = false
}
// 隐藏辅助输入框
@@ -410,7 +564,9 @@ const createNewTabForServer = async (server) => {
error: null,
terminal: null,
fitAddon: null,
- socket: null
+ socket: null,
+ autoScroll: true,
+ viewportWheelHandler: null
}
tabs.value.push(newTab)
@@ -474,6 +630,9 @@ const performConnection = async (tab, serverId) => {
const output = typeof data === 'string' ? data : String(data)
if (tab.terminal) {
tab.terminal.write(output)
+ if (tab.autoScroll && tab.terminal.scrollToBottom) {
+ tab.terminal.scrollToBottom()
+ }
}
})
// 错误
@@ -560,6 +719,14 @@ const closeTab = async (tabId) => {
if (tab && tab.terminal) {
tab.terminal.dispose()
}
+ if (tab && tab.viewportWheelHandler) {
+ const terminalEl = document.querySelector(`[data-tab-id="${tab.id}"]`)
+ const viewportEl = terminalEl?.querySelector('.xterm-viewport')
+ if (viewportEl) {
+ viewportEl.removeEventListener('wheel', tab.viewportWheelHandler)
+ }
+ tab.viewportWheelHandler = null
+ }
// 移除页签
tabs.value = tabs.value.filter(t => t.id !== tabId)
@@ -1046,6 +1213,7 @@ onUnmounted(() => {
height: 100%;
padding: 0;
cursor: text;
+ position: relative;
}
.connection-prompt,
@@ -1081,16 +1249,40 @@ onUnmounted(() => {
position: relative !important;
height: 100% !important;
width: 100% !important;
+ display: flex !important;
+ flex-direction: column !important;
}
:deep(.xterm-viewport) {
background-color: #1e1e1e !important;
overflow-y: auto !important;
overflow-x: hidden !important;
+ overscroll-behavior: contain;
+ direction: ltr !important;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.1);
+ position: relative !important;
+ width: 100% !important;
+ height: 100% !important;
+ pointer-events: auto !important;
}
:deep(.xterm-screen) {
background-color: #1e1e1e !important;
+ width: 100% !important;
+ height: 100% !important;
+ display: flex !important;
+ flex-direction: column !important;
+}
+
+:deep(.xterm .xterm-selection) {
+ background-color: rgba(54, 99, 181, 0.35) !important;
+ border-radius: 2px;
+ mix-blend-mode: screen;
+}
+
+:deep(.xterm-selection-layer) {
+ background-color: rgba(54, 99, 181, 0.35) !important;
}
:deep(.xterm-helper-textarea) {
@@ -1127,4 +1319,38 @@ onUnmounted(() => {
:deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
background: rgba(255, 255, 255, 0.5);
}
+
+.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: 160px;
+ padding: 6px 0;
+}
+
+.menu-item {
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ cursor: pointer;
+ color: #ffffff;
+ font-size: 13px;
+ transition: background-color 0.2s ease;
+}
+
+.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;
+}