Merge branch 'chaos-zhu:main' into main

This commit is contained in:
ZhangHao
2025-10-20 08:57:01 +08:00
committed by GitHub
33 changed files with 1509 additions and 101 deletions

View File

@@ -1,17 +1,23 @@
## TODO
* 批量下发后台定时任务
* AI对话收藏
* 移动端Ctrl、Tab的按键面板
* 数据导出(2fa强制)
* 进程管理
* 当前会话连接|w
* 日志打印优化
* rdp
* 探针接入
* 通知模块重构支持webhook
* 支持csv模板特定格式导入
## [3.5.0](https://github.com/chaos-zhu/easynode/releases) (2025-10-18)
* 🖥️ 支持 RDP 远程 Windows 桌面连接(支持移动端交互 & 剪贴板互动)
* 🧩 脚本库增强:新增脚本执行模式 — 多行脚本@zhanghao-njmu
* 💻 终端增强:输出高亮自定义、配置持久化管理、样式优化、全屏下 bug 修复@zhanghao-njmu
* 📂 SFTP增强宽度拖拽、个性化显示文件信息列(大小、修改时间、权限、拥有者)@zhanghao-njmu
* ⚙️ 其他优化与bug修复
* ❤️ 特别感谢 @zhanghao-njmu 的功能PR
## [3.4.2](https://github.com/chaos-zhu/easynode/releases) (2025-08-24)
* 🔒鉴权增强
* SFTP连接优化、支持搜索文件(夹)、新建文件(夹)功能名称缓存建议

View File

@@ -77,11 +77,29 @@ docker-compose up -d
### docker镜像
**注意!!!**
**v3.5.0版本新增RDP连接windows服务器功能此功能依赖单独的guacd服务**
- 如果你不知道guacd服务请使用上面的 docker-compose.yml 进行部署
- 如果你不想使用 docker-compose.yml 进行部署,请配置环境变量 `GUACD_HOST``GUACD_PORT`
```shell
docker run -d -p 8082:8082 --restart=always -v /root/easynode/db:/easynode/app/db chaoszhu/easynode
# GUACD_HOST: 自建 guacd 服务 IP【此处127.0.0.1仅为示例,需自建服务】
# GUACD_PORT: 自建 guacd 服务端口
docker run -d \
-p 8082:8082 \
--restart=always \
-v /root/easynode/db:/easynode/app/db \
-e GUACD_HOST=127.0.0.1 \
-e GUACD_PORT=4822 \
chaoszhu/easynode
```
环境变量:
- `GUACD_HOST`: 自建guacd服务IP
- `GUACD_PORT`: 自建guacd服务PORT
- `DEBUG`: 启动debug日志 0关闭 1开启, 默认关闭
注意: **docker默认不启用ipv6请自行配置或者使用支持ipv6的跳板机中转.**
@@ -110,11 +128,7 @@ webssh与监控服务都将以`该服务器作为中转`。中国大陆用户建
CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne: EdgeOne offers a long-term free plan with unlimited traffic and requests, covering Mainland China nodes, with no overage charges. Interested friends can click the link below to claim it. [Best Asian CDN, Edge, and Secure Solutions - Tencent EdgeOne](https://edgeone.ai/zh?from=github)
[![EdgeOne Logo](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)](https://edgeone.ai/?from=github)
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
[![image](https://img.shields.io/badge/NodeSupport-YXVM-red)](https://yxvm.com/)
![Image](https://github.com/user-attachments/assets/a50409e4-9394-4a59-a125-18ffe64c5fb0)
![Image](https://github.com/user-attachments/assets/a50409e4-9394-4a59-a125-18ffe64c5fb0) [![image](https://img.shields.io/badge/NodeSupport-YXVM-red)](https://yxvm.com/)

View File

@@ -2,6 +2,7 @@ version: '3'
services:
easynode:
container_name: easynode
image: chaoszhu/easynode
restart: always
ports:
@@ -11,14 +12,42 @@ services:
environment:
- TZ=Asia/Shanghai
- DEBUG=0
- GUACD_HOST=easynode-guacd
- GUACD_PORT=4822
depends_on:
easynode-guacd:
condition: service_healthy
networks:
- easynode-network
labels:
- "com.centurylinklabs.watchtower.enable=true"
easynode-guacd:
container_name: easynode-guacd
image: docker.1ms.run/guacamole/guacd
restart: always
expose:
- "4822"
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 4822"]
interval: 5s
timeout: 2s
retries: 10
networks:
- easynode-network
labels:
- "com.centurylinklabs.watchtower.enable=false"
watchtower:
container_name: easynode-watchtower
image: containrrr/watchtower
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --schedule "0 8 * * *" --label-enable
restart: always
environment:
- TZ=Asia/Shanghai
networks:
easynode-network:
driver: bridge

View File

@@ -52,7 +52,7 @@ async function updateHost({ res, request }) {
delete updateFiled.privateKey
delete updateFiled.credential
}
console.log('updateFiled: ', updateFiled)
// console.log('updateFiled: ', updateFiled)
await hostListDB.updateAsync({ _id: id }, { $set: { ...updateFiled } })
res.success({ msg: '修改成功' })
}

File diff suppressed because one or more lines are too long

View File

@@ -101,11 +101,21 @@ const decryptPrivateKey = async ({ res, request }) => {
}
}
const getRdpToken = async ({ res, request }) => {
let { genRdpToken } = (await decryptAndExecuteAsync(path.join(__dirname, 'plus.js'))) || {}
if (genRdpToken) {
await genRdpToken({ res, request })
} else {
return res.fail({ data: false, msg: 'Plus专属功能无法获取RDP Token!' })
}
}
module.exports = {
getSSHList,
addSSH,
updateSSH,
removeSSH,
getCommand,
decryptPrivateKey
decryptPrivateKey,
getRdpToken
}

View File

@@ -21,8 +21,7 @@ const useAuth = async ({ request, res }, next) => {
case enumLoginCode.EXPIRES:
return res.fail({ msg: '登录态已过期, 请重新登录', status: 401 })
case enumLoginCode.ERROR_TOKEN:
consola.warn('TOKEN校验错误(可能存在外部攻击): ', path)
return res.fail({ msg: 'TOKEN错误!!!', status: 403 })
return res.fail({ msg: 'TOKEN校验失败, 请重新登录', status: 401 })
case enumLoginCode.ERROR_UID:
consola.warn('用户id校验失败(可能存在外部攻击): ', path)
return res.fail({ msg: 'UID错误!!!', status: 403 })

View File

@@ -1,4 +1,4 @@
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand, decryptPrivateKey } = require('../controller/ssh')
const { getSSHList, addSSH, updateSSH, removeSSH, getCommand, decryptPrivateKey, getRdpToken } = require('../controller/ssh')
const { getHostList, addHost, updateHost, batchUpdateHost, removeHost, importHost } = require('../controller/host')
const { login, getpublicKey, updatePwd, getEasynodeVersion, getMFA2Status, getMFA2Code, enableMFA2, disableMFA2, getPlusInfo, getPlusDiscount, getPlusConf, updatePlusKey } = require('../controller/user')
const { getNotifyConfig, updateNotifyConfig, getNotifyList, updateNotifyList } = require('../controller/notify')
@@ -41,6 +41,11 @@ const ssh = [
method: 'post',
path: '/decrypt-private-key',
controller: decryptPrivateKey
},
{
method: 'get',
path: '/get-rdp-token',
controller: getRdpToken
}
]
const host = [

View File

@@ -3,6 +3,7 @@ const compose = require('koa-compose') // 组合中间件,简化写法
const http = require('http')
const { httpPort } = require('./config')
const middlewares = require('./middlewares')
const wsRdp = require('./socket/rdp')
const wsTerminal = require('./socket/terminal')
const wsSftpV2 = require('./socket/sftp-v2')
const wsDocker = require('./socket/docker')
@@ -24,6 +25,7 @@ const httpServer = () => {
// 服务
function serverHandler(app, server) {
app.proxy = true // 用于nginx反代时获取真实客户端ip
wsRdp(server) // RDP
wsTerminal(server) // 终端
wsSftpV2(server) // sftp-v2
wsDocker(server) // docker

File diff suppressed because one or more lines are too long

40
server/app/socket/rdp.js Normal file
View File

@@ -0,0 +1,40 @@
const GuacamoleLite = require('guacamole-lite')
const GUACD_HOST = process.env.GUACD_HOST || 'guacd'
const GUACD_PORT = Number(process.env.GUACD_PORT) || 4822
const getOptions = (server) => {
const websocketOptions = {
server,
path: '/guac'
}
const guacdOptions = {
host: GUACD_HOST,
port: GUACD_PORT
}
const clientOptions = {
crypt: {
cypher: 'AES-256-CBC',
key: global.rpdToken
}
}
return { websocketOptions, guacdOptions, clientOptions }
}
module.exports = (httpServer) => {
const { websocketOptions, guacdOptions, clientOptions } = getOptions(httpServer)
let guacamole = new GuacamoleLite(websocketOptions, guacdOptions, clientOptions)
try {
guacamole.on('connection', () => {
consola.success('✔ RDP guacamole连接成功')
})
guacamole.on('error', (err) => {
consola.error('❌ RDP guacamole连接错误', err)
})
} catch (error) {
guacamole.close()
guacamole = null
consola.error('❌ RDP 初始化失败:', error.message)
}
}

View File

@@ -1 +1 @@
U2FsdGVkX1/ZvAPYoB/CtzeytYO1I2b3w9dMsJCC7Ij/bQSMkkrORQSfc2kzqp6ZOuRvrYaEj8/SoMyukViiB14z5DVhX+Pjfm8VjQ3sNtPfN/Kc8nxJkipB8bjWVuiK9VuTxxBVatGqQ+ESDF8Vzq1ygL3NTGz0QqnfMiY3NXecc/PGi/dP99feMPpL/5XmbqMhDz8FG/MlGhuXIjd+Zl2qkxhGgI2bgOwcyJBLCOuugaAVNUUSZIq4rrsJPgnj/kFf6rh1qgXkTMa3dE7XACoNyjnYPUCX0brIenKUqd7aehFKiRczudmzSjrPJXxb3txUbzjWJzhjW0FA0odNQCWog0Qvh+WXDc76QpMOD/Bq/yxmQvKwx8AniTTn8zhYUnwSWO+b4+H6v/kGcY9LSocQhbK7Ef5cfn7SRnTE5fKcjTrLnXQ2sc3j77VQFcJZ9lCQk0WgfZhtR37ZjzDeMkV/og9Kw4hJpluAFti+4cD2vpqF8c8rs45CtzqpIfc1evQPbxKH4kKtM0gWXrMuyQN3bIuLydsSFX95GZahg8PGdJkRngcHOKjjR/VMsAibdaGHuuQ3UlAqehWGJodSO81+ULVz5qt9VeSB1qZkdopy6GAuZsRbXibTbCv1E9CduFCiZQ8+eWpQexf83jieYevTEpQoS4L5DTxrYK7zavS+3qlECcpDbPiQ8Ji6AkmUokbe5Mnm9WSR+WaIY3gp1T0Of5f6GDvxce1HrM2ysWt5LjLd4sOACL8mVzbqXoMKaU5HC69CtaXsOee68GSr0P2V0glojMS2awRImA3BMUCGABn8SfVn45w1vf2jcwnITD4QrKTXeARuvs1vltvRXeoKyXa+HesQkgBidgIyIBk=
U2FsdGVkX1+ILBpE7oSk+UTXJz7RbpPv3UNrL+5f1NfCyRjBvdtSnyeR3UAwyY3JwjE1o/AFHGcCA3Cb259Hkrxkz3HjGv9tVIdwE5mt8AoV7na7jKIPXRIcbhsgAeiPa6TO+WKr8Y0Mcnt2AG5UaX0579UnGBNx312wRanSiZsOX5sNUafxEMikp2/RxM7Mmbh9M0Jf75EfXCWIwruYJiZ4/7i5cDlVxv8MlhgUOc61M8OZKqoO5IHlBGAY/sZmznaLiaZHkwBlFmCwIoJfJ4K/iVHTsacIev/W+raeK5iieIwyuO/xCNUGMc0cGii30T4JvGTxBDHTfHjUWFX83nv4aAoEl//i0OgQ7pP8rUl7YhTPgMVpko2XbtFHi6MsrQ+qGsNntHngVYIO4zb4USp4Ulm+WRr9ZGco3+ij8qgee/+789WtidpLojfXAPSfmi1aEBWIVb9jiXOOr4+8kZwHafZjnrvRG9xfKE79KGLqZrAEbi4ycmG57hwsCm4UrbkTtE1w1KQDJRwrDnghOGDEdH21oadR+trHwSh983NVL7jxAf9rrbgs6YupyfccKbS7/srffI7kS0abFXbspcDr4YrB9MwCaLIXW6xHOdHtSSeajPJaRLbrHiPhONbBQSrQ2v1ZI5kOYlVGjtNVCP91DvQ0kIaH8q6jdEhL2fH1AU4/l7ao25/Zu0bgehTMlaXTy/QH0uFvCIS13+aMXgczTnSM891N28g2QgDgp4Krut7MZAT6c9NgI+hTI9j0IpAant+CbsWO3LDsnCLXpbbZfyL5HkluY6ahyYLI7TRAwTZGU581OgjHMjx4S2Dc6DBvCL2qZjLmysRo8UspZp4SUJqEW7LI9ye+1UMtHKE=

View File

@@ -1,4 +1,5 @@
const consola = require('consola')
global.consola = consola
global.rpdToken = Array.from({ length:32 },()=>Math.random().toString(36)[2]).join('')
require('dotenv').config()
require('./app/main.js')

View File

@@ -1,6 +1,6 @@
{
"name": "server",
"version": "3.4.2",
"version": "3.5.0",
"description": "easynode-server",
"bin": "./bin/www",
"scripts": {
@@ -21,13 +21,14 @@
"dependencies": {
"@koa/cors": "^5.0.0",
"@seald-io/nedb": "^4.0.4",
"axios": "^1.8.3",
"axios": "^1.12.0",
"consola": "^3.2.3",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"dotenv": "^16.4.5",
"fs-extra": "^11.2.0",
"global": "^4.4.0",
"guacamole-lite": "^1.2.0",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "^9.0.2",
"koa": "2.16.2",
@@ -44,7 +45,7 @@
"node-rsa": "^1.1.1",
"node-schedule": "^2.1.1",
"node-telegram-bot-api": "^0.66.0",
"nodemailer": "^6.9.14",
"nodemailer": "^7.0.7",
"qrcode": "^1.5.4",
"socket.io": "^4.7.5",
"socket.io-client": "^4.7.5",

View File

@@ -1,4 +1,16 @@
[
{
"version": "v3.5.0",
"date": "2025-10-18",
"features": [
"🖥️ 支持 RDP 远程 Windows 桌面连接(支持移动端交互 & 剪贴板互动)",
"🧩 脚本库增强:新增脚本执行模式 — 多行脚本 @zhanghao-njmu",
"💻 终端增强:输出高亮自定义、配置持久化管理、样式优化、全屏下 bug 修复 @zhanghao-njmu",
"📂 SFTP增强宽度拖拽、个性化显示文件信息列(大小、修改时间、权限、拥有者) @zhanghao-njmu",
"⚙️ 其他优化与bug修复",
"❤️ 特别感谢 @zhanghao-njmu 的功能PR"
]
},
{
"version": "v3.4.2",
"date": "2025-08-24",

View File

@@ -34,6 +34,7 @@ module.exports = {
'vue/singleline-html-element-content-newline': 0,
// js
'no-case-declarations': 'off',
'space-before-blocks': ['error', 'always',],
'space-in-parens': ['error', 'never',],
'keyword-spacing': ['error', { 'before': true, 'after': true },],

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_6nmd68hw8cb.js"></script>
<script src="//at.alicdn.com/t/c/font_3309550_y72t0y604xj.js"></script>
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"name": "web",
"version": "3.4.2",
"version": "3.5.0",
"description": "easynode-web",
"private": true,
"scripts": {
@@ -37,7 +37,8 @@
"crypto-js": "^4.2.0",
"csv-parse": "^5.6.0",
"dayjs": "^1.11.13",
"element-plus": "^2.9.7",
"element-plus": "^2.11.4",
"guacamole-common-js": "^1.5.0",
"highlight.js": "^11.11.1",
"jsencrypt": "^3.3.2",
"markdown-it": "^14.1.0",

View File

@@ -19,6 +19,9 @@ export default {
removeSSH(id) {
return axios({ url: `/remove-ssh/${ id }`, method: 'delete' })
},
getRdpToken(config = {}) {
return axios({ url: '/get-rdp-token', method: 'get', params: config })
},
getPlusInfo() {
return axios({ url: '/plus-info', method: 'get' })
},

View File

@@ -48,15 +48,20 @@ const route = useRoute()
const list = reactive([
{
name: '实例配置',
name: '实例列表',
icon: markRaw(IconMenu),
index: '/server'
},
{
name: '连接终端',
name: '远程终端',
icon: markRaw(ScaleToOriginal),
index: '/terminal'
},
{
name: '远程桌面',
icon: markRaw(ScaleToOriginal),
index: '/rdp'
},
{
name: '文件对传',
icon: markRaw(FolderOpened),

View File

@@ -95,6 +95,7 @@ const basicFeatures = [
const plusFeatures = [
'AI Chat对话组件',
'服务器代理&跳板机功能',
'RDP远程win桌面连接',
'文件对传',
'终端单窗口模式',
'批量修改实例配置',

View File

@@ -38,7 +38,7 @@ const emit = defineEmits(['update:modelValue', 'change',])
const { proxy: { $store } } = getCurrentInstance()
const serverList = computed(() => $store.hostList?.filter(item => item.isConfig))
const serverList = computed(() => $store.hostList?.filter(item => item.connectType !== 'rdp' && item.isConfig))
const isPlusActive = computed(() => $store.isPlusActive)
const selectedServer = computed({

View File

@@ -18,6 +18,9 @@ globalComponents(app)
app.use(createPinia())
app.use(router)
const isDev = import.meta.env.DEV
app.config.globalProperties.$isDev = isDev
app.config.globalProperties.$api = api
app.config.globalProperties.$tools = tools
app.config.globalProperties.$http = axios

View File

@@ -4,6 +4,7 @@ import Login from '@views/login/index.vue'
import Container from '@views/index.vue'
import Server from '@views/server/index.vue'
import Terminal from '@views/terminal/index.vue'
import Rdp from '@views/rdp/index.vue'
import Credentials from '@views/credentials/index.vue'
import File from '@views/file/index.vue'
import Onekey from '@views/onekey/index.vue'
@@ -27,6 +28,7 @@ const routes = [
children: [
{ path: '/server', component: Server },
{ path: '/terminal', component: Terminal },
{ path: '/rdp', component: Rdp },
{ path: '/credentials', component: Credentials },
{ path: '/file', component: File },
{ path: '/onekey', component: Onekey },

View File

@@ -1,14 +1,41 @@
// 终端连接状态
export const terminalStatus = {
CONNECT_READY: 'connect_ready',
CONNECTING: 'connecting',
CONNECT_FAIL: 'connect_fail',
CONNECT_SUCCESS: 'connect_success'
}
export const terminalStatusList = [
{ value: terminalStatus.CONNECT_READY, label: '待连接', color: 'gray' },
{ value: terminalStatus.CONNECTING, label: '连接中', color: '#FFA500' },
{ value: terminalStatus.CONNECT_FAIL, label: '连接失败', color: '#DC3545' },
{ value: terminalStatus.CONNECT_SUCCESS, label: '已连接', color: '#28A745' },
]
// RDP连接状态
export const rdpStatus = {
IDLE: 'idle',
CONNECTING: 'connecting',
WAITING: 'waiting',
CONNECTED: 'connected',
DISCONNECTING: 'disconnecting',
DISCONNECTED: 'disconnected',
TIMEOUT: 'timeout',
ERROR: 'error'
}
export const rdpStatusList = [
{ value: rdpStatus.IDLE, label: '准备连接', color: '#909399' },
{ value: rdpStatus.CONNECTING, label: '正在连接...', color: '#E6A23C' },
{ value: rdpStatus.WAITING, label: '等待响应...', color: '#409EFF' },
{ value: rdpStatus.CONNECTED, label: '已连接', color: '#67C23A' },
{ value: rdpStatus.DISCONNECTING, label: '正在断开...', color: '#E6A23C' },
{ value: rdpStatus.DISCONNECTED, label: '已断开', color: '#909399' },
{ value: rdpStatus.TIMEOUT, label: '连接超时', color: '#F56C6C' },
{ value: rdpStatus.ERROR, label: '连接错误', color: '#F56C6C' },
]
export const virtualKeyType = {
LONG_PRESS: 'long-press',
SINGLE_PRESS: 'single-press'

View File

@@ -0,0 +1,927 @@
<template>
<el-dialog
v-model="visible"
:width="dialogWidth"
center
modal-penetrable
:modal="true"
:align-center="true"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
:fullscreen="fullscreen"
class="rdp_content"
header-class="rdp_header"
body-class="rdp_body"
@close="handleClose"
>
<template #header>
<div class="rdp_dialog_header">
<div class="rdp_left">
<div class="host_info">
<span v-show="!isMobile()" class="hostname">{{ name }}</span>
<div class="status_info">
<el-icon :color="currentStatusInfo.color" class="status_icon">
<Connection v-if="currentStatus === rdpStatus.CONNECTED" />
<Loading v-else-if="isConnecting" />
<Close v-else-if="isError" />
<Clock v-else />
</el-icon>
<span class="status_text" :style="{ color: currentStatusInfo.color }">
{{ currentStatusInfo.label }}
</span>
</div>
</div>
</div>
<div class="rdp_center">
<div class="resolution_controls">
<el-select
v-model="displayMode"
class="display_mode_select"
size="small"
:disabled="isConnecting"
placeholder="选择显示模式"
@change="handleDisplayModeChange"
>
<el-option label="最大化" value="maximize" />
<el-option label="自适应" value="adaptive" />
<el-option label="自定义" value="custom" @click="handleCustomOptionClick" />
</el-select>
</div>
</div>
<div class="rdp_right">
<el-button
type="primary"
size="small"
:disabled="isConnecting"
:loading="isConnecting"
@click="connectRdp"
>
{{ isConnecting ? '连接中' : '重连' }}
</el-button>
<el-button type="info" size="small" @click="moveToBackground"> 挂起 </el-button>
<el-button
v-show="isMobile()"
type="success"
size="small"
@click="toggleKeyboard"
>
键盘
</el-button>
</div>
</div>
</template>
<div
ref="rdpContainer"
v-loading="isConnecting"
element-loading-text="连接中..."
class="rdp_container"
/>
</el-dialog>
<!-- 自定义分辨率设置对话框 -->
<el-dialog
v-model="customDialogVisible"
title="自定义分辨率"
width="400px"
:close-on-click-modal="false"
>
<el-form label-width="60px">
<el-form-item label="宽度">
<el-input
v-model="tempCustomWidth"
type="number"
:max="maxWidth"
:placeholder="`最大${maxWidth}`"
>
<template #append>px</template>
</el-input>
</el-form-item>
<el-form-item label="高度">
<el-input
v-model="tempCustomHeight"
type="number"
:max="maxHeight"
:placeholder="`最大${maxHeight}`"
>
<template #append>px</template>
</el-input>
</el-form-item>
<el-alert type="info" :closable="false">
<template #default>
<div style="display: flex; align-items: center; gap: 8px;">
<span>最大可用分辨率:</span>
<el-link
type="primary"
:underline="false"
style="font-weight: 600;"
@click="applyMaxResolution"
>
{{ maxWidth }}x{{ maxHeight }}
</el-link>
</div>
</template>
</el-alert>
</el-form>
<template #footer>
<el-button @click="cancelCustomSettings">取消</el-button>
<el-button type="primary" @click="confirmCustomSettings">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, getCurrentInstance, onBeforeUnmount, watch } from 'vue'
import { Connection, Loading, Close, Clock } from '@element-plus/icons-vue'
import Guacamole from 'guacamole-common-js'
import { rdpStatus, rdpStatusList } from '@/utils/enum'
import { isMobile } from '@/utils'
const { proxy: { $api, $message, $isDev } } = getCurrentInstance()
const props = defineProps({
host: {
type: Object,
required: true
}
})
const emit = defineEmits(['close:dialog', 'status:change',])
const rdpContainer = ref(null)
let client = ref(null)
let mouse
let touch
let keyboard
const host = computed(() => props.host)
const hostId = computed(() => props.host.id)
const name = computed(() => host.value.name)
const show = computed(() => host.value.show)
const fullscreen = computed(() => host.value.fullscreen || false)
// 从localStorage获取缓存的显示配置
const getCachedDisplayConfig = () => {
const cached = localStorage.getItem('rdp_display_config')
if (cached) {
try {
return JSON.parse(cached)
} catch (error) {
console.warn('解析缓存的显示配置失败:', error)
}
}
// 默认返回自适应模式
return {
mode: 'adaptive',
customWidth: '1366',
customHeight: '768'
}
}
// 显示模式maximize(最大化)、adaptive(自适应)、custom(自定义)
const displayMode = ref(isMobile() ? 'maximize' : 'adaptive')
// 自定义宽高
const customWidth = ref('1366')
const customHeight = ref('768')
// 自定义设置对话框
const customDialogVisible = ref(false)
const tempCustomWidth = ref('1366')
const tempCustomHeight = ref('768')
// 初始化显示配置
const initDisplayConfig = () => {
const config = getCachedDisplayConfig()
displayMode.value = config.mode || (isMobile() ? 'maximize' : 'adaptive')
customWidth.value = config.customWidth || '1366'
customHeight.value = config.customHeight || '768'
}
initDisplayConfig()
// 实际应用的配置
const appliedConfig = ref({
width: '1366',
height: '768'
})
// 浏览器可视区域限制
const maxWidth = computed(() => Math.floor(window.innerWidth))
const maxHeight = computed(() => Math.floor(window.innerHeight - 46)) // 减去header的高度
const tunnel = ref(null)
const display = ref(null)
const currentStatus = ref(rdpStatus.IDLE)
const connectionTimeout = ref(null)
const visible = computed(() => show.value)
// 保存显示配置到localStorage
const saveDisplayConfigToCache = () => {
const config = {
mode: displayMode.value,
customWidth: customWidth.value,
customHeight: customHeight.value
}
localStorage.setItem('rdp_display_config', JSON.stringify(config))
}
// 根据显示模式计算实际宽高
const calculateResolution = () => {
const maxW = maxWidth.value
const maxH = maxHeight.value
let width, height
switch (displayMode.value) {
case 'maximize':
// 最大化:使用最大可用空间
width = maxW
height = maxH
break
case 'adaptive':
// 自适应宽80%高90%
width = Math.floor(maxW * 0.8)
height = Math.floor(maxH * 0.9)
break
case 'custom':
// 自定义:使用用户设置的值
width = parseInt(customWidth.value) || 1366
height = parseInt(customHeight.value) || 768
// 确保不超过最大值
width = Math.min(width, maxW)
height = Math.min(height, maxH)
break
default:
width = 1366
height = 768
}
return { width, height }
}
// 显示模式变化处理
const handleDisplayModeChange = (mode) => {
if (mode === 'custom') {
// 打开自定义设置对话框
openCustomDialog()
} else {
// 直接重连
saveDisplayConfigToCache()
connectRdp()
}
}
// 处理自定义选项点击(包括重复点击)
const handleCustomOptionClick = () => {
if (displayMode.value === 'custom') {
// 如果已经是自定义模式,直接打开对话框
openCustomDialog()
}
}
// 打开自定义设置对话框
const openCustomDialog = () => {
tempCustomWidth.value = customWidth.value
tempCustomHeight.value = customHeight.value
customDialogVisible.value = true
}
// 应用最大分辨率
const applyMaxResolution = () => {
tempCustomWidth.value = maxWidth.value.toString()
tempCustomHeight.value = maxHeight.value.toString()
}
// 确认自定义设置
const confirmCustomSettings = () => {
customWidth.value = tempCustomWidth.value
customHeight.value = tempCustomHeight.value
customDialogVisible.value = false
saveDisplayConfigToCache()
connectRdp()
}
// 取消自定义设置
const cancelCustomSettings = () => {
// 恢复到上次的模式
const lastConfig = getCachedDisplayConfig()
displayMode.value = lastConfig.mode === 'custom' ? 'adaptive' : (isMobile() ? 'maximize' : 'adaptive')
customDialogVisible.value = false
}
// 计算dialog的宽度和高度基于实际应用的配置
const dialogWidth = computed(() => {
const rdpWidth = parseInt(appliedConfig.value.width) || 1366
// 考虑dialog的padding和边框增加最小的必要空间
return rdpWidth + 'px'
})
const dialogBodyHeight = computed(() => {
const rdpHeight = parseInt(appliedConfig.value.height) || 768
return rdpHeight + 'px' // 为header和padding留出空间
})
// 状态相关计算属性
const currentStatusInfo = computed(() => {
return rdpStatusList.find(item => item.value === currentStatus.value) || rdpStatusList[0]
})
const isConnecting = computed(() => {
return [rdpStatus.CONNECTING, rdpStatus.WAITING,].includes(currentStatus.value)
})
const isError = computed(() => {
return [rdpStatus.ERROR, rdpStatus.TIMEOUT, rdpStatus.DISCONNECTED,].includes(currentStatus.value)
})
// const canDisconnect = computed(() => {
// return [rdpStatus.CONNECTED, rdpStatus.CONNECTING, rdpStatus.WAITING,].includes(currentStatus.value)
// })
// 监听dialog显示状态首次打开自动连接
watch(visible, (newVisible) => {
if (newVisible && (currentStatus.value === rdpStatus.IDLE || currentStatus.value === rdpStatus.DISCONNECTED)) {
// dialog打开时自动连接
setTimeout(() => {
connectRdp()
}, 200) // 稍微延迟以确保DOM已渲染
}
}, { immediate: true })
// 监听状态变化并发射事件给父组件
watch(currentStatus, (newStatus) => {
emit('status:change', newStatus)
}, { immediate: true })
const handleClose = () => {
emit('close:dialog')
}
const moveToBackground = () => {
emit('close:dialog')
}
const getRdpWsUrl = async () => {
const { width, height } = appliedConfig.value
const { data } = await $api.getRdpToken({ hostId: hostId.value, width, height })
if (!data) return $message.error('获取RDP WS URL失败')
const wsHost = $isDev ? `ws://${ location.hostname }:8082` : location.origin.replace('http', 'ws')
return `${ wsHost }/guac?token=${ encodeURIComponent(data) }`
}
const connectRdp = async () => {
try {
// 根据显示模式计算实际分辨率
const { width, height } = calculateResolution()
// 更新实际应用的配置
appliedConfig.value = {
width: width.toString(),
height: height.toString()
}
// 保存到localStorage
saveDisplayConfigToCache()
// 清理之前的连接
clearConnectionTimeout()
disconnectRdp()
currentStatus.value = rdpStatus.CONNECTING
const wsUrl = await getRdpWsUrl()
console.log('wsUrl:', wsUrl)
// 设置连接超时30秒
connectionTimeout.value = setTimeout(() => {
if (isConnecting.value) {
console.warn('⏰ RDP连接超时')
currentStatus.value = rdpStatus.TIMEOUT
$message.error('RDP连接超时请检查网络或目标主机状态')
disconnectRdp()
}
}, 30000)
// 开始建立连接
tunnel.value = new Guacamole.WebSocketTunnel(wsUrl)
// 添加隧道事件监听
tunnel.value.onerror = (error) => {
console.error('🚇 WebSocket 隧道错误:', error)
currentStatus.value = rdpStatus.ERROR
$message.error('WebSocket连接错误')
clearConnectionTimeout()
}
// tunnel.value.onstatechange = (state) => {
// console.log('🚇 WebSocket 隧道状态变化:', state)
// }
client.value = new Guacamole.Client(tunnel.value)
display.value = client.value.getDisplay()
// 设置事件处理器
setupEventHandlers()
console.log('🚀 开始RDP连接...')
client.value.connect()
} catch (error) {
console.error('RDP连接失败:', error)
currentStatus.value = rdpStatus.ERROR
$message.error('RDP连接失败: ' + error.message)
clearConnectionTimeout()
}
}
const getStateText = (state) => {
const stateMap = {
0: 'IDLE',
1: 'CONNECTING',
2: 'WAITING',
3: 'CONNECTED',
4: 'DISCONNECTING',
5: 'DISCONNECTED'
}
return stateMap[state] || `UNKNOWN(${ state })`
}
function mapGuacError(status) {
const code = status?.code
const msg = (status?.message || '').toLowerCase()
// 1) 直接从 message 关键词判断(不同版本/系统更稳)
if (msg.includes('auth') || msg.includes('credential') || msg.includes('logon') || msg.includes('password'))
return '认证失败:用户名或密码错误,或账户被锁定。'
if (msg.includes('security') && msg.includes('negotiation'))
return '安全协商失败:目标主机需要的安全级别不匹配(可能是 NLA/TLS 相关)。'
// 2) 用状态码兜底(不同版本的码值不完全一致)
// 提示:这些常量在不同版本可能有差异,保守用“范围 + 兜底”
try {
const C = Guacamole.Status.Code // 常量枚举(如果有的话)
if (code === C?.CLIENT_UNAUTHORIZED || code === C?.UPSTREAM_UNAUTHORIZED)
return '认证失败:用户名或密码错误,或账户被禁用。'
if (code === C?.UPSTREAM_NOT_FOUND)
return '无法连接目标:主机/端口不可达或未开放 RDP。'
if (code === C?.UPSTREAM_TIMEOUT)
return '连接超时:网络不通或目标服务器响应过慢。'
if (code === C?.UPSTREAM_ERROR)
return '上游服务错误RDP/guacd 发生异常。'
} catch (_) {
console.error('❌ Guac error:', status, 'code:', status?.code, 'msg:', status?.message)
}
// 3) 最后兜底
return '连接失败可能是凭证不正确、NLA 要求或网络问题。'
}
const setupEventHandlers = () => {
client.value.onstatechange = (state) => {
console.log('🔄 RDP连接状态变化:', state, '对应状态:', getStateText(state))
switch (state) {
case 0: // IDLE
currentStatus.value = rdpStatus.IDLE
break
case 1: // CONNECTING
currentStatus.value = rdpStatus.CONNECTING
break
case 2: // WAITING
currentStatus.value = rdpStatus.WAITING
break
case 3: // CONNECTED
console.log('🎉 RDP连接已建立')
currentStatus.value = rdpStatus.CONNECTED
clearConnectionTimeout()
onConnected()
break
case 4: // DISCONNECTING
currentStatus.value = rdpStatus.DISCONNECTING
break
case 5: // DISCONNECTED
currentStatus.value = rdpStatus.DISCONNECTED
onDisconnected()
break
default:
console.warn('未知RDP连接状态:', state)
currentStatus.value = rdpStatus.ERROR
break
}
}
client.value.onerror = (status) => {
// 有些环境 message 可能为空,这里都打印出来,方便定位
console.error('❌ Guac error:', status, 'code:', status?.code, 'msg:', status?.message)
// 友好文案映射
const humanMsg = mapGuacError(status)
$message.error(humanMsg)
}
}
let hiddenInput = null
const toggleKeyboard = () => {
if (!hiddenInput) return
hiddenInput.focus()
}
const enableMobileKeyboard = () => {
if (hiddenInput) return // 避免重复创建
// 创建隐藏 input
hiddenInput = document.createElement('input')
hiddenInput.type = 'text'
hiddenInput.autocapitalize = 'off'
hiddenInput.autocorrect = 'off'
hiddenInput.spellcheck = false
hiddenInput.style.position = 'absolute'
hiddenInput.style.opacity = '0'
hiddenInput.style.height = '1px'
hiddenInput.style.width = '1px'
hiddenInput.style.fontSize = '16px' // 防止 iOS 放大
hiddenInput.style.zIndex = '-1'
document.body.appendChild(hiddenInput)
let composing = false
// 中文拼音等输入法
hiddenInput.addEventListener('compositionstart', () => {
composing = true
})
hiddenInput.addEventListener('compositionend', (e) => {
composing = false
sendText(e.data)
hiddenInput.value = ''
})
// 普通输入
hiddenInput.addEventListener('input', (e) => {
if (composing) return
const text = e.target.value
if (!text) return
sendText(text)
hiddenInput.value = ''
})
// 处理特殊键(如删除键、回车键等)
hiddenInput.addEventListener('keydown', (e) => {
// 如果正在输入中文拼音,跳过
if (composing) return
const specialKeys = {
'Backspace': 0xFF08, // Backspace
'Delete': 0xFFFF, // Delete
'Enter': 0xFF0D, // Enter/Return
'Tab': 0xFF09, // Tab
'Escape': 0xFF1B, // Escape
'ArrowLeft': 0xFF51, // Left Arrow
'ArrowUp': 0xFF52, // Up Arrow
'ArrowRight': 0xFF53, // Right Arrow
'ArrowDown': 0xFF54 // Down Arrow
}
const keysym = specialKeys[e.key]
if (keysym) {
e.preventDefault()
client.value.sendKeyEvent(1, keysym)
client.value.sendKeyEvent(0, keysym)
}
})
const sendText = (text) => {
for (const ch of text) {
let keysym = null
if (Guacamole.Keyboard.fromUnicode) {
keysym = Guacamole.Keyboard.fromUnicode(ch)
} else {
keysym = ch.charCodeAt(0)
}
if (!keysym) continue
client.value.sendKeyEvent(1, keysym)
client.value.sendKeyEvent(0, keysym)
}
}
// 点击远程桌面时自动 focus调出键盘
// const displayElement = client.value.getDisplay().getElement()
// displayElement.addEventListener('touchend', () => {
// hiddenInput.focus()
// })
}
const disableMobileKeyboard = () => {
if (hiddenInput) {
hiddenInput.remove()
hiddenInput = null
}
}
// 设置剪贴板事件处理
const setupClipboardHandlers = () => {
if (!client.value) return
client.value.onclipboard = (stream, mimetype) => {
// console.log('📋 接收到远程剪贴板数据,类型:', mimetype)
let clipboardData = ''
stream.onblob = (data) => {
try {
// console.log('📋 接收到数据块,类型:', typeof data, '长度:', data?.length || data?.byteLength || 'unknown')
if (data instanceof ArrayBuffer) {
// 如果是 ArrayBuffer直接转换为字符串
const decoder = new TextDecoder('utf-8')
clipboardData += decoder.decode(data)
} else if (typeof data === 'string') {
// 如果已经是字符串,可能是 base64 编码的
try {
const decoded = atob(data)
clipboardData += decodeURIComponent(escape(decoded))
} catch (e) {
clipboardData += data
}
} else if (data && data.constructor === Uint8Array) {
const decoder = new TextDecoder('utf-8')
clipboardData += decoder.decode(data)
} else {
clipboardData += String(data)
}
} catch (error) {
console.warn('❌ 处理剪贴板数据时出错:', error, data)
}
}
stream.onend = () => {
if (clipboardData && mimetype === 'text/plain') {
navigator.clipboard.writeText(clipboardData) // 异步写入本地剪贴板
console.log('📋 已将远程剪贴板内容写入本地:', clipboardData.substring(0, 50))
}
}
if (stream.onerror) {
stream.onerror = (error) => {
console.warn('❌ 剪贴板流错误:', error)
}
}
}
}
// 发送剪贴板内容到远程服务器
const sendClipboardToRemote = async (text) => {
if (!client.value || !text) return
try {
console.log('📋 发送本地剪贴板内容到远程:', text.substring(0, 50))
// 按 RDP 习惯把 \n 规范成 \r\n
const normalized = text.replace(/\r?\n/g, '\r\n')
// createClipboardStream + StringWriter
const stream = client.value.createClipboardStream('text/plain')
const writer = new Guacamole.StringWriter(stream)
writer.sendText(normalized)
writer.sendEnd()
} catch (err) {
console.warn('❌ 发送剪贴板内容到远程失败:', err)
}
}
const onConnected = () => {
// 清空容器并添加显示元素
if (rdpContainer.value) {
rdpContainer.value.innerHTML = ''
const display = client.value.getDisplay()
const displayElement = display.getElement()
// 重要设置显示缩放为1:1确保鼠标位置准确
display.scale(1.0)
// 不要设置CSS尺寸让Guacamole自己管理canvas尺寸
displayElement.style.display = 'block'
rdpContainer.value.appendChild(displayElement)
}
// 设置鼠标事件
const displayElement = client.value.getDisplay().getElement()
mouse = new Guacamole.Mouse(displayElement)
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (e) => {
client.value.sendMouseState(e.state ?? e)
}
// 设置触摸事件(移动端支持)
if (isMobile()) {
const touchscreen = new Guacamole.Mouse.Touchscreen(displayElement)
touchscreen.onEach(['mousedown', 'mousemove', 'mouseup',], (e) => {
// 第二个 true 表示这是来自触屏的事件(新版本示例用法)
client.value.sendMouseState(e.state ?? e, true)
})
}
// 让 canvas 可以获得焦点,捕获键盘事件
displayElement.setAttribute('tabindex', '0')
displayElement.focus()
// 当用户点击 canvas 时,重新获得焦点(解决切换窗口后失焦问题)
displayElement.addEventListener('mousedown', () => {
displayElement.focus()
})
// 当用户聚焦 RDP 容器时,推送一次本地剪贴板
displayElement.addEventListener('focus', async () => {
try {
if (!navigator.clipboard?.readText) return
const text = await navigator.clipboard.readText()
if (!text) return
sendClipboardToRemote(text)
} catch (err) {
console.debug('无法读取本地剪贴板:', err.message)
}
})
keyboard = new Guacamole.Keyboard(displayElement)
keyboard.onkeydown = (keysym) => client.value.sendKeyEvent(1, keysym)
keyboard.onkeyup = (keysym) => client.value.sendKeyEvent(0, keysym)
// 处理显示尺寸变化
const display = client.value.getDisplay()
display.onresize = (width, height) => {
console.log('RDP显示尺寸变化:', width, 'x', height)
// 确保显示缩放始终为1:1
display.scale(1.0)
}
// 处理移动端键盘输入
if (isMobile()) {
enableMobileKeyboard()
}
setupClipboardHandlers()
}
const onDisconnected = () => {
console.log('RDP连接已断开')
clearConnectionTimeout()
cleanupResources()
disableMobileKeyboard()
}
const disconnectRdp = () => {
if (client.value) {
try {
client.value.disconnect()
} catch (error) {
console.warn('断开RDP连接时出错:', error)
}
}
currentStatus.value = rdpStatus.DISCONNECTED
clearConnectionTimeout()
cleanupResources()
}
const clearConnectionTimeout = () => {
if (connectionTimeout.value) {
clearTimeout(connectionTimeout.value)
connectionTimeout.value = null
}
}
const cleanupResources = () => {
// 清理鼠标和键盘事件
if (mouse) {
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = null
mouse = null
}
// 清理触摸事件
if (touch) {
touch.onmousedown = touch.onmouseup = touch.onmousemove = null
touch = null
}
if (keyboard) {
keyboard.onkeydown = keyboard.onkeyup = null
keyboard = null
}
// 清理客户端和隧道
if (client.value) {
client.value = null
}
if (tunnel.value) {
try {
tunnel.value.disconnect()
} catch (error) {
console.warn('关闭WebSocket隧道时出错:', error)
}
tunnel.value = null
}
// 清空显示容器
if (rdpContainer.value) {
rdpContainer.value.innerHTML = ''
}
}
// 组件销毁时清理资源
onBeforeUnmount(() => {
disconnectRdp()
})
// 暴露给父组件的方法
defineExpose({
disconnectRdp
})
</script>
<style scoped lang="scss">
.rdp_container {
width: 100%;
min-height: 400px;
height: 100%;
position: relative;
z-index: 1;
overflow-y: auto;
overflow-x: hidden;
display: flex;
justify-content: center;
align-items: center;
background-color: #000;
// 隐藏本地鼠标指针,避免与远程指针重合
:deep(canvas) {
cursor: none !important;
display: block;
}
}
.rdp_dialog_header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0 4px;
.rdp_left {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-start;
.host_info {
display: flex;
align-items: center;
.hostname {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-right: 12px;
}
.status_info {
display: inline-flex;
align-items: center;
gap: 4px;
.status_icon {
font-size: 14px;
}
.status_text {
font-size: 13px;
font-weight: 500;
}
}
}
}
.rdp_center {
flex: 0 0 auto;
margin: 0 16px;
.resolution_controls {
display: flex;
align-items: center;
gap: 8px;
.display_mode_select {
width: 80px;
}
.info_icon {
margin-left: 4px;
font-size: 14px;
color: var(--el-color-info);
cursor: help;
}
}
}
.rdp_right {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
}
</style>
<style>
.rdp_content {
padding: 0!important;
/* margin: 0 auto!important; */
.rdp_header {
padding: 8px;
}
}
.rdp_body {
height: v-bind(dialogBodyHeight);
}
</style>

227
web/src/views/rdp/index.vue Normal file
View File

@@ -0,0 +1,227 @@
<template>
<div class="rdp_container">
<div class="header">
<el-dropdown trigger="click">
<el-button type="primary">
新建连接<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in rdpHostList" :key="item.id" @click="addRDP(item)">
{{ item.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div v-if="noRDPHost" class="no_rdp_host">
<el-empty description="无远程桌面连接" />
</div>
<div v-else class="rdp_host_list">
<ul>
<li v-for="item in rdpTabs" :key="item.id" @click="handleUpdateShow(item)">
<el-icon><Monitor /></el-icon>
<span class="hostname">{{ item.name }}</span>
<span class="status" :style="{ color: getStatusColor(item.status) }">{{ getStatusLabel(item.status) }}</span>
<el-icon class="close_icon" @click.stop="handleRemoveRdpTab(item)"><Close /></el-icon>
</li>
</ul>
<div class="rdp_host_list_container">
<Rdp
v-for="item in rdpTabs"
:key="item.id+item.name"
:ref="el => rdpRefs[item.id] = el"
:host="item"
@close:dialog="() => item.show = false"
@status:change="(status) => handleStatusChange(item.id, status)"
/>
</div>
</div>
<HostForm
v-model:show="hostFormVisible"
:default-data="updateHostData"
@update-list="handleUpdateList"
@closed="updateHostData = null"
/>
</div>
</template>
<script setup>
import { onActivated, computed, nextTick, getCurrentInstance, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Monitor, Close, ArrowDown } from '@element-plus/icons-vue'
import { rdpStatus, rdpStatusList } from '@/utils/enum'
import Rdp from './components/rdp.vue'
import HostForm from '../server/components/host-form.vue'
const route = useRoute()
const { proxy: { $store, $message } } = getCurrentInstance()
// RDP组件引用
const rdpRefs = ref({})
let rdpTabs = reactive([])
let hostFormVisible = ref(false)
let updateHostData = ref(null)
const hostList = computed(() => $store.hostList)
const noRDPHost = computed(() => !Boolean(rdpTabs.length))
const rdpHostList = computed(() => hostList.value.filter(item => item.connectType === 'rdp'))
const getStatusColor = (status) => {
return (
rdpStatusList.find((item) => item.value === status)?.color || 'gray'
)
}
const getStatusLabel = (status) => {
return (
rdpStatusList.find((item) => item.value === status)?.label || '未知状态'
)
}
onActivated(async () => {
await nextTick()
const { hostIds } = route.query
if (!hostIds) return
if (rdpTabs.some(item => hostIds.includes(item.id))) return $message.warning('已存在该实例的RDP连接')
let targetHosts = hostList.value.filter(item => hostIds.includes(item.id)).map(item => {
const { id, name, host } = item
return { show: true, status: rdpStatus.IDLE, id, name, host }
})
if (!targetHosts || !targetHosts.length) return
rdpTabs.push(...targetHosts)
})
const handleUpdateShow = (item) => {
console.log(item.show)
item.show = !item.show
}
const handleStatusChange = (hostId, status) => {
const item = rdpTabs.find(tab => tab.id === hostId)
if (item) {
item.status = status
console.log(`RDP状态更新: ${ item.name } -> ${ status }`)
}
}
const handleRemoveRdpTab = (item) => {
const index = rdpTabs.findIndex(tab => tab.id === item.id)
if (index !== -1) {
// 尝试断开RDP连接
const rdpComponent = rdpRefs.value[item.id]
if (rdpComponent && rdpComponent.disconnectRdp) {
try {
rdpComponent.disconnectRdp()
console.log(`已断开RDP连接: ${ item.name }`)
} catch (error) {
console.warn('断开RDP连接时出错:', error)
}
}
// 如果当前标签正在显示,先关闭它
if (item.show) {
item.show = false
}
// 从数组中移除该标签
rdpTabs.splice(index, 1)
// 清理对应的ref引用
delete rdpRefs.value[item.id]
console.log(`已移除RDP标签: ${ item.name }`)
}
}
const addRDP = (item) => {
const { id, name, host, isConfig } = item
if (!isConfig) return $message.warning('请先配置RDP连接信息')
if (rdpTabs.some(tab => tab.id === id)) return $message.warning('已存在该实例的RDP连接')
rdpTabs.push({ show: true, status: rdpStatus.IDLE, id, name, host })
}
const handleUpdateList = async ({ host }) => {
try {
await $store.getHostList()
let targetHost = hostList.value.find((item) => item.host === host)
if (targetHost) addRDP(targetHost)
} catch (err) {
$message.error('获取实例列表失败')
console.error('获取实例列表失败: ', err)
}
}
</script>
<style lang="scss" scoped>
.rdp_container {
height: 100%;
padding: 0 20px 20px 20px;
.header {
padding: 15px;
display: flex;
align-items: center;
justify-content: end;
}
.no_rdp_host {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.rdp_host_list {
height: 100%;
ul {
height: 100%;
display: flex;
gap: 16px;
li {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
height: 150px;
width: 150px;
border:1px solid var(--el-color-primary);
cursor: pointer;
transition: all 0.2s;
border-radius: 4px;
position: relative;
padding: 8px;
&:hover {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
.el-icon {
font-size: 36px;
margin-bottom: 8px;
}
.hostname {
display: inline-block;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.status {
font-size: 14px;
margin-top: 8px;
}
.close_icon {
font-size: 15px;
margin-top: 8px;
position: absolute;
right: 8px;
top: 2px;
cursor: pointer;
&:hover {
color: var(--el-color-danger);
}
}
}
}
}
}
</style>

View File

@@ -29,6 +29,14 @@
label-width="100px"
:show-message="false"
>
<el-form-item key="connectType" label="连接类型" prop="connectType">
<el-radio-group v-model="hostForm.connectType">
<el-radio value="ssh">SSH <svg-icon name="icon-linux" class="icon" /></el-radio>
<PlusSupportTip>
<el-radio value="rdp" :disabled="!isPlusActive">RDP <svg-icon name="icon-Windows" class="icon" /></el-radio>
</PlusSupportTip>
</el-radio-group>
</el-form-item>
<el-form-item key="group" label="分组" prop="group">
<el-select
v-model="hostForm.group"
@@ -115,13 +123,18 @@
</template>
</el-autocomplete>
</el-form-item>
<el-form-item key="authType" label="认证方式" prop="authType">
<el-form-item
v-if="isSSH"
key="authType"
label="认证方式"
prop="authType"
>
<el-radio v-model="hostForm.authType" value="privateKey">密钥</el-radio>
<el-radio v-model="hostForm.authType" value="password">密码</el-radio>
<el-radio v-model="hostForm.authType" value="credential">凭据</el-radio>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'privateKey'"
v-if="isSSH && hostForm.authType === 'privateKey'"
key="privateKey"
prop="privateKey"
label="密钥"
@@ -148,7 +161,7 @@
/>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'password'"
v-if="hostForm.authType === 'password' || isRDP"
key="password"
prop="password"
label="密码"
@@ -163,7 +176,7 @@
/>
</el-form-item>
<el-form-item
v-if="hostForm.authType === 'credential'"
v-if="isSSH && hostForm.authType === 'credential'"
key="credential"
prop="credential"
label="凭据"
@@ -196,6 +209,7 @@
<el-collapse-item name="advanced" title="其他设置">
<PlusSupportTip>
<el-form-item
v-if="isSSH"
key="proxyType"
label="代理类型"
prop="proxyType"
@@ -208,7 +222,7 @@
</el-form-item>
</PlusSupportTip>
<el-form-item
v-if="hostForm.proxyType === 'jumpHosts'"
v-if="isSSH && hostForm.proxyType === 'jumpHosts'"
key="jumpHosts"
prop="jumpHosts"
label="跳板机"
@@ -237,7 +251,7 @@
</el-select>
</el-form-item>
<el-form-item
v-if="hostForm.proxyType === 'proxyServer'"
v-if="isSSH && hostForm.proxyType === 'proxyServer'"
key="proxyServer"
prop="proxyServer"
label="代理服务"
@@ -270,7 +284,12 @@
</el-option>
</el-select>
</el-form-item>
<el-form-item key="command" prop="command" label="登录指令">
<el-form-item
v-if="isSSH"
key="command"
prop="command"
label="登录指令"
>
<el-input
v-model="hostForm.command"
type="textarea"
@@ -370,6 +389,7 @@ const props = defineProps({
const emit = defineEmits(['update:show', 'update-list', 'closed',])
const formField = {
connectType: 'ssh', // ssh, rdp
group: 'default',
name: '',
host: '',
@@ -406,6 +426,7 @@ const batchHosts = computed(() => props.batchHosts)
const defaultData = computed(() => props.defaultData)
const rules = computed(() => {
return {
connectType: { required: true, message: '选择一个连接类型' },
group: { required: !isBatchModify.value, message: '选择一个分组' },
name: { required: !isBatchModify.value, message: '输入实例别名', trigger: 'change' },
host: { required: !isBatchModify.value, message: '输入IP/域名', trigger: 'change' },
@@ -431,12 +452,32 @@ const title = computed(() => {
return isBatchModify.value ? '批量修改实例' : (defaultData.value ? '修改实例' : '添加实例')
})
// 连接类型计算属性
const isSSH = computed(() => hostForm.value.connectType === 'ssh')
const isRDP = computed(() => hostForm.value.connectType === 'rdp')
const groupList = computed(() => $store.groupList)
const sshList = computed(() => $store.sshList)
const hostList = computed(() => $store.hostList)
const confHostList = computed(() => hostList.value?.filter(item => item.isConfig))
const proxyList = computed(() => $store.proxyList)
// 监听连接类型变化自动修正port&Username
watch(
() => hostForm.value.connectType,
(newVal) => {
if (defaultData.value || isBatchModify.value) return
if (newVal === 'rdp') {
hostForm.value.port = 3389
hostForm.value.username = 'Administrator'
}
if (newVal === 'ssh') {
hostForm.value.port = 22
hostForm.value.username = 'root'
}
}
)
// 监听折叠状态变化保存到localStorage
watch(advancedSettingsCollapsed, (newVal) => {
localStorage.setItem('hostFormAdvancedSettingsCollapsed', JSON.stringify(newVal.includes('advanced')))
@@ -461,6 +502,7 @@ const setBatchDefaultData = () => {
if (!isBatchModify.value) return
Object.assign(hostForm.value, { ...formField }, { group: '', port: '', username: '', authType: '', proxyType: '', jumpHosts: [], proxyServer: '' })
}
const handleOpen = async () => {
if (isBatchModify.value) {
setBatchDefaultData()
@@ -502,10 +544,20 @@ const defaultUsers = [
{ value: 'opc' },
{ value: 'admin' },
]
const defaultWindowsUsers = [
{ value: 'Administrator' },
{ value: 'admin' },
{ value: 'user' },
{ value: 'Guest' },
]
const userSearch = (keyword, cb) => {
// 根据连接类型选择不同的用户名建议
const users = isRDP.value ? defaultWindowsUsers : defaultUsers
let res = keyword
? defaultUsers.filter((item) => item.value.includes(keyword))
: defaultUsers
? users.filter((item) => item.value.toLowerCase().includes(keyword.toLowerCase()))
: users
cb(res)
}
@@ -529,6 +581,7 @@ const handleSave = () => {
if (Array.isArray(value)) return value.length > 0
return Boolean(value)
})) // 剔除掉未更改的值
if (isRDP.value) updateFieldData.authType = 'password'
let { authType = '' } = updateFieldData
if (authType && !updateFieldData[authType]) {
delete updateFieldData.authType
@@ -547,6 +600,7 @@ const handleSave = () => {
let { msg } = await $api.batchUpdateHost({ updateIds, updateFieldData })
$message({ type: 'success', center: true, message: msg })
} else {
if (isRDP.value) formData.authType = 'password'
let { authType } = formData
if (formData[authType]) {
let tempKey = randomStr(16)

View File

@@ -23,7 +23,16 @@
sortable
:sort-method="(a, b) => a.name - b.name"
>
<template #default="scope">{{ scope.row.name }}</template>
<template #default="scope">
<span v-if="scope.row.connectType !== 'rdp'">
<svg-icon name="icon-linux" class="icon" />
{{ scope.row.name }}
</span>
<span v-else>
<svg-icon name="icon-Windows" class="icon" />
{{ scope.row.name }}
</span>
</template>
</el-table-column>
<el-table-column v-if="props.columnSettings.username" property="username" label="用户名" />
<el-table-column v-if="props.columnSettings.host" property="host" label="IP">
@@ -173,8 +182,8 @@ const handleToConsole = ({ consoleUrl }) => {
}
const handleSSH = async (row) => {
let { id } = row
$router.push({ path: '/terminal', query: { hostIds: id } })
let { id, connectType } = row
$router.push({ path: connectType === 'rdp' ? '/rdp' : '/terminal', query: { hostIds: id } })
}
const defaultSortLocal = localStorage.getItem('host_table_sort')

View File

@@ -9,7 +9,7 @@
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleBatchSSH">连接终端</el-dropdown-item>
<el-dropdown-item @click="handleBatchConnect">连接终端</el-dropdown-item>
<el-dropdown-item @click="handleBatchModify">批量修改</el-dropdown-item>
<el-dropdown-item @click="handleBatchRemove">批量删除</el-dropdown-item>
<el-dropdown-item @click="handleSelectAll">反选所有</el-dropdown-item>
@@ -202,13 +202,20 @@ const collectSelectHost = () => {
selectHosts.value = allSelectHosts
}
const handleBatchSSH = () => {
const handleBatchConnect = () => {
collectSelectHost()
if (!selectHosts.value.length) return $message.warning('请选择要批量操作的实例')
let ids = selectHosts.value.filter(item => item.isConfig).map(item => item.id)
if (!ids.length) return $message.warning('所选实例未配置ssh连接信息')
if (ids.length < selectHosts.value.length) $message.warning('部分实例未配置ssh连接信息,已忽略')
$router.push({ path: '/terminal', query: { hostIds: ids.join(',') } })
// $router.push({ path: '/terminal', query: { hostIds: ids.join(',') } })
if (selectHosts.value.every(item => item.connectType === 'rdp')) {
$router.push({ path: '/rdp', query: { hostIds: ids.join(',') } })
} else if (selectHosts.value.every(item => !item.connectType || item.connectType === 'ssh')) {
$router.push({ path: '/terminal', query: { hostIds: ids.join(',') } })
} else {
$message.warning('所选实例包含rdp和ssh连接信息,请选择同一终端类型进行批量连接')
}
}
const handleBatchModify = async () => {

View File

@@ -199,32 +199,32 @@
</div>
</template>
<div class="file_name_cell">
<img :src="getIcon(row.type)" class="file_icon">
<template v-if="isEditing(row)">
<el-input
v-model="editingName"
size="small"
class="rename_input"
@click.stop
@keyup.enter.stop="confirmRename(row)"
@keyup.esc.stop="cancelRename"
/>
<el-icon class="rename_icon" @click.stop="confirmRename(row)"><Check /></el-icon>
<el-icon class="rename_icon" @click.stop="cancelRename"><CloseIcon /></el-icon>
</template>
<template v-else>
<span class="file_name" v-text="row.name" />
<el-icon
class="star_icon"
:class="{ 'favorited': isFavorited(row) }"
:title="isFavorited(row) ? '取消收藏' : '收藏'"
@click.stop="toggleFavorite(row)"
>
<StarFilled v-if="isFavorited(row)" />
<Star v-else />
</el-icon>
</template>
</div>
<img :src="getIcon(row.type)" class="file_icon">
<template v-if="isEditing(row)">
<el-input
v-model="editingName"
size="small"
class="rename_input"
@click.stop
@keyup.enter.stop="confirmRename(row)"
@keyup.esc.stop="cancelRename"
/>
<el-icon class="rename_icon" @click.stop="confirmRename(row)"><Check /></el-icon>
<el-icon class="rename_icon" @click.stop="cancelRename"><CloseIcon /></el-icon>
</template>
<template v-else>
<span class="file_name" v-text="row.name" />
<el-icon
class="star_icon"
:class="{ 'favorited': isFavorited(row) }"
:title="isFavorited(row) ? '取消收藏' : '收藏'"
@click.stop="toggleFavorite(row)"
>
<StarFilled v-if="isFavorited(row)" />
<Star v-else />
</el-icon>
</template>
</div>
</el-tooltip>
</template>
</el-table-column>
@@ -2379,10 +2379,10 @@ const performFileUpload = async (task) => {
throw new Error('WebSocket连接未建立')
}
// 检查文件大小限制 (1GB)
const maxFileSize = 1024 * 1024 * 1024 // 1GB
// 检查文件大小限制 (3GB)
const maxFileSize = 1024 * 1024 * 1024 * 3 // 3GB
if (task.totalSize > maxFileSize) {
throw new Error(`文件过大(${ formatSize(task.totalSize) }),单个文件不能超过1GB`)
throw new Error(`文件过大(${ formatSize(task.totalSize) }),单个文件不能超过3GB`)
}
// 发送上传开始事件

View File

@@ -666,7 +666,10 @@ const isPlusActive = computed(() => $store.isPlusActive)
const terminalTabs = computed(() => props.terminalTabs)
const terminalTabsLen = computed(() => props.terminalTabs.length)
const hostGroupList = computed(() => $store.groupList)
const hostList = computed(() => $store.hostList)
const hostList = computed(() => {
if (!Array.isArray($store.hostList)) return []
return $store.hostList.filter(item => item.connectType !== 'rdp')
})
// const curHost = computed(() =>
// hostList.value.find(
// (item) => item.host === terminalTabs.value[activeTabIndex.value]?.host

View File

@@ -1309,18 +1309,23 @@
"@types/koa-compose" "*"
"@types/node" "*"
"@types/lodash-es@^4.17.6":
"@types/lodash-es@^4.17.12":
version "4.17.12"
resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"
integrity sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==
dependencies:
"@types/lodash" "*"
"@types/lodash@*", "@types/lodash@^4.14.182":
"@types/lodash@*":
version "4.17.7"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612"
integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==
"@types/lodash@^4.17.20":
version "4.17.20"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.20.tgz#1ca77361d7363432d29f5e55950d9ec1e1c6ea93"
integrity sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==
"@types/mime@^1":
version "1.3.5"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
@@ -1746,22 +1751,13 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.2.tgz#0aa167216965ac9474ccfa83892cfb6b3e1e52ef"
integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==
axios@^1.8.3:
version "1.8.3"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.3.tgz#9ebccd71c98651d547162a018a1a95a4b4ed4de8"
integrity sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==
axios@^1.12.0, axios@^1.8.4:
version "1.12.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.0.tgz#11248459be05a5ee493485628fa0e4323d0abfc3"
integrity sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
axios@^1.8.4:
version "1.8.4"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.4.tgz#78990bb4bc63d2cae072952d374835950a82f447"
integrity sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.0"
form-data "^4.0.4"
proxy-from-env "^1.1.0"
balanced-match@^1.0.0:
@@ -2320,6 +2316,11 @@ deep-equal@~1.0.1:
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
deep-extend@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
@@ -2447,24 +2448,24 @@ electron-to-chromium@^1.5.73:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz#8ea537b369c32527b3cc47df7973bffe5d3c2980"
integrity sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==
element-plus@^2.9.7:
version "2.9.7"
resolved "https://registry.yarnpkg.com/element-plus/-/element-plus-2.9.7.tgz#05bcc35de1d98192d25ebfd06fff7d6d2de9f911"
integrity sha512-6vjZh5SXBncLhUwJGTVKS5oDljfgGMh6J4zVTeAZK3YdMUN76FgpvHkwwFXocpJpMbii6rDYU3sgie64FyPerQ==
element-plus@^2.11.4:
version "2.11.4"
resolved "https://registry.yarnpkg.com/element-plus/-/element-plus-2.11.4.tgz#00c7cdec627e8f83f9175aa9a55b00846c58803a"
integrity sha512-sLq+Ypd0cIVilv8wGGMEGvzRVBBsRpJjnAS5PsI/1JU1COZXqzH3N1UYMUc/HCdvdjf6dfrBy80Sj7KcACsT7w==
dependencies:
"@ctrl/tinycolor" "^3.4.1"
"@element-plus/icons-vue" "^2.3.1"
"@floating-ui/dom" "^1.0.1"
"@popperjs/core" "npm:@sxzz/popperjs-es@^2.11.7"
"@types/lodash" "^4.14.182"
"@types/lodash-es" "^4.17.6"
"@types/lodash" "^4.17.20"
"@types/lodash-es" "^4.17.12"
"@vueuse/core" "^9.1.0"
async-validator "^4.2.5"
dayjs "^1.11.13"
escape-html "^1.0.3"
lodash "^4.17.21"
lodash-es "^4.17.21"
lodash-unified "^1.0.2"
lodash-unified "^1.0.3"
memoize-one "^6.0.0"
normalize-wheel-es "^1.2.0"
@@ -2960,7 +2961,7 @@ forever-agent@~0.6.1:
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==
form-data@^4.0.0, form-data@~4.0.0:
form-data@^4.0.4, form-data@~4.0.0:
version "4.0.4"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4"
integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==
@@ -3186,6 +3187,19 @@ graphemer@^1.4.0:
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
guacamole-common-js@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz#50d814c09628b8a65451973d1acc68bc1612558d"
integrity sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg==
guacamole-lite@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/guacamole-lite/-/guacamole-lite-1.2.0.tgz#90d00d9f42585724ff957c6dff8ab552a81dad42"
integrity sha512-NeSYgbT5s5rxF0SE/kzJsV5Gg0IvnqoTOCbNIUMl23z1+SshaVfLExpxrEXSGTG0cdvY5lfZC1fOAepYriaXGg==
dependencies:
deep-extend "^0.6.0"
ws "^8.15.1"
has-bigints@^1.0.2:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
@@ -3960,7 +3974,7 @@ lodash-es@^4.17.21:
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash-unified@^1.0.2:
lodash-unified@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/lodash-unified/-/lodash-unified-1.0.3.tgz#80b1eac10ed2eb02ed189f08614a29c27d07c894"
integrity sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==
@@ -4251,10 +4265,10 @@ node-telegram-bot-api@^0.66.0:
mime "^1.6.0"
pump "^2.0.0"
nodemailer@^6.9.14:
version "6.9.14"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.9.14.tgz#845fda981f9fd5ac264f4446af908a7c78027f75"
integrity sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==
nodemailer@^7.0.7:
version "7.0.7"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-7.0.7.tgz#91a16235c08abb7805a4ec1537ca63edca79687f"
integrity sha512-jGOaRznodf62TVzdyhKt/f1Q/c3kYynk8629sgJHpRzGZj01ezbgMMWJSAjHADcwTKxco3B68/R+KHJY2T5BaA==
nodemon@^3.1.4:
version "3.1.4"
@@ -5856,6 +5870,11 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
ws@^8.15.1:
version "8.18.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
ws@~8.17.1:
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"