update

This commit is contained in:
chaoszhu 2024-07-10 16:52:59 +08:00
parent b8e08666a6
commit 8d52e34d6f
79 changed files with 9735 additions and 210 deletions

6
.gitignore vendored
View File

@ -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

View File

@ -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**
### 服务端

View File

@ -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"
}
}

View File

@ -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'),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -1,5 +1,5 @@
{
"name": "easynode-server",
"name": "server",
"version": "0.0.1",
"description": "easynode-server",
"bin": "./bin/www",

13
web/.editorconfig Normal file
View 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

View File

@ -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
View 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
View File

@ -0,0 +1 @@
sass_binary_site "https://registry.npmmirror.com/node-sass/"

4
web/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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"
}
}

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

22
web/src/App.vue Normal file
View 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
View 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' })
}
}

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

BIN
web/src/assets/download.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
web/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

18
web/src/assets/scss/animate.scss vendored Normal file
View 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;
}

View 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;
}

View 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;
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View 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>

View 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
}

View 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>

View 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>

View 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
View 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')

View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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,])) // paramsobject 可选: 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
View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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' // token12hsessionStroage
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>

View 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>

View 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'
})
})
},
// socketon
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>

View 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 }) // fitresize
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 tabdisplay:nonetab)
//
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() // codePointAtASCII
// // 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>

View 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>

View 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
View 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
))
}
}
})

2507
web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

2090
yarn.lock

File diff suppressed because it is too large Load Diff