mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 15:08:33 +08:00
✨ 新增终端右键菜单功能(待优化样式
This commit is contained in:
+5
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user