feat: 支持单窗口模式&修复bug&其他优化项

This commit is contained in:
chaos-zhu
2025-07-19 23:10:50 +08:00
parent 830bf53ae2
commit bfbbbcdc9c
24 changed files with 3277 additions and 1880 deletions
+20 -2
View File
@@ -1,7 +1,25 @@
## TODO
* 新增服务器定时任务功能
* 添加文件对传功能
* 服务器文件对传
* 批量下发后台定时任务
* 终端快速连接更改为最近连接
* AI对话收藏
* 移动端Ctrl、Tab的按键面板
* 客户端功能采用原生指令实现
* 数据导出(2fa强制)
* 进程管理
* 当前会话连接|w
* 日志打印优化
## [3.1.1](https://github.com/chaos-zhu/easynode/releases) (2025-07-19)
* 🎉终端新增单窗口模式
* SFTP新增cd目录快捷菜单
* 微调终端布局&优化文案&底部栏调整为纯脚本库,支持拖动设定高度
* 状态、SFTP等状态本地记忆
* 修复脚本库与窗口输入同步问题
* 优化终端大小重置后自动滚动到最后一行
## [3.1.0](https://github.com/chaos-zhu/easynode/releases) (2025-07-13)
File diff suppressed because one or more lines are too long
+14 -16
View File
@@ -4,10 +4,9 @@ const speakeasy = require('speakeasy')
const QRCode = require('qrcode')
const version = require('../../package.json').version
const getLicenseInfo = require('../utils/get-plus')
const { plusServer1, plusServer2 } = require('../utils/plus-server')
const { sendNoticeAsync } = require('../utils/notify')
const { RSADecryptAsync, AESEncryptAsync, SHA1Encrypt } = require('../utils/encrypt')
const { getNetIPInfo } = require('../utils/tools')
const { getNetIPInfo, requestWithFailover } = require('../utils/tools')
const { KeyDB, LogDB, PlusDB } = require('../utils/db-class')
const keyDB = new KeyDB().getInstance()
@@ -177,23 +176,22 @@ const getPlusInfo = async ({ res }) => {
const getPlusDiscount = async ({ res } = {}) => {
if (process.env.EXEC_ENV === 'local') return res.success({ discount: false })
const servers = [plusServer1, plusServer2]
for (const server of servers) {
try {
const url = `${ server }/api/announcement/public?version=${ version }`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${ response.status }`)
}
try {
const response = await requestWithFailover(`/api/announcement/public?version=${ version }`)
if (response.ok) {
const data = await response.json()
return res.success({ data, msg: 'success' })
} catch (error) {
if (server === servers[servers.length - 1]) {
consola.error('All servers failed:', error.message)
return res.success({ discount: false })
}
continue
}
// 如果是403或其他错误状态码
consola.error('获取折扣信息失败,状态码:', response.status)
return res.success({ discount: false })
} catch (error) {
consola.error('获取折扣信息失败:', error.message)
return res.success({ discount: false })
}
}
+2
View File
@@ -4,6 +4,7 @@ const http = require('http')
const { httpPort } = require('./config')
const middlewares = require('./middlewares')
const wsTerminal = require('./socket/terminal')
const wsTerminalSingleWindow = require('./socket/terminal-single-window')
const wsSftp = require('./socket/sftp')
const wsSftpV2 = require('./socket/sftp-v2')
const wsDocker = require('./socket/docker')
@@ -25,6 +26,7 @@ const httpServer = () => {
function serverHandler(app, server) {
app.proxy = true // 用于nginx反代时获取真实客户端ip
wsTerminal(server) // 终端
wsTerminalSingleWindow(server) // 终端-单窗口
wsSftp(server) // sftp
wsSftpV2(server) // sftp-v2
wsDocker(server) // docker
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
const path = require('path')
const { Server } = require('socket.io')
const decryptAndExecuteAsync = require('../utils/decrypt-file')
async function createServerIo(serverIo) {
let { createSingleWindowServerIo = null } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (!createSingleWindowServerIo) {
consola.info('单窗口模式功能未解锁')
return
}
createSingleWindowServerIo(serverIo)
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/terminal-single-window',
cors: {
origin: '*'
}
})
createServerIo(serverIo)
}
+98 -41
View File
@@ -31,20 +31,45 @@ async function getConnectionOptions(hostId) {
}
function createInteractiveShell(socket, targetSSHClient) {
return new Promise((resolve) => {
targetSSHClient.shell({ term: 'xterm-color' }, (err, stream) => {
resolve(stream)
if (err) return socket.emit('output', err.toString())
stream
.on('data', (data) => {
socket.emit('output', data.toString())
})
.on('close', () => {
consola.info('交互终端已关闭')
targetSSHClient.end()
})
socket.emit('terminal_connect_shell_success') // 已连接终端,web端可以执行指令了
})
return new Promise((resolve, reject) => {
// 检查SSH客户端连接状态
if (!targetSSHClient || !targetSSHClient._sock || !targetSSHClient._sock.writable) {
const errorMsg = 'SSH客户端连接已断开,无法创建交互式终端'
consola.error(errorMsg)
socket.emit('terminal_connect_fail', errorMsg)
return reject(new Error(errorMsg))
}
try {
targetSSHClient.shell({ term: 'xterm-color' }, (err, stream) => {
if (err) {
consola.error('创建交互式终端失败:', err.message)
socket.emit('terminal_connect_fail', err.message)
return reject(err)
}
resolve(stream)
stream
.on('data', (data) => {
socket.emit('output', data.toString())
})
.on('close', () => {
consola.info('交互终端已关闭')
targetSSHClient.end()
})
.on('error', (streamErr) => {
consola.error('终端流错误:', streamErr.message)
socket.emit('terminal_connect_fail', streamErr.message)
})
socket.emit('terminal_connect_shell_success') // 已连接终端,web端可以执行指令了
})
} catch (shellError) {
consola.error('调用shell方法失败:', shellError.message)
socket.emit('terminal_connect_fail', shellError.message)
reject(shellError)
}
})
}
@@ -77,8 +102,16 @@ async function createTerminal(hostId, socket, targetSSHClient, isInteractiveShel
sendNoticeAsync('host_login', '终端登录', `别名: ${ name } \n IP:${ host } \n 端口:${ port } \n 状态: 登录成功`)
socket.emit('terminal_print_info', `终端连接成功: ${ name } - ${ host }`)
socket.emit('terminal_connect_success', `终端连接成功:${ host }`)
let stream = await createInteractiveShell(socket, targetSSHClient)
resolve({ stream, jumpSshClients })
try {
let stream = await createInteractiveShell(socket, targetSSHClient)
resolve({ stream, jumpSshClients })
} catch (shellError) {
consola.error('创建交互式终端失败:', host, shellError.message)
// 连接已经成功但创建shell失败,需要清理连接
targetSSHClient.end()
jumpSshClients?.forEach(sshClient => sshClient && sshClient.end())
}
} else {
resolve({ jumpSshClients })
}
@@ -110,14 +143,7 @@ async function createTerminal(hostId, socket, targetSSHClient, isInteractiveShel
})
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/terminal',
cors: {
origin: '*'
}
})
function createServerIo(serverIo) {
let connectionCount = 0
serverIo.on('connection', (socket) => {
@@ -133,24 +159,44 @@ module.exports = (httpServer) => {
let targetSSHClient = null
let jumpSshClients = []
socket.on('ws_terminal', async ({ hostId, token }) => {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
socket.disconnect()
return
try {
const { code } = await verifyAuthSync(token, requestIP)
if (code !== 1) {
socket.emit('token_verify_fail')
socket.disconnect()
return
}
targetSSHClient = new SSHClient()
let result = await createTerminal(hostId, socket, targetSSHClient, true)
// 如果创建终端失败,result可能为undefined
if (!result) {
consola.error('创建终端失败,未返回结果')
return
}
let { stream = null, jumpSshClients: jumpSshClientsFromCreate } = result
jumpSshClients = jumpSshClientsFromCreate || []
const listenerInput = (key) => {
if (!targetSSHClient || !targetSSHClient._sock || !targetSSHClient._sock.writable) {
consola.info('终端连接已关闭,禁止输入')
return
}
stream && stream.write(key)
}
const resizeShell = ({ rows, cols }) => {
stream && stream.setWindow(rows, cols)
}
socket.on('input', listenerInput)
socket.on('resize', resizeShell)
} catch (error) {
consola.error('ws_terminal事件处理失败:', error.message)
socket.emit('terminal_connect_fail', `连接失败: ${ error.message }`)
}
targetSSHClient = new SSHClient()
let { stream = null, jumpSshClients: jumpSshClientsFromCreate } = await createTerminal(hostId, socket, targetSSHClient, true)
jumpSshClients = jumpSshClientsFromCreate
function listenerInput(key) {
if (targetSSHClient._sock.writable === false) return consola.info('终端连接已关闭,禁止输入')
stream && stream.write(key)
}
function resizeShell({ rows, cols }) {
stream && stream.setWindow(rows, cols)
}
socket.on('input', listenerInput)
socket.on('resize', resizeShell)
})
socket.on('get_ping', async (ip) => {
@@ -172,5 +218,16 @@ module.exports = (httpServer) => {
})
}
module.exports = (httpServer) => {
const serverIo = new Server(httpServer, {
path: '/terminal',
cors: {
origin: '*'
}
})
createServerIo(serverIo)
}
module.exports.getConnectionOptions = getConnectionOptions
module.exports.createTerminal = createTerminal
module.exports.createServerIo = createServerIo
+7 -30
View File
@@ -1,15 +1,11 @@
const { getLocalNetIP } = require('./tools')
const { getLocalNetIP, requestWithFailover } = require('./tools')
const { AESEncryptAsync } = require('./encrypt')
const version = require('../../package.json').version
const { plusServer1, plusServer2 } = require('./plus-server')
const { PlusDB } = require('./db-class')
const plusDB = new PlusDB().getInstance()
async function getLicenseInfo(key = '') {
const { key: plusKey } = await plusDB.findOneAsync({}) || {}
// console.log('plusKey: ', plusKey)
// console.log('key: ', key)
// console.log('process.env.PLUS_KEY: ', process.env.PLUS_KEY)
key = key || plusKey || process.env.PLUS_KEY
if (!key || key.length < 16) return { success: false, msg: 'Invalid Plus Key' }
let ip = ''
@@ -28,33 +24,14 @@ async function getLicenseInfo(key = '') {
return { success: false, msg: 'get public ip failed' }
}
try {
let response
let method = 'POST'
let body = JSON.stringify({ ip, key, version })
let headers = { 'Content-Type': 'application/json' }
let timeout = 10000
try {
response = await fetch(plusServer1 + '/api/licenses/activate', {
method,
headers,
body,
timeout
})
if (!response.ok && (response.status !== 403)) {
throw new Error('port1 error')
}
} catch (error) {
consola.log('retry to activate plus by backup server')
response = await fetch(plusServer2 + '/api/licenses/activate', {
method,
headers,
body,
timeout
})
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip, key, version })
}
const response = await requestWithFailover('/api/licenses/activate', requestOptions)
if (!response.ok) {
consola.log('activate plus failed: ', response.status)
if (response.status === 403) {
+6 -2
View File
@@ -1,4 +1,8 @@
module.exports = {
plusServer1: 'https://en1.221022.xyz',
plusServer2: 'https://en2.221022.xyz'
plusServers: [
'https://en1-plus-active-tencent-eo.chaoszhu.com',
'https://en2-plus-active-tencent-eo.chaoszhu.com',
'https://en1.221022.xyz',
'https://en2.221022.xyz'
]
}
+1 -1
View File
@@ -1 +1 @@
U2FsdGVkX1/0WBuJm6/aJntcPq4eyOfQj6zUiDepQcZ+XEkvnCDV/v/x4NBb2uolBryIiLf/dOVkGwMtm0FrtWwvbplESnlHkeAXAPJFQFZSFJfhAAlLobIIHhkCEVEHnA7oBE9GkobuM5yXFNQE0ciYNHkwRkAxk+CdXsdlaTKdnFyInWQ92ljQcZVzr7sW8wnNNkKk9WhmdvsvFtljfkA3YY7AMtEiZLAOdqCpaC5WIzP7sos8C/cmdeY9jmkmtZr79NnCWsyV5MWQB9jvKk7pk0o8wmnlGhsmACyJzzRdzFXFOTouh+pXloohb/uVHx/v2yrrWHjpwwPIbM1d462/iEWv5t4cOHlGyhze0Po26y7QCwId3M2m/nCB726wlKQl6KMjhoavVOGKrkRp4tfLtKb/wgauyQAUSDgVmCAesCVkxS7q+z5u+GJLoAgQDJbnHG0AQxF1lQK3+XrcrO0qr98omnHUOkuBVdPvfZN87HlW20khMEzYW95z9gS2BmlJ+1hyEcOhgIPpG03GAuE6A7NHTpAmWjH9pQeQ8ZhyjywpEEHGRUUgZWuN9s1Mh60sw+JzjI0gXMuX0FfrG3W2zmQa6XB2RjULrYVMNRUDrpOrP2/s8uz9qL/S0dzNsUDsEWgwUgYhW075z932xLhQIJ087WeqHXO0QZ35OSib3I1EaSKIADWgvs1dz5xuLjXBWKaNewFS6w0+CCm6Hg4QUvZeX+9gyNygynEZX1Oe2RJX0hnfYoa9ZbnbB3vGd/Ka59Wi58YOKyIRGV5YX9lRPqdN0OyLxzFhAXqR8DXabvJgaidsJEvyxpP7odpBgMuPb4KO5axeuvlFLp1GrxbRpv6quEhjFCSLQyQMJ8o=
U2FsdGVkX188iPUyO1f2xahkUzOdBH0ifn8ZV/KNKGkTSDSDEhT8qEioUui8+nM/p4NQJ2LAbVqK4fmyuG1rUy8VHbFBJGxEWCa5cgFemgxRWpZE2K9lWr1sopjfzsyN1hGfdi2oWZE8/x1MMw6Rc1K6guFYBuj4LGuGS/yp8wUpGe4Ifb5d/W81Y/YHqoH/WRUJc8V3EbnlHgweY7Yx6NcuRMHaqMVdL7P/1do4Xig4N5zqHdPmZE6gp2tAKh8WwpzpLgTqlXEEUFQk0KL07lYf7PNVKudsD2G5s1VYrhVhvR+1jeSh7AwLa4pABXg8bUo53SC4CCgSC57GLqldmnqJP8Kqr8ULSEdFVL0uLS4ogTALgXrrTvzr3hXAccIQSRLihD2BDK0MuDNcLpOXbYI6KQ6L4Z1/cT8VOteV7OMtJMC3EZDUroOwYS1oeToIQoZFHYKIyqlyThLEkb9zZBRDBvLisirGGvTh52bzw1BKE5IZznAe1A5N0n9U2eawqIUDJMlIRE4LEPuSzkNPDV6v3IiNsb3c7JO7t06WQMIs4Nene2v0SDwYbbZgBhfgEu+GkKwXh16w1txX/juJ09gfTj6vZiJNRk4bOGZJsPWujQhOjncjbYRq7dhZA1/0gjOQppX312g/yIznIYFKkl5LF868hptbFu6WgTbclqwLiUKX0D+Oe25mcMIDXdhjWeZZoEIXg5FFv1PaE5Qrj35Uowo4MzP6aVYw2zn9WlxGzbHPtAFCTKChcOuSV7RYuRETR8OIHyNcOlNBSbJ5KlALb2XS5XEtluhEF/G2iljXSvlACNHLz9EZ3E4Q9XubvMW5YBecdcW3XHipbusKZCA542BKxadQYQGGWryZ/wQ=
+46 -1
View File
@@ -4,6 +4,7 @@ const net = require('net')
const iconv = require('iconv-lite')
const axios = require('axios')
const request = axios.create({ timeout: 3000 })
const { plusServers } = require('./plus-server')
// 为空时请求本地IP
const getNetIPInfo = async (searchIp = '') => {
@@ -310,6 +311,49 @@ const ping = (ip, timeout = 5000) => {
})
}
async function requestWithFailover(path, options = {}, timeout = 5000) {
const { method = 'GET', headers = {}, body = null } = options
for (let i = 0; i < plusServers.length; i++) {
const server = plusServers[i]
try {
consola.info(`Plus服务: ${ i + 1 }/${ plusServers.length }: ${ server }${ path }`)
const response = await fetch(server + path, {
method,
headers,
body,
timeout
})
// 如果状态码是200或403,不需要尝试下一个服务器
if (response.ok || response.status === 403) {
consola.info(`Plus服务: ${ i + 1 }/${ plusServers.length }: ${ server }${ path } 响应成功,状态码: ${ response.status }`)
return response
}
// 尝试下一个服务器
if (i < plusServers.length - 1) {
consola.info(`Plus服务: ${ i + 1 }/${ plusServers.length }: ${ server }${ path } 响应状态码 ${ response.status },尝试下一个服务器`)
continue
}
// 最后一个服务失败了
throw new Error(`Plus服务: 所有服务器请求失败: ${ response.status }`)
} catch (error) {
// 如果不是最后一个服务,尝试下一个服务器[防止异常报错]
if (i < plusServers.length - 1) {
consola.info(`Plus服务: ${ i + 1 }/${ plusServers.length }: ${ server }${ path } 请求失败: ${ error.message },尝试下一个服务器`)
continue
}
// 如果是最后一个服务器,抛出错误
consola.error(`Plus服务: 所有服务器请求失败: ${ error.message }`)
throw error
}
}
}
module.exports = {
getNetIPInfo,
getLocalNetIP,
@@ -322,5 +366,6 @@ module.exports = {
shellThrottle,
isProd,
isAllowedIp,
ping
ping,
requestWithFailover
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "3.1.0",
"version": "3.1.1",
"description": "easynode-server",
"bin": "./bin/www",
"scripts": {
+11
View File
@@ -1,4 +1,15 @@
[
{
"version": "v3.1.1",
"date": "2025-07-19",
"features": [
"🎉终端新增单窗口模式",
"SFTP新增cd目录快捷菜单",
"微调终端布局&优化文案&底部栏调整为纯脚本库,支持拖动设定高度",
"状态、SFTP等状态本地记忆",
"修复脚本库与窗口输入同步问题"
]
},
{
"version": "v3.1.0",
"date": "2025-07-13",
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0">
<title>EasyNode</title>
<script src="//at.alicdn.com/t/c/font_3309550_ldx6nvwonvh.js"></script>
<script src="//at.alicdn.com/t/c/font_3309550_zznptezhsva.js"></script>
</head>
<body>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.1.0",
"version": "3.1.1",
"description": "easynode-web",
"private": true,
"scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+12 -11
View File
@@ -2,17 +2,7 @@
<div class="server_group_container">
<div class="server_group_header">
<el-button type="primary" class="add_host_btn" @click="hostFormVisible = true">添加实例</el-button>
<el-dropdown trigger="click">
<el-button type="primary" class="group_action_btn">
导入导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="importVisible = true">导入实例</el-dropdown-item>
<el-dropdown-item @click="handleBatchExport">导出实例</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click">
<el-button type="primary" class="group_action_btn">
批量操作<el-icon class="el-icon--right"><arrow-down /></el-icon>
@@ -26,6 +16,17 @@
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click">
<el-button type="primary" class="group_action_btn">
导入导出<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="importVisible = true">导入实例</el-dropdown-item>
<el-dropdown-item @click="handleBatchExport">导出实例</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="primary" @click="groupDialogVisible = true">分组管理</el-button>
</div>
<div class="server_group_collapse">
@@ -1,26 +1,24 @@
<template>
<div class="footer_bar_container">
<div class="footer_bar_content">
<el-tabs v-model="activeTab" type="border-card" class="footer_bar_tabs">
<el-tab-pane label="脚本库" name="script">
<ScriptInput :host-id="hostId" @exec-command="execCommand" />
</el-tab-pane>
<el-tab-pane label="容器管理" name="docker">
<Docker v-if="show" :host-id="hostId" />
</el-tab-pane>
<!-- <el-tab-pane label="进程管理" name="process">
</el-tab-pane> -->
</el-tabs>
<div class="script_input_container">
<!-- 拖拽调整条 -->
<div
class="resize_handle"
@mousedown="startResize"
>
<div class="resize_handle_line" />
</div>
<ScriptInput :host-id="hostId" @exec-command="execCommand" />
<!-- <Docker v-if="show" :host-id="hostId" /> -->
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onUnmounted } from 'vue'
import ScriptInput from './script-input.vue'
import Docker from './docker.vue'
// import Docker from './docker.vue'
defineProps({
const props = defineProps({
hostId: {
required: true,
type: String
@@ -28,30 +26,105 @@ defineProps({
show: {
required: true,
type: Boolean
},
height: {
type: Number,
default: 250
}
})
const emit = defineEmits(['resize', 'exec-command',])
const activeTab = ref('script')
const emit = defineEmits(['resize', 'exec-command', 'height-change',])
// 拖拽相关状态
const isResizing = ref(false)
const startY = ref(0)
const startHeight = ref(0)
// 开始拖拽
const startResize = (e) => {
isResizing.value = true
startY.value = e.clientY
startHeight.value = props.height
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
document.body.style.cursor = 'ns-resize'
document.body.style.userSelect = 'none'
e.preventDefault()
}
// 处理拖拽
const handleResize = (e) => {
if (!isResizing.value) return
const deltaY = startY.value - e.clientY // 向上拖拽为正值
const newHeight = Math.max(250, Math.min(380, startHeight.value + deltaY)) // 最小250px,最大380px
emit('height-change', newHeight)
}
// 停止拖拽
const stopResize = () => {
isResizing.value = false
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
const execCommand = (command) => {
emit('exec-command', command)
}
// 清理事件监听
onUnmounted(() => {
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
})
</script>
<style lang="scss" scoped>
.footer_bar_container {
background: #ffffff;
.script_input_container {
position: relative;
border: 1px solid var(--el-border-color);
height: 100%;
.resize_handle {
position: absolute;
top: -3px;
left: 0;
right: 0;
height: 6px;
cursor: ns-resize;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
&:hover .resize_handle_line {
height: 6px;
background-color: var(--el-color-primary);
opacity: 1;
}
.resize_handle_line {
width: 40px;
height: 2px;
background-color: var(--el-border-color-darker);
border-radius: 1px;
opacity: 0.6;
transition: all 0.2s;
}
}
.footer_bar_content {
height: 100%;
background-color: var(--el-fill-color-light);
padding-top: 3px; // 为拖拽条留出空间
}
.footer_bar_tabs {
height: 100%;
height: calc(100% - 3px); // 减去padding-top的高度
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="script_input_container">
<div class="script_input_container" @click.stop>
<PlusLimitTip />
<div class="left_box">
<div class="group_list">
@@ -103,8 +103,8 @@
>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>发送至当前窗口终端</el-dropdown-item>
<el-dropdown-item>发送至所有窗口终端</el-dropdown-item>
<!-- <el-dropdown-item>发送至当前窗口终端</el-dropdown-item> -->
<!-- <el-dropdown-item>发送至所有窗口终端</el-dropdown-item> -->
<el-dropdown-item @click="handleSaveAsScript">保存至脚本库</el-dropdown-item>
<el-dropdown-item @click="handleSendClearContent">发送后清空内容: {{ isClearContent ? '是' : '否' }}</el-dropdown-item>
</el-dropdown-menu>
@@ -243,6 +243,8 @@ if (scriptGroupList.value.length) {
<style lang="scss" scoped>
.script_input_container {
height: 100%;
max-height: 50vh;
// overflow: auto;
display: flex;
gap: 10px;
padding: 10px;
File diff suppressed because it is too large Load Diff
@@ -1,748 +0,0 @@
<template>
<div class="terminal_tab_container">
<div
ref="terminalRef"
class="terminal_container"
@contextmenu="handleRightClick"
@mouseup="handleMouseUp"
@mousedown="emit('tab-focus', uid)"
/>
<!-- <div class="terminal_command_history">
<CommandHistory :list="commandHistoryList" />
</div> -->
</div>
</template>
<script setup>
import { ref, onMounted, computed, onBeforeUnmount, getCurrentInstance, watch, nextTick } from 'vue'
// import CommandHistory from './command_history.vue'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import { CanvasAddon } from '@xterm/addon-canvas'
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
// import { SearchBarAddon } from 'xterm-addon-search-bar'
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, isDockerId, isDockerComposeYml } from '@/utils'
import useMobileWidth from '@/composables/useMobileWidth'
const { CONNECTING, CONNECT_SUCCESS, CONNECT_FAIL } = terminalStatus
const instance = getCurrentInstance()
const { uid } = instance
const { proxy: { $api, $store, $serviceURI, $notification, $router, $message, $messageBox } } = instance
const { io } = socketIo
const { isMobileScreen } = useMobileWidth()
const props = defineProps({
hostObj: {
required: true,
type: Object
},
longPressCtrl: {
type: Boolean,
default: false
},
longPressAlt: {
type: Boolean,
default: false
},
autoFocus: { type: Boolean, default: true }
})
const emit = defineEmits(['inputCommand', 'cdCommand', 'ping-data', 'reset-long-press', 'tab-focus',])
const socket = ref(null)
// const commandHistoryList = ref([])
const term = ref(null)
const initCommand = ref('')
const timer = ref(null)
const pingTimer = ref(null)
const fitAddon = ref(null)
// const searchBar = ref(null)
const socketConnected = ref(false)
const curStatus = ref(CONNECTING)
const terminalRef = ref(null)
const { showMenu, closeMenu, isVisible } = useContextMenu()
const cursorTheme = {
cursor: '#00ff41',
cursorAccent: '#000000'
}
const token = computed(() => $store.token)
const theme = computed(() => themeList[$store.terminalConfig.themeName])
const fontSize = computed(() => $store.terminalConfig.fontSize)
const background = computed(() => $store.terminalConfig.background)
const hostObj = computed(() => props.hostObj)
const hostId = computed(() => hostObj.value.id)
const host = computed(() => hostObj.value.host)
const menuCollapse = computed(() => $store.menuCollapse)
const autoExecuteScript = computed(() => $store.terminalConfig.autoExecuteScript)
const autoReconnect = computed(() => $store.terminalConfig.autoReconnect)
const isPlusActive = computed(() => $store.isPlusActive)
const isLongPressCtrl = computed(() => props.longPressCtrl)
const isLongPressAlt = computed(() => props.longPressAlt)
watch(menuCollapse, () => {
nextTick(() => {
handleResize()
})
})
watch(theme, () => {
nextTick(() => {
// 自定义光标颜色
const customTheme = {
...theme.value,
...cursorTheme
}
if (!background.value) {
term.value.options.theme = customTheme
} else {
term.value.options.theme = { ...customTheme, background: '#00000080' }
}
})
})
watch(fontSize, () => {
nextTick(() => {
term.value.options.fontSize = fontSize.value
handleResize()
})
})
watch(background, (newVal) => {
nextTick(() => {
const customTheme = {
...theme.value,
...cursorTheme
}
if (newVal) {
term.value.options.theme = { ...customTheme, background: '#00000080' }
terminalRef.value.style.backgroundImage = background.value?.startsWith('http') ? `url(${ background.value })` : `${ background.value }`
// terminalRef.value.style.backgroundImage = `linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.15)), url(${ background.value })`
} else {
term.value.options.theme = customTheme
terminalRef.value.style.backgroundImage = null
}
})
}, { immediate: true })
watch(curStatus, () => {
console.warn(`status: ${ curStatus.value }`)
hostObj.value.status = curStatus.value
})
const getCommand = async () => {
let { data } = await $api.getCommand(hostId.value)
if (data) initCommand.value = data
}
const connectIO = () => {
curStatus.value = CONNECTING
socket.value = io($serviceURI, {
path: '/terminal',
forceNew: false,
reconnection: false,
reconnectionAttempts: 0
})
socket.value.on('connect', () => {
console.log('/terminal socket已连接:', hostId.value)
socketConnected.value = true
socket.value.emit('ws_terminal', { hostId: hostId.value, token: token.value })
socket.value.on('terminal_connect_success', () => {
socket.value.on('output', (str) => {
term.value.write(str)
terminalText.value += str
})
socket.value.on('terminal_connect_shell_success', () => {
curStatus.value = CONNECT_SUCCESS
shellResize()
if (initCommand.value) socket.value.emit('input', initCommand.value + '\n')
})
// socket.value.on('terminal_command_history', (data) => {
// console.log(data)
// commandHistoryList.value = data
// })
})
if (pingTimer.value) clearInterval(pingTimer.value)
pingTimer.value = setInterval(() => {
socket.value?.emit('get_ping', host.value)
}, 3000)
socket.value.emit('get_ping', host.value) // 获取服务端到客户端的ping值
socket.value.on('ping_data', (pingMs) => {
emit('ping-data', Object.assign({ ip: host.value }, pingMs))
})
socket.value.on('token_verify_fail', () => {
$notification({ title: 'Error', message: 'token校验失败,请重新登录', type: 'error' })
$router.push('/login')
})
socket.value.on('terminal_print_info', (msg) => {
term.value.write(`${ msg }\r\n`)
})
socket.value.on('terminal_connect_close', () => {
curStatus.value = CONNECT_FAIL
term.value.write('\r\n\x1b[91m终端主动断开连接, 回车重新发起连接\x1b[0m')
})
socket.value.on('terminal_connect_fail', (message) => {
curStatus.value = CONNECT_FAIL
term.value.write(`\r\n\x1b[91m连接终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
})
socket.value.on('terminal_create_fail', (message) => {
curStatus.value = CONNECT_FAIL
term.value.write(`\r\n\x1b[91m创建终端失败: ${ message }, 回车重新发起连接\x1b[0m`)
})
})
socket.value.on('disconnect', (reason) => {
console.warn('terminal websocket 连接断开:', reason)
switch (reason) {
case 'io server disconnect':
reconnectTerminal(true, '服务端主动断开连接')
break
case 'io client disconnect': // 客户端主动断开连接
break
case 'transport close':
reconnectTerminal(true, '本地网络连接异常')
break
case 'transport error':
reconnectTerminal(true, '建立连接错误')
break
case 'parse error':
reconnectTerminal(true, '数据解析错误')
break
default:
reconnectTerminal(true, '连接意外断开')
}
})
socket.value.on('connect_error', (err) => {
console.error('EasyNode服务端连接错误:', err)
curStatus.value = CONNECT_FAIL
term.value.write('\r\n\x1b[91mError: 连接失败,请检查EasyNode服务端是否正常, 回车重新发起连接\x1b[0m \r\n')
$notification({
title: '服务端连接失败',
message: '请检查EasyNode服务端是否正常',
type: 'error'
})
})
}
const reconnectTerminal = (isCommonTips = false, tips) => {
socket.value.removeAllListeners()
socket.value.close()
socket.value = null
socketConnected.value = false
if (isCommonTips) {
if (isPlusActive.value && autoReconnect.value) {
term.value.write(`\r\n\x1b[91m${ tips },自动重连中...\x1b[0m \r\n`)
connectIO()
} else {
term.value.write(`\r\n\x1b[91mError: ${ tips },请重新连接。([功能项->本地设置->快捷操作]中开启自动重连)\x1b[0m \r\n`)
}
} else {
term.value.write(`\n${ tips } \n`)
connectIO()
}
}
const createLocalTerminal = () => {
let terminalInstance = new Terminal({
bellStyle: 'sound',
convertEol: true,
cursorBlink: true,
disableStdin: false,
minimumContrastRatio: 7,
allowTransparency: true,
fontFamily: 'Cascadia Code, Menlo, monospace',
fontSize: fontSize.value,
theme: theme.value,
scrollback: 5000, // 滚动缓冲区大小
tabStopWidth: 4,
windowsMode: false, // 禁用Windows模式提升性能
macOptionIsMeta: true, // macOS优化
smoothScrollDuration: 0 // 平滑滚动时间
})
const canvasAddon = new CanvasAddon()
// const webglAddon = new WebglAddon()
// Canvas渲染器错误处理
// if (canvasAddon.onContextLoss) {
// canvasAddon.onContextLoss(() => {
// console.warn('Canvas context lost, attempting to recover...')
// })
// }
// terminalInstance.loadAddon(canvasAddon)
term.value = terminalInstance
terminalInstance.open(terminalRef.value)
!isMobileScreen.value && terminalInstance.loadAddon(canvasAddon)
terminalInstance.writeln('\x1b[1;32mWelcome to EasyNode terminal\x1b[0m.')
terminalInstance.writeln('\x1b[1;32mAn experimental Web-SSH Terminal\x1b[0m.')
if (props.autoFocus) terminalInstance.focus()
onFindText()
onWebLinks()
onResize()
}
const shellResize = () => {
fitAddon.value.fit()
let { rows, cols } = term.value
socket.value?.emit('resize', { rows, cols })
}
const onResize = () => {
fitAddon.value = new FitAddon()
term.value.loadAddon(fitAddon.value)
window.addEventListener('resize', handleResize)
}
const handleResize = () => {
if (timer.value) clearTimeout(timer.value)
timer.value = setTimeout(async () => {
// 由于非当前的el-tab-pane的display属性为none, 调用fitAddon.value?.fit()时无法获取宽高,因此先展示,再fit,最后再隐藏
let temp = []
let panes = Array.from(document.getElementsByClassName('el-tab-pane'))
panes.forEach((item, index) => {
temp[index] = item.style.display
item.style.display = 'block'
})
shellResize()
panes.forEach((item, index) => {
item.style.display = temp[index]
})
}, 200)
}
const onWebLinks = () => {
term.value.loadAddon(new WebLinksAddon((event, uri) => {
if (event.ctrlKey || event.altKey) window.open(uri, '_blank')
}))
}
// :TODO: 重写终端搜索功能
const onFindText = () => {
const searchAddon = new SearchAddon()
// searchBar.value = new SearchBarAddon({ searchAddon })
term.value.loadAddon(searchAddon)
// term.value.loadAddon(searchBar.value)
}
const terminalText = ref(null)
const enterTimer = ref(null)
function filterAnsiSequences(str) {
// 使用正则表达式移除ANSI转义序列
// return str.replace(/\x1b\[[0-9;]*m|\x1b\[?[\d;]*[A-HJKSTfmin]/g, '')
// eslint-disable-next-line
return str.replace(/\x1b\[[0-9;]*[mGK]|(\x1b\][0-?]*[0-7;]*\x07)|(\x1b[\[\]()#%;][0-9;?]*[0-9A-PRZcf-ntqry=><])/g, '')
}
// 处理 Backspace,删除前一个字符
function applyBackspace(text) {
let result = []
for (let i = 0; i < text.length; i++) {
if (text[i] === '\b') {
if (result.length > 0) {
result.pop()
}
} else {
result.push(text[i])
}
}
return result.join('')
}
function extractLastCdPath(text) {
const regex = /cd\s+([^\s]+)(?=\s|$)/g
let lastMatch
let match
regex.lastIndex = 0
while ((match = regex.exec(text)) !== null) {
lastMatch = match
}
return lastMatch ? lastMatch[1] : null
}
const onData = () => {
term.value.onData((key) => {
if ('\r' === key && curStatus.value === CONNECT_FAIL) {
reconnectTerminal(false, '重新连接中...')
return
}
if (!socket.value || !socketConnected.value) return
// 检查是否按下ESC键且右键菜单正在显示
if (key === '\x1b') { // ESC键的ASCII码是27,对应字符是'\x1b'
if (isVisible.value) return closeMenu()
}
if (isLongPressCtrl.value || isLongPressAlt.value) {
const keyCode = key.toUpperCase().charCodeAt(0)
console.log('keyCode: ', keyCode)
const ansiCode = keyCode - 64
console.log('ansiCode:', ansiCode)
if (ansiCode >= 1 && ansiCode <= 26) {
const controlChar = String.fromCharCode(ansiCode)
socket.value.emit('input', isLongPressCtrl.value ? controlChar : `\x1b${ key }`)
}
emit('reset-long-press')
return
}
let acsiiCode = key.codePointAt()
// console.log(acsiiCode)
if (acsiiCode === 22) return handlePaste() // Ctrl + V
// if (acsiiCode === 6) return searchBar.value.show() // Ctrl + F
enterTimer.value = setTimeout(() => {
if (enterTimer.value) clearTimeout(enterTimer.value)
if (key === '\r') { // Enter
if (curStatus.value === CONNECT_SUCCESS) {
let cleanText = applyBackspace(filterAnsiSequences(terminalText.value))
const lines = cleanText.split('\n')
// console.log('lines: ', lines)
const lastLine = lines[lines.length - 1].trim()
// console.log('lastLine: ', lastLine)
// 截取最后一个提示符后的内容('$'或'#'后的内容)
const commandStartIndex = lastLine.lastIndexOf('#') + 1
const commandText = lastLine.substring(commandStartIndex).trim()
// eslint-disable-next-line
const cdPath = extractLastCdPath(commandText)
if (cdPath) {
console.log('cd command path:', cdPath)
let firstChar = cdPath.charAt(0)
if (!['/',].includes(firstChar)) return console.log('err fullpath:', cdPath) // 后端依赖不支持 '~'
emit('cdCommand', cdPath)
}
terminalText.value = ''
}
}
})
if (curStatus.value !== CONNECT_SUCCESS) return
emit('inputCommand', key, uid)
socket.value.emit('input', key)
})
}
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 plusTips = () => {
if (!isPlusActive.value) {
// $message.warning('Plus功能未激活')
$messageBox.confirm('Plus功能未激活', 'Warning', {
confirmButtonText: '前往设置',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
$router.push('/setting?tabKey=plus')
})
return false
}
return true
}
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()
focusTab()
}
} : null
const pasteSelection = str ? {
label: '粘贴选中内容',
onClick: async () => {
await handleCopySelection()
handlePaste()
}
} : null
const clear = {
label: '清屏',
onClick: () => {
handleClear()
}
}
const dockerId = isDockerId(str) ? {
label: 'docker容器ID',
children: [
{
label: '[plus]检测选中内容可能为docker容器ID',
disabled: true
},
{
label: `登录: docker exec -it ${ str } bash`,
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand(`docker exec -it ${ str } bash`)
}
},
{
label: `停止: docker stop ${ str }`,
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand(`docker stop ${ str }`)
}
},
{
label: `重启: docker restart ${ str }`,
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand(`docker restart ${ str }`)
}
},
{
label: `删除: docker rm -f ${ str }`,
onClick: () => {
if (!plusTips()) return
$messageBox.confirm(`确认删除该容器:${ str }`, 'Warning', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
focusTab()
inputCommand(`docker rm -f ${ str }`)
})
}
},
{
label: `日志: docker logs -f ${ str }`,
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand(`docker logs -f ${ str }`)
}
},
]
} : null
const dockerComposeYml = isDockerComposeYml(str) ? {
label: 'docker-compose文件',
children: [
{
label: '[plus]检测选中内容可能为docker-compose文件',
disabled: true
},
{
label: '启动: docker-compose up -d',
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand('docker-compose up -d')
}
},
{
label: '停止并删除: docker-compose down',
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand('docker-compose down')
}
},
{
label: '重启: docker-compose restart',
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand('docker-compose restart')
}
},
{
label: '查看日志: docker-compose logs -f',
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand('docker-compose logs -f')
}
},
{
label: '拉取新镜像: docker-compose pull',
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand('docker-compose pull')
}
},
{
label: '重建: docker-compose up -d --force-recreate',
onClick: () => {
if (!plusTips()) return
focusTab()
inputCommand('docker-compose up -d --force-recreate')
}
},
]
} : null
const menu = [
sendToAI,
copySelection,
paste,
pasteSelection,
clear,
dockerId,
dockerComposeYml,
].filter(Boolean)
showMenu(e, menu)
}
const handleClear = () => {
term.value.clearSelection()
term.value.clear()
term.value.focus()
}
const handlePaste = async () => {
let key = await navigator.clipboard.readText()
// 如果粘贴的内容以换行符结尾则去掉换行符(防止自动执行)
while (key.endsWith('\n')) {
key = key.slice(0, -1)
}
emit('inputCommand', key, uid)
socket.value.emit('input', key)
term.value.focus()
term.value.clearSelection()
}
const focusTab = () => {
term.value.blur()
setTimeout(() => {
term.value.focus()
}, 200)
}
const inputCommand = (command, isSyncAllSession = false) => {
command = command + (isSyncAllSession ? '' : (autoExecuteScript.value ? '\n' : ''))
socket.value.emit('input', command)
}
const execExternalCommand = (command) => {
if (!socket.value || !socket.value.connected || curStatus.value !== CONNECT_SUCCESS) {
$message.error('终端连接已断开,无法执行指令')
return
}
socket.value.emit('input', command + '\n')
term.value?.focus()
}
onMounted(async () => {
createLocalTerminal()
await getCommand()
connectIO()
await nextTick()
onData()
EventBus.$on('exec_external_command', execExternalCommand)
})
onBeforeUnmount(() => {
EventBus.$off('exec_external_command', execExternalCommand)
socket.value?.close()
window.removeEventListener('resize', handleResize)
clearInterval(pingTimer.value)
})
defineExpose({
focusTab,
handleResize,
inputCommand,
handleClear
})
</script>
<style lang="scss" scoped>
.terminal_tab_container {
height: 100%;
min-height: 200px;
position: relative;
.terminal_container {
background-size: 100% 100%;
background-repeat: no-repeat;
:deep(.xterm) {
height: 100%;
}
:deep(.xterm-viewport) {
overflow-y: auto;
}
:deep(.xterm-screen) {
padding: 0;
}
}
.terminal_command_history {
width: 200px;
height: 100%;
overflow: auto;
position: absolute;
top: 0;
right: 0;
z-index: 1;
background-color: #fff;
border-radius: 6px
}
}
</style>
<style lang="scss">
.terminals {
.el-tabs__header {
padding-left: 55px;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -38,8 +38,7 @@
</el-table>
</div>
<div v-else>
<Terminal
ref="terminalRef"
<TerminalWrapper
:terminal-tabs="terminalTabs"
@remove-tab="handleRemoveTab"
@add-host="linkTerminal"
@@ -58,7 +57,7 @@
<script setup>
import { ref, computed, onActivated, getCurrentInstance, reactive, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import Terminal from './components/terminal.vue'
import TerminalWrapper from './components/terminal-wrapper.vue'
import HostForm from '../server/components/host-form.vue'
import { randomStr } from '@utils/index.js'
import { terminalStatus } from '@/utils/enum'
@@ -69,7 +68,6 @@ const { proxy: { $store, $message } } = getCurrentInstance()
let terminalTabs = reactive([])
let hostFormVisible = ref(false)
let updateHostData = ref(null)
const terminalRef = ref(null)
const route = useRoute()
let showLinkTips = computed(() => !Boolean(terminalTabs.length))