✨ 新增终端右键菜单功能(待优化样式

This commit is contained in:
chaos-zhu
2025-04-24 22:23:52 +08:00
parent 2bf98bc886
commit 8f8e9538cf
9 changed files with 143 additions and 82 deletions
+5 -3
View File
@@ -1,17 +1,19 @@
## TODO
* 新增MCP server功能
* AI问答集成终端、脚本一键执行等便捷功能
* serv00 SFTP修复
* 连接服务器下拉菜单分组展示
* 新增终端链接跳转需按住ctrl键(防止误触)
* 新增服务器定时任务功能
## [3.0.6](https://github.com/chaos-zhu/easynode/releases) (2025-04-04)
## [3.0.6](https://github.com/chaos-zhu/easynode/releases) (2025-04-xx)
* AI问答支持历史记录&标题生成
* AI对话生成脚本支持一键发送到终端(TODO
* 新增终端选中多功能菜单(一键询问AI、docker容器ID识别快捷操作. 欢迎issue反馈更多快捷功能)(TODO
* 添加一些新的内置脚本
* 新增终端链接跳转需按住ctrl|alt键(防止误触)
* 修复跳板机提示多余空格
* 修复xx情景下多终端tab同步会话bug(TODO
## [3.0.5](https://github.com/chaos-zhu/easynode/releases) (2025-04-04)
+1
View File
@@ -25,6 +25,7 @@
"@codemirror/language": "^6.11.0",
"@codemirror/legacy-modes": "^6.5.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@imengyu/vue3-context-menu": "^1.4.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
+10 -3
View File
@@ -182,6 +182,7 @@ import { Avatar, Refresh, CopyDocument, Delete, EditPen, CircleCheck, Loading, S
import { useAIChat } from '@/composables/useAIChat'
import { loadMarkdownCSS } from '@/utils/markdown'
import AiApiConfig from './ai-api-config.vue'
import { EventBus } from '@/utils'
const md = new MarkdownIt({
html: false,
@@ -234,7 +235,7 @@ const {
const aiApiConfigVisible = ref(false)
const activeModel = ref(localStorage.getItem('activeModel') || '')
const question = ref('') // debian设置swap为2gb的步骤
const question = ref('')
const isDark = computed(() => $store.isDark)
const isPlusActive = computed(() => $store.isPlusActive)
const aiConfig = computed(() => $store.aiConfig)
@@ -282,8 +283,8 @@ watch(() => visible.value, (newValue) => {
const inputFocus = () => {
let input = document.querySelector('.ant-sender-input')
setTimeout(() => {
input.focus()
}, 100)
input?.focus()
}, 300)
}
const handleModelChange = (model) => {
@@ -317,6 +318,12 @@ const editMessage = (content) => {
question.value = content
}
EventBus.$on('sendToAIInput', (text) => {
question.value = text
visible.value = true
inputFocus()
})
const submit = async function (questionStr) {
if (!questionStr || loading.value) return
question.value = ''
+34
View File
@@ -0,0 +1,34 @@
import { ref } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
export function useContextMenu() {
const menuRef = ref(null)
const showMenu = (event, items) => {
event.preventDefault()
menuRef.value = ContextMenu.showContextMenu({
x: event.x,
y: event.y,
items: items || [
{
label: 'A menu item',
onClick: () => {
alert('You click a menu item')
}
},
{
label: 'A submenu'
},
]
})
}
const closeMenu = () => {
menuRef.value?.closeMenu()
}
return {
menuRef,
showMenu,
closeMenu
}
}
+3
View File
@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import useStore from '@store/index'
import ContextMenu from '@imengyu/vue3-context-menu'
import router from './router'
import tools from './plugins/tools'
import elementPlugins from './plugins/element'
@@ -8,6 +9,7 @@ import globalComponents from './plugins/components'
import axios from '@/utils/axios'
import api from './api'
import App from './app.vue'
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
import './assets/scss/reset.scss'
import './assets/scss/global.scss'
import './assets/scss/mobile.scss'
@@ -17,6 +19,7 @@ elementPlugins(app)
globalComponents(app)
app.use(createPinia())
app.use(router)
app.use(ContextMenu)
app.config.globalProperties.$api = api
app.config.globalProperties.$tools = tools
-2
View File
@@ -38,8 +38,6 @@ const useStore = defineStore('global', {
fontSize: 16,
themeName: 'Afterglow',
background: 'linear-gradient(-225deg, #CBBACC 0%, #2580B3 100%)',
quickCopy: isHttps(),
quickPaste: isHttps(),
autoReconnect: true,
autoExecuteScript: false
},
@@ -82,38 +82,6 @@
</span>
</PlusSupportTip>
</el-form-item>
<el-form-item label="选中复制" prop="quickCopy">
<el-tooltip
effect="dark"
content="启用后选中文本自动复制到剪贴板(需https支持)"
placement="right"
>
<el-switch
v-model="quickCopy"
class="swtich"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="开启"
inactive-text="关闭"
/>
</el-tooltip>
</el-form-item>
<el-form-item label="快捷粘贴" prop="quickPaste">
<el-tooltip
effect="dark"
content="启用后右键粘贴剪贴板内容(需https支持)"
placement="right"
>
<el-switch
v-model="quickPaste"
class="swtich"
inline-prompt
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
active-text="开启"
inactive-text="关闭"
/>
</el-tooltip>
</el-form-item>
<el-form-item label="脚本执行" prop="autoExecuteScript">
<el-tooltip
effect="dark"
@@ -150,7 +118,12 @@
inactive-text="关闭"
/>
</span>
<el-radio-group v-model="scriptLibraryCascader" size="small" text-color="#fff" fill="#13ce66">
<el-radio-group
v-model="scriptLibraryCascader"
size="small"
text-color="#fff"
fill="#13ce66"
>
<el-radio-button :value="true">分组级联展示</el-radio-button>
<el-radio-button :value="false">不分组单列展示</el-radio-button>
</el-radio-group>
@@ -200,18 +173,10 @@ const fontSize = computed({
get: () => $store.terminalConfig.fontSize,
set: (newVal) => $store.setTerminalSetting({ fontSize: newVal })
})
const quickCopy = computed({
get: () => $store.terminalConfig.quickCopy,
set: (newVal) => $store.setTerminalSetting({ quickCopy: newVal })
})
const autoReconnect = computed({
get: () => $store.terminalConfig.autoReconnect,
set: (newVal) => $store.setTerminalSetting({ autoReconnect: newVal })
})
const quickPaste = computed({
get: () => $store.terminalConfig.quickPaste,
set: (newVal) => $store.setTerminalSetting({ quickPaste: newVal })
})
const autoExecuteScript = computed({
get: () => $store.terminalConfig.autoExecuteScript,
set: (newVal) => $store.setTerminalSetting({ autoExecuteScript: newVal })
@@ -4,6 +4,7 @@
ref="terminalRef"
class="terminal_container"
@contextmenu="handleRightClick"
@mouseup="handleMouseUp"
/>
<!-- <div class="terminal_command_history">
<CommandHistory :list="commandHistoryList" />
@@ -23,6 +24,8 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
import socketIo from 'socket.io-client'
import themeList from 'xterm-theme'
import { terminalStatus } from '@/utils/enum'
import { useContextMenu } from '@/composables/useContextMenu'
import { EventBus } from '@/utils'
const { CONNECTING, CONNECT_SUCCESS, CONNECT_FAIL } = terminalStatus
@@ -54,11 +57,12 @@ const timer = ref(null)
const pingTimer = ref(null)
const fitAddon = ref(null)
// const searchBar = ref(null)
const showContextMenu = ref(false)
const socketConnected = ref(false)
const curStatus = ref(CONNECTING)
const terminal = ref(null)
const terminalRef = ref(null)
const { showMenu, closeMenu } = useContextMenu()
const token = computed(() => $store.token)
const theme = computed(() => themeList[$store.terminalConfig.themeName])
@@ -68,8 +72,6 @@ const hostObj = computed(() => props.hostObj)
const hostId = computed(() => hostObj.value.id)
const host = computed(() => hostObj.value.host)
const menuCollapse = computed(() => $store.menuCollapse)
const quickCopy = computed(() => $store.terminalConfig.quickCopy)
const quickPaste = computed(() => $store.terminalConfig.quickPaste)
const autoExecuteScript = computed(() => $store.terminalConfig.autoExecuteScript)
const autoReconnect = computed(() => $store.terminalConfig.autoReconnect)
const isPlusActive = computed(() => $store.isPlusActive)
@@ -254,7 +256,6 @@ const createLocalTerminal = () => {
terminalInstance.writeln('\x1b[1;32mWelcome to EasyNode terminal\x1b[0m.')
terminalInstance.writeln('\x1b[1;32mAn experimental Web-SSH Terminal\x1b[0m.')
terminalInstance.focus()
onSelectionChange()
onFindText()
onWebLinks()
onResize()
@@ -303,19 +304,6 @@ const onFindText = () => {
// term.value.loadAddon(searchBar.value)
}
const onSelectionChange = () => {
term.value.onSelectionChange(() => {
if (!quickCopy.value) return
let str = term.value.getSelection()
if (!str) return
const text = new Blob([str,], { type: 'text/plain' })
const item = new ClipboardItem({
'text/plain': text
})
navigator.clipboard.write([item,])
})
}
const terminalText = ref(null)
const enterTimer = ref(null)
@@ -408,34 +396,85 @@ const onData = () => {
})
}
const handleRightClick = async (e) => {
if (!quickPaste.value) return
e.preventDefault()
try {
const clipboardText = await navigator.clipboard.readText()
if (!clipboardText) return
// 移除多余空格与换行符
const formattedText = clipboardText.trim().replace(/\s+/g, ' ')
// console.log(formattedText)
if (formattedText.includes('rm -rf /')) return $message.warning(`高危指令,禁止粘贴: ${ formattedText }`)
const safeText = formattedText.replace(/\r?\n|\r/g, '')
// console.log(safeText)
socket.value.emit('input', safeText)
} catch (error) {
$message.warning('右键默认粘贴行为,需要https支持')
const handleCopySelection = async () => {
let str = term.value.getSelection().trim()
if (!str) return
const text = new Blob([str,], { type: 'text/plain' })
const item = new ClipboardItem({
'text/plain': text
})
await navigator.clipboard.write([item,])
term.value.clearSelection()
}
const handleMouseUp = async (e) => {
if (e.button === 1) return handlePaste() // 鼠标中键粘贴
if (e.button === 0) {
let str = term.value.getSelection().trim()
if (!str) return closeMenu()
handleRightClick(e)
}
}
const handleRightClick = async (e) => {
let str = term.value.getSelection().trim()
const sendToAI = str ? {
label: '发送到AI会话',
onClick: () => {
EventBus.$emit('sendToAIInput', `\`\`\`shell\n${ str }\n\`\`\`\n`)
term.value.clearSelection()
}
} : null
const paste = {
label: '粘贴',
onClick: () => {
handlePaste()
term.value.clearSelection()
}
}
const copySelection = str ? {
label: '复制',
onClick: async () => await handleCopySelection()
} : null
const pasteSelection = str ? {
label: '粘贴选中内容',
onClick: async () => {
await handleCopySelection()
handlePaste()
}
} : null
const clear = {
label: '清屏',
onClick: () => {
handleClear()
}
}
const menu = [
sendToAI,
copySelection,
paste,
pasteSelection,
clear,
].filter(Boolean)
showMenu(e, menu)
}
const handleClear = () => {
term.value.clearSelection()
term.value.clear()
term.value.focus()
}
const handlePaste = async () => {
if (!quickPaste.value) return
let key = await navigator.clipboard.readText()
// 如果粘贴的内容以换行符结尾则去掉换行符(防止自动执行)
while (key.endsWith('\n')) {
key = key.slice(0, -1)
}
emit('inputCommand', key)
socket.value.emit('input', key)
term.value.focus()
term.value.clearSelection()
}
const focusTab = () => {
+12
View File
@@ -814,6 +814,18 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
"@imengyu/vue-scroll-rect@^0.1.1":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@imengyu/vue-scroll-rect/-/vue-scroll-rect-0.1.2.tgz#e404152323656fd08cc39eac77048fe29b0626b7"
integrity sha512-0cCCiLZMjO710MW0ov6R65gzF3UIIFaS2MvLeiST0QxhjIhrQETTelg3oCxgseIjqBV2AmNKKR9oPHuP6Ky8+g==
"@imengyu/vue3-context-menu@^1.4.8":
version "1.4.8"
resolved "https://registry.yarnpkg.com/@imengyu/vue3-context-menu/-/vue3-context-menu-1.4.8.tgz#c9c3a51a0ce729153db6e5d59774e53bcc07c612"
integrity sha512-Qjm5G6ZLLog5ghPvhXrJxQ7Tai9vDhn3vuY7e5yEsNOY9ocu6fkJ/0/z2H0WkC3DV6/q/vexVrfwmKNKbJiXQQ==
dependencies:
"@imengyu/vue-scroll-rect" "^0.1.1"
"@isaacs/cliui@^8.0.2":
version "8.0.2"
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"