✨ update
6
.gitignore
vendored
@ -2,9 +2,9 @@ node_modules
|
||||
!.gitkeep
|
||||
dist
|
||||
easynode-server.zip
|
||||
server/app/static/upload/*
|
||||
server/app/socket/temp/*
|
||||
app/socket/.sftp-cache/*
|
||||
server/app/static/*
|
||||
server/app/socket/sftp-cache/*
|
||||
!server/app/socket/sftp-cache/.gitkeep
|
||||
server/app/logs/*
|
||||
server/app/db/*
|
||||
!server/app/db/README.md
|
||||
|
@ -1,4 +1,4 @@
|
||||
# EasyNode v1.2
|
||||
# EasyNode
|
||||
|
||||
> 一个简易的个人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**
|
||||
|
||||
### 服务端
|
||||
|
14
package.json
@ -3,7 +3,11 @@
|
||||
"version": "1.0.0",
|
||||
"description": "web ssh",
|
||||
"private": true,
|
||||
"workspaces": ["server", "client"],
|
||||
"workspaces": [
|
||||
"server",
|
||||
"web",
|
||||
"client"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/chaos-zhu/easynode.git"
|
||||
@ -17,8 +21,14 @@
|
||||
],
|
||||
"author": "chaoszhu",
|
||||
"license": "ISC",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"yarn workspace web run dev\" \"yarn workspace server run local\""
|
||||
},
|
||||
"bugs": {
|
||||
"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 = {
|
||||
httpPort: 8082,
|
||||
clientPort: 22022, // 暂不支持更改
|
||||
uploadDir: path.join(process.cwd(),'app/static/upload'),
|
||||
uploadDir: path.join(process.cwd(),'app/db'),
|
||||
staticDir: path.join(process.cwd(),'app/static'),
|
||||
sftpCacheDir: path.join(process.cwd(),'app/socket/sftp-cache'),
|
||||
sshRecordDBPath: path.join(process.cwd(),'app/db/ssh-record.db'),
|
||||
|
@ -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",
|
||||
"description": "easynode-server",
|
||||
"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/
|
||||
module.exports = {
|
||||
root: true, // 当前配置文件不能往父级查找
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es6: true
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended' // 应用Eslint全部默认规则
|
||||
],
|
||||
'parserOptions': {
|
||||
'ecmaVersion': 'latest',
|
||||
'sourceType': 'module' // 目标类型 Node项目得添加这个
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
// parser: 'babel-eslint',
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
'jsx': true
|
||||
}
|
||||
},
|
||||
// 自定义规则,可以覆盖 extends 的配置【安装Eslint插件可以静态检查本地文件是否符合以下规则】
|
||||
'ignorePatterns': ['*.html', 'node-os-utils'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
],
|
||||
ignorePatterns: ['*.html',],
|
||||
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-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'template-curly-spacing': ['error', 'always'], // 模板字符串空格
|
||||
'template-curly-spacing': ['error', 'always',], // 模板字符串空格
|
||||
'default-case': 0,
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'no-multi-spaces': ['error'],
|
||||
indent: ['error', 2, { 'SwitchCase': 1 }], // 缩进:2
|
||||
quotes: ['error', 'single'], // 引号:single单引 double双引
|
||||
semi: ['error', 'never'], // 结尾分号:never禁止 always必须
|
||||
'comma-dangle': ['error', 'never'], // 对象拖尾逗号
|
||||
'no-redeclare': ['error', { builtinGlobals: true }], // 禁止重复对象声明
|
||||
'eslint-comments/no-unlimited-disable': 0,
|
||||
'object-curly-spacing': ['error', 'always',],
|
||||
'no-multi-spaces': ['error',],
|
||||
indent: ['error', 2, { 'SwitchCase': 1 },], // 缩进:2
|
||||
quotes: ['error', 'single',], // 引号:single单引 double双引
|
||||
semi: ['error', 'never',], // 结尾分号:never禁止 always必须
|
||||
'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-restricted-globals': 0,
|
||||
'space-before-function-paren': 0, // 函数定义时括号前面空格
|
||||
@ -71,11 +100,11 @@ module.exports = {
|
||||
'no-extra-semi': 2, // 禁止不必要的分号
|
||||
// curly: ['error', 'multi'], // if、else、for、while 语句单行代码时不使用大括号
|
||||
'dot-notation': 0, // 允许使用点号或方括号来访问对象属性
|
||||
'dot-location': ['error', 'property'], // 点操作符位置,要求跟随下一行
|
||||
'dot-location': ['error', 'property',], // 点操作符位置,要求跟随下一行
|
||||
'no-else-return': 2, // 禁止if中有return后又else
|
||||
'no-implicit-coercion': [2, { allow: ['!!', '~', '+'] }], // 禁止隐式转换,allow字段内符号允许
|
||||
'no-implicit-coercion': [2, { allow: ['!!', '~', '+',] },], // 禁止隐式转换,allow字段内符号允许
|
||||
'no-trailing-spaces': 1, //一行结束后面不要有空格
|
||||
'no-multiple-empty-lines': [1, { 'max': 1 }], // 空行最多不能超过1行
|
||||
'no-multiple-empty-lines': [1, { 'max': 1 },], // 空行最多不能超过1行
|
||||
'no-useless-return': 2,
|
||||
'wrap-iife': 0, // 允许自调用函数
|
||||
'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
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|