mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-06-20 19:46:00 +08:00
Merge branch 'chaos-zhu:main' into main
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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连接优化、支持搜索文件(夹)、新建文件(夹)功能名称缓存建议
|
||||
|
||||
28
README.md
28
README.md
@@ -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)
|
||||
[](https://edgeone.ai/?from=github)
|
||||
|
||||
[](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
|
||||
|
||||
|
||||
[](https://yxvm.com/)
|
||||
|
||||

|
||||
|
||||
 [](https://yxvm.com/)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
40
server/app/socket/rdp.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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=
|
||||
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -95,6 +95,7 @@ const basicFeatures = [
|
||||
const plusFeatures = [
|
||||
'AI Chat对话组件',
|
||||
'服务器代理&跳板机功能',
|
||||
'RDP远程win桌面连接',
|
||||
'文件对传',
|
||||
'终端单窗口模式',
|
||||
'批量修改实例配置',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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'
|
||||
|
||||
927
web/src/views/rdp/components/rdp.vue
Normal file
927
web/src/views/rdp/components/rdp.vue
Normal 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
227
web/src/views/rdp/index.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
// 发送上传开始事件
|
||||
|
||||
@@ -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
|
||||
|
||||
77
yarn.lock
77
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user