feat: 新增终端内右键挂起&单窗口模式头部右键菜单

This commit is contained in:
chaos-zhu
2026-03-13 20:04:49 +08:00
parent f7f03def12
commit 7e752c4671
4 changed files with 211 additions and 49 deletions
@@ -0,0 +1,82 @@
/**
* useTerminalTabContextMenu
* 抽离终端 Tab / 标题栏右键菜单的通用逻辑,供多窗口模式和单窗口模式复用。
*
* @param {Object} options
* @param {import('vue').ComputedRef<Array>} options.terminalTabs - 当前所有终端 tab 列表
* @param {Function} options.onSuspend - (item, index) => Promise<boolean> 挂起单个终端
* @param {Function} options.onSuspendAll - () => void 挂起所有
* @param {Function} options.onCloseOther - (index) => void 关闭其他
* @param {Function} options.onCloseAll - () => void 关闭所有
* @param {import('@/composables/useContextMenu').showMenu} options.showMenu - 显示右键菜单的方法
* @param {import('@/utils/enum').terminalStatus} options.terminalStatus - 终端状态枚举
*/
export function useTerminalTabContextMenu({
terminalTabs,
onSuspend,
onSuspendAll,
onCloseOther,
onCloseAll,
showMenu,
terminalStatus
}) {
/**
* 构建 Tab / 标题栏右键菜单项
* @param {object} item - 当前 tab 对象
* @param {number} index - 当前 tab 索引(单窗口模式中也传入相应索引)
*/
const buildTabMenuItems = (item, index) => {
const menuItems = []
// 挂起终端(仅在已连接状态显示)
if (item.status === terminalStatus.CONNECT_SUCCESS) {
menuItems.push({
label: '挂起',
onClick: () => onSuspend(item, index)
})
}
// 挂起所有会话(存在已连接会话时显示)
const tabs = typeof terminalTabs === 'function' ? terminalTabs() : terminalTabs.value
if (tabs.some(tab => tab.status === terminalStatus.CONNECT_SUCCESS)) {
menuItems.push({
label: '挂起所有',
onClick: () => onSuspendAll()
})
}
// 关闭其他终端
if (tabs.length > 1) {
menuItems.push({
label: '关闭其他',
onClick: () => onCloseOther(index, item)
})
}
// 关闭所有终端
menuItems.push({
label: '关闭所有',
onClick: () => onCloseAll()
})
return menuItems
}
/**
* 处理 Tab / 标题栏右键事件
* @param {MouseEvent} e
* @param {object} item
* @param {number} index
*/
const handleTabContextMenu = (e, item, index) => {
e.preventDefault()
e.stopPropagation()
const menuItems = buildTabMenuItems(item, index)
showMenu(e, menuItems)
}
return {
buildTabMenuItems,
handleTabContextMenu
}
}
@@ -18,7 +18,10 @@
:style="panel.style"
>
<!-- 终端标题栏 -->
<div class="terminal_header">
<div
class="terminal_header"
@contextmenu.prevent="handleHeaderContextMenu($event, panel.terminal, index)"
>
<div class="terminal_title">
<span class="terminal_index">{{ String(index + 1).padStart(2, '0') }}</span>
<span
@@ -61,6 +64,7 @@
@ping-data="getPingData"
@reset-long-press="resetLongPress"
@tab-focus="handleTabFocus"
@request-suspend="() => handleRequestSuspendSingle(panel.terminal)"
/>
</div>
<el-dialog
@@ -96,12 +100,14 @@
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
import { Close, FullScreen, Aim } from '@element-plus/icons-vue'
import Terminal from './terminal.vue'
import { terminalStatusList } from '@/utils/enum'
import { terminalStatusList, terminalStatus } from '@/utils/enum'
import ScriptInput from './script-input.vue'
import PlusLimitTip from '@/components/common/PlusLimitTip.vue'
import { useContextMenu } from '@/composables/useContextMenu'
import { useTerminalTabContextMenu } from '@/composables/useTerminalTabContextMenu'
const props = defineProps({
terminalTabs: {
@@ -133,7 +139,10 @@ const props = defineProps({
// 容器引用
const containerRef = ref(null)
const emit = defineEmits(['close-terminal', 'terminal-input', 'ping-data', 'reset-long-press',])
const { proxy: { $message } } = getCurrentInstance()
const { showMenu } = useContextMenu()
const emit = defineEmits(['close-terminal', 'terminal-input', 'ping-data', 'reset-long-press', 'suspend-terminal'])
// 响应式数据
const focusedTerminalKey = ref(null)
@@ -584,6 +593,62 @@ const handleCloseTerminal = (terminalKey) => {
})
}
// 挂起单个终端(单窗口模式下)
const handleSuspendTerminalSingle = async (terminalItem, { silent = false } = {}) => {
const terminalRef = terminalRefs.value[terminalItem.key]
if (!terminalRef) {
if (!silent) $message.error('获取终端引用失败')
return false
}
const success = await terminalRef.suspendTerminal()
if (success) {
if (!silent) $message.success('终端已挂起')
// 通知父组件关闭该终端
emit('suspend-terminal', terminalItem.key)
return true
}
return false
}
// 挂起所有已连接终端(单窗口模式下)
const handleSuspendAllSessionsSingle = async () => {
const connectedTabs = props.terminalTabs.filter(tab => tab.status === terminalStatus.CONNECT_SUCCESS)
if (connectedTabs.length === 0) {
$message.warning('暂无可挂起的已连接会话')
return
}
let successCount = 0
let failCount = 0
for (const tab of [...connectedTabs].reverse()) {
const ok = await handleSuspendTerminalSingle(tab, { silent: true })
if (ok) successCount += 1
else failCount += 1
}
if (successCount > 0) {
$message.success(`已挂起 ${ successCount } 个会话`)
}
if (failCount > 0) {
$message.warning(`有 ${ failCount } 个会话挂起失败`)
}
}
// 关闭其他终端(单窗口模式下)
const handleCloseOtherSingle = (keepKey) => {
const toClose = props.terminalTabs.filter(tab => tab.key !== keepKey)
toClose.forEach(tab => handleCloseTerminal(tab.key))
}
// 关闭所有终端(单窗口模式下)
const handleCloseAllSingle = () => {
const allKeys = [...props.terminalTabs].map(tab => tab.key)
allKeys.forEach(key => handleCloseTerminal(key))
}
// 处理 Terminal 组件的 request-suspend 事件(终端内右键挂起)
const handleRequestSuspendSingle = (terminalItem) => {
handleSuspendTerminalSingle(terminalItem)
}
const handleInputCommand = (cmd, uid, terminalKey) => {
// 如果启用了同步所有会话,则同步输入到其他终端
if (props.isSyncAllSession) {
@@ -841,6 +906,24 @@ watch(() => props.layoutMode, () => {
})
})
// 初始化 Tab 右键菜单 composable(单窗口模式下的 header 右键)
// 注意:单窗口模式下 terminalTabs 是 prop(Array),需要包装为 getter
const { handleTabContextMenu: _headerCtxMenu } = useTerminalTabContextMenu({
terminalTabs: { value: props.terminalTabs },
onSuspend: (item) => handleSuspendTerminalSingle(item),
onSuspendAll: () => handleSuspendAllSessionsSingle(),
// onCloseOther 的第二个参数是当前 tab item(由 composable 传入)
onCloseOther: (index, item) => handleCloseOtherSingle(item.key),
onCloseAll: () => handleCloseAllSingle(),
showMenu,
terminalStatus
})
// header 右键菜单入口(单窗口模式)
const handleHeaderContextMenu = (e, terminalItem, index) => {
_headerCtxMenu(e, terminalItem, index)
}
// 暴露方法
defineExpose({
focusTerminal: (key) => {
@@ -221,6 +221,7 @@
@close-terminal="handleCloseTerminalSingle"
@ping-data="getPingData"
@reset-long-press="resetLongPress"
@suspend-terminal="handleSuspendTerminalSingleDone"
/>
</div>
@@ -337,6 +338,7 @@
@reset-long-press="resetLongPress"
@tab-focus="handleTabFocus"
@sync-path-to-sftp="(path) => handleSyncPathToSftp(path)"
@request-suspend="() => handleSuspendTerminal(item, index)"
/>
</div>
</template>
@@ -434,6 +436,7 @@ import { ArrowDown, VideoPause, Setting } from '@element-plus/icons-vue'
import useMobileWidth from '@/composables/useMobileWidth'
import { terminalStatusList, terminalStatus } from '@/utils/enum'
import { useContextMenu } from '@/composables/useContextMenu'
import { useTerminalTabContextMenu } from '@/composables/useTerminalTabContextMenu'
import Terminal from './terminal.vue'
import ServerStatus from './server-status.vue'
import HostForm from '../../server/components/host-form.vue'
@@ -1051,48 +1054,6 @@ const handleInputCommand = async (command, type = 'input', useBase64 = false) =>
}
}
// Tab 右键菜单处理
const handleTabContextMenu = (e, item, index) => {
e.preventDefault()
e.stopPropagation()
const menuItems = []
// 挂起终端选项(仅在已连接状态显示)
if (item.status === terminalStatus.CONNECT_SUCCESS) {
menuItems.push({
label: '挂起',
onClick: () => handleSuspendTerminal(item, index)
})
}
// 挂起所有会话(存在已连接会话时显示)
if (
terminalTabs.value.some(tab => tab.status === terminalStatus.CONNECT_SUCCESS)
) {
menuItems.push({
label: '挂起所有',
onClick: () => handleSuspendAllSessions()
})
}
// 关闭其他终端
if (terminalTabs.value.length > 1) {
menuItems.push({
label: '关闭其他',
onClick: () => handleCloseOtherTabs(index)
})
}
// 关闭所有终端
menuItems.push({
label: '关闭所有',
onClick: () => handleCloseAllTab()
})
showMenu(e, menuItems)
}
// 挂起终端
const handleSuspendTerminal = async (item, index, { silent = false } = {}) => {
const terminalRef = getFirstTerminalRefOfTab(index)
@@ -1108,8 +1069,8 @@ const handleSuspendTerminal = async (item, index, { silent = false } = {}) => {
if (!silent) $message.success('终端已挂起')
// 立即关闭该tab
removeTab(index)
// 更新挂起会话状态,显示恢复会话菜单
hasSuspendedSessions.value = true
// 刷新挂起会话列表
fetchSuspendedSessions()
return true
}
@@ -1162,6 +1123,22 @@ const handleCloseOtherTabs = (keepIndex) => {
})
}
// Tab 右键菜单处理(复用 useTerminalTabContextMenu composable)
// 在所有依赖函数定义后初始化 composable(避免 hoisting 问题)
const { handleTabContextMenu: _tabCtxMenu } = useTerminalTabContextMenu({
terminalTabs,
onSuspend: (item, index) => handleSuspendTerminal(item, index),
onSuspendAll: () => handleSuspendAllSessions(),
onCloseOther: (index) => handleCloseOtherTabs(index),
onCloseAll: () => handleCloseAllTab(),
showMenu,
terminalStatus
})
const handleTabContextMenu = (e, item, index) => {
_tabCtxMenu(e, item, index)
}
// 单窗口模式相关函数
const handleCloseTerminalSingle = (terminalKey) => {
const tabIndex = terminalTabs.value.findIndex(tab => tab.key === terminalKey)
@@ -1170,6 +1147,16 @@ const handleCloseTerminalSingle = (terminalKey) => {
}
}
// 单窗口模式下挂起完成后的处理(TerminalSingleWindow emit suspend-terminal 时触发)
const handleSuspendTerminalSingleDone = (terminalKey) => {
const tabIndex = terminalTabs.value.findIndex(tab => tab.key === terminalKey)
if (tabIndex !== -1) {
emit('removeTab', tabIndex)
}
// 刷新挂起会话列表
fetchSuspendedSessions()
}
const handleToFullScreen = () => {
if (isSingleWindowMode.value) {
const singleWindowWrapper = document.querySelector('.single_window_wrapper')
+11 -1
View File
@@ -73,7 +73,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['inputCommand', 'ping-data', 'reset-long-press', 'tab-focus', 'sync-path-to-sftp',])
const emit = defineEmits(['inputCommand', 'ping-data', 'reset-long-press', 'tab-focus', 'sync-path-to-sftp', 'request-suspend'])
const socket = ref(null)
// const commandHistoryList = ref([])
@@ -831,6 +831,15 @@ const handleRightClick = async (e) => {
]
} : null
// 挂起选项(仅在已连接状态显示)
const suspend = curStatus.value === CONNECT_SUCCESS ? {
label: '挂起',
onClick: () => {
emit('request-suspend')
term.value.clearSelection()
}
} : null
const menu = [
sendToAI,
copySelection,
@@ -839,6 +848,7 @@ const handleRightClick = async (e) => {
search,
clear,
reconnect,
suspend,
syncToSftp,
dockerId,
dockerComposeYml,