✨ update
6
.gitignore
vendored
@ -2,9 +2,9 @@ node_modules
|
|||||||
!.gitkeep
|
!.gitkeep
|
||||||
dist
|
dist
|
||||||
easynode-server.zip
|
easynode-server.zip
|
||||||
server/app/static/upload/*
|
server/app/static/*
|
||||||
server/app/socket/temp/*
|
server/app/socket/sftp-cache/*
|
||||||
app/socket/.sftp-cache/*
|
!server/app/socket/sftp-cache/.gitkeep
|
||||||
server/app/logs/*
|
server/app/logs/*
|
||||||
server/app/db/*
|
server/app/db/*
|
||||||
!server/app/db/README.md
|
!server/app/db/README.md
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# EasyNode v1.2
|
# EasyNode
|
||||||
|
|
||||||
> 一个简易的个人Linux服务器管理面板(基于Node.js).
|
> 一个简易的个人Linux服务器管理面板(基于Node.js).
|
||||||
|
|
||||||
@ -135,6 +135,10 @@ wget -qO- --no-check-certificate https://ghproxy.com/https://raw.githubuserconte
|
|||||||
|
|
||||||
## 升级指南
|
## 升级指南
|
||||||
|
|
||||||
|
- **v1.2 to v1.3**
|
||||||
|
|
||||||
|
因储存方式变更,需重新安装服务
|
||||||
|
|
||||||
- **v1.1 to v1.2**
|
- **v1.1 to v1.2**
|
||||||
|
|
||||||
### 服务端
|
### 服务端
|
||||||
|
14
package.json
@ -3,7 +3,11 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "web ssh",
|
"description": "web ssh",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["server", "client"],
|
"workspaces": [
|
||||||
|
"server",
|
||||||
|
"web",
|
||||||
|
"client"
|
||||||
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/chaos-zhu/easynode.git"
|
"url": "git+https://github.com/chaos-zhu/easynode.git"
|
||||||
@ -17,8 +21,14 @@
|
|||||||
],
|
],
|
||||||
"author": "chaoszhu",
|
"author": "chaoszhu",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\""
|
||||||
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/chaos-zhu/easynode/issues"
|
"url": "https://github.com/chaos-zhu/easynode/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/chaos-zhu/easynode#readme"
|
"homepage": "https://github.com/chaos-zhu/easynode#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ const path = require('path')
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
httpPort: 8082,
|
httpPort: 8082,
|
||||||
clientPort: 22022, // 暂不支持更改
|
clientPort: 22022, // 暂不支持更改
|
||||||
uploadDir: path.join(process.cwd(),'app/static/upload'),
|
uploadDir: path.join(process.cwd(),'app/db'),
|
||||||
staticDir: path.join(process.cwd(),'app/static'),
|
staticDir: path.join(process.cwd(),'app/static'),
|
||||||
sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'),
|
sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'),
|
||||||
sshRecordDBPath: path.join(process.cwd(),'app/db/ssh-record.db'),
|
sshRecordDBPath: path.join(process.cwd(),'app/db/ssh-record.db'),
|
||||||
|
@ -10,7 +10,7 @@ async function getClientsInfo(socketId) {
|
|||||||
let hostList = await readHostList()
|
let hostList = await readHostList()
|
||||||
hostList
|
hostList
|
||||||
?.map(({ host, name }) => {
|
?.map(({ host, name }) => {
|
||||||
let clientSocket = ClientIO(`http://${host}:${clientPort}`, {
|
let clientSocket = ClientIO(`http://${ host }:${ clientPort }`, {
|
||||||
path: '/client/os-info',
|
path: '/client/os-info',
|
||||||
forceNew: true,
|
forceNew: true,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
@ -1 +0,0 @@
|
|||||||
<html lang="zh"> <head> <meta charset="UTF-8" /> <link rel="icon" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes"> <title>EasyNode</title> <script src="//at.alicdn.com/t/c/font_3309550_flid0kihu36.js"></script> <script type="module" crossorigin src="/assets/index.be6b9da9.js"></script> <link rel="stylesheet" href="/assets/index.de24ebdf.css"> </head> <body> <div id="app"></div> <script> var _hmt = _hmt || []; (function() { var hm = document.createElement("script"); hm.src = "https://hm.baidu.com/hm.js?9cd0d4e4da3a7f1d4f6e4aaaa0ce8f25"; var s = document.getElementsByTagName("script")[0]; s.parentNode.insertBefore(hm, s); })(); </script> </body> </html>
|
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "easynode-server",
|
"name": "server",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "easynode-server",
|
"description": "easynode-server",
|
||||||
"bin": "./bin/www",
|
"bin": "./bin/www",
|
||||||
|
13
web/.editorconfig
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
@ -1,32 +1,61 @@
|
|||||||
// 规则参见:https://cn.eslint.org/docs/rules/
|
// 规则参见:https://cn.eslint.org/docs/rules/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
root: true, // 当前配置文件不能往父级查找
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
|
browser: true,
|
||||||
node: true,
|
node: true,
|
||||||
es6: true
|
es6: true
|
||||||
},
|
},
|
||||||
extends: [
|
parser: 'vue-eslint-parser',
|
||||||
'eslint:recommended' // 应用Eslint全部默认规则
|
parserOptions: {
|
||||||
],
|
// parser: 'babel-eslint',
|
||||||
'parserOptions': {
|
ecmaVersion: 2020,
|
||||||
'ecmaVersion': 'latest',
|
sourceType: 'module',
|
||||||
'sourceType': 'module' // 目标类型 Node项目得添加这个
|
ecmaFeatures: {
|
||||||
|
'jsx': true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// 自定义规则,可以覆盖 extends 的配置【安装Eslint插件可以静态检查本地文件是否符合以下规则】
|
extends: [
|
||||||
'ignorePatterns': ['*.html', 'node-os-utils'],
|
'eslint:recommended',
|
||||||
|
'plugin:vue/vue3-recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['*.html',],
|
||||||
rules: {
|
rules: {
|
||||||
// 0: 关闭规则(允许) 1/2: 警告warning/错误error(不允许)
|
// vue
|
||||||
|
'vue/max-attributes-per-line': ['error', {
|
||||||
|
singleline: 3,
|
||||||
|
multiline: {
|
||||||
|
max: 1
|
||||||
|
}
|
||||||
|
},],
|
||||||
|
'vue/no-v-model-argument': 0,
|
||||||
|
'vue/multi-word-component-names': 0,
|
||||||
|
'vue/no-multiple-template-root': 0,
|
||||||
|
'vue/singleline-html-element-content-newline': 0,
|
||||||
|
|
||||||
|
// js
|
||||||
|
'import/no-extraneous-dependencies': 0,
|
||||||
'no-console': 'off',
|
'no-console': 'off',
|
||||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||||
'template-curly-spacing': ['error', 'always'], // 模板字符串空格
|
'template-curly-spacing': ['error', 'always',], // 模板字符串空格
|
||||||
'default-case': 0,
|
'default-case': 0,
|
||||||
'object-curly-spacing': ['error', 'always'],
|
'eslint-comments/no-unlimited-disable': 0,
|
||||||
'no-multi-spaces': ['error'],
|
'object-curly-spacing': ['error', 'always',],
|
||||||
indent: ['error', 2, { 'SwitchCase': 1 }], // 缩进:2
|
'no-multi-spaces': ['error',],
|
||||||
quotes: ['error', 'single'], // 引号:single单引 double双引
|
indent: ['error', 2, { 'SwitchCase': 1 },], // 缩进:2
|
||||||
semi: ['error', 'never'], // 结尾分号:never禁止 always必须
|
quotes: ['error', 'single',], // 引号:single单引 double双引
|
||||||
'comma-dangle': ['error', 'never'], // 对象拖尾逗号
|
semi: ['error', 'never',], // 结尾分号:never禁止 always必须
|
||||||
'no-redeclare': ['error', { builtinGlobals: true }], // 禁止重复对象声明
|
'comma-dangle': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
arrays: 'always',
|
||||||
|
objects: 'never',
|
||||||
|
imports: 'never',
|
||||||
|
exports: 'never',
|
||||||
|
functions: 'never'
|
||||||
|
},
|
||||||
|
], // ['error', 'never'], // 拖尾逗号
|
||||||
|
'no-redeclare': ['error', { builtinGlobals: true },], // 禁止重复对象声明
|
||||||
'no-multi-assign': 0,
|
'no-multi-assign': 0,
|
||||||
'no-restricted-globals': 0,
|
'no-restricted-globals': 0,
|
||||||
'space-before-function-paren': 0, // 函数定义时括号前面空格
|
'space-before-function-paren': 0, // 函数定义时括号前面空格
|
||||||
@ -71,11 +100,11 @@ module.exports = {
|
|||||||
'no-extra-semi': 2, // 禁止不必要的分号
|
'no-extra-semi': 2, // 禁止不必要的分号
|
||||||
// curly: ['error', 'multi'], // if、else、for、while 语句单行代码时不使用大括号
|
// curly: ['error', 'multi'], // if、else、for、while 语句单行代码时不使用大括号
|
||||||
'dot-notation': 0, // 允许使用点号或方括号来访问对象属性
|
'dot-notation': 0, // 允许使用点号或方括号来访问对象属性
|
||||||
'dot-location': ['error', 'property'], // 点操作符位置,要求跟随下一行
|
'dot-location': ['error', 'property',], // 点操作符位置,要求跟随下一行
|
||||||
'no-else-return': 2, // 禁止if中有return后又else
|
'no-else-return': 2, // 禁止if中有return后又else
|
||||||
'no-implicit-coercion': [2, { allow: ['!!', '~', '+'] }], // 禁止隐式转换,allow字段内符号允许
|
'no-implicit-coercion': [2, { allow: ['!!', '~', '+',] },], // 禁止隐式转换,allow字段内符号允许
|
||||||
'no-trailing-spaces': 1, //一行结束后面不要有空格
|
'no-trailing-spaces': 1, //一行结束后面不要有空格
|
||||||
'no-multiple-empty-lines': [1, { 'max': 1 }], // 空行最多不能超过1行
|
'no-multiple-empty-lines': [1, { 'max': 1 },], // 空行最多不能超过1行
|
||||||
'no-useless-return': 2,
|
'no-useless-return': 2,
|
||||||
'wrap-iife': 0, // 允许自调用函数
|
'wrap-iife': 0, // 允许自调用函数
|
||||||
'yoda': 0, // 允许yoda语句
|
'yoda': 0, // 允许yoda语句
|
28
web/.gitignore
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
1
web/.yarnrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
sass_binary_site "https://registry.npmmirror.com/node-sass/"
|
4
web/Dockerfile
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
FROM nginx:stable-alpine
|
||||||
|
WORKDIR /easynode-web
|
||||||
|
COPY dist /easynode-web
|
||||||
|
COPY web.conf /etc/nginx/conf.d/default.conf
|
21
web/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 chaoszhu
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
15
web/index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=0,user-scalable=yes">
|
||||||
|
<title>EasyNode</title>
|
||||||
|
<script src="//at.alicdn.com/t/c/font_3309550_flid0kihu36.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
12
web/jsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@views/*": ["./src/views/*"],
|
||||||
|
"@utils/*": ["./src/utils/*"],
|
||||||
|
"@components/*": ["./src/components/*"],
|
||||||
|
"@assets/*": ["./src/assets/*"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
56
web/package.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "easynode-web",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --port 5050",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/lang-cpp": "^6.0.1",
|
||||||
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
|
"@codemirror/lang-html": "^6.1.0",
|
||||||
|
"@codemirror/lang-java": "^6.0.0",
|
||||||
|
"@codemirror/lang-javascript": "^6.0.1",
|
||||||
|
"@codemirror/lang-json": "^6.0.0",
|
||||||
|
"@codemirror/lang-markdown": "^6.0.0",
|
||||||
|
"@codemirror/lang-php": "^6.0.0",
|
||||||
|
"@codemirror/lang-python": "^6.0.0",
|
||||||
|
"@codemirror/lang-sql": "^6.0.0",
|
||||||
|
"@codemirror/lang-xml": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.2.0",
|
||||||
|
"@codemirror/legacy-modes": "^6.1.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.0.0",
|
||||||
|
"axios": "^0.26.1",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
|
"crypto-js": "^4.1.1",
|
||||||
|
"element-plus": "^2.1.7",
|
||||||
|
"jsencrypt": "^3.0.0-rc.1",
|
||||||
|
"pinia": "^2.0.16",
|
||||||
|
"socket.io-client": "^4.4.1",
|
||||||
|
"vue": "^3.2.31",
|
||||||
|
"vue-codemirror": "^6.0.0",
|
||||||
|
"vue-router": "^4.0.14",
|
||||||
|
"xterm": "^4.19.0",
|
||||||
|
"xterm-addon-fit": "^0.5.0",
|
||||||
|
"xterm-addon-search": "^0.9.0",
|
||||||
|
"xterm-addon-search-bar": "^0.2.0",
|
||||||
|
"xterm-addon-web-links": "^0.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^2.3.1",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^1.3.9",
|
||||||
|
"eslint": "^8.5.0",
|
||||||
|
"eslint-plugin-vue": "^8.2.0",
|
||||||
|
"sass": "^1.49.11",
|
||||||
|
"unplugin-auto-import": "^0.6.9",
|
||||||
|
"unplugin-vue-components": "^0.18.5",
|
||||||
|
"vite": "^2.9.1",
|
||||||
|
"vite-plugin-compression": "^0.5.1",
|
||||||
|
"vite-plugin-style-import": "^1.4.1",
|
||||||
|
"vue-eslint-parser": "^9.0.2"
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
0
web/public/upload/.gitkeep
Normal file
22
web/src/App.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider :locale="locale">
|
||||||
|
<router-view />
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'App',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
locale: zhCn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
82
web/src/api/index.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import axios from '@/utils/axios'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getOsInfo (params = {}) {
|
||||||
|
return axios({ url: '/monitor', method: 'get', params })
|
||||||
|
},
|
||||||
|
getIpInfo (params = {}) {
|
||||||
|
return axios({ url: '/ip-info', method: 'get', params })
|
||||||
|
},
|
||||||
|
updateSSH(data) {
|
||||||
|
return axios({ url: '/update-ssh', method: 'post', data })
|
||||||
|
},
|
||||||
|
removeSSH(host) {
|
||||||
|
return axios({ url: '/remove-ssh', method: 'post', data: { host } })
|
||||||
|
},
|
||||||
|
existSSH(host) {
|
||||||
|
return axios({ url: '/exist-ssh', method: 'post', data: { host } })
|
||||||
|
},
|
||||||
|
getCommand(host) {
|
||||||
|
return axios({ url: '/command', method: 'get', params: { host } })
|
||||||
|
},
|
||||||
|
getHostList() {
|
||||||
|
return axios({ url: '/host-list', method: 'get' })
|
||||||
|
},
|
||||||
|
saveHost(data) {
|
||||||
|
return axios({ url: '/host-save', method: 'post', data })
|
||||||
|
},
|
||||||
|
updateHost(data) {
|
||||||
|
return axios({ url: '/host-save', method: 'put', data })
|
||||||
|
},
|
||||||
|
removeHost(data) {
|
||||||
|
return axios({ url: '/host-remove', method: 'post', data })
|
||||||
|
},
|
||||||
|
getPubPem() {
|
||||||
|
return axios({ url: '/get-pub-pem', method: 'get' })
|
||||||
|
},
|
||||||
|
login(data) {
|
||||||
|
return axios({ url: '/login', method: 'post', data })
|
||||||
|
},
|
||||||
|
getLoginRecord() {
|
||||||
|
return axios({ url: '/get-login-record', method: 'get' })
|
||||||
|
},
|
||||||
|
updatePwd(data) {
|
||||||
|
return axios({ url: '/pwd', method: 'put', data })
|
||||||
|
},
|
||||||
|
updateHostSort(data) {
|
||||||
|
return axios({ url: '/host-sort', method: 'put', data })
|
||||||
|
},
|
||||||
|
getUserEmailList() {
|
||||||
|
return axios({ url: '/user-email', method: 'get' })
|
||||||
|
},
|
||||||
|
getSupportEmailList() {
|
||||||
|
return axios({ url: '/support-email', method: 'get' })
|
||||||
|
},
|
||||||
|
updateUserEmailList(data) {
|
||||||
|
return axios({ url: '/user-email', method: 'post', data })
|
||||||
|
},
|
||||||
|
deleteUserEmail(email) {
|
||||||
|
return axios({ url: `/user-email/${ email }`, method: 'delete' })
|
||||||
|
},
|
||||||
|
pushTestEmail(data) {
|
||||||
|
return axios({ url: '/push-email', method: 'post', data })
|
||||||
|
},
|
||||||
|
getNotifyList() {
|
||||||
|
return axios({ url: '/notify', method: 'get' })
|
||||||
|
},
|
||||||
|
updateNotifyList(data) {
|
||||||
|
return axios({ url: '/notify', method: 'put', data })
|
||||||
|
},
|
||||||
|
getGroupList() {
|
||||||
|
return axios({ url: '/group', method: 'get' })
|
||||||
|
},
|
||||||
|
addGroup(data) {
|
||||||
|
return axios({ url: '/group', method: 'post', data })
|
||||||
|
},
|
||||||
|
updateGroup(id, data) {
|
||||||
|
return axios({ url: `/group/${ id }`, method: 'put', data })
|
||||||
|
},
|
||||||
|
deleteGroup(id) {
|
||||||
|
return axios({ url: `/group/${ id }`, method: 'delete' })
|
||||||
|
}
|
||||||
|
}
|
BIN
web/src/assets/bellSound.mp3
Normal file
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
BIN
web/src/assets/download.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
BIN
web/src/assets/image/system/dir.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
web/src/assets/image/system/download.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
web/src/assets/image/system/file.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
BIN
web/src/assets/image/system/return.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
web/src/assets/image/system/search.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
web/src/assets/image/system/unknow.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
web/src/assets/image/system/upload.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
web/src/assets/logo-easynode.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
web/src/assets/logo.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
18
web/src/assets/scss/animate.scss
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// vue transition 动画
|
||||||
|
.list-move, /* apply transition to moving elements */
|
||||||
|
.list-enter-active,
|
||||||
|
.list-leave-active {
|
||||||
|
transition: all 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-enter-from,
|
||||||
|
.list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ensure leaving items are taken out of layout flow so that moving
|
||||||
|
animations can be calculated correctly. */
|
||||||
|
.list-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
}
|
18
web/src/assets/scss/element-ui.scss
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// element css bug
|
||||||
|
.el-notification__content {
|
||||||
|
text-align: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-date-editor {
|
||||||
|
--el-date-editor-width: 100%;
|
||||||
|
}
|
||||||
|
.el-input__wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tabs__nav-scroll .el-tabs__nav {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.el-tabs__content {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
44
web/src/assets/scss/global.scss
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
// 滚动条
|
||||||
|
html, body, div, ul, section, textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
// 滚动条整体部分
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
width: 2px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底层轨道
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动滑块
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 10px;
|
||||||
|
// background-color: #1989fa;
|
||||||
|
background-image: -webkit-gradient(linear, 40% 0%, 75% 84%, from(#a18cd1), to(#fbc2eb), color-stop(.6, #54DE5D));
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #067ef7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 全局背景
|
||||||
|
body {
|
||||||
|
// background-color: #f4f6f9;
|
||||||
|
background-position: center center;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-image: url(../bg.jpg), linear-gradient(to bottom, #010179, #F5C4C1, #151799);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
min-width: 1200px;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
54
web/src/assets/scss/reset.scss
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
html, body, div, span, applet, object, iframe,
|
||||||
|
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||||
|
a, abbr, acronym, address, big, cite, code,
|
||||||
|
del, dfn, em, img, ins, kbd, q, s, samp,
|
||||||
|
small, strike, strong, sub, sup, tt, var,
|
||||||
|
b, u, i, center,
|
||||||
|
dl, dt, dd, ol, ul, li,
|
||||||
|
fieldset, form, label, legend,
|
||||||
|
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||||
|
article, aside, canvas, details, embed,
|
||||||
|
figure, figcaption, footer, header, hgroup,
|
||||||
|
menu, nav, output, ruby, section, summary,
|
||||||
|
time, mark, audio, video {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
/* font-size: 100%; */
|
||||||
|
font: inherit;
|
||||||
|
vertical-align: baseline;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTML5 display-role reset for older browsers */
|
||||||
|
article, aside, details, figcaption, figure,
|
||||||
|
footer, header, hgroup, menu, nav, section {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol, ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote, q {
|
||||||
|
quotes: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote::before, blockquote::after,
|
||||||
|
q::before, q::after {
|
||||||
|
content: '';
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
BIN
web/src/assets/upload.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
234
web/src/components/code-edit/index.vue
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
width="80%"
|
||||||
|
:top="'20px'"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:show-close="false"
|
||||||
|
center
|
||||||
|
custom-class="container"
|
||||||
|
@closed="handleClosed"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="title">
|
||||||
|
FileName - <span>{{ status }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<codemirror
|
||||||
|
v-model="code"
|
||||||
|
placeholder="Code goes here..."
|
||||||
|
:style="{ height: '79vh', minHeight: '500px' }"
|
||||||
|
:autofocus="true"
|
||||||
|
:indent-with-tab="true"
|
||||||
|
:tab-size="4"
|
||||||
|
:extensions="extensions"
|
||||||
|
@ready="status = '准备中'"
|
||||||
|
@change="handleChange"
|
||||||
|
@focus="status = '编辑中'"
|
||||||
|
@blur="status = '未聚焦'"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<footer>
|
||||||
|
<div>
|
||||||
|
<el-select v-model="curLang" placeholder="Select language" size="small">
|
||||||
|
<el-option
|
||||||
|
v-for="item in languageKey"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleSave">保存</el-button>
|
||||||
|
<el-button type="info" @click="handleClose">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Codemirror } from 'vue-codemirror'
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
|
import languages from './languages'
|
||||||
|
import { sortString, getSuffix } from '@/utils'
|
||||||
|
|
||||||
|
const languageKey = sortString(Object.keys(languages))
|
||||||
|
// console.log('languages: ', languages)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CodeEdit',
|
||||||
|
components: {
|
||||||
|
Codemirror
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
show: {
|
||||||
|
required: true,
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
originalCode: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:show', 'save', 'closed',],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
languageKey,
|
||||||
|
curLang: null,
|
||||||
|
status: '准备中',
|
||||||
|
loading: false,
|
||||||
|
isTips: false,
|
||||||
|
code: 'hello world'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
extensions() {
|
||||||
|
let res = []
|
||||||
|
if(this.curLang) res.push(languages[this.curLang]())
|
||||||
|
res.push(oneDark)
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
get() {
|
||||||
|
return this.show
|
||||||
|
},
|
||||||
|
set(newVal) {
|
||||||
|
this.$emit('update:show', newVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
originalCode(newVal) {
|
||||||
|
this.code = newVal
|
||||||
|
},
|
||||||
|
filename(newVal) {
|
||||||
|
try {
|
||||||
|
let name = String(newVal).toLowerCase()
|
||||||
|
let suffix = getSuffix(name)
|
||||||
|
switch(suffix) {
|
||||||
|
case 'js': return this.curLang = 'javascript'
|
||||||
|
case 'ts': return this.curLang = 'typescript'
|
||||||
|
case 'jsx': return this.curLang = 'jsx'
|
||||||
|
case 'tsx': return this.curLang = 'tsx'
|
||||||
|
case 'html': return this.curLang = 'html'
|
||||||
|
case 'css': return this.curLang = 'css'
|
||||||
|
case 'json': return this.curLang = 'json'
|
||||||
|
case 'swift': return this.curLang = 'swift'
|
||||||
|
case 'yaml': return this.curLang = 'yaml'
|
||||||
|
case 'yml': return this.curLang = 'yaml'
|
||||||
|
case 'vb': return this.curLang = 'vb'
|
||||||
|
case 'dockerfile': return this.curLang = 'dockerFile'
|
||||||
|
case 'sh': return this.curLang = 'shell'
|
||||||
|
case 'r': return this.curLang = 'r'
|
||||||
|
case 'ruby': return this.curLang = 'ruby'
|
||||||
|
case 'go': return this.curLang = 'go'
|
||||||
|
case 'julia': return this.curLang = 'julia'
|
||||||
|
case 'conf': return this.curLang = 'shell'
|
||||||
|
case 'cpp': return this.curLang = 'cpp'
|
||||||
|
case 'java': return this.curLang = 'java'
|
||||||
|
case 'xml': return this.curLang = 'xml'
|
||||||
|
case 'php': return this.curLang = 'php'
|
||||||
|
case 'sql': return this.curLang = 'sql'
|
||||||
|
case 'md': return this.curLang = 'markdown'
|
||||||
|
case 'py': return this.curLang = 'python'
|
||||||
|
default:
|
||||||
|
console.log('不支持的文件类型: ', newVal)
|
||||||
|
console.log('默认: ', 'shell')
|
||||||
|
return this.curLang = 'shell'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('未知文件类型', newVal, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleSave() {
|
||||||
|
if(this.isTips) {
|
||||||
|
this.$messageBox.confirm( '文件已变更, 确认保存?', 'Warning', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
this.visible = false
|
||||||
|
this.$emit('save', this.code)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.visible = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleClosed() {
|
||||||
|
this.isTips = false
|
||||||
|
this.$emit('closed')
|
||||||
|
},
|
||||||
|
handleClose() {
|
||||||
|
if(this.isTips) {
|
||||||
|
this.$messageBox.confirm( '文件已变更, 确认丢弃?', 'Warning', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
this.visible = false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.visible = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleChange() {
|
||||||
|
this.isTips = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.container {
|
||||||
|
.el-dialog__header {
|
||||||
|
padding: 5px 0;
|
||||||
|
.title {
|
||||||
|
color: #409eff;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-dialog__body {
|
||||||
|
padding: 0;
|
||||||
|
.cm-scroller {
|
||||||
|
// 滚动条整体部分
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
width: 8px;
|
||||||
|
background-color: #282c34;
|
||||||
|
}
|
||||||
|
// 底层轨道
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background-color: #282c34;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-dialog__footer {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 15px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
50
web/src/components/code-edit/languages.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { javascript } from '@codemirror/lang-javascript'
|
||||||
|
import { html } from '@codemirror/lang-html'
|
||||||
|
import { cpp } from '@codemirror/lang-cpp'
|
||||||
|
import { css } from '@codemirror/lang-css'
|
||||||
|
import { StreamLanguage } from '@codemirror/language'
|
||||||
|
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
|
||||||
|
import { julia } from '@codemirror/legacy-modes/mode/julia'
|
||||||
|
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
|
||||||
|
import { r } from '@codemirror/legacy-modes/mode/r'
|
||||||
|
import { ruby } from '@codemirror/legacy-modes/mode/ruby'
|
||||||
|
import { shell } from '@codemirror/legacy-modes/mode/shell'
|
||||||
|
import { swift } from '@codemirror/legacy-modes/mode/swift'
|
||||||
|
import { vb } from '@codemirror/legacy-modes/mode/vb'
|
||||||
|
import { yaml } from '@codemirror/legacy-modes/mode/yaml'
|
||||||
|
|
||||||
|
import { go } from '@codemirror/legacy-modes/mode/go'
|
||||||
|
import { java } from '@codemirror/lang-java'
|
||||||
|
import { json } from '@codemirror/lang-json'
|
||||||
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
|
import { sql, MySQL } from '@codemirror/lang-sql'
|
||||||
|
import { php } from '@codemirror/lang-php'
|
||||||
|
import { python } from '@codemirror/lang-python'
|
||||||
|
import { xml } from '@codemirror/lang-xml'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
javascript,
|
||||||
|
typescript: () => javascript({ typescript: true }),
|
||||||
|
jsx: () => javascript({ jsx: true }),
|
||||||
|
tsx: () => javascript({ typescript: true, jsx: true }),
|
||||||
|
html,
|
||||||
|
css,
|
||||||
|
json,
|
||||||
|
swift: () => StreamLanguage.define(swift),
|
||||||
|
yaml: () => StreamLanguage.define(yaml),
|
||||||
|
vb: () => StreamLanguage.define(vb),
|
||||||
|
dockerFile: () => StreamLanguage.define(dockerFile),
|
||||||
|
shell: () => StreamLanguage.define(shell),
|
||||||
|
r: () => StreamLanguage.define(r),
|
||||||
|
ruby: () => StreamLanguage.define(ruby),
|
||||||
|
go: () => StreamLanguage.define(go),
|
||||||
|
julia: () => StreamLanguage.define(julia),
|
||||||
|
nginx: () => StreamLanguage.define(nginx),
|
||||||
|
cpp,
|
||||||
|
java,
|
||||||
|
xml,
|
||||||
|
php,
|
||||||
|
sql: () => sql({ dialect: MySQL }),
|
||||||
|
markdown,
|
||||||
|
python
|
||||||
|
}
|
102
web/src/components/input-command/index.vue
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
width="800px"
|
||||||
|
:top="'20vh'"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:show-close="false"
|
||||||
|
center
|
||||||
|
custom-class="container"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="title">
|
||||||
|
输入多行命令发送到终端执行
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-input
|
||||||
|
v-model="command"
|
||||||
|
:autosize="{ minRows: 10, maxRows: 20 }"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="Please input command"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<footer>
|
||||||
|
<div class="btns">
|
||||||
|
<el-button type="primary" @click="handleSave">执行</el-button>
|
||||||
|
<el-button type="info" @click="visible = false">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'InputCommand',
|
||||||
|
props: {
|
||||||
|
show: {
|
||||||
|
required: true,
|
||||||
|
type: Boolean
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:show', 'closed', 'input-command',],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
command: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
visible: {
|
||||||
|
get() {
|
||||||
|
return this.show
|
||||||
|
},
|
||||||
|
set(newVal) {
|
||||||
|
this.$emit('update:show', newVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleSave() {
|
||||||
|
this.$emit('input-command', this.command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.container {
|
||||||
|
.el-dialog__header {
|
||||||
|
padding: 5px 0;
|
||||||
|
.title {
|
||||||
|
color: #409eff;
|
||||||
|
text-align: left;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-dialog__body {
|
||||||
|
padding: 10px!important;
|
||||||
|
}
|
||||||
|
.el-dialog__footer {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 15px;
|
||||||
|
justify-content: space-between;
|
||||||
|
.btns {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
32
web/src/components/svg-icon.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<svg class="icon" aria-hidden="true">
|
||||||
|
<use :xlink:href="href" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<script >
|
||||||
|
export default {
|
||||||
|
name: 'IconSvg',
|
||||||
|
props: {
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
href() {
|
||||||
|
return `#${ this.name }`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.icon {
|
||||||
|
// width: 16px;
|
||||||
|
// height: 16px;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
fill: currentColor;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
36
web/src/components/tooltip.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
:show-after="showAfter"
|
||||||
|
:hide-after="0"
|
||||||
|
:content="content"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'Tooltip',
|
||||||
|
props: {
|
||||||
|
showAfter: {
|
||||||
|
required: false,
|
||||||
|
type: Number,
|
||||||
|
default: 1000
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
31
web/src/main.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import useStore from '@store/index'
|
||||||
|
import router from './router'
|
||||||
|
import tools from './plugins/tools'
|
||||||
|
import elementPlugins from './plugins/element'
|
||||||
|
import globalComponents from './plugins/components'
|
||||||
|
import api from './api'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './assets/scss/reset.scss'
|
||||||
|
import './assets/scss/global.scss'
|
||||||
|
import './assets/scss/element-ui.scss'
|
||||||
|
import './assets/scss/animate.scss'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
elementPlugins(app)
|
||||||
|
globalComponents(app)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.config.globalProperties.$api = api
|
||||||
|
app.config.globalProperties.$tools = tools
|
||||||
|
app.config.globalProperties.$store = useStore()
|
||||||
|
|
||||||
|
const serviceURI = import.meta.env.DEV ? process.env.serviceURI : location.origin
|
||||||
|
app.config.globalProperties.$serviceURI = serviceURI
|
||||||
|
app.config.globalProperties.$clientPort = process.env.clientPort || 22022
|
||||||
|
console.warn('ISDEV: ', import.meta.env.DEV)
|
||||||
|
console.warn('serviceURI: ', serviceURI)
|
||||||
|
|
||||||
|
app.mount('#app')
|
7
web/src/plugins/components.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import svgIcon from '../components/svg-icon.vue'
|
||||||
|
import tooltip from '../components/tooltip.vue'
|
||||||
|
|
||||||
|
export default (app) => {
|
||||||
|
app.component('SvgIcon', svgIcon)
|
||||||
|
app.component('Tooltip', tooltip)
|
||||||
|
}
|
8
web/src/plugins/element.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
|
||||||
|
|
||||||
|
export default (app) => {
|
||||||
|
app.config.globalProperties.$ELEMENT = { size: 'default' }
|
||||||
|
app.config.globalProperties.$message = ElMessage
|
||||||
|
app.config.globalProperties.$messageBox = ElMessageBox
|
||||||
|
app.config.globalProperties.$notification = ElNotification
|
||||||
|
}
|
40
web/src/plugins/tools.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import ping from '../utils/ping'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
toFixed(value, count = 1) {
|
||||||
|
value = Number(value)
|
||||||
|
return isNaN(value) ? '--' : value.toFixed(count)
|
||||||
|
},
|
||||||
|
formatTime(second = 0) {
|
||||||
|
let day = Math.floor(second / 60 / 60 / 24)
|
||||||
|
let hour = Math.floor(second / 60 / 60 % 24)
|
||||||
|
let minute = Math.floor(second / 60 % 60)
|
||||||
|
return `${ day }天${ hour }时${ minute }分`
|
||||||
|
},
|
||||||
|
formatNetSpeed(netSpeedMB) {
|
||||||
|
netSpeedMB = Number(netSpeedMB) || 0
|
||||||
|
if (netSpeedMB >= 1) return `${ netSpeedMB.toFixed(2) } MB/s`
|
||||||
|
return `${ (netSpeedMB * 1024).toFixed(1) } KB/s`
|
||||||
|
},
|
||||||
|
// format: time OR date
|
||||||
|
formatTimestamp: (timestamp, format = 'time') => {
|
||||||
|
if(typeof(timestamp) !== 'number') return '--'
|
||||||
|
let date = new Date(timestamp)
|
||||||
|
let padZero = (num) => String(num).padStart(2, '0')
|
||||||
|
let year = date.getFullYear()
|
||||||
|
let mounth = padZero(date.getMonth() + 1)
|
||||||
|
let day = padZero(date.getDate())
|
||||||
|
let hours = padZero(date.getHours())
|
||||||
|
let minute = padZero(date.getMinutes())
|
||||||
|
let second = padZero(date.getSeconds())
|
||||||
|
switch (format) {
|
||||||
|
case 'date':
|
||||||
|
return `${ year }-${ mounth }-${ day }`
|
||||||
|
case 'time':
|
||||||
|
return `${ year }-${ mounth }-${ day } ${ hours }:${ minute }:${ second }`
|
||||||
|
default:
|
||||||
|
return `${ year }-${ mounth }-${ day } ${ hours }:${ minute }:${ second }`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ping
|
||||||
|
}
|
22
web/src/router/index.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import hostList from '@views/list/index.vue'
|
||||||
|
import login from '@views/login/index.vue'
|
||||||
|
import terminal from '@views/terminal/index.vue'
|
||||||
|
import test from '@views/test/index.vue'
|
||||||
|
|
||||||
|
// const hostList = () => import('@views/list/index.vue')
|
||||||
|
// const login = () => import('@views/login/index.vue')
|
||||||
|
// const terminal = () => import('@views/terminal/index.vue')
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', component: hostList },
|
||||||
|
{ path: '/login', component: login },
|
||||||
|
{ path: '/terminal', component: terminal },
|
||||||
|
{ path: '/test', component: test },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
54
web/src/store/index.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||||
|
import $api from '@/api'
|
||||||
|
import ping from '@/utils/ping'
|
||||||
|
|
||||||
|
const useStore = defineStore({
|
||||||
|
id: 'global',
|
||||||
|
state: () => ({
|
||||||
|
hostList: [],
|
||||||
|
token: sessionStorage.getItem('token') || localStorage.getItem('token') || null
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async setJwtToken(token, isSession = true) {
|
||||||
|
if(isSession) sessionStorage.setItem('token', token)
|
||||||
|
else localStorage.setItem('token', token)
|
||||||
|
this.$patch({ token })
|
||||||
|
},
|
||||||
|
async clearJwtToken() {
|
||||||
|
localStorage.clear('token')
|
||||||
|
sessionStorage.clear('token')
|
||||||
|
this.$patch({ token: null })
|
||||||
|
},
|
||||||
|
async getHostList() {
|
||||||
|
const { data: hostList } = await $api.getHostList()
|
||||||
|
this.$patch({ hostList })
|
||||||
|
// console.log('pinia: ', this.hostList)
|
||||||
|
// this.getHostPing()
|
||||||
|
},
|
||||||
|
getHostPing() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.hostList.forEach((item) => {
|
||||||
|
const { host } = item
|
||||||
|
ping(`http://${ host }:${this.$clientPort}`)
|
||||||
|
.then((res) => {
|
||||||
|
item.ping = res
|
||||||
|
})
|
||||||
|
})
|
||||||
|
console.clear()
|
||||||
|
// console.warn('Please tick \'Preserve Log\'')
|
||||||
|
}, 1500)
|
||||||
|
},
|
||||||
|
async sortHostList(list) {
|
||||||
|
let hostList = list.map(({ host }) => {
|
||||||
|
return this.hostList.find(item => item.host === host)
|
||||||
|
})
|
||||||
|
this.$patch({ hostList })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.accept(acceptHMRUpdate(useStore, import.meta.hot))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useStore
|
56
web/src/utils/axios.js
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import router from '../router'
|
||||||
|
import useStore from '../store'
|
||||||
|
|
||||||
|
axios.defaults.timeout = 10 * 1000
|
||||||
|
axios.defaults.withCredentials = true
|
||||||
|
axios.defaults.baseURL = process.env.serviceApiPrefix || '/api/v1'
|
||||||
|
|
||||||
|
const instance = axios.create()
|
||||||
|
|
||||||
|
instance.interceptors.request.use((config) => {
|
||||||
|
config.headers.token = useStore().token
|
||||||
|
return config
|
||||||
|
}, (error) => {
|
||||||
|
ElMessage.error({ message: '请求超时!' })
|
||||||
|
return Promise.reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.interceptors.response.use((response) => {
|
||||||
|
if (response.status === 200) return response.data
|
||||||
|
}, (error) => {
|
||||||
|
let { response } = error
|
||||||
|
if(error?.message?.includes('timeout')) {
|
||||||
|
ElMessage({ message: '请求超时', type: 'error', center: true })
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
switch (response?.data.status) {
|
||||||
|
case 401: // token过期
|
||||||
|
// ElMessageBox.alert(
|
||||||
|
// '<strong>登录态已失效</strong>',
|
||||||
|
// 'Error',
|
||||||
|
// {
|
||||||
|
// dangerouslyUseHTMLString: true,
|
||||||
|
// confirmButtonText: '重新登录'
|
||||||
|
// }
|
||||||
|
// ).then(() => {
|
||||||
|
// router.push('login')
|
||||||
|
// })
|
||||||
|
// ElMessage({ message: '登录态已失效', type: 'error', center: true })
|
||||||
|
router.push('login')
|
||||||
|
return Promise.reject(error)
|
||||||
|
case 403: // 无token 不提示
|
||||||
|
router.push('login')
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
switch(response?.status) {
|
||||||
|
case 404:
|
||||||
|
ElMessage({ message: '404 Not Found', type: 'error', center: true })
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
ElMessage({ message: response?.data.msg || error?.message || '网络错误', type: 'error', center: true })
|
||||||
|
return Promise.reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default instance
|
110
web/src/utils/index.js
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
|
||||||
|
import JSRsaEncrypt from 'jsencrypt'
|
||||||
|
import CryptoJS from 'crypto-js'
|
||||||
|
|
||||||
|
export const randomStr = (e) =>{
|
||||||
|
e = e || 16
|
||||||
|
let str = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678',
|
||||||
|
a = str.length,
|
||||||
|
res = ''
|
||||||
|
for (let i = 0; i < e; i++) res += str.charAt(Math.floor(Math.random() * a))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// rsa公钥加密
|
||||||
|
export const RSAEncrypt = (text) => {
|
||||||
|
const publicKey = localStorage.getItem('publicKey')
|
||||||
|
if(!publicKey) return -1 // 公钥不存在
|
||||||
|
const RSAPubEncrypt = new JSRsaEncrypt() // 生成实例
|
||||||
|
RSAPubEncrypt.setPublicKey(publicKey) // 配置公钥(不是将公钥实例化时传入!!!)
|
||||||
|
const ciphertext = RSAPubEncrypt.encrypt(text) // 加密
|
||||||
|
// console.log('rsa加密:', ciphertext)
|
||||||
|
return ciphertext
|
||||||
|
}
|
||||||
|
|
||||||
|
// rsa公钥解密
|
||||||
|
export const RSADecrypt = (text) => {
|
||||||
|
const publicKey = localStorage.getItem('publicKey')
|
||||||
|
if(!publicKey) return -1 // 公钥不存在
|
||||||
|
const RSAPubEncrypt = new JSRsaEncrypt() // 生成实例
|
||||||
|
RSAPubEncrypt.setPublicKey(publicKey) // 配置公钥(不是将公钥实例化时传入!!!)
|
||||||
|
const ciphertext = RSAPubEncrypt.encrypt(text) // 加密
|
||||||
|
return ciphertext
|
||||||
|
}
|
||||||
|
|
||||||
|
// aes加密
|
||||||
|
export const AESEncrypt = (text, secretKey) => {
|
||||||
|
let ciphertext = CryptoJS.AES.encrypt(text, secretKey).toString()
|
||||||
|
return ciphertext
|
||||||
|
}
|
||||||
|
|
||||||
|
// aes解密
|
||||||
|
export const AESDecrypt = (ciphertext, secretKey) => {
|
||||||
|
let bytes = CryptoJS.AES.decrypt(ciphertext, secretKey)
|
||||||
|
let originalText = bytes.toString(CryptoJS.enc.Utf8)
|
||||||
|
return originalText
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortString = (arr = []) => {
|
||||||
|
return arr.sort((a, b) => {
|
||||||
|
let c1 = ''
|
||||||
|
let c2 = ''
|
||||||
|
let temp = a.length > b.length ? b : a
|
||||||
|
for(let i = 0; i < temp.length; i++) {
|
||||||
|
c1 = a[i].toLowerCase()
|
||||||
|
c2 = b[i].toLowerCase()
|
||||||
|
if(c1 !== c2) break
|
||||||
|
}
|
||||||
|
return c1.charCodeAt() - c2.charCodeAt()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dirType = ['d', 'l',] // 文件夹或者链接文件夹
|
||||||
|
|
||||||
|
export const fileType = ['-',] // 文本文件或者二进制文件
|
||||||
|
|
||||||
|
export const isDir = (type) => dirType.includes(type)
|
||||||
|
|
||||||
|
export const isFile = (type) => fileType.includes(type)
|
||||||
|
|
||||||
|
export const sortDirTree = (tree = []) => {
|
||||||
|
const dirsAndlinks = tree.filter(item => isDir(item.type))
|
||||||
|
const others = tree.filter(item => !(isDir(item.type)))
|
||||||
|
const sort = (arr = []) => {
|
||||||
|
return arr.sort((a, b) => {
|
||||||
|
const { name: aName } = a
|
||||||
|
const { name: bName } = b
|
||||||
|
let c1 = ''
|
||||||
|
let c2 = ''
|
||||||
|
let temp = aName.length > bName.length ? bName : aName
|
||||||
|
for(let i = 0; i < temp.length; i++) {
|
||||||
|
c1 = aName[i].toLowerCase()
|
||||||
|
c2 = bName[i].toLowerCase()
|
||||||
|
if(c1 !== c2) break
|
||||||
|
}
|
||||||
|
return c1.charCodeAt() - c2.charCodeAt()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort(dirsAndlinks)
|
||||||
|
sort(others)
|
||||||
|
return [].concat(dirsAndlinks, others)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const downloadFile = ({ buffer, name }) => {
|
||||||
|
let contentUrl = window.URL.createObjectURL(new Blob([buffer,])) // params:object 可选: File对象、Blob对象、MediaSource对象。
|
||||||
|
let link = document.createElement('a')
|
||||||
|
link.style.display = 'none'
|
||||||
|
link.href = contentUrl
|
||||||
|
console.log(name)
|
||||||
|
link.setAttribute('download', name) // 文件名称
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(contentUrl)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSuffix = (name = '') => {
|
||||||
|
return String(name).split(/\./).pop()
|
||||||
|
}
|
26
web/src/utils/ping.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
function request_image(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let img = new Image()
|
||||||
|
img.onload = () => resolve()
|
||||||
|
img.onerror = () => reject()
|
||||||
|
img.src = url + '?random-no-cache=' + Math.floor((1 + Math.random()) * 0x10000).toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ping(url, timeout = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let start = Date.now()
|
||||||
|
let response = () => {
|
||||||
|
let delay = (Date.now() - start) + 'ms'
|
||||||
|
resolve(delay)
|
||||||
|
}
|
||||||
|
request_image(url).then(response).catch(response)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// reject(Error('Timeout'))
|
||||||
|
resolve('timeout')
|
||||||
|
}, timeout)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ping
|
470
web/src/views/list/components/host-card.vue
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
<template>
|
||||||
|
<el-card shadow="always" class="host-card">
|
||||||
|
<div class="host-state">
|
||||||
|
<span v-if="isError" class="offline">未连接</span>
|
||||||
|
<span v-else class="online">已连接 {{ ping }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<div class="weizhi field">
|
||||||
|
<el-popover
|
||||||
|
placement="bottom-start"
|
||||||
|
:width="200"
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<svg-icon
|
||||||
|
name="icon-fuwuqi"
|
||||||
|
class="svg-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div class="field-detail">
|
||||||
|
<h2>系统</h2>
|
||||||
|
<h3><span>名称:</span> {{ osInfo.hostname }}</h3>
|
||||||
|
<h3><span>类型:</span> {{ osInfo.type }}</h3>
|
||||||
|
<h3><span>架构:</span> {{ osInfo.arch }}</h3>
|
||||||
|
<h3><span>平台:</span> {{ osInfo.platform }}</h3>
|
||||||
|
<h3><span>版本:</span> {{ osInfo.release }}</h3>
|
||||||
|
<h3><span>开机时长:</span> {{ $tools.formatTime(osInfo.uptime) }}</h3>
|
||||||
|
<h3><span>到期时间:</span> {{ expiredTime }}</h3>
|
||||||
|
<h3><span>本地IP:</span> {{ osInfo.ip }}</h3>
|
||||||
|
<h3><span>连接数:</span> {{ openedCount || 0 }}</h3>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<div class="fields">
|
||||||
|
<span class="name" @click="handleUpdate">
|
||||||
|
{{ name || '--' }}
|
||||||
|
<svg-icon name="icon-xiugai" class="svg-icon" />
|
||||||
|
</span>
|
||||||
|
<span>{{ osInfo?.type || '--' }}</span>
|
||||||
|
<!-- <span>{{ osInfo?.hostname || '--' }}</span> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="weizhi field">
|
||||||
|
<el-popover
|
||||||
|
placement="bottom-start"
|
||||||
|
:width="200"
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<svg-icon
|
||||||
|
name="icon-position"
|
||||||
|
class="svg-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div class="field-detail">
|
||||||
|
<h2>位置信息</h2>
|
||||||
|
<h3><span>详细:</span> {{ ipInfo.country || '--' }} {{ ipInfo.regionName }}</h3>
|
||||||
|
<!-- <h3><span>详细:</span> {{ ipInfo.country || '--' }} {{ ipInfo.regionName }} {{ ipInfo.city }}</h3> -->
|
||||||
|
<!-- <h3><span>IP:</span> {{ hostIp }}</h3> -->
|
||||||
|
<h3><span>提供商:</span> {{ ipInfo.isp || '--' }}</h3>
|
||||||
|
<h3><span>线路:</span> {{ ipInfo.as || '--' }}</h3>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<div class="fields">
|
||||||
|
<span>{{ `${ipInfo?.country || '--' } ${ipInfo?.regionName || '--'}` }}</span>
|
||||||
|
<!-- <span>{{ `${ipInfo?.country || '--' } ${ipInfo?.regionName || '--'} ${ipInfo?.city || '--'}` }}</span> -->
|
||||||
|
<span>{{ hostIp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cpu field">
|
||||||
|
<el-popover
|
||||||
|
placement="bottom-start"
|
||||||
|
:width="200"
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<svg-icon
|
||||||
|
name="icon-xingzhuang"
|
||||||
|
class="svg-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div class="field-detail">
|
||||||
|
<h2>CPU</h2>
|
||||||
|
<h3><span>利用率:</span> {{ cpuInfo.cpuUsage }}%</h3>
|
||||||
|
<h3><span>物理核心:</span> {{ cpuInfo.cpuCount }}</h3>
|
||||||
|
<h3><span>型号:</span> {{ cpuInfo.cpuModel }}</h3>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<div class="fields">
|
||||||
|
<span :style="{color: setColor(cpuInfo.cpuUsage)}">{{ cpuInfo.cpuUsage || '0' || '--' }}%</span>
|
||||||
|
<span>{{ cpuInfo.cpuCount || '--' }} 核心</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ram field">
|
||||||
|
<el-popover
|
||||||
|
placement="bottom-start"
|
||||||
|
:width="200"
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<svg-icon
|
||||||
|
name="icon-neicun1"
|
||||||
|
class="svg-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div class="field-detail">
|
||||||
|
<h2>内存</h2>
|
||||||
|
<h3><span>总大小:</span> {{ $tools.toFixed(memInfo.totalMemMb / 1024) }} GB</h3>
|
||||||
|
<h3><span>已使用:</span> {{ $tools.toFixed(memInfo.usedMemMb / 1024) }} GB</h3>
|
||||||
|
<h3><span>占比:</span> {{ $tools.toFixed(memInfo.usedMemPercentage) }}%</h3>
|
||||||
|
<h3><span>空闲:</span> {{ $tools.toFixed(memInfo.freeMemMb / 1024) }} GB</h3>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<div class="fields">
|
||||||
|
<span :style="{color: setColor(memInfo.usedMemPercentage)}">{{ $tools.toFixed(memInfo.usedMemPercentage) }}%</span>
|
||||||
|
<span>{{ $tools.toFixed(memInfo.usedMemMb / 1024) }} | {{ $tools.toFixed(memInfo.totalMemMb / 1024) }} GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="yingpan field">
|
||||||
|
<el-popover
|
||||||
|
placement="bottom-start"
|
||||||
|
:width="200"
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<svg-icon
|
||||||
|
name="icon-xingzhuang1"
|
||||||
|
class="svg-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div class="field-detail">
|
||||||
|
<h2>存储</h2>
|
||||||
|
<h3><span>总空间:</span> {{ driveInfo.totalGb || '--' }} GB</h3>
|
||||||
|
<h3><span>已使用:</span> {{ driveInfo.usedGb || '--' }} GB</h3>
|
||||||
|
<h3><span>剩余:</span> {{ driveInfo.freeGb || '--' }} GB</h3>
|
||||||
|
<h3><span>占比:</span> {{ driveInfo.usedPercentage || '--' }}%</h3>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<div class="fields">
|
||||||
|
<span :style="{color: setColor(driveInfo.usedPercentage)}">{{ driveInfo.usedPercentage || '--' }}%</span>
|
||||||
|
<span>{{ driveInfo.usedGb || '--' }} | {{ driveInfo.totalGb || '--' }} GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wangluo field">
|
||||||
|
<el-popover
|
||||||
|
placement="bottom-start"
|
||||||
|
:width="200"
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<svg-icon
|
||||||
|
name="icon-wangluo1"
|
||||||
|
class="svg-icon"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div class="field-detail">
|
||||||
|
<h2>网卡</h2>
|
||||||
|
<!-- <h3>
|
||||||
|
<span>实时流量</span>
|
||||||
|
<div>↑ {{ $tools.toFixed(netstatInfo.netTotal?.outputMb) || 0 }}MB / s</div>
|
||||||
|
<div>↓ {{ $tools.toFixed(netstatInfo.netTotal?.inputMb) || 0 }}MB / s</div>
|
||||||
|
</h3> -->
|
||||||
|
<div
|
||||||
|
v-for="(value, key) in netstatInfo.netCards"
|
||||||
|
:key="key"
|
||||||
|
style="display: flex; flex-direction: column;"
|
||||||
|
>
|
||||||
|
<h3>
|
||||||
|
<span>{{ key }}</span>
|
||||||
|
<div>↑ {{ $tools.formatNetSpeed(value?.outputMb) || 0 }}</div>
|
||||||
|
<div>↓ {{ $tools.formatNetSpeed(value?.inputMb) || 0 }}</div>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<div class="fields">
|
||||||
|
<span>↑ {{ $tools.formatNetSpeed(netstatInfo.netTotal?.outputMb) || 0 }}</span>
|
||||||
|
<span>↓ {{ $tools.formatNetSpeed(netstatInfo.netTotal?.inputMb) || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fields terminal">
|
||||||
|
<el-dropdown
|
||||||
|
class="web-ssh"
|
||||||
|
type="primary"
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<!-- <el-button type="primary" @click="handleSSH">Web SSH</el-button> -->
|
||||||
|
<el-button type="primary">功能</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item @click="handleSSH">连接终端</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="consoleUrl" @click="handleToConsole">控制台</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="handleUpdate">修改服务器</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="handleRemoveHost"><span style="color: #727272;">移除主机</span></el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="handleRemoveSSH"><span style="color: #727272;">移除凭证</span></el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SSHForm
|
||||||
|
v-model:show="sshFormVisible"
|
||||||
|
:temp-host="tempHost"
|
||||||
|
:name="name"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import SSHForm from './ssh-form.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'HostCard',
|
||||||
|
components: {
|
||||||
|
SSHForm
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
hostInfo: {
|
||||||
|
required: true,
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
hiddenIp: {
|
||||||
|
required: true,
|
||||||
|
type: [Number, Boolean,]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update-list', 'update-host',],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
sshFormVisible: false,
|
||||||
|
tempHost: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hostIp() {
|
||||||
|
let ip = this.ipInfo?.query || this.host || '--'
|
||||||
|
try {
|
||||||
|
// let formatIp = ip.replace(/(?<=\d*\.\d*\.)(\d*)/g, (matchStr) => matchStr.replace(/\d/g, '*'))
|
||||||
|
let formatIp = ip.replace(/\d/g, '*').split('.').map((item) => item.padStart(3, '*')).join('.')
|
||||||
|
return this.hiddenIp ? formatIp : ip
|
||||||
|
} catch (error) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
},
|
||||||
|
host() {
|
||||||
|
return this.hostInfo?.host
|
||||||
|
},
|
||||||
|
name() {
|
||||||
|
return this.hostInfo?.name
|
||||||
|
},
|
||||||
|
ping() {
|
||||||
|
return this.hostInfo?.ping || ''
|
||||||
|
},
|
||||||
|
expiredTime() {
|
||||||
|
return this.$tools.formatTimestamp(this.hostInfo?.expired, 'date')
|
||||||
|
},
|
||||||
|
consoleUrl() {
|
||||||
|
return this.hostInfo?.consoleUrl
|
||||||
|
},
|
||||||
|
ipInfo() {
|
||||||
|
return this.hostInfo?.ipInfo || {}
|
||||||
|
},
|
||||||
|
isError() {
|
||||||
|
return !Boolean(this.hostInfo?.osInfo) // 没获取系统信息默认未连接
|
||||||
|
},
|
||||||
|
cpuInfo() {
|
||||||
|
return this.hostInfo?.cpuInfo || {}
|
||||||
|
},
|
||||||
|
memInfo() {
|
||||||
|
return this.hostInfo?.memInfo || {}
|
||||||
|
},
|
||||||
|
osInfo() {
|
||||||
|
return this.hostInfo?.osInfo || {}
|
||||||
|
},
|
||||||
|
driveInfo() {
|
||||||
|
return this.hostInfo?.driveInfo || {}
|
||||||
|
},
|
||||||
|
netstatInfo() {
|
||||||
|
let { total: netTotal, ...netCards } = this.hostInfo?.netstatInfo || {}
|
||||||
|
return { netTotal, netCards: netCards || {} }
|
||||||
|
},
|
||||||
|
openedCount() {
|
||||||
|
return this.hostInfo?.openedCount || 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// if (data?.message === 'private range') {
|
||||||
|
// data.country = '本地'
|
||||||
|
// data.city = '局域网'
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setColor(num){
|
||||||
|
num = Number(num)
|
||||||
|
return num ? (num < 80 ? '#595959' : ((num >= 80 && num < 90) ? '#FF6600' : '#FF0000')) : '#595959'
|
||||||
|
},
|
||||||
|
handleUpdate() {
|
||||||
|
let { name, host, hostInfo: { expired, expiredNotify, group, consoleUrl, remark } } = this
|
||||||
|
this.$emit('update-host', { name, host, expired, expiredNotify, group, consoleUrl, remark })
|
||||||
|
},
|
||||||
|
handleToConsole() {
|
||||||
|
window.open(this.consoleUrl)
|
||||||
|
},
|
||||||
|
async handleSSH() {
|
||||||
|
let { host, name } = this
|
||||||
|
let { data } = await this.$api.existSSH(host)
|
||||||
|
console.log('是否存在凭证:', data)
|
||||||
|
if (data) return window.open(`/terminal?host=${ host }&name=${ name }`)
|
||||||
|
if (!host) {
|
||||||
|
return ElMessage({
|
||||||
|
message: '请等待获取服务器ip或刷新页面重试',
|
||||||
|
type: 'warning',
|
||||||
|
center: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.tempHost = host
|
||||||
|
this.sshFormVisible = true
|
||||||
|
},
|
||||||
|
async handleRemoveSSH() {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
'确认删除SSH凭证',
|
||||||
|
'Warning',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
|
let { host } = this
|
||||||
|
let { data } = await this.$api.removeSSH(host)
|
||||||
|
ElMessage({
|
||||||
|
message: data,
|
||||||
|
type: 'success',
|
||||||
|
center: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleRemoveHost() {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
'确认删除主机',
|
||||||
|
'Warning',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
|
let { host } = this
|
||||||
|
let { data } = await this.$api.removeHost({ host })
|
||||||
|
ElMessage({
|
||||||
|
message: data,
|
||||||
|
type: 'success',
|
||||||
|
center: true
|
||||||
|
})
|
||||||
|
this.$emit('update-list')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
.host-card {
|
||||||
|
margin: 0px 30px 20px;
|
||||||
|
transition: all 0.5s;
|
||||||
|
position: relative;
|
||||||
|
&:hover {
|
||||||
|
box-shadow:0px 0px 15px rgba(6, 30, 37, 0.5);
|
||||||
|
}
|
||||||
|
.host-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
span {
|
||||||
|
font-size: 8px;
|
||||||
|
// transform: rotate(-45deg);
|
||||||
|
transform: scale(0.9);
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 5px;
|
||||||
|
}
|
||||||
|
.online {
|
||||||
|
color: #009933;
|
||||||
|
background-color: #e8fff3;
|
||||||
|
}
|
||||||
|
.offline {
|
||||||
|
color: #FF0033;
|
||||||
|
background-color: #fff5f8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 50px;
|
||||||
|
& > div {
|
||||||
|
flex: 1
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
.svg-icon {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
color: #1989fa;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
// justify-content: center;
|
||||||
|
span {
|
||||||
|
padding: 3px 0;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
display: inline-block;
|
||||||
|
height: 19px;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-decoration-color: #1989fa;
|
||||||
|
.svg-icon {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.svg-icon {
|
||||||
|
display: none;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.web-ssh {
|
||||||
|
// ::v-deep has been deprecated. Use :deep(<inner-selector>) instead.
|
||||||
|
:deep(.el-dropdown__caret-button) {
|
||||||
|
margin-left: -5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.field-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
h2 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0px 0 8px 0;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
span {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #797979;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
219
web/src/views/list/components/host-form.vue
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
width="400px"
|
||||||
|
:title="title"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@open="setDefaultData"
|
||||||
|
@closed="handleClosed"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="form"
|
||||||
|
:model="hostForm"
|
||||||
|
:rules="rules"
|
||||||
|
:hide-required-asterisk="true"
|
||||||
|
label-suffix=":"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<transition-group
|
||||||
|
name="list"
|
||||||
|
mode="out-in"
|
||||||
|
tag="div"
|
||||||
|
>
|
||||||
|
<el-form-item key="group" label="分组" prop="group">
|
||||||
|
<el-select
|
||||||
|
v-model="hostForm.group"
|
||||||
|
placeholder="服务器分组"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in groupList"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item key="name" label="主机别名" prop="name">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="hostForm.name"
|
||||||
|
clearable
|
||||||
|
placeholder="主机别名"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item key="host" label="IP/域名" prop="host">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="hostForm.host"
|
||||||
|
clearable
|
||||||
|
placeholder="IP/域名"
|
||||||
|
autocomplete="off"
|
||||||
|
@keyup.enter="handleSave"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item key="expired" label="到期时间" prop="expired">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="hostForm.expired"
|
||||||
|
type="date"
|
||||||
|
value-format="x"
|
||||||
|
placeholder="服务器到期时间"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
v-if="hostForm.expired"
|
||||||
|
key="expiredNotify"
|
||||||
|
label="到期提醒"
|
||||||
|
prop="expiredNotify"
|
||||||
|
>
|
||||||
|
<el-tooltip content="将在服务器到期前7、3、1天发送提醒(需在设置中绑定有效邮箱)" placement="right">
|
||||||
|
<el-switch
|
||||||
|
v-model="hostForm.expiredNotify"
|
||||||
|
:active-value="true"
|
||||||
|
:inactive-value="false"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item key="consoleUrl" label="控制台URL" prop="consoleUrl">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="hostForm.consoleUrl"
|
||||||
|
clearable
|
||||||
|
placeholder="用于直达服务器控制台"
|
||||||
|
autocomplete="off"
|
||||||
|
@keyup.enter="handleSave"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item key="remark" label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="hostForm.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
clearable
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="用于简单记录服务器用途"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</transition-group>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="visible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="handleSave">确认</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const resetForm = () => {
|
||||||
|
return {
|
||||||
|
group: 'default',
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
expired: null,
|
||||||
|
expiredNotify: false,
|
||||||
|
consoleUrl: '',
|
||||||
|
remark: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default {
|
||||||
|
name: 'HostForm',
|
||||||
|
props: {
|
||||||
|
show: {
|
||||||
|
required: true,
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
defaultData: {
|
||||||
|
required: false,
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:show', 'update-list', 'closed',],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hostForm: resetForm(),
|
||||||
|
oldHost: '',
|
||||||
|
groupList: [],
|
||||||
|
rules: {
|
||||||
|
group: { required: true, message: '选择一个分组' },
|
||||||
|
name: { required: true, message: '输入主机别名', trigger: 'change' },
|
||||||
|
host: { required: true, message: '输入IP/域名', trigger: 'change' },
|
||||||
|
expired: { required: false },
|
||||||
|
expiredNotify: { required: false },
|
||||||
|
consoleUrl: { required: false },
|
||||||
|
remark: { required: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
visible: {
|
||||||
|
get() {
|
||||||
|
return this.show
|
||||||
|
},
|
||||||
|
set(newVal) {
|
||||||
|
this.$emit('update:show', newVal)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.defaultData ? '修改服务器' : '新增服务器'
|
||||||
|
},
|
||||||
|
formRef() {
|
||||||
|
return this.$refs['form']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if(!newVal) return
|
||||||
|
this.getGroupList()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getGroupList() {
|
||||||
|
this.$api.getGroupList()
|
||||||
|
.then(({ data }) => {
|
||||||
|
this.groupList = data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleClosed() {
|
||||||
|
console.log('handleClosed')
|
||||||
|
this.hostForm = resetForm()
|
||||||
|
this.$emit('closed')
|
||||||
|
this.$nextTick(() => this.formRef.resetFields())
|
||||||
|
},
|
||||||
|
setDefaultData() {
|
||||||
|
if(!this.defaultData) return
|
||||||
|
let { name, host, expired, expiredNotify, consoleUrl, group, remark } = this.defaultData
|
||||||
|
this.oldHost = host // 保存旧的host用于后端查找
|
||||||
|
this.hostForm = { name, host, expired, expiredNotify, consoleUrl, group, remark }
|
||||||
|
|
||||||
|
},
|
||||||
|
handleSave() {
|
||||||
|
this.formRef.validate()
|
||||||
|
.then(async () => {
|
||||||
|
if(!this.hostForm.expired || !this.hostForm.expiredNotify) {
|
||||||
|
this.hostForm.expired = null
|
||||||
|
this.hostForm.expiredNotify = false
|
||||||
|
}
|
||||||
|
if(this.defaultData) {
|
||||||
|
let { oldHost } = this
|
||||||
|
let { msg } = await this.$api.updateHost(Object.assign({}, this.hostForm, { oldHost }))
|
||||||
|
this.$message({ type: 'success', center: true, message: msg })
|
||||||
|
}else {
|
||||||
|
let { msg } = await this.$api.saveHost(this.hostForm)
|
||||||
|
this.$message({ type: 'success', center: true, message: msg })
|
||||||
|
}
|
||||||
|
this.visible = false
|
||||||
|
this.$emit('update-list')
|
||||||
|
this.hostForm = resetForm()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
181
web/src/views/list/components/setting-tab/email-list.vue
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div v-loading="loading">
|
||||||
|
<el-form
|
||||||
|
ref="email-form"
|
||||||
|
:model="emailForm"
|
||||||
|
:rules="rules"
|
||||||
|
:inline="true"
|
||||||
|
:hide-required-asterisk="true"
|
||||||
|
label-suffix=":"
|
||||||
|
>
|
||||||
|
<el-form-item label="" prop="target" style="width: 200px;">
|
||||||
|
<el-select
|
||||||
|
v-model="emailForm.target"
|
||||||
|
placeholder="邮件服务商"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in supportEmailList"
|
||||||
|
:key="item.target"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.target"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="" prop="auth.user" style="width: 200px;">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="emailForm.auth.user"
|
||||||
|
clearable
|
||||||
|
placeholder="邮箱"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="" prop="auth.pass" style="width: 200px;">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="emailForm.auth.pass"
|
||||||
|
clearable
|
||||||
|
placeholder="SMTP授权码"
|
||||||
|
autocomplete="off"
|
||||||
|
@keyup.enter="addEmail"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="">
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
content="重复添加的邮箱将会被覆盖"
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
<el-button type="primary" @click="addEmail">
|
||||||
|
添加
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<!-- 提示 -->
|
||||||
|
<el-alert type="success" :closable="false">
|
||||||
|
<template #title>
|
||||||
|
<span style="letter-spacing: 2px;"> Tips: 系统所有通知邮件将会下发到所有已经配置成功的邮箱中 </span>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
<!-- 表格 -->
|
||||||
|
<el-table :data="userEmailList" class="table">
|
||||||
|
<el-table-column prop="email" label="Email" />
|
||||||
|
<el-table-column prop="name" label="服务商" />
|
||||||
|
<el-table-column label="操作">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="row.loading"
|
||||||
|
@click="pushTestEmail(row)"
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
@click="deleteUserEmail(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'UserEmailList',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
userEmailList: [],
|
||||||
|
supportEmailList: [],
|
||||||
|
emailForm: {
|
||||||
|
target: 'qq',
|
||||||
|
auth: {
|
||||||
|
user: '',
|
||||||
|
pass: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'auth.user': { required: true, type: 'email', message: '需输入邮箱', trigger: 'change' },
|
||||||
|
'auth.pass': { required: true, message: '需输入SMTP授权码', trigger: 'change' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getUserEmailList()
|
||||||
|
this.getSupportEmailList()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getUserEmailList() {
|
||||||
|
this.loading = true
|
||||||
|
this.$api.getUserEmailList()
|
||||||
|
.then(({ data }) => {
|
||||||
|
this.userEmailList = data.map(item => {
|
||||||
|
item.loading = false
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => this.loading = false)
|
||||||
|
},
|
||||||
|
getSupportEmailList() {
|
||||||
|
this.$api.getSupportEmailList()
|
||||||
|
.then(({ data }) => {
|
||||||
|
this.supportEmailList = data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
addEmail() {
|
||||||
|
let emailFormRef = this.$refs['email-form']
|
||||||
|
emailFormRef.validate()
|
||||||
|
.then(() => {
|
||||||
|
this.$api.updateUserEmailList({ ...this.emailForm })
|
||||||
|
.then(() => {
|
||||||
|
this.$message.success('添加成功, 点击[测试]按钮发送测试邮件')
|
||||||
|
let { target } = this.emailForm
|
||||||
|
this.emailForm = { target, auth: { user: '', pass: '' } }
|
||||||
|
this.$nextTick(() => emailFormRef.resetFields())
|
||||||
|
this.getUserEmailList()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
pushTestEmail(row) {
|
||||||
|
row.loading = true
|
||||||
|
const { email: toEmail } = row
|
||||||
|
this.$api.pushTestEmail({ isTest: true, toEmail })
|
||||||
|
.then(() => {
|
||||||
|
this.$message.success(`发送成功, 请检查邮箱: ${ toEmail }`)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.$notification({
|
||||||
|
title: '发送测试邮件失败, 请检查邮箱SMTP配置',
|
||||||
|
message: error.response?.data.msg,
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
row.loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteUserEmail({ email }) {
|
||||||
|
this.$messageBox.confirm(
|
||||||
|
`确认删除邮箱:${ email }`,
|
||||||
|
'Warning',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
|
await this.$api.deleteUserEmail(email)
|
||||||
|
this.$message.success('success')
|
||||||
|
this.getUserEmailList()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
236
web/src/views/list/components/setting-tab/group.vue
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<el-form
|
||||||
|
ref="group-form"
|
||||||
|
:model="groupForm"
|
||||||
|
:rules="rules"
|
||||||
|
:inline="true"
|
||||||
|
:hide-required-asterisk="true"
|
||||||
|
label-suffix=":"
|
||||||
|
>
|
||||||
|
<el-form-item label="" prop="name" style="width: 200px;">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="groupForm.name"
|
||||||
|
clearable
|
||||||
|
placeholder="分组名称"
|
||||||
|
autocomplete="off"
|
||||||
|
@keyup.enter="addGroup"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="" prop="index" style="width: 200px;">
|
||||||
|
<!-- <el-input-number v-model="groupForm.index" :min="1" :max="10" /> -->
|
||||||
|
<el-input
|
||||||
|
v-model.number="groupForm.index"
|
||||||
|
clearable
|
||||||
|
placeholder="序号(数字, 用于分组排序)"
|
||||||
|
autocomplete="off"
|
||||||
|
@keyup.enter="addGroup"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="">
|
||||||
|
<el-button type="primary" @click="addGroup">
|
||||||
|
添加
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<!-- 提示 -->
|
||||||
|
<el-alert type="success" :closable="false">
|
||||||
|
<template #title>
|
||||||
|
<span style="letter-spacing: 2px;">
|
||||||
|
Tips: 已添加服务器数量 <u>{{ hostGroupInfo.total }}</u>
|
||||||
|
<span v-show="hostGroupInfo.notGroupCount">, 有 <u>{{ hostGroupInfo.notGroupCount }}</u> 台服务器尚未分组</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-alert><br>
|
||||||
|
<el-alert type="success" :closable="false">
|
||||||
|
<template #title>
|
||||||
|
<span style="letter-spacing: 2px;"> Tips: 删除分组会将分组内所有服务器移至默认分组 </span>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
<el-table v-loading="loading" :data="list">
|
||||||
|
<el-table-column prop="index" label="序号" />
|
||||||
|
<el-table-column prop="id" label="ID" />
|
||||||
|
<el-table-column prop="name" label="分组名称" />
|
||||||
|
<el-table-column label="关联服务器数量">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-popover
|
||||||
|
v-if="row.hosts.list.length !== 0"
|
||||||
|
placement="right"
|
||||||
|
:width="350"
|
||||||
|
trigger="hover"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<u class="host-count">{{ row.hosts.count }}</u>
|
||||||
|
</template>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in row.hosts.list" :key="item.host">
|
||||||
|
<span>{{ item.host }}</span>
|
||||||
|
-
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</el-popover>
|
||||||
|
<u v-else class="host-count">0</u>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-button type="primary" @click="handleChange(row)">修改</el-button>
|
||||||
|
<el-button v-show="row.id !== 'default'" type="danger" @click="deleteGroup(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
width="400px"
|
||||||
|
title="修改分组"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="update-form"
|
||||||
|
:model="updateForm"
|
||||||
|
:rules="rules"
|
||||||
|
:hide-required-asterisk="true"
|
||||||
|
label-suffix=":"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="分组名称" prop="name">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="updateForm.name"
|
||||||
|
clearable
|
||||||
|
placeholder="分组名称"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分组序号" prop="index">
|
||||||
|
<el-input
|
||||||
|
v-model.number="updateForm.index"
|
||||||
|
clearable
|
||||||
|
placeholder="分组序号"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="visible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="updateGroup">修改</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'NotifyList',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
visible: false,
|
||||||
|
groupList: [],
|
||||||
|
groupForm: {
|
||||||
|
name: '',
|
||||||
|
index: ''
|
||||||
|
},
|
||||||
|
updateForm: {
|
||||||
|
name: '',
|
||||||
|
index: ''
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'name': { required: true, message: '需输入分组名称', trigger: 'change' },
|
||||||
|
'index': { required: true, type: 'number', message: '需输入数字', trigger: 'change' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hostGroupInfo() {
|
||||||
|
let total = this.$store.hostList.length
|
||||||
|
let notGroupCount = this.$store.hostList.reduce((prev, next) => {
|
||||||
|
if(!next.group) prev++
|
||||||
|
return prev
|
||||||
|
}, 0)
|
||||||
|
return { total, notGroupCount }
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return this.groupList.map(item => {
|
||||||
|
let hosts = this.$store.hostList.reduce((prev, next) => {
|
||||||
|
if(next.group === item.id) {
|
||||||
|
prev.count++
|
||||||
|
prev.list.push(next)
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
}, { count: 0, list: [] })
|
||||||
|
return { ...item, hosts }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getGroupList()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getGroupList() {
|
||||||
|
this.loading = true
|
||||||
|
this.$api.getGroupList()
|
||||||
|
.then(({ data }) => {
|
||||||
|
this.groupList = data
|
||||||
|
this.groupForm.index = data.length
|
||||||
|
})
|
||||||
|
.finally(() => this.loading = false)
|
||||||
|
},
|
||||||
|
addGroup() {
|
||||||
|
let formRef = this.$refs['group-form']
|
||||||
|
formRef.validate()
|
||||||
|
.then(() => {
|
||||||
|
const { name, index } = this.groupForm
|
||||||
|
this.$api.addGroup({ name, index })
|
||||||
|
.then(() => {
|
||||||
|
this.$message.success('success')
|
||||||
|
this.groupForm = { name: '', index: '' }
|
||||||
|
this.$nextTick(() => formRef.resetFields())
|
||||||
|
this.getGroupList()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleChange({ id, name, index }) {
|
||||||
|
this.updateForm = { id, name, index }
|
||||||
|
this.visible = true
|
||||||
|
},
|
||||||
|
updateGroup() {
|
||||||
|
let formRef = this.$refs['update-form']
|
||||||
|
formRef.validate()
|
||||||
|
.then(() => {
|
||||||
|
const { id, name, index } = this.updateForm
|
||||||
|
this.$api.updateGroup(id, { name, index })
|
||||||
|
.then(() => {
|
||||||
|
this.$message.success('success')
|
||||||
|
this.visible = false
|
||||||
|
this.getGroupList()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteGroup({ id, name }) {
|
||||||
|
this.$messageBox.confirm( `确认删除分组:${ name }`, 'Warning', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await this.$api.deleteGroup(id)
|
||||||
|
await this.$store.getHostList()
|
||||||
|
this.$message.success('success')
|
||||||
|
this.getGroupList()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.host-count {
|
||||||
|
display: block;
|
||||||
|
width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #87cf63;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
65
web/src/views/list/components/setting-tab/notify-list.vue
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 提示 -->
|
||||||
|
<el-alert type="success" :closable="false">
|
||||||
|
<template #title>
|
||||||
|
<span style="letter-spacing: 2px;"> Tips: 请添加邮箱并确保测试邮件通过 </span>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
<el-table v-loading="notifyListLoading" :data="notifyList">
|
||||||
|
<el-table-column prop="desc" label="通知类型" />
|
||||||
|
<el-table-column prop="sw" label="开关">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-switch
|
||||||
|
v-model="row.sw"
|
||||||
|
:active-value="true"
|
||||||
|
:inactive-value="false"
|
||||||
|
:loading="row.loading"
|
||||||
|
@change="handleChangeSw(row, $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'NotifyList',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
notifyListLoading: false,
|
||||||
|
notifyList: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getNotifyList()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getNotifyList(flag = true) {
|
||||||
|
if(flag) this.notifyListLoading = true
|
||||||
|
this.$api.getNotifyList()
|
||||||
|
.then(({ data }) => {
|
||||||
|
this.notifyList = data.map((item) => {
|
||||||
|
item.loading = false
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => this.notifyListLoading = false)
|
||||||
|
},
|
||||||
|
async handleChangeSw(row) {
|
||||||
|
row.loading = true
|
||||||
|
const { type, sw } = row
|
||||||
|
try {
|
||||||
|
await this.$api.updateNotifyList({ type, sw })
|
||||||
|
// if(this.userEmailList.length === 0) this.$message.warning('未配置邮箱, 此开关将不会生效')
|
||||||
|
} finally {
|
||||||
|
row.loading = true
|
||||||
|
}
|
||||||
|
this.getNotifyList(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
90
web/src/views/list/components/setting-tab/password.vue
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<el-form
|
||||||
|
ref="form"
|
||||||
|
class="password-form"
|
||||||
|
:model="formData"
|
||||||
|
:rules="rules"
|
||||||
|
:hide-required-asterisk="true"
|
||||||
|
label-suffix=":"
|
||||||
|
label-width="90px"
|
||||||
|
>
|
||||||
|
<el-form-item label="旧密码" prop="oldPwd">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="formData.oldPwd"
|
||||||
|
clearable
|
||||||
|
placeholder="旧密码"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码" prop="newPwd">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="formData.newPwd"
|
||||||
|
clearable
|
||||||
|
placeholder="新密码"
|
||||||
|
autocomplete="off"
|
||||||
|
@keyup.enter="handleUpdate"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" prop="confirmPwd">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="formData.confirmPwd"
|
||||||
|
clearable
|
||||||
|
placeholder="确认密码"
|
||||||
|
autocomplete="off"
|
||||||
|
@keyup.enter="handleUpdate"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleUpdate">确认</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { RSAEncrypt } from '@utils/index.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'UpdatePassword',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
formData: {
|
||||||
|
oldPwd: '',
|
||||||
|
newPwd: '',
|
||||||
|
confirmPwd: ''
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
oldPwd: { required: true, message: '输入旧密码', trigger: 'change' },
|
||||||
|
newPwd: { required: true, message: '输入新密码', trigger: 'change' },
|
||||||
|
confirmPwd: { required: true, message: '输入确认密码', trigger: 'change' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
formRef() {
|
||||||
|
return this.$refs['form']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleUpdate() {
|
||||||
|
this.formRef.validate()
|
||||||
|
.then(async () => {
|
||||||
|
let { oldPwd, newPwd, confirmPwd } = this.formData
|
||||||
|
if(newPwd !== confirmPwd) return this.$message.error({ center: true, message: '两次密码输入不一致' })
|
||||||
|
oldPwd = RSAEncrypt(oldPwd)
|
||||||
|
newPwd = RSAEncrypt(newPwd)
|
||||||
|
let { msg } = await this.$api.updatePwd({ oldPwd, newPwd })
|
||||||
|
this.$message({ type: 'success', center: true, message: msg })
|
||||||
|
this.formData = { oldPwd: '', newPwd: '', confirmPwd: '' }
|
||||||
|
this.formRef.resetFields()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.password-form {
|
||||||
|
width: 500px;
|
||||||
|
}
|
||||||
|
</style>
|
50
web/src/views/list/components/setting-tab/record.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<el-alert type="success" :closable="false">
|
||||||
|
<template #title>
|
||||||
|
<span style="letter-spacing: 2px;"> Tips: 系统只保存最近10条登录记录, 检测到更换IP后需重新登录 </span>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
<el-table v-loading="loading" :data="loginRecordList">
|
||||||
|
<el-table-column prop="ip" label="IP" />
|
||||||
|
<el-table-column prop="address" label="地点" show-overflow-tooltip>
|
||||||
|
<template #default="scope">
|
||||||
|
<span style="letter-spacing: 2px;"> {{ scope.row.country }} {{ scope.row.city }} </span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="date" label="时间" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'LoginRecord',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loginRecordList: [],
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.handleLookupLoginRecord()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleLookupLoginRecord() {
|
||||||
|
this.loading = true
|
||||||
|
this.$api.getLoginRecord()
|
||||||
|
.then(({ data }) => {
|
||||||
|
this.loginRecordList = data.map((item) => {
|
||||||
|
item.date = this.$tools.formatTimestamp(item.date)
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
103
web/src/views/list/components/setting-tab/sort.vue
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<transition-group
|
||||||
|
name="list"
|
||||||
|
mode="out-in"
|
||||||
|
tag="ul"
|
||||||
|
class="host-list"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in list"
|
||||||
|
:key="item.host"
|
||||||
|
:draggable="true"
|
||||||
|
class="host-item"
|
||||||
|
@dragenter="dragenter($event, index)"
|
||||||
|
@dragover="dragover($event)"
|
||||||
|
@dragstart="dragstart(index)"
|
||||||
|
>
|
||||||
|
<span>{{ item.host }}</span>
|
||||||
|
---
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</li>
|
||||||
|
</transition-group>
|
||||||
|
<div style="display: flex; justify-content: center;margin-top: 25px">
|
||||||
|
<el-button type="primary" @click="handleUpdateSort">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'HostSort',
|
||||||
|
emits: ['update-list',],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
targetIndex: 0,
|
||||||
|
list: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.list = this.$store.hostList.map(({ name, host }) => ({ name, host }))
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
dragstart(index) {
|
||||||
|
// console.log('拖动目标:', index)
|
||||||
|
this.targetIndex = index
|
||||||
|
},
|
||||||
|
dragenter(e, curIndex) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (this.targetIndex !== curIndex) {
|
||||||
|
// console.log('拖动进入:', curIndex)
|
||||||
|
let target = this.list.splice(this.targetIndex, 1)[0]
|
||||||
|
this.list.splice(curIndex, 0, target)
|
||||||
|
this.targetIndex = curIndex // 每次拖动排序后重置目标元素下标
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dragover(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
},
|
||||||
|
handleUpdateSort() {
|
||||||
|
let { list } = this
|
||||||
|
this.$api.updateHostSort({ list })
|
||||||
|
.then(({ msg }) => {
|
||||||
|
this.$message({ type: 'success', center: true, message: msg })
|
||||||
|
this.$store.sortHostList(this.list)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.drag-move {
|
||||||
|
transition: transform .3s;
|
||||||
|
}
|
||||||
|
.host-list {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-right: 50px;
|
||||||
|
}
|
||||||
|
.host-item {
|
||||||
|
transition: all .3s;
|
||||||
|
box-shadow: var(--el-box-shadow-lighter);
|
||||||
|
cursor: move;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #595959;
|
||||||
|
// width: 300px;
|
||||||
|
padding: 0 20px;
|
||||||
|
margin: 0 auto;
|
||||||
|
// background: #c8c8c8;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #000;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
height: 35px;
|
||||||
|
line-height: 35px;
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--el-box-shadow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
87
web/src/views/list/components/setting.vue
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
width="1100px"
|
||||||
|
:title="'功能设置'"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
>
|
||||||
|
<el-tabs
|
||||||
|
style="height: 500px;"
|
||||||
|
tab-position="left"
|
||||||
|
>
|
||||||
|
<el-tab-pane label="分组管理">
|
||||||
|
<Group />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="登录记录">
|
||||||
|
<Record />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="主机排序" lazy>
|
||||||
|
<Sort @update-list="$emit('update-list')" />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="全局通知" lazy>
|
||||||
|
<NotifyList />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="邮箱配置" lazy>
|
||||||
|
<EmailList />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="修改密码" lazy>
|
||||||
|
<Password />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import NotifyList from './setting-tab/notify-list.vue'
|
||||||
|
import EmailList from './setting-tab/email-list.vue'
|
||||||
|
import Sort from './setting-tab/sort.vue'
|
||||||
|
import Record from './setting-tab/record.vue'
|
||||||
|
import Group from './setting-tab/group.vue'
|
||||||
|
import Password from './setting-tab/password.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Setting',
|
||||||
|
components: {
|
||||||
|
NotifyList,
|
||||||
|
EmailList,
|
||||||
|
Sort,
|
||||||
|
Record,
|
||||||
|
Group,
|
||||||
|
Password
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
show: {
|
||||||
|
required: true,
|
||||||
|
type: Boolean
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:show', 'update-list',],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
visible: {
|
||||||
|
get() {
|
||||||
|
return this.show
|
||||||
|
},
|
||||||
|
set(newVal) {
|
||||||
|
this.$emit('update:show', newVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.table {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
209
web/src/views/list/components/ssh-form.vue
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="SSH连接"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@closed="$nextTick(() => formRef.resetFields())"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="form"
|
||||||
|
:model="sshForm"
|
||||||
|
:rules="rules"
|
||||||
|
:hide-required-asterisk="true"
|
||||||
|
label-suffix=":"
|
||||||
|
label-width="90px"
|
||||||
|
>
|
||||||
|
<el-form-item label="主机" prop="host">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="sshForm.host"
|
||||||
|
disabled
|
||||||
|
clearable
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="端口" prop="port">
|
||||||
|
<el-input v-model.trim="sshForm.port" clearable autocomplete="off" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="用户名" prop="username">
|
||||||
|
<el-autocomplete
|
||||||
|
v-model.trim="sshForm.username"
|
||||||
|
:fetch-suggestions="userSearch"
|
||||||
|
style="width: 100%;"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<template #default="{item}">
|
||||||
|
<div class="value">{{ item.value }}</div>
|
||||||
|
</template>
|
||||||
|
</el-autocomplete>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="认证方式" prop="type">
|
||||||
|
<el-radio v-model.trim="sshForm.type" label="privateKey">密钥</el-radio>
|
||||||
|
<el-radio v-model.trim="sshForm.type" label="password">密码</el-radio>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="sshForm.type === 'password'" prop="password" label="密码">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="sshForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Please input password"
|
||||||
|
autocomplete="off"
|
||||||
|
clearable
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="sshForm.type === 'privateKey'" prop="privateKey" label="密钥">
|
||||||
|
<el-button type="primary" size="small" @click="handleClickUploadBtn">
|
||||||
|
选择私钥...
|
||||||
|
</el-button>
|
||||||
|
<input
|
||||||
|
ref="privateKey"
|
||||||
|
type="file"
|
||||||
|
name="privateKey"
|
||||||
|
style="display: none;"
|
||||||
|
@change="handleSelectPrivateKeyFile"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model.trim="sshForm.privateKey"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
clearable
|
||||||
|
autocomplete="off"
|
||||||
|
style="margin-top: 5px;"
|
||||||
|
placeholder="-----BEGIN RSA PRIVATE KEY-----"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="command" label="执行指令">
|
||||||
|
<el-input
|
||||||
|
v-model="sshForm.command"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
clearable
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="连接服务器后自动执行的指令(例如: sudo -i)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSaveSSH">保存</el-button>
|
||||||
|
<!-- <el-button type="primary" @click="handleSaveSSH">保存并连接</el-button> -->
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import $api from '@/api'
|
||||||
|
import { randomStr, AESEncrypt, RSAEncrypt } from '@utils/index.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SSHForm',
|
||||||
|
props: {
|
||||||
|
show: {
|
||||||
|
required: true,
|
||||||
|
type: Boolean
|
||||||
|
},
|
||||||
|
tempHost: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:show',],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
sshForm: {
|
||||||
|
host: '',
|
||||||
|
port: 22,
|
||||||
|
username: '',
|
||||||
|
type: 'privateKey',
|
||||||
|
password: '',
|
||||||
|
privateKey: '',
|
||||||
|
command: ''
|
||||||
|
},
|
||||||
|
defaultUsers: [
|
||||||
|
{ value: 'root' },
|
||||||
|
{ value: 'ubuntu' },
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
host: { required: true, message: '需输入主机', trigger: 'change' },
|
||||||
|
port: { required: true, message: '需输入端口', trigger: 'change' },
|
||||||
|
username: { required: true, message: '需输入用户名', trigger: 'change' },
|
||||||
|
type: { required: true },
|
||||||
|
password: { required: true, message: '需输入密码', trigger: 'change' },
|
||||||
|
privateKey: { required: true, message: '需输入密钥', trigger: 'change' },
|
||||||
|
command: { required: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
visible: {
|
||||||
|
get() {
|
||||||
|
return this.show
|
||||||
|
},
|
||||||
|
set(newVal) {
|
||||||
|
this.$emit('update:show', newVal)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formRef() {
|
||||||
|
return this.$refs['form']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
tempHost: {
|
||||||
|
handler(newVal) {
|
||||||
|
this.sshForm.host = newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleClickUploadBtn() {
|
||||||
|
this.$refs['privateKey'].click()
|
||||||
|
},
|
||||||
|
handleSelectPrivateKeyFile(event) {
|
||||||
|
let file = event.target.files[0]
|
||||||
|
let reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
this.sshForm.privateKey = e.target.result
|
||||||
|
this.$refs['privateKey'].value = ''
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
},
|
||||||
|
handleSaveSSH() {
|
||||||
|
this.formRef.validate()
|
||||||
|
.then(async () => {
|
||||||
|
let randomKey = randomStr(16)
|
||||||
|
let formData = JSON.parse(JSON.stringify(this.sshForm))
|
||||||
|
// 加密传输
|
||||||
|
if(formData.password) formData.password = AESEncrypt(formData.password, randomKey)
|
||||||
|
if(formData.privateKey) formData.privateKey = AESEncrypt(formData.privateKey, randomKey)
|
||||||
|
formData.randomKey = RSAEncrypt(randomKey)
|
||||||
|
await $api.updateSSH(formData)
|
||||||
|
this.$notification({
|
||||||
|
title: '保存成功',
|
||||||
|
message: '下次点击 [Web SSH] 可直接登录终端\n如无法登录请 [移除凭证] 后重新添加',
|
||||||
|
type: 'success'
|
||||||
|
})
|
||||||
|
this.visible = false
|
||||||
|
// this.$message({ type: 'success', center: true, message: data })
|
||||||
|
// setTimeout(() => {
|
||||||
|
// window.open(`/terminal?host=${ this.tempHost }&name=${ this.name }`)
|
||||||
|
// }, 1000)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
userSearch(keyword, cb) {
|
||||||
|
let res = keyword
|
||||||
|
? this.defaultUsers.filter((item) => item.value.includes(keyword))
|
||||||
|
: this.defaultUsers
|
||||||
|
cb(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
197
web/src/views/list/index.vue
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<header>
|
||||||
|
<div class="logo-wrap">
|
||||||
|
<img src="@/assets/logo.png" alt="logo">
|
||||||
|
<h1>EasyNode</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" @click="hostFormVisible = true">
|
||||||
|
新增服务器
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="settingVisible = true">
|
||||||
|
功能设置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="handleHiddenIP">
|
||||||
|
{{ hiddenIp ? '显示IP' : '隐藏IP' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" plain @click="handleLogout">安全退出</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section
|
||||||
|
v-loading="loading"
|
||||||
|
element-loading-background="rgba(122, 122, 122, 0.58)"
|
||||||
|
>
|
||||||
|
<HostCard
|
||||||
|
v-for="(item, index) in hostListStatus"
|
||||||
|
:key="index"
|
||||||
|
:host-info="item"
|
||||||
|
:hidden-ip="hiddenIp"
|
||||||
|
@update-list="handleUpdateList"
|
||||||
|
@update-host="handleUpdateHost"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<footer>
|
||||||
|
<span>Release v1.2.1, Powered by <a href="https://github.com/chaos-zhu/easynode" target="_blank">EasyNode</a></span>
|
||||||
|
</footer>
|
||||||
|
<HostForm
|
||||||
|
v-model:show="hostFormVisible"
|
||||||
|
:default-data="updateHostData"
|
||||||
|
@update-list="handleUpdateList"
|
||||||
|
@closed="updateHostData = null"
|
||||||
|
/>
|
||||||
|
<Setting
|
||||||
|
v-model:show="settingVisible"
|
||||||
|
@update-list="handleUpdateList"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { io } from 'socket.io-client'
|
||||||
|
import HostForm from './components/host-form.vue'
|
||||||
|
import Setting from './components/setting.vue'
|
||||||
|
import HostCard from './components/host-card.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'App',
|
||||||
|
components: {
|
||||||
|
HostCard,
|
||||||
|
HostForm,
|
||||||
|
Setting
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
socket: null,
|
||||||
|
loading: true,
|
||||||
|
hostListStatus: [],
|
||||||
|
updateHostData: null,
|
||||||
|
hostFormVisible: false,
|
||||||
|
settingVisible: false,
|
||||||
|
hiddenIp: Number(localStorage.getItem('hiddenIp') || 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.getHostList()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.socket?.close && this.socket.close()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleLogout() {
|
||||||
|
this.$store.clearJwtToken()
|
||||||
|
this.$message({ type: 'success', message: '已安全退出', center: true })
|
||||||
|
this.$router.push('/login')
|
||||||
|
},
|
||||||
|
async getHostList() {
|
||||||
|
try {
|
||||||
|
this.loading = true
|
||||||
|
await this.$store.getHostList()
|
||||||
|
this.connectIo()
|
||||||
|
} catch(err) {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
connectIo() {
|
||||||
|
let socket = io(this.$serviceURI, {
|
||||||
|
path: '/clients',
|
||||||
|
forceNew: true, // 强制新的实例
|
||||||
|
reconnectionDelay: 5000,
|
||||||
|
reconnectionAttempts: 2 // 每5s后尝试重新连接次数
|
||||||
|
})
|
||||||
|
this.socket = socket
|
||||||
|
socket.on('connect', () => {
|
||||||
|
let flag = 5
|
||||||
|
this.loading = false
|
||||||
|
console.log('clients websocket 已连接: ', socket.id)
|
||||||
|
let token = this.$store.token
|
||||||
|
socket.emit('init_clients_data', { token })
|
||||||
|
socket.on('clients_data', (data) => {
|
||||||
|
if((flag++ % 5) === 0) this.$store.getHostPing()
|
||||||
|
this.hostListStatus = this.$store.hostList.map(item => {
|
||||||
|
const { host } = item
|
||||||
|
if(data[host] === null) return { ...item }// 为null时表示该服务器断开连接
|
||||||
|
return Object.assign({}, item, data[host])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
socket.on('token_verify_fail', (message) => {
|
||||||
|
this.$notification({
|
||||||
|
title: '鉴权失败',
|
||||||
|
message,
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
this.$router.push('/login')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
// this.$notification({
|
||||||
|
// title: 'server websocket error',
|
||||||
|
// message: '与服务器连接断开',
|
||||||
|
// type: 'error'
|
||||||
|
// })
|
||||||
|
console.error('clients websocket 连接断开')
|
||||||
|
})
|
||||||
|
socket.on('connect_error', (message) => {
|
||||||
|
this.loading = false
|
||||||
|
console.error('clients websocket 连接出错: ', message)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleUpdateList() {
|
||||||
|
this.socket.close && this.socket.close()
|
||||||
|
this.getHostList()
|
||||||
|
},
|
||||||
|
handleUpdateHost(defaultData) {
|
||||||
|
this.hostFormVisible = true
|
||||||
|
this.updateHostData = defaultData
|
||||||
|
},
|
||||||
|
handleHiddenIP() {
|
||||||
|
this.hiddenIp = this.hiddenIp ? 0 : 1
|
||||||
|
localStorage.setItem('hiddenIp', String(this.hiddenIp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$height:70px;
|
||||||
|
header {
|
||||||
|
// position: sticky;
|
||||||
|
// top: 0px;
|
||||||
|
// z-index: 1;
|
||||||
|
// background: rgba(255,255,255,0);
|
||||||
|
padding: 0 30px;
|
||||||
|
height: $height;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
.logo-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
img {
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
opacity: 0.9;
|
||||||
|
height: calc(100vh - $height - 25px);
|
||||||
|
padding: 10px 0 250px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
height: 25px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
span {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #48ff00;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
151
web/src/views/login/index.vue
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
width="500px"
|
||||||
|
:top="'30vh'"
|
||||||
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:show-close="false"
|
||||||
|
center
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<h2 v-if="notKey" style="color: #f56c6c;"> Error </h2>
|
||||||
|
<h2 v-else style="color: #409eff;"> LOGIN </h2>
|
||||||
|
</template>
|
||||||
|
<div v-if="notKey">
|
||||||
|
<el-alert
|
||||||
|
title="Error: 用于加密的公钥获取失败,请尝试重新启动或部署服务"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<el-form
|
||||||
|
ref="login-form"
|
||||||
|
:model="loginForm"
|
||||||
|
:rules="rules"
|
||||||
|
:hide-required-asterisk="true"
|
||||||
|
label-suffix=":"
|
||||||
|
label-width="90px"
|
||||||
|
>
|
||||||
|
<el-form-item prop="pwd" label="密码">
|
||||||
|
<el-input
|
||||||
|
v-model.trim="loginForm.pwd"
|
||||||
|
type="password"
|
||||||
|
placeholder="Please input password"
|
||||||
|
autocomplete="off"
|
||||||
|
:trigger-on-focus="false"
|
||||||
|
clearable
|
||||||
|
show-password
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
v-show="false"
|
||||||
|
prop="pwd"
|
||||||
|
label="密码"
|
||||||
|
>
|
||||||
|
<el-input v-model.trim="loginForm.pwd" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
prop="jwtExpires"
|
||||||
|
label="有效期"
|
||||||
|
>
|
||||||
|
<el-radio-group v-model="isSession" class="login-indate">
|
||||||
|
<el-radio :label="true">一次性会话</el-radio>
|
||||||
|
<el-radio :label="false">自定义(小时)</el-radio>
|
||||||
|
<el-input-number
|
||||||
|
v-model="loginForm.jwtExpires"
|
||||||
|
:disabled="isSession"
|
||||||
|
placeholder="单位:小时"
|
||||||
|
class="input"
|
||||||
|
:min="1"
|
||||||
|
:max="72"
|
||||||
|
value-on-clear="min"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleLogin"
|
||||||
|
>登录</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { RSAEncrypt } from '@utils/index.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'App',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isSession: true,
|
||||||
|
visible: true,
|
||||||
|
notKey: false,
|
||||||
|
loading: false,
|
||||||
|
loginForm: {
|
||||||
|
pwd: '',
|
||||||
|
jwtExpires: 8
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
pwd: { required: true, message: '需输入密码', trigger: 'change' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
if(localStorage.getItem('jwtExpires')) this.loginForm.jwtExpires = Number(localStorage.getItem('jwtExpires'))
|
||||||
|
// console.log(localStorage.getItem('jwtExpires'))
|
||||||
|
// 获取公钥
|
||||||
|
let { data } = await this.$api.getPubPem()
|
||||||
|
if (!data) return (this.notKey = true)
|
||||||
|
localStorage.setItem('publicKey', data)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleLogin() {
|
||||||
|
this.$refs['login-form'].validate().then(() => {
|
||||||
|
let { isSession, loginForm: { pwd, jwtExpires } } = this
|
||||||
|
if(isSession) jwtExpires = '12h' // 一次性token有效期12h,存储sessionStroage
|
||||||
|
else {
|
||||||
|
localStorage.setItem('jwtExpires', jwtExpires)
|
||||||
|
jwtExpires = `${ jwtExpires }h`
|
||||||
|
}
|
||||||
|
const ciphertext = RSAEncrypt(pwd)
|
||||||
|
if(ciphertext === -1) return this.$message.error({ message: '公钥加载失败', center: true })
|
||||||
|
this.loading = true
|
||||||
|
// console.log('加密后:', ciphertext)
|
||||||
|
this.$api.login({ ciphertext, jwtExpires })
|
||||||
|
.then(({ data, msg }) => {
|
||||||
|
let { token } = data
|
||||||
|
this.$store.setJwtToken(token, isSession)
|
||||||
|
this.$message.success({ message: msg || 'success', center: true })
|
||||||
|
this.$router.push('/')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.login-indate {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
.input {
|
||||||
|
margin-left: -25px;
|
||||||
|
// width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
482
web/src/views/terminal/components/info-side.vue
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
<template>
|
||||||
|
<div class="info-container" :style="{width: visible ? `250px` : 0}">
|
||||||
|
<header>
|
||||||
|
<a href="/">
|
||||||
|
<img src="@/assets/logo-easynode.png" alt="logo">
|
||||||
|
</a>
|
||||||
|
<!-- <div class="visible" @click="visibleSidebar">
|
||||||
|
<svg-icon
|
||||||
|
name="icon-xianshi"
|
||||||
|
class="svg-icon"
|
||||||
|
/>
|
||||||
|
</div> -->
|
||||||
|
</header>
|
||||||
|
<el-divider class="first-divider" content-position="center">POSITION</el-divider>
|
||||||
|
<el-descriptions
|
||||||
|
class="margin-top"
|
||||||
|
:column="1"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
IP
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span style="margin-right: 10px;">{{ host }}</span>
|
||||||
|
<el-tag size="small" style="cursor: pointer;" @click="handleCopy">复制</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
位置
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- <div size="small">{{ ipInfo.country || '--' }} {{ ipInfo.regionName }} {{ ipInfo.city }}</div> -->
|
||||||
|
<div size="small">{{ ipInfo.country || '--' }} {{ ipInfo.regionName }}</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
延迟
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span style="margin-right: 10px;" class="host-ping">{{ ping }}</span>
|
||||||
|
<!-- <span>(http)</span> -->
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-divider content-position="center">INDICATOR</el-divider>
|
||||||
|
<el-descriptions
|
||||||
|
class="margin-top"
|
||||||
|
:column="1"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
CPU
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-progress
|
||||||
|
:text-inside="true"
|
||||||
|
:stroke-width="18"
|
||||||
|
:percentage="cpuUsage"
|
||||||
|
:color="handleColor(cpuUsage)"
|
||||||
|
/>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
内存
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-progress
|
||||||
|
:text-inside="true"
|
||||||
|
:stroke-width="18"
|
||||||
|
:percentage="usedMemPercentage"
|
||||||
|
:color="handleColor(usedMemPercentage)"
|
||||||
|
/>
|
||||||
|
<div class="position-right">
|
||||||
|
{{ $tools.toFixed(memInfo.usedMemMb / 1024) }}/{{ $tools.toFixed(memInfo.totalMemMb / 1024) }}G
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
硬盘
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-progress
|
||||||
|
:text-inside="true"
|
||||||
|
:stroke-width="18"
|
||||||
|
:percentage="usedPercentage"
|
||||||
|
:color="handleColor(usedPercentage)"
|
||||||
|
/>
|
||||||
|
<div class="position-right">
|
||||||
|
{{ driveInfo.usedGb || '--' }}/{{ driveInfo.totalGb || '--' }}G
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
网络
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="netstat-info">
|
||||||
|
<div class="wrap">
|
||||||
|
<img src="@/assets/upload.png" alt="">
|
||||||
|
<span class="upload">{{ output || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="wrap">
|
||||||
|
<img src="@/assets/download.png" alt="">
|
||||||
|
<span class="download">{{ input || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-divider content-position="center">INFORMATION</el-divider>
|
||||||
|
<el-descriptions
|
||||||
|
class="margin-top"
|
||||||
|
:column="1"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
名称
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div size="small">
|
||||||
|
{{ osInfo.hostname }}
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
核心
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div size="small">
|
||||||
|
{{ cpuInfo.cpuCount }}
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
型号
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div size="small">
|
||||||
|
{{ cpuInfo.cpuModel }}
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
类型
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div size="small">
|
||||||
|
{{ osInfo.type }} {{ osInfo.release }} {{ osInfo.arch }}
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
在线
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div size="small">
|
||||||
|
{{ $tools.formatTime(osInfo.uptime) }}
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item>
|
||||||
|
<template #label>
|
||||||
|
<div class="item-title">
|
||||||
|
本地
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div size="small">
|
||||||
|
{{ osInfo.ip }}
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-divider content-position="center">FEATURE</el-divider>
|
||||||
|
<el-button
|
||||||
|
:type="sftpStatus ? 'primary' : 'success'"
|
||||||
|
style="display: block;width: 80%;margin: 30px auto;"
|
||||||
|
@click="handleSftp"
|
||||||
|
>
|
||||||
|
{{ sftpStatus ? '关闭SFTP' : '连接SFTP' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:type="inputCommandStatus ? 'primary' : 'success'"
|
||||||
|
style="display: block;width: 80%;margin: 30px auto;"
|
||||||
|
@click="clickInputCommand"
|
||||||
|
>
|
||||||
|
命令输入框
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import socketIo from 'socket.io-client'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'InfoSide',
|
||||||
|
props: {
|
||||||
|
token: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
required: true,
|
||||||
|
type: Boolean
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['connect-sftp', 'click-input-command',],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
socket: null,
|
||||||
|
name: '',
|
||||||
|
hostData: null,
|
||||||
|
ping: 0,
|
||||||
|
pingTimer: null,
|
||||||
|
sftpStatus: false,
|
||||||
|
inputCommandStatus: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
ipInfo() {
|
||||||
|
return this.hostData?.ipInfo || {}
|
||||||
|
},
|
||||||
|
isError() {
|
||||||
|
return !Boolean(this.hostData?.osInfo) // 没获取系统信息默认未连接
|
||||||
|
},
|
||||||
|
cpuInfo() {
|
||||||
|
return this.hostData?.cpuInfo || {}
|
||||||
|
},
|
||||||
|
memInfo() {
|
||||||
|
return this.hostData?.memInfo || {}
|
||||||
|
},
|
||||||
|
osInfo() {
|
||||||
|
return this.hostData?.osInfo || {}
|
||||||
|
},
|
||||||
|
driveInfo() {
|
||||||
|
return this.hostData?.driveInfo || {}
|
||||||
|
},
|
||||||
|
netstatInfo() {
|
||||||
|
let { total: netTotal, ...netCards } = this.hostData?.netstatInfo || {}
|
||||||
|
return { netTotal, netCards: netCards || {} }
|
||||||
|
},
|
||||||
|
openedCount() {
|
||||||
|
return this.hostData?.openedCount || 0
|
||||||
|
},
|
||||||
|
cpuUsage() {
|
||||||
|
return Number(this.cpuInfo?.cpuUsage) || 0
|
||||||
|
},
|
||||||
|
usedMemPercentage() {
|
||||||
|
return Number(this.memInfo?.usedMemPercentage) || 0
|
||||||
|
},
|
||||||
|
usedPercentage() {
|
||||||
|
return Number(this.driveInfo?.usedPercentage) || 0
|
||||||
|
},
|
||||||
|
output() {
|
||||||
|
let outputMb = Number(this.netstatInfo.netTotal?.outputMb) || 0
|
||||||
|
if(outputMb >= 1 ) return `${ outputMb.toFixed(2) } MB/s`
|
||||||
|
return `${ (outputMb * 1024).toFixed(1) } KB/s`
|
||||||
|
},
|
||||||
|
input() {
|
||||||
|
let inputMb = Number(this.netstatInfo.netTotal?.inputMb) || 0
|
||||||
|
if(inputMb >= 1 ) return `${ inputMb.toFixed(2) } MB/s`
|
||||||
|
return `${ (inputMb * 1024).toFixed(1) } KB/s`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.name = this.$route.query.name || ''
|
||||||
|
if(!this.host || !this.name) return this.$message.error('参数错误')
|
||||||
|
this.connectIO()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.socket && this.socket.close()
|
||||||
|
this.pingTimer && clearInterval(this.pingTimer)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleSftp() {
|
||||||
|
this.sftpStatus = !this.sftpStatus
|
||||||
|
this.$emit('connect-sftp', this.sftpStatus)
|
||||||
|
},
|
||||||
|
clickInputCommand() {
|
||||||
|
this.inputCommandStatus = true
|
||||||
|
this.$emit('click-input-command')
|
||||||
|
},
|
||||||
|
connectIO() {
|
||||||
|
let { host, token } = this
|
||||||
|
this.socket = socketIo(this.$serviceURI, {
|
||||||
|
path: '/host-status',
|
||||||
|
forceNew: true, // 强制新的实例
|
||||||
|
timeout: 5000,
|
||||||
|
reconnectionDelay: 3000,
|
||||||
|
reconnectionAttempts: 100
|
||||||
|
})
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('/host-status socket已连接:', this.socket.id)
|
||||||
|
this.socket.emit('init_host_data', { token, host })
|
||||||
|
this.getHostPing()
|
||||||
|
this.socket.on('host_data', (hostData) => {
|
||||||
|
if(!hostData) return this.hostData = null
|
||||||
|
this.hostData = hostData
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.socket.on('connect_error', (err) => {
|
||||||
|
console.error('host status websocket 连接错误:', err)
|
||||||
|
this.$notification({
|
||||||
|
title: '连接客户端失败(重连中...)',
|
||||||
|
message: '请检查客户端服务是否正常',
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.socket.on('disconnect', () => {
|
||||||
|
this.hostData = null
|
||||||
|
this.$notification({
|
||||||
|
title: '客户端连接主动断开(重连中...)',
|
||||||
|
message: '请检查客户端服务是否正常',
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async handleCopy() {
|
||||||
|
await navigator.clipboard.writeText(this.host)
|
||||||
|
this.$message.success({ message: 'success', center: true })
|
||||||
|
},
|
||||||
|
handleColor(num) {
|
||||||
|
if(num < 65) return '#8AE234'
|
||||||
|
if(num < 85) return '#FFD700'
|
||||||
|
if(num < 90) return '#FFFF33'
|
||||||
|
if(num <= 100) return '#FF3333'
|
||||||
|
},
|
||||||
|
getHostPing() {
|
||||||
|
this.pingTimer = setInterval(() => {
|
||||||
|
this.$tools.ping(`http://${ this.host }:22022`)
|
||||||
|
.then(res => {
|
||||||
|
this.ping = res
|
||||||
|
if(!import.meta.env.DEV) {
|
||||||
|
console.clear()
|
||||||
|
console.warn('Please tick \'Preserve Log\'')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.info-container {
|
||||||
|
// min-width: 250px;
|
||||||
|
// max-width: 250px;
|
||||||
|
// flex-shrink: 0;
|
||||||
|
// width: 250px;
|
||||||
|
overflow: scroll;
|
||||||
|
background-color: #fff; //#E0E2EF;
|
||||||
|
transition: all 0.3s;
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
margin: 10px;
|
||||||
|
position: relative;
|
||||||
|
img {
|
||||||
|
cursor: pointer;
|
||||||
|
height: 80%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 表格中系统标识的title
|
||||||
|
.item-title {
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 30px;
|
||||||
|
max-width: 30px;
|
||||||
|
}
|
||||||
|
.host-ping {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #009933;
|
||||||
|
background-color: #e8fff3;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
// 分割线title
|
||||||
|
:deep(.el-divider__text) {
|
||||||
|
color: #a0cfff;
|
||||||
|
padding: 0 8px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
// 分割线间距
|
||||||
|
:deep(.el-divider--horizontal) {
|
||||||
|
margin: 28px 0 10px;
|
||||||
|
}
|
||||||
|
.first-divider {
|
||||||
|
margin: 15px 0 10px;
|
||||||
|
}
|
||||||
|
// 表格
|
||||||
|
:deep(.el-descriptions__table) {
|
||||||
|
tr {
|
||||||
|
display: flex;
|
||||||
|
.el-descriptions__label {
|
||||||
|
min-width: 35px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.el-descriptions__content {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
.el-progress {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
// 进度条右边参数定位
|
||||||
|
.position-right {
|
||||||
|
position: absolute;
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 进度条
|
||||||
|
:deep(.el-progress-bar__inner) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
.el-progress-bar__innerText {
|
||||||
|
display: flex;
|
||||||
|
span {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 网络
|
||||||
|
.netstat-info {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
.wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
// justify-content: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
img {
|
||||||
|
width: 15px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
.upload {
|
||||||
|
color: #CF8A20;
|
||||||
|
}
|
||||||
|
.download {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.el-descriptions__label {
|
||||||
|
vertical-align: middle;
|
||||||
|
max-width: 35px;
|
||||||
|
}
|
||||||
|
</style>
|
653
web/src/views/terminal/components/sftp-footer.vue
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
<template>
|
||||||
|
<div class="sftp-container">
|
||||||
|
<div ref="adjust" class="adjust" />
|
||||||
|
<section>
|
||||||
|
<div class="left box">
|
||||||
|
<div class="header">
|
||||||
|
<div class="operation">
|
||||||
|
根目录
|
||||||
|
<span style="font-size: 12px;color: gray;transform: scale(0.8);margin-left: -10px;">
|
||||||
|
(单击选择, 双击打开)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="dir-list">
|
||||||
|
<li
|
||||||
|
v-for="item in rootLs"
|
||||||
|
:key="item.name"
|
||||||
|
@click="openRootChild(item)"
|
||||||
|
>
|
||||||
|
<img :src="icons[item.type]" :alt="item.type">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="right box">
|
||||||
|
<div class="header">
|
||||||
|
<div class="operation">
|
||||||
|
<tooltip content="上级目录">
|
||||||
|
<div class="img">
|
||||||
|
<img src="@/assets/image/system/return.png" alt="" @click="handleReturn">
|
||||||
|
</div>
|
||||||
|
</tooltip>
|
||||||
|
<tooltip content="刷新">
|
||||||
|
<div class="img">
|
||||||
|
<img
|
||||||
|
src="@/assets/image/system/refresh.png"
|
||||||
|
style=" width: 15px; height: 15px; margin-top: 2px; margin-left: 2px;"
|
||||||
|
@click="handleRefresh"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</tooltip>
|
||||||
|
<tooltip content="删除">
|
||||||
|
<div class="img">
|
||||||
|
<img
|
||||||
|
src="@/assets/image/system/delete.png"
|
||||||
|
style="height: 20px; width: 20px;"
|
||||||
|
@click="handleDelete"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</tooltip>
|
||||||
|
<tooltip content="下载选择文件">
|
||||||
|
<div class="img">
|
||||||
|
<img
|
||||||
|
src="@/assets/image/system/download.png"
|
||||||
|
style=" height: 22px; width: 22px; margin-left: -3px; "
|
||||||
|
@click="handleDownload"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</tooltip>
|
||||||
|
<tooltip content="上传到当前目录">
|
||||||
|
<div class="img">
|
||||||
|
<img
|
||||||
|
src="@/assets/image/system/upload.png"
|
||||||
|
style=" width: 19px; height: 19px; "
|
||||||
|
@click="$refs['upload_file'].click()"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="upload_file"
|
||||||
|
type="file"
|
||||||
|
style="display: none;"
|
||||||
|
multiple
|
||||||
|
@change="handleUpload"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</tooltip>
|
||||||
|
<!-- <tooltip content="搜索">
|
||||||
|
<div class="img">
|
||||||
|
<img
|
||||||
|
src="@/assets/image/system/search.png"
|
||||||
|
style="width: 20px; height: 20px; margin-top: 1px;"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</tooltip> -->
|
||||||
|
</div>
|
||||||
|
<div class="filter-input">
|
||||||
|
<el-input
|
||||||
|
v-model="filterKey"
|
||||||
|
size="small"
|
||||||
|
placeholder="Filter Files"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="path">{{ curPath }}</span>
|
||||||
|
<div v-if="showFileProgress">
|
||||||
|
<span>{{ curUploadFileName }}</span>
|
||||||
|
<el-progress
|
||||||
|
class="up-file-progress-wrap"
|
||||||
|
:percentage="upFileProgress"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
v-if="fileList.length !== 0"
|
||||||
|
ref="child-dir"
|
||||||
|
v-loading="childDirLoading"
|
||||||
|
element-loading-text="加载中..."
|
||||||
|
class="dir-list"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="item in fileList"
|
||||||
|
:key="item.name"
|
||||||
|
:class="curTarget === item ? 'active' : ''"
|
||||||
|
@click="selectFile(item)"
|
||||||
|
@dblclick="openTarget(item)"
|
||||||
|
>
|
||||||
|
<img :src="icons[item.type]" :alt="item.type">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else>
|
||||||
|
<el-empty :image-size="100" description="空空如也~" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<CodeEdit
|
||||||
|
v-model:show="visible"
|
||||||
|
:original-code="originalCode"
|
||||||
|
:filename="filename"
|
||||||
|
@save="handleSaveCode"
|
||||||
|
@closed="handleClosedCode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import socketIo from 'socket.io-client'
|
||||||
|
import CodeEdit from '@/components/code-edit/index.vue'
|
||||||
|
import { isDir, isFile, sortDirTree, downloadFile } from '@/utils'
|
||||||
|
import dirIcon from '@/assets/image/system/dir.png'
|
||||||
|
import linkIcon from '@/assets/image/system/link.png'
|
||||||
|
import fileIcon from '@/assets/image/system/file.png'
|
||||||
|
import unknowIcon from '@/assets/image/system/unknow.png'
|
||||||
|
|
||||||
|
const { io } = socketIo
|
||||||
|
export default {
|
||||||
|
name: 'Sftp',
|
||||||
|
components: { CodeEdit },
|
||||||
|
props: {
|
||||||
|
token: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['resize',],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
originalCode: '',
|
||||||
|
filename: '',
|
||||||
|
filterKey: '',
|
||||||
|
socket: null,
|
||||||
|
icons: {
|
||||||
|
'-': fileIcon,
|
||||||
|
l: linkIcon,
|
||||||
|
d: dirIcon,
|
||||||
|
c: dirIcon,
|
||||||
|
p: unknowIcon,
|
||||||
|
s: unknowIcon,
|
||||||
|
b: unknowIcon
|
||||||
|
},
|
||||||
|
paths: ['/',],
|
||||||
|
rootLs: [],
|
||||||
|
childDir: [],
|
||||||
|
childDirLoading: false,
|
||||||
|
curTarget: null,
|
||||||
|
showFileProgress: false,
|
||||||
|
upFileProgress: 0,
|
||||||
|
curUploadFileName: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
curPath() {
|
||||||
|
return this.paths.join('/').replace(/\/{2,}/g, '/')
|
||||||
|
},
|
||||||
|
fileList() {
|
||||||
|
return this.childDir.filter(({ name }) => name.includes(this.filterKey))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.connectSftp()
|
||||||
|
this.adjustHeight()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.socket && this.socket.close()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
connectSftp() {
|
||||||
|
let { host, token } = this
|
||||||
|
this.socket = io(this.$serviceURI, {
|
||||||
|
path: '/sftp',
|
||||||
|
forceNew: false, // 强制新的连接
|
||||||
|
reconnectionAttempts: 1 // 尝试重新连接次数
|
||||||
|
})
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('/sftp socket已连接:', this.socket.id)
|
||||||
|
this.listenSftp()
|
||||||
|
// 验证身份并连接终端
|
||||||
|
this.socket.emit('create', { host, token })
|
||||||
|
this.socket.on('root_ls', (tree) => {
|
||||||
|
// console.log(tree)
|
||||||
|
let temp = sortDirTree(tree).filter((item) => isDir(item.type)) // 只保留文件夹类型的文件
|
||||||
|
temp.unshift({ name: '/', type: 'd' })
|
||||||
|
this.rootLs = temp
|
||||||
|
})
|
||||||
|
this.socket.on('create_fail', (message) => {
|
||||||
|
// console.error(message)
|
||||||
|
this.$notification({
|
||||||
|
title: 'Sftp连接失败',
|
||||||
|
message,
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.socket.on('token_verify_fail', () => {
|
||||||
|
this.$notification({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'token校验失败,需重新登录',
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
// this.$router.push('/login')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.socket.on('disconnect', () => {
|
||||||
|
console.warn('sftp websocket 连接断开')
|
||||||
|
if(this.showFileProgress) {
|
||||||
|
this.$notification({
|
||||||
|
title: '上传失败',
|
||||||
|
message: '请检查socket服务是否正常',
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
this.handleRefresh()
|
||||||
|
this.resetFileStatusFlag()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.socket.on('connect_error', (err) => {
|
||||||
|
console.error('sftp websocket 连接错误:', err)
|
||||||
|
this.$notification({
|
||||||
|
title: 'sftp连接失败',
|
||||||
|
message: '请检查socket服务是否正常',
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// 这个方法连接socket只能调用一次,否则on回调会执行多次
|
||||||
|
listenSftp() {
|
||||||
|
this.socket.on('dir_ls', (dirLs) => {
|
||||||
|
// console.log('dir_ls: ', dirLs)
|
||||||
|
this.childDir = sortDirTree(dirLs)
|
||||||
|
this.childDirLoading = false
|
||||||
|
})
|
||||||
|
this.socket.on('not_exists_dir', (errMsg) => {
|
||||||
|
this.$message.error(errMsg)
|
||||||
|
this.childDirLoading = false
|
||||||
|
})
|
||||||
|
this.socket.on('rm_success', (res) => {
|
||||||
|
this.$message.success(res)
|
||||||
|
this.childDirLoading = false
|
||||||
|
this.handleRefresh()
|
||||||
|
})
|
||||||
|
// this.socket.on('down_dir_success', (res) => {
|
||||||
|
// console.log(res)
|
||||||
|
// this.$message.success(res)
|
||||||
|
// this.childDirLoading = false
|
||||||
|
// })
|
||||||
|
this.socket.on('down_file_success', (res) => {
|
||||||
|
const { buffer, name } = res
|
||||||
|
downloadFile({ buffer, name })
|
||||||
|
this.$message.success('success')
|
||||||
|
this.resetFileStatusFlag()
|
||||||
|
})
|
||||||
|
this.socket.on('preview_file_success', (res) => {
|
||||||
|
const { buffer, name } = res
|
||||||
|
console.log('preview_file: ', name, buffer)
|
||||||
|
// String.fromCharCode.apply(null, new Uint8Array(temp1))
|
||||||
|
this.originalCode = new TextDecoder().decode(buffer)
|
||||||
|
this.filename = name
|
||||||
|
this.visible = true
|
||||||
|
})
|
||||||
|
this.socket.on('sftp_error', (res) => {
|
||||||
|
console.log('操作失败:', res)
|
||||||
|
this.$message.error(res)
|
||||||
|
this.resetFileStatusFlag()
|
||||||
|
})
|
||||||
|
this.socket.on('up_file_progress', (res) => {
|
||||||
|
// console.log('上传进度:', res)
|
||||||
|
// 浏览器到服务端占比50%,服务端到服务器占比50%
|
||||||
|
let progress = Math.ceil(50 + (res / 2))
|
||||||
|
this.upFileProgress = progress > 100 ? 100 : progress
|
||||||
|
})
|
||||||
|
this.socket.on('down_file_progress', (res) => {
|
||||||
|
// console.log('下载进度:', res)
|
||||||
|
this.upFileProgress = res
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openRootChild(item) {
|
||||||
|
const { name, type } = item
|
||||||
|
if(isDir(type)) {
|
||||||
|
this.childDirLoading = true
|
||||||
|
this.paths.length = 2
|
||||||
|
this.paths[1] = name
|
||||||
|
this.$refs['child-dir']?.scrollTo(0, 0)
|
||||||
|
this.openDir()
|
||||||
|
this.filterKey = '' // 移除搜索条件
|
||||||
|
}else {
|
||||||
|
console.log('暂不支持打开文件', name, type)
|
||||||
|
this.$message.warning(`暂不支持打开文件${ name } ${ type }`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openTarget(item) {
|
||||||
|
console.log(item)
|
||||||
|
const { name, type, size } = item
|
||||||
|
if(isDir(type)) {
|
||||||
|
this.paths.push(name)
|
||||||
|
this.$refs['child-dir']?.scrollTo(0, 0)
|
||||||
|
this.openDir()
|
||||||
|
} else if(isFile(type)) {
|
||||||
|
if(size/1024/1024 > 1) return this.$message.warning('暂不支持打开1M及以上文件, 请下载本地查看')
|
||||||
|
const path = this.getPath(name)
|
||||||
|
this.socket.emit('down_file', { path, name, size, target: 'preview' })
|
||||||
|
} else {
|
||||||
|
this.$message.warning(`暂不支持打开文件${ name } ${ type }`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleSaveCode(code) {
|
||||||
|
// console.log('code: ', code)
|
||||||
|
let file = new TextEncoder('utf-8').encode(code)
|
||||||
|
let name = this.filename
|
||||||
|
const fullPath = this.getPath(name)
|
||||||
|
const targetPath = this.curPath
|
||||||
|
this.socket.emit('up_file', { targetPath, fullPath, name, file })
|
||||||
|
},
|
||||||
|
handleClosedCode() {
|
||||||
|
this.filename = ''
|
||||||
|
this.originalCode = ''
|
||||||
|
},
|
||||||
|
selectFile(item) {
|
||||||
|
this.curTarget = item
|
||||||
|
},
|
||||||
|
handleReturn() {
|
||||||
|
if(this.paths.length === 1) return
|
||||||
|
this.paths.pop()
|
||||||
|
this.openDir()
|
||||||
|
},
|
||||||
|
handleRefresh() {
|
||||||
|
this.openDir()
|
||||||
|
},
|
||||||
|
handleDownload() {
|
||||||
|
if(this.curTarget === null) return this.$message.warning('先选择一个文件')
|
||||||
|
const { name, size, type } = this.curTarget
|
||||||
|
if(isDir(type)) return this.$message.error('暂不支持下载文件夹')
|
||||||
|
this.$messageBox.confirm( `确认下载:${ name }`, 'Warning', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.childDirLoading = true
|
||||||
|
const path = this.getPath(name)
|
||||||
|
if(isDir(type)) {
|
||||||
|
// '暂不支持下载文件夹'
|
||||||
|
// this.socket.emit('down_dir', path)
|
||||||
|
}else if(isFile(type)) {
|
||||||
|
this.showFileProgress = true
|
||||||
|
this.socket.emit('down_file', { path, name, size, target: 'down' })
|
||||||
|
}else {
|
||||||
|
this.$message.error('不支持下载的文件类型')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleDelete() {
|
||||||
|
if(this.curTarget === null) return this.$message.warning('先选择一个文件(夹)')
|
||||||
|
const { name, type } = this.curTarget
|
||||||
|
this.$messageBox.confirm( `确认删除:${ name }`, 'Warning', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.childDirLoading = true
|
||||||
|
const path = this.getPath(name)
|
||||||
|
if(isDir(type)) {
|
||||||
|
this.socket.emit('rm_dir', path)
|
||||||
|
}else {
|
||||||
|
this.socket.emit('rm_file', path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async handleUpload(event) {
|
||||||
|
if(this.showFileProgress) return this.$message.warning('需等待当前任务完成')
|
||||||
|
let { files } = event.target
|
||||||
|
for(let file of files) {
|
||||||
|
console.log(file)
|
||||||
|
try {
|
||||||
|
await this.uploadFile(file)
|
||||||
|
} catch (error) {
|
||||||
|
this.$message.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.$refs['upload_file'].value = ''
|
||||||
|
},
|
||||||
|
uploadFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if(!file) return reject('file is not defined')
|
||||||
|
if((file.size/1024/1024)> 1000) {
|
||||||
|
this.$message.warn('用网页传这么大文件你是认真的吗?')
|
||||||
|
}
|
||||||
|
let reader = new FileReader()
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
// console.log('buffer:', e.target.result)
|
||||||
|
const { name } = file
|
||||||
|
const fullPath = this.getPath(name)
|
||||||
|
const targetPath = this.curPath
|
||||||
|
this.curUploadFileName = name
|
||||||
|
this.socket.emit('create_cache_dir', { targetPath, name })
|
||||||
|
// 每次上传只监听一次,多次监听会导致回调重复执行
|
||||||
|
this.socket.once('create_cache_success', async () => {
|
||||||
|
let start = 0
|
||||||
|
let end = 0
|
||||||
|
let range = 1024 * 512 // 每段512KB
|
||||||
|
let size = file.size
|
||||||
|
let fileIndex = 0
|
||||||
|
let multipleFlag = false // 用于防止上一个文件失败导致多次执行once
|
||||||
|
try {
|
||||||
|
console.log('=========开始上传分片=========')
|
||||||
|
this.upFileProgress = 0
|
||||||
|
this.showFileProgress = true
|
||||||
|
this.childDirLoading = true
|
||||||
|
let totalSliceCount = Math.ceil(size / range)
|
||||||
|
while(end < size) {
|
||||||
|
fileIndex++
|
||||||
|
end += range
|
||||||
|
let sliceFile = file.slice(start, end)
|
||||||
|
start = end
|
||||||
|
await this.uploadSliceFile({ name, sliceFile, fileIndex })
|
||||||
|
// 浏览器到服务端占比50%,服务端到服务器占比50%
|
||||||
|
this.upFileProgress = parseInt((fileIndex / totalSliceCount * 100) / 2)
|
||||||
|
}
|
||||||
|
console.log('=========分片上传完成(等待服务端上传至客户端)=========')
|
||||||
|
this.socket.emit('up_file_slice_over', { name, fullPath, range, size })
|
||||||
|
this.socket.once('up_file_success', (res) => {
|
||||||
|
if(multipleFlag) return
|
||||||
|
console.log('=========服务端上传至客户端上传完成✔=========')
|
||||||
|
// console.log('up_file_success:', res)
|
||||||
|
// this.$message.success(res)
|
||||||
|
this.handleRefresh()
|
||||||
|
this.resetFileStatusFlag()
|
||||||
|
multipleFlag = true
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
this.socket.once('up_file_fail', (res) => {
|
||||||
|
if(multipleFlag) return
|
||||||
|
console.log('=========服务端上传至客户端上传失败❌=========')
|
||||||
|
// console.log('up_file_fail:', res)
|
||||||
|
this.$message.error(res)
|
||||||
|
this.handleRefresh()
|
||||||
|
this.resetFileStatusFlag()
|
||||||
|
multipleFlag = true
|
||||||
|
reject()
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
let errMsg = `上传失败, ${ err }`
|
||||||
|
console.error(errMsg)
|
||||||
|
this.$message.error(errMsg)
|
||||||
|
this.handleRefresh()
|
||||||
|
this.resetFileStatusFlag()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
resetFileStatusFlag() {
|
||||||
|
this.upFileProgress = 0
|
||||||
|
this.curUploadFileName = ''
|
||||||
|
this.showFileProgress = false
|
||||||
|
this.childDirLoading = false
|
||||||
|
},
|
||||||
|
uploadSliceFile(fileInfo) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.socket.emit('up_file_slice', fileInfo)
|
||||||
|
this.socket.once('up_file_slice_success', () => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
this.socket.once('up_file_slice_fail', () => {
|
||||||
|
reject('分片文件上传失败')
|
||||||
|
})
|
||||||
|
this.socket.once('not_exists_dir', (errMsg) => {
|
||||||
|
reject(errMsg)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
openDir() {
|
||||||
|
this.childDirLoading = true
|
||||||
|
this.curTarget = null
|
||||||
|
this.socket.emit('open_dir', this.curPath)
|
||||||
|
},
|
||||||
|
getPath(name = '') {
|
||||||
|
return this.curPath.length === 1 ? `/${ name }` : `${ this.curPath }/${ name }`
|
||||||
|
},
|
||||||
|
adjustHeight() {
|
||||||
|
let startAdjust = false
|
||||||
|
let timer = null
|
||||||
|
this.$nextTick(() => {
|
||||||
|
let sftpHeight = localStorage.getItem('sftpHeight')
|
||||||
|
if(sftpHeight) document.querySelector('.sftp-container').style.height = sftpHeight
|
||||||
|
else document.querySelector('.sftp-container').style.height = '33vh' // 默认占据页面高度1/3
|
||||||
|
|
||||||
|
this.$refs['adjust'].addEventListener('mousedown', () => {
|
||||||
|
// console.log('开始调整')
|
||||||
|
startAdjust = true
|
||||||
|
})
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if(!startAdjust) return
|
||||||
|
if(timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
sftpHeight = `calc(100vh - ${ e.pageY }px)`
|
||||||
|
document.querySelector('.sftp-container').style.height = sftpHeight
|
||||||
|
this.$emit('resize')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
document.addEventListener('mouseup', (e) => {
|
||||||
|
if(!startAdjust) return
|
||||||
|
startAdjust = false
|
||||||
|
sftpHeight = `calc(100vh - ${ e.pageY }px)`
|
||||||
|
localStorage.setItem('sftpHeight', sftpHeight)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sftp-container {
|
||||||
|
position: relative;
|
||||||
|
background: #ffffff;
|
||||||
|
height: 400px;
|
||||||
|
.adjust {
|
||||||
|
user-select: none;
|
||||||
|
position: absolute;
|
||||||
|
top: -5px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-25px);
|
||||||
|
width: 50px;
|
||||||
|
height: 5px;
|
||||||
|
background: rgb(138, 226, 52);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
// common
|
||||||
|
.box {
|
||||||
|
$header_height: 30px;
|
||||||
|
.header {
|
||||||
|
user-select: none;
|
||||||
|
height: $header_height;
|
||||||
|
padding: 0 5px;
|
||||||
|
background: #e1e1e2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
.operation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
// margin-right: 20px;
|
||||||
|
.img {
|
||||||
|
margin: 0 5px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background: #cec4c4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter-input {
|
||||||
|
width: 200px;
|
||||||
|
margin: 0 20px 0 10px;
|
||||||
|
}
|
||||||
|
.path {
|
||||||
|
flex: 1;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.up-file-progress-wrap {
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dir-list {
|
||||||
|
overflow: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
height: calc(100% - $header_height);
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
.active {
|
||||||
|
background: #e9e9e9;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 5px 3px;
|
||||||
|
color: #303133;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
// cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
background: #e9e9e9;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
span {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.left {
|
||||||
|
width: 200px;
|
||||||
|
border-right: 1px solid #dcdfe6;
|
||||||
|
.dir-list {
|
||||||
|
li:nth-child(n+2){
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
305
web/src/views/terminal/components/terminal-tab.vue
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
<template>
|
||||||
|
<header>
|
||||||
|
<!-- 功能 -->
|
||||||
|
<!-- <el-button type="primary" @click="handleClear">
|
||||||
|
清空
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="handlePaste">
|
||||||
|
粘贴
|
||||||
|
</el-button> -->
|
||||||
|
</header>
|
||||||
|
<div ref="terminal" class="terminal-container" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Terminal } from 'xterm'
|
||||||
|
import 'xterm/css/xterm.css'
|
||||||
|
import { FitAddon } from 'xterm-addon-fit'
|
||||||
|
import { SearchAddon } from 'xterm-addon-search'
|
||||||
|
import { SearchBarAddon } from 'xterm-addon-search-bar'
|
||||||
|
import { WebLinksAddon } from 'xterm-addon-web-links'
|
||||||
|
import socketIo from 'socket.io-client'
|
||||||
|
|
||||||
|
const { io } = socketIo
|
||||||
|
export default {
|
||||||
|
name: 'Terminal',
|
||||||
|
props: {
|
||||||
|
token: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
required: true,
|
||||||
|
type: String
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
socket: null,
|
||||||
|
term: null,
|
||||||
|
command: '',
|
||||||
|
timer: null,
|
||||||
|
fitAddon: null,
|
||||||
|
searchBar: null,
|
||||||
|
isManual: false // 是否手动断开的socket连接
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.createLocalTerminal()
|
||||||
|
await this.getCommand()
|
||||||
|
this.connectIO()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
// this.term.dispose() // 销毁终端
|
||||||
|
this.isManual = true
|
||||||
|
this.socket?.close() // 关闭socket连接
|
||||||
|
window.removeEventListener('resize', this.handleResize) // 移除resize监听
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async getCommand() {
|
||||||
|
let { data } = await this.$api.getCommand(this.host)
|
||||||
|
if(data) this.command = data
|
||||||
|
},
|
||||||
|
connectIO() {
|
||||||
|
let { host, token } = this
|
||||||
|
this.socket = io(this.$serviceURI, {
|
||||||
|
path: '/terminal',
|
||||||
|
forceNew: false, // 强制新的连接
|
||||||
|
reconnectionAttempts: 1 // 尝试重新连接次数
|
||||||
|
})
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('/terminal socket已连接:', this.socket.id)
|
||||||
|
// 验证身份并连接终端
|
||||||
|
this.socket.emit('create', { host, token })
|
||||||
|
this.socket.on('connect_success', () => {
|
||||||
|
this.onData() // 监听输入输出
|
||||||
|
this.socket.on('connect_terminal', () => {
|
||||||
|
this.onResize() // 自适应窗口(终端创建完成再适应)
|
||||||
|
this.onFindText() // 查找插件
|
||||||
|
this.onWebLinks() // link链接识别插件
|
||||||
|
if(this.command) this.socket.emit('input', this.command + '\n')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.socket.on('create_fail', (message) => {
|
||||||
|
console.error(message)
|
||||||
|
this.$notification({
|
||||||
|
title: '创建失败',
|
||||||
|
message,
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.socket.on('token_verify_fail', () => {
|
||||||
|
this.$notification({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'token校验失败,请重新登录',
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
this.$router.push('/login')
|
||||||
|
})
|
||||||
|
this.socket.on('connect_fail', (message) => {
|
||||||
|
console.error(message)
|
||||||
|
this.$notification({
|
||||||
|
title: '连接失败',
|
||||||
|
message,
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
this.socket.on('disconnect', () => {
|
||||||
|
console.warn('terminal websocket 连接断开')
|
||||||
|
if(!this.isManual) this.reConnect()
|
||||||
|
})
|
||||||
|
this.socket.on('connect_error', (err) => {
|
||||||
|
console.error('terminal websocket 连接错误:', err)
|
||||||
|
this.$notification({
|
||||||
|
title: '终端连接失败',
|
||||||
|
message: '请检查socket服务是否正常',
|
||||||
|
type: 'error'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reConnect() {
|
||||||
|
this.socket.close && this.socket.close()
|
||||||
|
this.$messageBox.alert(
|
||||||
|
'<strong>终端连接断开</strong>',
|
||||||
|
'Error',
|
||||||
|
{
|
||||||
|
dangerouslyUseHTMLString: true,
|
||||||
|
confirmButtonText: '刷新页面'
|
||||||
|
}
|
||||||
|
).then(() => {
|
||||||
|
// this.fitAddon && this.fitAddon.dispose()
|
||||||
|
// this.term && this.term.dispose()
|
||||||
|
// this.connectIO()
|
||||||
|
location.reload()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
createLocalTerminal() {
|
||||||
|
// https://xtermjs.org/docs/api/terminal/interfaces/iterminaloptions/
|
||||||
|
let term = new Terminal({
|
||||||
|
rendererType: 'dom', // 渲染类型 canvas dom
|
||||||
|
bellStyle: 'sound',
|
||||||
|
// bellSound: './tip.mp3',
|
||||||
|
convertEol: true, // 启用时,光标将设置为下一行的开头
|
||||||
|
cursorBlink: true, // 光标闪烁
|
||||||
|
disableStdin: false, // 是否应禁用输入
|
||||||
|
fontSize: 18,
|
||||||
|
minimumContrastRatio: 7, // 文字对比度
|
||||||
|
theme: {
|
||||||
|
foreground: '#ECECEC', // 字体
|
||||||
|
background: '#000000', // 背景色
|
||||||
|
cursor: 'help', // 设置光标
|
||||||
|
selection: '#ff9900', // 选择文字颜色
|
||||||
|
lineHeight: 20
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.term = term
|
||||||
|
term.open(this.$refs['terminal'])
|
||||||
|
term.writeln('\x1b[1;32mWelcome to EasyNode terminal\x1b[0m.')
|
||||||
|
term.writeln('\x1b[1;32mAn experimental Web-SSH Terminal\x1b[0m.')
|
||||||
|
// 换行并输入起始符 $
|
||||||
|
// term.prompt = () => {
|
||||||
|
// term.write('\r\n\x1b[33m$ \x1b[0m ')
|
||||||
|
// }
|
||||||
|
term.focus()
|
||||||
|
this.onSelectionChange()
|
||||||
|
},
|
||||||
|
onResize() {
|
||||||
|
this.fitAddon = new FitAddon()
|
||||||
|
this.term.loadAddon(this.fitAddon)
|
||||||
|
this.fitAddon.fit()
|
||||||
|
let { rows, cols } = this.term
|
||||||
|
this.socket.emit('resize', { rows, cols }) // 首次fit完成后resize一次
|
||||||
|
window.addEventListener('resize', this.handleResize)
|
||||||
|
},
|
||||||
|
handleResize() {
|
||||||
|
if(this.timer) clearTimeout(this.timer)
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
let temp = []
|
||||||
|
let panes= Array.from(document.getElementsByClassName('el-tab-pane'))
|
||||||
|
// 先block
|
||||||
|
panes.forEach((item, index) => {
|
||||||
|
temp[index] = item.style.display
|
||||||
|
item.style.display = 'block'
|
||||||
|
})
|
||||||
|
this.fitAddon?.fit() // 需要获取元素宽度(而element tab组件会display:none隐藏非当前tab)
|
||||||
|
// 还原
|
||||||
|
panes.forEach((item, index) => {
|
||||||
|
item.style.display = temp[index]
|
||||||
|
})
|
||||||
|
let { rows, cols } = this.term
|
||||||
|
// console.log('resize: ', { rows, cols })
|
||||||
|
this.socket?.emit('resize', { rows, cols })
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
onWebLinks() {
|
||||||
|
this.term.loadAddon(new WebLinksAddon())
|
||||||
|
},
|
||||||
|
onFindText() {
|
||||||
|
const searchAddon = new SearchAddon()
|
||||||
|
this.searchBar = new SearchBarAddon({ searchAddon })
|
||||||
|
this.term.loadAddon(searchAddon)
|
||||||
|
// searchAddon.findNext('SSH', { decorations: { activeMatchBackground: '#ff0000' } })
|
||||||
|
this.term.loadAddon(this.searchBar)
|
||||||
|
// this.searchBar.show()
|
||||||
|
},
|
||||||
|
onSelectionChange() {
|
||||||
|
this.term.onSelectionChange(() => {
|
||||||
|
let str = this.term.getSelection()
|
||||||
|
if(!str) return
|
||||||
|
const text = new Blob([str,], { type: 'text/plain' })
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const item = new ClipboardItem({
|
||||||
|
'text/plain': text
|
||||||
|
})
|
||||||
|
navigator.clipboard.write([item,])
|
||||||
|
// this.$message.success('copy success')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onData() {
|
||||||
|
this.socket.on('output', (str) => {
|
||||||
|
this.term.write(str)
|
||||||
|
})
|
||||||
|
this.term.onData((key) => {
|
||||||
|
let acsiiCode = key.codePointAt()
|
||||||
|
// console.log(acsiiCode)
|
||||||
|
if(acsiiCode === 22) return this.handlePaste() // ctrl + v
|
||||||
|
if(acsiiCode === 6) return this.searchBar.show() // ctrl + f
|
||||||
|
this.socket.emit('input', key)
|
||||||
|
})
|
||||||
|
// this.term.onKey(({ key }) => { // , domEvent
|
||||||
|
// // https://blog.csdn.net/weixin_30311605/article/details/98277379
|
||||||
|
// let acsiiCode = key.codePointAt() // codePointAt转换成ASCII码
|
||||||
|
// // console.log({ acsiiCode, domEvent })
|
||||||
|
// if(acsiiCode === 22) return this.handlePaste() // ctrl + v
|
||||||
|
// this.socket.emit('input', key)
|
||||||
|
// })
|
||||||
|
},
|
||||||
|
handleClear() {
|
||||||
|
this.term.clear()
|
||||||
|
},
|
||||||
|
async handlePaste() {
|
||||||
|
let str = await navigator.clipboard.readText()
|
||||||
|
// this.term.paste(str)
|
||||||
|
this.socket.emit('input', str)
|
||||||
|
this.term.focus()
|
||||||
|
},
|
||||||
|
// 供父组件调用
|
||||||
|
focusTab() {
|
||||||
|
this.term.blur()
|
||||||
|
setTimeout(() => {
|
||||||
|
this.term.focus()
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
// 供父组件调用
|
||||||
|
handleInputCommand(command) {
|
||||||
|
this.socket.emit('input', command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
header {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1;
|
||||||
|
right: 10px;
|
||||||
|
top: 50px;
|
||||||
|
}
|
||||||
|
.terminal-container {
|
||||||
|
height: 100%;
|
||||||
|
:deep(.xterm-viewport), :deep(.xterm-screen) {
|
||||||
|
width: 100%!important;
|
||||||
|
height: 100%!important;
|
||||||
|
// 滚动条整体部分
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 5px;
|
||||||
|
width: 5px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底层轨道
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background-color: #000;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动滑块
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #067ef7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.terminals {
|
||||||
|
.el-tabs__header {
|
||||||
|
padding-left: 55px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
265
web/src/views/terminal/index.vue
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<InfoSide
|
||||||
|
ref="info-side"
|
||||||
|
:token="token"
|
||||||
|
:host="host"
|
||||||
|
:visible="visible"
|
||||||
|
@connect-sftp="connectSftp"
|
||||||
|
@click-input-command="clickInputComand"
|
||||||
|
/>
|
||||||
|
<section>
|
||||||
|
<div class="terminals">
|
||||||
|
<el-button class="full-screen-button" type="success" @click="handleFullScreen">
|
||||||
|
{{ isFullScreen ? '退出全屏' : '全屏' }}
|
||||||
|
</el-button>
|
||||||
|
<div class="visible" @click="handleVisibleSidebar">
|
||||||
|
<svg-icon
|
||||||
|
name="icon-jiantou_zuoyouqiehuan"
|
||||||
|
class="svg-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-tabs
|
||||||
|
v-model="activeTab"
|
||||||
|
type="border-card"
|
||||||
|
addable
|
||||||
|
tab-position="top"
|
||||||
|
@tab-remove="removeTab"
|
||||||
|
@tab-change="tabChange"
|
||||||
|
@tab-add="tabAdd"
|
||||||
|
>
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="item in terminalTabs"
|
||||||
|
:key="item.key"
|
||||||
|
:label="item.title"
|
||||||
|
:name="item.key"
|
||||||
|
:closable="closable"
|
||||||
|
>
|
||||||
|
<TerminalTab :ref="item.key" :token="token" :host="host" />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
<div v-if="showSftp" class="sftp">
|
||||||
|
<SftpFooter
|
||||||
|
:token="token"
|
||||||
|
:host="host"
|
||||||
|
@resize="resizeTerminal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<InputCommand
|
||||||
|
v-model:show="showInputCommand"
|
||||||
|
@input-command="handleInputCommand"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import TerminalTab from './components/terminal-tab.vue'
|
||||||
|
import InfoSide from './components/info-side.vue'
|
||||||
|
import SftpFooter from './components/sftp-footer.vue'
|
||||||
|
import InputCommand from '@/components/input-command/index.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Terminals',
|
||||||
|
components: {
|
||||||
|
TerminalTab,
|
||||||
|
InfoSide,
|
||||||
|
SftpFooter,
|
||||||
|
InputCommand
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
host: '',
|
||||||
|
token: this.$store.token,
|
||||||
|
activeTab: '',
|
||||||
|
terminalTabs: [],
|
||||||
|
isFullScreen: false,
|
||||||
|
timer: null,
|
||||||
|
showSftp: false,
|
||||||
|
showInputCommand: false,
|
||||||
|
visible: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
closable() {
|
||||||
|
return this.terminalTabs.length > 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
showInputCommand(newVal) {
|
||||||
|
if(!newVal) this.$refs['info-side'].inputCommandStatus = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
if (!this.token) return this.$router.push('login')
|
||||||
|
let { host, name } = this.$route.query
|
||||||
|
this.name = name
|
||||||
|
this.host = host
|
||||||
|
document.title = `${ document.title }-${ name }`
|
||||||
|
let key = Date.now().toString()
|
||||||
|
this.terminalTabs.push({ title: name, key })
|
||||||
|
this.activeTab = key
|
||||||
|
this.registryDbClick()
|
||||||
|
},
|
||||||
|
// mounted() {
|
||||||
|
// window.onbeforeunload = () => {
|
||||||
|
// return ''
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
methods: {
|
||||||
|
connectSftp(flag) {
|
||||||
|
this.showSftp = flag
|
||||||
|
this.resizeTerminal()
|
||||||
|
},
|
||||||
|
clickInputComand() {
|
||||||
|
this.showInputCommand = true
|
||||||
|
},
|
||||||
|
tabAdd() {
|
||||||
|
if(this.timer) clearTimeout(this.timer)
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
let { name } = this
|
||||||
|
let title = name
|
||||||
|
let key = Date.now().toString()
|
||||||
|
this.terminalTabs.push({ title, key })
|
||||||
|
this.activeTab = key
|
||||||
|
this.registryDbClick()
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
removeTab(removeKey) {
|
||||||
|
let idx = this.terminalTabs.findIndex(({ key }) => removeKey === key)
|
||||||
|
this.terminalTabs.splice(idx, 1)
|
||||||
|
if(removeKey !== this.activeTab) return
|
||||||
|
this.activeTab = this.terminalTabs[0].key
|
||||||
|
},
|
||||||
|
tabChange(key) {
|
||||||
|
this.$refs[key][0].focusTab()
|
||||||
|
},
|
||||||
|
handleFullScreen() {
|
||||||
|
if(this.isFullScreen) document.exitFullscreen()
|
||||||
|
else document.getElementsByClassName('terminals')[0].requestFullscreen()
|
||||||
|
this.isFullScreen = !this.isFullScreen
|
||||||
|
},
|
||||||
|
registryDbClick() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
let tabItems = Array.from(document.getElementsByClassName('el-tabs__item'))
|
||||||
|
tabItems.forEach(item => {
|
||||||
|
item.removeEventListener('dblclick', this.handleDblclick)
|
||||||
|
item.addEventListener('dblclick', this.handleDblclick)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleDblclick(e) {
|
||||||
|
if(this.terminalTabs.length > 1) {
|
||||||
|
let key = e.target.id.substring(4)
|
||||||
|
// console.log('dblclick', key)
|
||||||
|
this.removeTab(key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleVisibleSidebar() {
|
||||||
|
this.visible = !this.visible
|
||||||
|
this.resizeTerminal()
|
||||||
|
},
|
||||||
|
resizeTerminal() {
|
||||||
|
let terminals = this.$refs
|
||||||
|
for(let terminal in terminals) {
|
||||||
|
const { handleResize } = this.$refs[terminal][0] || {}
|
||||||
|
handleResize && handleResize()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleInputCommand(command) {
|
||||||
|
// console.log(command)
|
||||||
|
this.$refs[this.activeTab][0].handleInputCommand(`${ command }\n`)
|
||||||
|
this.showInputCommand = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: calc(100vw - 250px); // 减去左边栏
|
||||||
|
.terminals {
|
||||||
|
min-height: 150px;
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
.full-screen-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 4px;
|
||||||
|
z-index: 99999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sftp {
|
||||||
|
border: 1px solid rgb(236, 215, 187);
|
||||||
|
}
|
||||||
|
.visible {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 999999;
|
||||||
|
top: 13px;
|
||||||
|
left: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.el-tabs {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.el-tabs--border-card>.el-tabs__content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.el-tabs__header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.el-tabs__nav-scroll {
|
||||||
|
.el-tabs__nav {
|
||||||
|
padding-left: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-tabs__new-tab {
|
||||||
|
position: absolute;
|
||||||
|
left: 18px;
|
||||||
|
font-size: 50px;
|
||||||
|
z-index: 98;
|
||||||
|
// &::before {
|
||||||
|
// font-family: iconfont;
|
||||||
|
// content: '\eb0d';
|
||||||
|
// font-size: 12px;
|
||||||
|
// font-size: 18px;
|
||||||
|
// position: absolute;
|
||||||
|
// left: -28px;
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
.el-tabs--border-card {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tabs__content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon.is-icon-close {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
22
web/src/views/test/index.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- <codemirror /> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// import codemirror from '@/components/codemirror/index.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Test',
|
||||||
|
// components: { codemirror },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
</style>
|
96
web/vite.config.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { fileURLToPath, URL } from 'url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
import styleImport from 'vite-plugin-style-import'
|
||||||
|
import viteCompression from 'vite-plugin-compression'
|
||||||
|
|
||||||
|
const serviceURI = 'http://localhost:8082/'
|
||||||
|
const serviceApiPrefix = '/api/v1'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 18090,
|
||||||
|
strictPort: true,
|
||||||
|
cors: true,
|
||||||
|
proxy: {
|
||||||
|
[serviceApiPrefix]: {
|
||||||
|
target: serviceURI
|
||||||
|
// rewrite: (p) => p.replace(/^\/api/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 解决内网穿透一直重定向的问题
|
||||||
|
// hmr: {
|
||||||
|
// protocol: 'ws',
|
||||||
|
// host: 'localhost'
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../server/app/static',
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env': {
|
||||||
|
serviceURI,
|
||||||
|
serviceApiPrefix,
|
||||||
|
clientPort: 22022
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueJsx(),
|
||||||
|
AutoImport({
|
||||||
|
resolvers: [
|
||||||
|
ElementPlusResolver(),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [
|
||||||
|
ElementPlusResolver(),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
styleImport({
|
||||||
|
libs: [
|
||||||
|
{
|
||||||
|
libraryName: 'element-plus',
|
||||||
|
esModule: true,
|
||||||
|
resolveStyle: (name) => {
|
||||||
|
if (name.includes('el-')) name = name.replace('el-', '')
|
||||||
|
return `element-plus/theme-chalk/src/${ name }.scss` // 按需引入样式
|
||||||
|
// return `element-plus/theme-chalk/${ name }.css`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
viteCompression({
|
||||||
|
algorithm: 'gzip',
|
||||||
|
deleteOriginFile: false
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL(
|
||||||
|
'./src',
|
||||||
|
import.meta.url
|
||||||
|
)),
|
||||||
|
'@views': fileURLToPath(new URL(
|
||||||
|
'./src/views',
|
||||||
|
import.meta.url
|
||||||
|
)),
|
||||||
|
'@utils': fileURLToPath(new URL(
|
||||||
|
'./src/utils',
|
||||||
|
import.meta.url
|
||||||
|
)),
|
||||||
|
'@store': fileURLToPath(new URL(
|
||||||
|
'./src/store',
|
||||||
|
import.meta.url
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|