mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 17:27:53 +08:00
feat: 支持单窗口模式&修复bug&其他优化项
This commit is contained in:
+20
-2
@@ -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
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 @@
|
||||
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=
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.1",
|
||||
"description": "easynode-server",
|
||||
"bin": "./bin/www",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user