mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 13:18:34 +08:00
feat: 新增终端内右键挂起&单窗口模式头部右键菜单
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user