mirror of
https://github.com/hi2shark/nazhua.git
synced 2026-01-11 22:50:42 +08:00
✨ init
This commit is contained in:
commit
e2e186329e
3
.eslintignore
Normal file
3
.eslintignore
Normal file
@ -0,0 +1,3 @@
|
||||
build/*.js
|
||||
public
|
||||
dist
|
||||
98
.eslintrc.cjs
Normal file
98
.eslintrc.cjs
Normal file
@ -0,0 +1,98 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:vue/vue3-essential',
|
||||
'@vue/airbnb',
|
||||
],
|
||||
globals: {
|
||||
defineEmits: true,
|
||||
defineExpose: true,
|
||||
defineProps: true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
camelcase: 'off',
|
||||
'vue/component-definition-name-casing': ['error', 'PascalCase'],
|
||||
'vue/html-closing-bracket-newline': ['error', {
|
||||
singleline: 'never',
|
||||
multiline: 'always',
|
||||
}],
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/no-mutating-props': 'off',
|
||||
'vue/max-attributes-per-line': ['error', {
|
||||
singleline: {
|
||||
max: 1,
|
||||
},
|
||||
multiline: {
|
||||
max: 1,
|
||||
},
|
||||
}],
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/valid-v-slot': 'off',
|
||||
'vue/no-template-target-blank': 'off',
|
||||
'vuejs-accessibility/anchor-has-content': 'off',
|
||||
'vuejs-accessibility/alt-text': 'off',
|
||||
'vuejs-accessibility/label-has-for': 'off',
|
||||
'vuejs-accessibility/click-events-have-key-events': 'off',
|
||||
'vuejs-accessibility/form-control-has-label': 'off',
|
||||
'vuejs-accessibility/iframe-has-title': 'off',
|
||||
'vuejs-accessibility/media-has-caption': 'off',
|
||||
'accessor-pairs': 2,
|
||||
'arrow-spacing': [2, {
|
||||
before: true,
|
||||
after: true,
|
||||
}],
|
||||
indent: [
|
||||
2, 2,
|
||||
{
|
||||
SwitchCase: 1,
|
||||
offsetTernaryExpressions: false,
|
||||
},
|
||||
],
|
||||
'default-case-last': 'off',
|
||||
'func-names': ['error', 'never'],
|
||||
'no-console': 'off',
|
||||
'no-debugger': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
'no-unsafe-optional-chaining': 'off',
|
||||
'max-len': ['warn', 120],
|
||||
'vue/max-len': ['warn', 120],
|
||||
'object-property-newline': ['error', {
|
||||
allowAllPropertiesOnSameLine: false,
|
||||
}],
|
||||
'one-var-declaration-per-line': ['error', 'always'],
|
||||
'prefer-destructuring': ['error',
|
||||
{
|
||||
VariableDeclarator: {
|
||||
array: false,
|
||||
object: true,
|
||||
},
|
||||
AssignmentExpression: {
|
||||
array: true,
|
||||
object: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
'import/no-cycle': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
'import/extensions': ['error', 'never', {
|
||||
ignorePackages: true,
|
||||
pattern: {
|
||||
vue: 'always',
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
4
Dockerfile
Normal file
4
Dockerfile
Normal file
@ -0,0 +1,4 @@
|
||||
FROM nginx:1.27.3
|
||||
|
||||
COPY ./dist /home/wwwroot/html
|
||||
COPY ./nginx-default.conf.template /etc/nginx/templates/default.conf.template
|
||||
15
index.html
Normal file
15
index.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nazhua</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<script src="./config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</body>
|
||||
</html>
|
||||
35
nginx-default.conf.template
Normal file
35
nginx-default.conf.template
Normal file
@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN};
|
||||
client_max_body_size 1024m;
|
||||
|
||||
location /ws {
|
||||
proxy_pass ${NEZHA}ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass ${NEZHA}api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /nezha/ {
|
||||
proxy_pass ${NEZHA};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /home/wwwroot/html;
|
||||
}
|
||||
}
|
||||
38
package.json
Normal file
38
package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "nazhua",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.5.1",
|
||||
"flag-icons": "^7.2.3",
|
||||
"font-logos": "^1.3.0",
|
||||
"remixicon": "^4.5.0",
|
||||
"vue": "^3.5.12",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-router": "^4.4.5",
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.9",
|
||||
"@babel/eslint-parser": "^7.24.8",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue/eslint-config-airbnb": "^7.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-plugin-vue": "^9.9.0",
|
||||
"sass": "^1.81.0",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-babel": "^1.2.0",
|
||||
"vite-plugin-eslint": "^1.8.1"
|
||||
}
|
||||
}
|
||||
18
public/config.js
Normal file
18
public/config.js
Normal file
@ -0,0 +1,18 @@
|
||||
window.$$nazhuaConfig = {
|
||||
// title: '哪吒监控', // 网站标题
|
||||
// freeAmount: '白嫖', // 免费服务的费用名称
|
||||
// infinityCycle: '无限', // 无限周期名称
|
||||
// hideListItemBill: false, // 隐藏列表项的账单信息
|
||||
// buyBtnText: '购买', // 购买按钮文案
|
||||
// hideWorldMap: false, // 隐藏地图
|
||||
// hideHomeWorldMap: false, // 隐藏首页地图
|
||||
// hideDetailWorldMap: false, // 隐藏详情地图
|
||||
// hideFilter: false, // 隐藏筛选
|
||||
// hideTag: false, // 隐藏标签
|
||||
// customCodeMap: {}, // 自定义的地图点信息
|
||||
// apiMonitorPath: '/api/v1/monitor/{id}',
|
||||
// wsPath: '/ws',
|
||||
// nezhaPath: '/nezha/',
|
||||
// nezhaV0ConfigType: 'servers', // 哪吒v0数据读取类型
|
||||
// routeMode: 'h5', // 路由模式
|
||||
};
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 703 B |
0
public/style.css
Normal file
0
public/style.css
Normal file
104
readme.md
Normal file
104
readme.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Nazhua
|
||||
基于哪吒监控(nezha.wiki)v0版本构建的前端主题,目前暂不支持v1版本,关于v1支持需要等待后续版本。
|
||||
主题有点重,因为内置了`SarasaTermSC-SemiBold`字体。
|
||||
|
||||
## 劝退指南 用前必读
|
||||
1. 本主题是基于哪吒监控v0版本构建的,不支持v1版本。*未来根据情况可能会支持v1版本*
|
||||
2. 本主题是一个纯前端项目,需要解决跨域问题,通常需要一个nginx或者caddy反代请求解决跨域问题。
|
||||
3. 我不会提供任何技术支持,如果你有问题,可以提issue,但是我不保证会回答,可能询问GPT会更快。
|
||||
|
||||
## 数据来源
|
||||
1. 公开的全量配置,其中包括“公开备注”(PublicNote),来着探针主页上暴露的服务器节点列表配置信息。此处是根据正则匹配的方式,获取到的节点列表。在主题项目中,默认将访问`/nezha/`的指向此处。
|
||||
2. 实时数据,来着公开的ws服务接口,`/ws`。
|
||||
3. 监控数据,来着公开的api接口,`/api/v1/monitor/${id}`。
|
||||
|
||||
## 部署
|
||||
Nazhua主题是一个纯前端项目,可以部署在纯静态服务器上,但需要解决`/api/v1/monitor/${id}`监控数据、`/ws`WS服务和`/`主页的跨域访问。
|
||||
通常来说,你需要一个nginx或者caddy反代请求解决跨域问题。
|
||||
|
||||
### Nginx配置示例
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name nazhua.example.com;
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://nezha-dashboard.example.com/ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://nezha-dashboard.example.com/api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /nezha/ {
|
||||
proxy_pass http://nezha-dashboard.example.com/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /home/wwwroot/html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## 自定义配置
|
||||
可以通过修改`config.js`文件来自定义配置
|
||||
例如:
|
||||
```javascript
|
||||
window.$$nazhuaConfig = {
|
||||
title: '哪吒监控', // 网站标题
|
||||
freeAmount: '', // 免费服务的费用名称
|
||||
infinityCycle: '', // 无限周期名称
|
||||
hideListItemBill: false, // 隐藏列表项的账单信息
|
||||
buyBtnText: '', // 购买按钮文案
|
||||
hideWorldMap: false, // 隐藏地图
|
||||
hideHomeWorldMap: false, // 隐藏首页地图
|
||||
hideDetailWorldMap: false, // 隐藏详情地图
|
||||
hideFilter: false, // 隐藏筛选
|
||||
hideTag: false, // 隐藏标签
|
||||
customCodeMap: {}, // 自定义的地图点信息
|
||||
apiMonitorPath: '/api/v1/monitor/{id}',
|
||||
wsPath: '/ws',
|
||||
nezhaPath: '/nezha/',
|
||||
nezhaV0ConfigType: 'servers',
|
||||
routeMode: 'h5', // 路由模式 h5 | hash
|
||||
};
|
||||
```
|
||||
可以通过修改`style.css`文件来自定义样式
|
||||
例如:
|
||||
```css
|
||||
:root {
|
||||
/* 修改颜色 */
|
||||
/* 地图上标记点的颜色 */
|
||||
--world-map-point-color: #fff;
|
||||
/* 列表项显示的价格颜色 */
|
||||
--list-item-price-color: #ff6;
|
||||
/* 购买链接的主要颜色 */
|
||||
--list-item-buy-link-color: #f00;
|
||||
}
|
||||
```
|
||||
可以通过[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/)快速生成config.js配置文件
|
||||
|
||||
|
||||
## 二次开发提示
|
||||
`.env.development.local`配置变量
|
||||
```bash
|
||||
WS_HOST=http://127.0.0.1:9288 # 本地nezha ws反代
|
||||
API_HOST=http://nezha-dashboard.example.com # 本地nezha api反代
|
||||
NEZHA_HOST=http://nezha-dashboard.example.com # 本地nezha主页反代
|
||||
NEZHA_HOST_REPACE_PATH=1 # 是否替换主页路径`/nezha/`
|
||||
```
|
||||
74
src/App.vue
Normal file
74
src/App.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<layout-main>
|
||||
<router-view />
|
||||
</layout-main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
provide,
|
||||
onMounted,
|
||||
} from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import sleep from '@/utils/sleep';
|
||||
import LayoutMain from './layout/main.vue';
|
||||
|
||||
import activeWebsocketService, {
|
||||
msg,
|
||||
} from './ws';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const currentTime = ref(0);
|
||||
|
||||
provide('currentTime', currentTime);
|
||||
|
||||
function refreshTime() {
|
||||
currentTime.value = Date.now();
|
||||
setTimeout(() => {
|
||||
refreshTime();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function handleSystem() {
|
||||
const isWindows = /windows|win32/i.test(navigator.userAgent);
|
||||
if (isWindows) {
|
||||
document.body.classList.add('windows');
|
||||
}
|
||||
}
|
||||
|
||||
let stopReconnect = false;
|
||||
async function wsReconnect() {
|
||||
if (stopReconnect) {
|
||||
return;
|
||||
}
|
||||
await sleep(1000);
|
||||
console.log('reconnect ws');
|
||||
activeWebsocketService();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
handleSystem();
|
||||
refreshTime();
|
||||
await store.dispatch('loadServers');
|
||||
msg.on('close', () => {
|
||||
console.log('ws closed');
|
||||
wsReconnect();
|
||||
});
|
||||
msg.on('error', () => {
|
||||
console.log('ws error');
|
||||
stopReconnect = true;
|
||||
});
|
||||
msg.on('connect', () => {
|
||||
console.log('ws connected');
|
||||
store.dispatch('watchWsMsg');
|
||||
});
|
||||
activeWebsocketService();
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('未处理的rejection:', event.reason);
|
||||
event.preventDefault();
|
||||
});
|
||||
</script>
|
||||
BIN
src/assets/fonts/SarasaTermSC-SemiBold.woff
Normal file
BIN
src/assets/fonts/SarasaTermSC-SemiBold.woff
Normal file
Binary file not shown.
BIN
src/assets/fonts/SarasaTermSC-SemiBold.woff2
Normal file
BIN
src/assets/fonts/SarasaTermSC-SemiBold.woff2
Normal file
Binary file not shown.
BIN
src/assets/images/bg.webp
Normal file
BIN
src/assets/images/bg.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
6377
src/assets/images/world-map.svg
Normal file
6377
src/assets/images/world-map.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 240 KiB |
164
src/assets/scss/base.scss
Normal file
164
src/assets/scss/base.scss
Normal file
@ -0,0 +1,164 @@
|
||||
@use "./variables.scss";
|
||||
|
||||
body {
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
font-family: 'Microsoft YaHei', '微软雅黑', 'PingFang SC', 'HanHei SC', 'Helvetica Neue', 'Helvetica', 'STHeitiSC-Light', 'Arial', sans-serif;
|
||||
color: #555;
|
||||
background: #fff;
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
}
|
||||
|
||||
ul,
|
||||
ul li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
ul,
|
||||
li,
|
||||
ol,
|
||||
blockquote,
|
||||
pre,
|
||||
p,
|
||||
table,
|
||||
tbody,
|
||||
th,
|
||||
td,
|
||||
tr,
|
||||
span {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
img {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="text"]:focus,
|
||||
input[type="text"]:active,
|
||||
input[type="email"],
|
||||
input[type="email"]:focus,
|
||||
input[type="email"]:active,
|
||||
input[type="number"],
|
||||
input[type="number"]:focus,
|
||||
input[type="number"]:active,
|
||||
input[type="password"],
|
||||
input[type="password"]:focus,
|
||||
input[type="password"]:active,
|
||||
textarea,
|
||||
textarea:active,
|
||||
textarea:focus,
|
||||
button,
|
||||
button:active,
|
||||
button:focus,
|
||||
button:invalid,
|
||||
a:active,
|
||||
a:visited,
|
||||
a:link {
|
||||
outline: 0;
|
||||
outline-color: transparent;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-webkit-appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a:link,
|
||||
a:visited {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #08a;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #369;
|
||||
transition: color 150ms linear;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// 默认盒模型为border-box
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.fl {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.fr {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.clear,
|
||||
.clear::before,
|
||||
.clear::after {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.clear::before,
|
||||
.clear::after {
|
||||
content: '';
|
||||
display: table;
|
||||
}
|
||||
|
||||
div:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Sarasa Term SC";
|
||||
src: url("../fonts/SarasaTermSC-SemiBold.woff2") format("woff2"),
|
||||
url("../fonts/SarasaTermSC-SemiBold.woff2") format("woff");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
"Sarasa Term SC",
|
||||
'Microsoft YaHei',
|
||||
'微软雅黑',
|
||||
'PingFang SC',
|
||||
'HanHei SC',
|
||||
'Helvetica Neue',
|
||||
'Helvetica',
|
||||
'STHeitiSC-Light',
|
||||
'Arial',
|
||||
sans-serif;
|
||||
}
|
||||
41
src/assets/scss/variables.scss
Normal file
41
src/assets/scss/variables.scss
Normal file
@ -0,0 +1,41 @@
|
||||
// 原生CSS变量 -- 顶级作用域
|
||||
:root {
|
||||
--layout-header-height: 60px;
|
||||
--layout-main-height: calc(100vh - var(--layout-header-height));
|
||||
|
||||
--list-container-width: 1300px;
|
||||
|
||||
--detail-container-width: 900px;
|
||||
|
||||
--layout-main-bg-color: rgba(20, 30, 40, 0.75);
|
||||
|
||||
--layout-bg-color: #252748;
|
||||
--world-map-point-color: #fff143;
|
||||
--list-item-price-color: #eee;
|
||||
--list-item-buy-link-color: #ffc300;
|
||||
|
||||
// 针对1440px以下的屏幕
|
||||
@media screen and (max-width: 1440px) {
|
||||
--list-container-width: 1120px;
|
||||
}
|
||||
|
||||
// 针对1280px以下的屏幕
|
||||
@media screen and (max-width: 1280px) {
|
||||
--list-container-width: 1024px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
--list-container-width: 800px;
|
||||
--detail-container-width: 800px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
--list-container-width: 720px;
|
||||
--detail-container-width: 720px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
--list-container-width: 100vw;
|
||||
--detail-container-width: 100vw;
|
||||
}
|
||||
}
|
||||
85
src/components/charts/donut.js
Normal file
85
src/components/charts/donut.js
Normal file
@ -0,0 +1,85 @@
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import {
|
||||
BarChart,
|
||||
} from 'echarts/charts';
|
||||
import {
|
||||
PolarComponent,
|
||||
} from 'echarts/components';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
PolarComponent,
|
||||
]);
|
||||
|
||||
export default (used, total, itemColors, size = 100) => ({
|
||||
angleAxis: {
|
||||
max: total, // 满分
|
||||
// 隐藏刻度线
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
radiusAxis: {
|
||||
type: 'category',
|
||||
// 隐藏刻度线
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
polar: {
|
||||
center: ['50%', '50%'],
|
||||
radius: ['50%', '100%'],
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: [{
|
||||
value: used,
|
||||
}],
|
||||
itemStyle: {
|
||||
color: typeof itemColors === 'string' ? itemColors : itemColors?.used,
|
||||
borderRadius: 5,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.2)',
|
||||
shadowBlur: 10,
|
||||
shadowOffsetY: 2,
|
||||
},
|
||||
coordinateSystem: 'polar',
|
||||
cursor: 'default',
|
||||
roundCap: true,
|
||||
barWidth: Math.ceil((size / 100) * 10),
|
||||
barGap: '-100%', // 两环重叠
|
||||
z: 10,
|
||||
}, {
|
||||
type: 'bar',
|
||||
data: [{
|
||||
value: total,
|
||||
}],
|
||||
itemStyle: {
|
||||
color: itemColors?.total || 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
coordinateSystem: 'polar',
|
||||
cursor: 'default',
|
||||
barWidth: Math.ceil((size / 100) * 10),
|
||||
barGap: '-100%', // 两环重叠
|
||||
z: 5,
|
||||
}],
|
||||
});
|
||||
112
src/components/charts/donut.vue
Normal file
112
src/components/charts/donut.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="option"
|
||||
ref="chartBoxRef"
|
||||
class="donut-box"
|
||||
:class="{
|
||||
'donut-box--content': showContent,
|
||||
}"
|
||||
>
|
||||
<v-chart
|
||||
ref="chartRef"
|
||||
class="donut-box-v-chart"
|
||||
:option="option"
|
||||
/>
|
||||
<div
|
||||
v-if="showContent"
|
||||
class="donunt-content"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 环状图
|
||||
*/
|
||||
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import donut from './donut';
|
||||
|
||||
const props = defineProps({
|
||||
used: {
|
||||
type: [Number, String],
|
||||
default: 0,
|
||||
},
|
||||
total: {
|
||||
type: [Number, String],
|
||||
default: 100,
|
||||
},
|
||||
itemColors: {
|
||||
type: [Object, String],
|
||||
default: () => ({
|
||||
used: '#409EFF',
|
||||
total: '#E6A23C',
|
||||
}),
|
||||
},
|
||||
showContent: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const chartBoxRef = ref();
|
||||
const chartRef = ref();
|
||||
const chartSize = ref(100);
|
||||
const option = computed(() => {
|
||||
if (props.used) {
|
||||
return donut(
|
||||
props.used,
|
||||
props.total,
|
||||
props.itemColors,
|
||||
chartSize.value || 100,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function handleResize() {
|
||||
const {
|
||||
offsetWidth,
|
||||
offsetHeight,
|
||||
} = chartBoxRef.value;
|
||||
const oldSize = chartSize.value;
|
||||
chartSize.value = Math.floor(Math.min(offsetWidth, offsetHeight));
|
||||
if (oldSize !== chartSize.value && chartRef?.value?.resize) {
|
||||
chartRef.value.resize();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.donut-box {
|
||||
width: var(--donut-box-size, 100px);
|
||||
height: var(--donut-box-size, 100px);
|
||||
}
|
||||
|
||||
.donut-box--content {
|
||||
position: relative;
|
||||
.donunt-content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
103
src/components/charts/line.js
Normal file
103
src/components/charts/line.js
Normal file
@ -0,0 +1,103 @@
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { LineChart } from 'echarts/charts';
|
||||
import {
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
} from 'echarts/components';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
]);
|
||||
|
||||
export default (
|
||||
cateList,
|
||||
dateList,
|
||||
valueList,
|
||||
mode = 'dark',
|
||||
) => {
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
formatter: (params) => {
|
||||
const time = dayjs(parseInt(params[0].axisValue, 10)).format('YYYY.MM.DD HH:mm');
|
||||
let res = `${time}<br>`;
|
||||
params.forEach((i) => {
|
||||
res += `${i.marker} ${i.seriesName}: ${i.value}ms<br>`;
|
||||
});
|
||||
return res;
|
||||
},
|
||||
backgroundColor: mode === 'dark' ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
|
||||
borderColor: mode === 'dark' ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
|
||||
textStyle: {
|
||||
color: mode === 'dark' ? '#ddd' : '#222',
|
||||
fontFamily: 'Sarasa Term SC',
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
top: 5,
|
||||
data: cateList,
|
||||
textStyle: {
|
||||
color: mode === 'dark' ? '#ddd' : '#222',
|
||||
fontFamily: 'Sarasa Term SC',
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dateList,
|
||||
axisLabel: {
|
||||
hideOverlap: true,
|
||||
interval: Math.max(
|
||||
Math.ceil(dateList.length / 12),
|
||||
1,
|
||||
),
|
||||
nameTextStyle: {
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (val) => dayjs(parseInt(val, 10)).format('HH:mm'),
|
||||
fontFamily: 'Sarasa Term SC',
|
||||
color: mode === 'dark' ? '#eee' : '#222',
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
fontFamily: 'Sarasa Term SC',
|
||||
color: mode === 'dark' ? '#ddd' : '#222',
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
containLabel: true,
|
||||
},
|
||||
series: valueList.map((i) => ({
|
||||
type: 'line',
|
||||
data: i.data,
|
||||
name: i.name,
|
||||
smooth: true,
|
||||
connectNulls: true,
|
||||
legendHoverLink: false,
|
||||
symbol: 'none',
|
||||
})),
|
||||
};
|
||||
return option;
|
||||
};
|
||||
77
src/components/charts/line.vue
Normal file
77
src/components/charts/line.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="option"
|
||||
class="line-box"
|
||||
>
|
||||
<v-chart
|
||||
ref="chartRef"
|
||||
class="chart"
|
||||
:option="option"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 折线图
|
||||
*/
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import lineChart from './line';
|
||||
|
||||
const props = defineProps({
|
||||
cateList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
dateList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
valueList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const chartRef = ref();
|
||||
const option = computed(() => {
|
||||
if (props.cateList && props.dateList && props.valueList) {
|
||||
return lineChart(
|
||||
props.cateList,
|
||||
props.dateList,
|
||||
props.valueList,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function handleResize() {
|
||||
chartRef.value?.resize?.();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.line-box {
|
||||
width: 100%;
|
||||
height: var(--line-chart-size, 300px);
|
||||
}
|
||||
</style>
|
||||
89
src/components/world-map/world-map-point.vue
Normal file
89
src/components/world-map/world-map-point.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
ref="pointRef"
|
||||
class="world-map-point"
|
||||
:style="pointStyle"
|
||||
:title="info?.label || ''"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="point-block" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 世界地图点
|
||||
*/
|
||||
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits([
|
||||
'point-tap',
|
||||
]);
|
||||
|
||||
const pointRef = ref();
|
||||
|
||||
const pointStyle = computed(() => {
|
||||
const style = {};
|
||||
style['--map-point-left'] = `${props.info.left}px`;
|
||||
style['--map-point-top'] = `${props.info.top}px`;
|
||||
if (props.info?.size) {
|
||||
style['--map-point-size'] = `${props.info.size}px`;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
emits('point-tap', props.info);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.world-map-point {
|
||||
--map-point-size: 6px;
|
||||
--map-point-scale: 1;
|
||||
position: absolute;
|
||||
left: var(--map-point-left);
|
||||
top: var(--map-point-top);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
.point-block {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--map-point-size) * var(--map-point-scale));
|
||||
height: calc(var(--map-point-size) * var(--map-point-scale));
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--world-map-point-color);
|
||||
border-radius: 50%;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--map-point-size) * var(--map-point-scale) + (8px * var(--map-point-scale)));
|
||||
height: calc(var(--map-point-size) * var(--map-point-scale) + (8px * var(--map-point-scale)));
|
||||
transform: translate(-50%, -50%);
|
||||
border: calc(2px * var(--map-point-scale)) solid var(--world-map-point-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
--map-point-scale: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
188
src/components/world-map/world-map.vue
Normal file
188
src/components/world-map/world-map.vue
Normal file
@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div
|
||||
class="world-map-group"
|
||||
:style="mapStyle"
|
||||
>
|
||||
<div class="world-map-img" />
|
||||
<transition-group
|
||||
name="point"
|
||||
tag="div"
|
||||
class="world-map-point-container"
|
||||
>
|
||||
<world-map-point
|
||||
v-for="pointItem in mapPoints"
|
||||
:key="pointItem.key"
|
||||
:info="pointItem"
|
||||
@point-tap="handlePointTap"
|
||||
/>
|
||||
</transition-group>
|
||||
|
||||
<transition name="point">
|
||||
<div
|
||||
v-if="tipsShow"
|
||||
class="world-map-tips"
|
||||
:style="tipsContentStyle"
|
||||
>
|
||||
<span>{{ tipsContent }}</span>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 世界地图盒子
|
||||
*/
|
||||
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
} from 'vue';
|
||||
import validate from '@/utils/validate';
|
||||
|
||||
import WorldMapPoint from './world-map-point.vue';
|
||||
|
||||
const props = defineProps({
|
||||
width: {
|
||||
type: [Number, String],
|
||||
// default: 1280,
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
type: [Number, String],
|
||||
// default: 621,
|
||||
default: null,
|
||||
},
|
||||
locations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
// 计算地图大小 保持1280:621的比例 保证地图不变形
|
||||
const computedSize = computed(() => {
|
||||
if (!validate.isEmpty(props.width) && !validate.isEmpty(props.height)) {
|
||||
return {
|
||||
width: 1280,
|
||||
height: 621,
|
||||
};
|
||||
}
|
||||
const width = Number(props.width);
|
||||
const height = Number(props.height);
|
||||
if (!validate.isEmpty(props.width) && validate.isEmpty(props.height)) {
|
||||
return {
|
||||
width,
|
||||
height: Math.ceil((621 / 1280) * width),
|
||||
};
|
||||
}
|
||||
if (validate.isEmpty(props.width) && !validate.isEmpty(props.height)) {
|
||||
return {
|
||||
width: Math.ceil((1280 / 621) * height),
|
||||
height,
|
||||
};
|
||||
}
|
||||
if (width / height > 1280 / 621) {
|
||||
return {
|
||||
width: Math.ceil(height * (1280 / 621)),
|
||||
height,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height: Math.ceil(width * (621 / 1280)),
|
||||
};
|
||||
});
|
||||
|
||||
const mapStyle = computed(() => {
|
||||
const style = {};
|
||||
style['--world-map-width'] = `${computedSize.value.width}px`;
|
||||
style['--world-map-height'] = `${computedSize.value.height}px`;
|
||||
return style;
|
||||
});
|
||||
|
||||
const mapPoints = computed(() => props.locations.map((i) => {
|
||||
const item = {
|
||||
key: i.key,
|
||||
left: (computedSize.value.width / 1280) * i.x,
|
||||
top: (computedSize.value.height / 621) * i.y,
|
||||
size: i.size || 4,
|
||||
label: i.label,
|
||||
};
|
||||
return item;
|
||||
}));
|
||||
|
||||
/**
|
||||
* 提示框
|
||||
*/
|
||||
const tipsShow = ref(false);
|
||||
const tipsContent = ref('');
|
||||
const activeTipsXY = ref({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const tipsContentStyle = computed(() => {
|
||||
const style = {};
|
||||
if (window.innerWidth > 500) {
|
||||
style.top = `${activeTipsXY.value.y}px`;
|
||||
style.left = `${activeTipsXY.value.x}px`;
|
||||
style.transform = 'translate(-50%, 100%)';
|
||||
} else {
|
||||
style.bottom = '10px';
|
||||
style.left = '50%';
|
||||
style.transform = 'translate(-50%, -50%)';
|
||||
}
|
||||
return style;
|
||||
});
|
||||
let timer = null;
|
||||
function handlePointTap(e) {
|
||||
tipsContent.value = e.label;
|
||||
activeTipsXY.value = {
|
||||
x: e.left - (e.size / 2),
|
||||
y: e.top - e.size,
|
||||
};
|
||||
tipsShow.value = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
tipsShow.value = false;
|
||||
}, 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.world-map-group {
|
||||
width: var(--world-map-width, 1280px);
|
||||
height: var(--world-map-height, 621px);
|
||||
position: relative;
|
||||
|
||||
.world-map-img {
|
||||
width: var(--world-map-width, 1280px);
|
||||
height: var(--world-map-height, 621px);
|
||||
background: url(@/assets/images/world-map.svg) 50% 50% no-repeat;
|
||||
background-size: 100%;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.world-map-tips {
|
||||
position: absolute;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
line-height: 20px;
|
||||
color: #eee;
|
||||
background: rgba(#000, 0.8);
|
||||
box-shadow: 1px 4px 8px rgba(#303841, 0.4);
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.point-move,
|
||||
.point-enter-active,
|
||||
.point-leave-active {
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
.point-enter-from,
|
||||
.point-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
30
src/config/index.js
Normal file
30
src/config/index.js
Normal file
@ -0,0 +1,30 @@
|
||||
const config = {
|
||||
request: {
|
||||
headers: {
|
||||
// 如果设置的是json请求。api的defaultContentType为false的时候,contentType为form请求,反之亦如此
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
codeField: 'code', // code字段
|
||||
dataField: 'result', // 数据字段
|
||||
msgField: 'message', // 消息字段
|
||||
okCode: '0', // 数据通过code
|
||||
limit: 10,
|
||||
},
|
||||
nazhua: {
|
||||
title: '哪吒监控',
|
||||
apiMonitorPath: '/api/v1/monitor/{id}',
|
||||
wsPath: '/ws',
|
||||
nezhaPath: '/nezha/',
|
||||
nezhaV0ConfigType: 'servers',
|
||||
// 解构载入自定义配置
|
||||
...(window.$$nazhuaConfig || {}),
|
||||
},
|
||||
};
|
||||
|
||||
export function mergeNazhuaConfig(customConfig) {
|
||||
Object.keys(customConfig).forEach((key) => {
|
||||
config.nazhua[key] = customConfig[key];
|
||||
});
|
||||
}
|
||||
|
||||
export default config;
|
||||
238
src/data/code-maps.js
Normal file
238
src/data/code-maps.js
Normal file
@ -0,0 +1,238 @@
|
||||
const codeMaps = {
|
||||
PEK: {
|
||||
x: 1025,
|
||||
y: 178,
|
||||
name: '北京',
|
||||
country: '中国',
|
||||
},
|
||||
PVG: {
|
||||
x: 1057,
|
||||
y: 225,
|
||||
name: '上海',
|
||||
country: '中国',
|
||||
},
|
||||
CKG: {
|
||||
x: 1010,
|
||||
y: 235,
|
||||
name: '重庆',
|
||||
country: '中国',
|
||||
},
|
||||
TFU: {
|
||||
x: 1000,
|
||||
y: 230,
|
||||
name: '成都',
|
||||
country: '中国',
|
||||
},
|
||||
HKG: {
|
||||
x: 1039,
|
||||
y: 263,
|
||||
name: '香港',
|
||||
country: '中国',
|
||||
},
|
||||
MFM: {
|
||||
x: 1035,
|
||||
y: 264,
|
||||
name: '澳门',
|
||||
country: '中国',
|
||||
},
|
||||
TPE: {
|
||||
x: 1067,
|
||||
y: 253,
|
||||
name: '台北',
|
||||
country: '中国',
|
||||
},
|
||||
OSA: {
|
||||
x: 1109,
|
||||
y: 207,
|
||||
name: '大阪',
|
||||
country: '日本',
|
||||
},
|
||||
TYO: {
|
||||
x: 1124,
|
||||
y: 199,
|
||||
name: '东京',
|
||||
country: '日本',
|
||||
},
|
||||
SEL: {
|
||||
x: 1077,
|
||||
y: 198,
|
||||
name: '首尔',
|
||||
country: '韩国',
|
||||
},
|
||||
SIN: {
|
||||
x: 1000,
|
||||
y: 354,
|
||||
name: '新加坡',
|
||||
country: '新加坡',
|
||||
},
|
||||
JHB: {
|
||||
x: 997,
|
||||
y: 350,
|
||||
name: '新山',
|
||||
country: '马来西亚',
|
||||
},
|
||||
KUL: {
|
||||
x: 990,
|
||||
y: 345,
|
||||
name: '吉隆坡',
|
||||
country: '马来西亚',
|
||||
},
|
||||
HAN: {
|
||||
x: 998,
|
||||
y: 274,
|
||||
name: '河内',
|
||||
country: '越南',
|
||||
},
|
||||
SGN: {
|
||||
x: 1015,
|
||||
y: 314,
|
||||
name: '胡志明市',
|
||||
country: '越南',
|
||||
},
|
||||
LAX: {
|
||||
x: 95,
|
||||
y: 207,
|
||||
name: '洛杉矶',
|
||||
country: '美国',
|
||||
},
|
||||
LAS: {
|
||||
x: 98,
|
||||
y: 198,
|
||||
name: '拉斯维加斯',
|
||||
country: '美国',
|
||||
},
|
||||
SLC: {
|
||||
x: 111,
|
||||
y: 189,
|
||||
name: '盐湖城',
|
||||
country: '美国',
|
||||
},
|
||||
SJC: {
|
||||
x: 87,
|
||||
y: 193,
|
||||
name: '圣何塞',
|
||||
country: '美国',
|
||||
},
|
||||
SEA: {
|
||||
x: 118,
|
||||
y: 143,
|
||||
name: '西雅图',
|
||||
country: '美国',
|
||||
},
|
||||
MIA: {
|
||||
x: 243,
|
||||
y: 244,
|
||||
name: '迈阿密',
|
||||
country: '美国',
|
||||
},
|
||||
ORD: {
|
||||
x: 233,
|
||||
y: 175,
|
||||
name: '芝加哥',
|
||||
country: '美国',
|
||||
},
|
||||
NYC: {
|
||||
x: 280,
|
||||
y: 179,
|
||||
name: '纽约',
|
||||
country: '美国',
|
||||
},
|
||||
YYZ: {
|
||||
x: 267,
|
||||
y: 161,
|
||||
name: '多伦多',
|
||||
country: '加拿大',
|
||||
},
|
||||
SYD: {
|
||||
x: 1167,
|
||||
y: 519,
|
||||
name: '悉尼',
|
||||
country: '澳大利亚',
|
||||
},
|
||||
AMS: {
|
||||
x: 595,
|
||||
y: 125,
|
||||
name: '阿姆斯特丹',
|
||||
country: '荷兰',
|
||||
},
|
||||
LON: {
|
||||
x: 571,
|
||||
y: 127,
|
||||
name: '伦敦',
|
||||
country: '英国',
|
||||
},
|
||||
FRA: {
|
||||
x: 603,
|
||||
y: 137,
|
||||
name: '法兰克福',
|
||||
country: '德国',
|
||||
},
|
||||
LUX: {
|
||||
x: 591,
|
||||
y: 140,
|
||||
name: '卢森堡',
|
||||
country: '卢森堡',
|
||||
},
|
||||
CDG: {
|
||||
x: 579,
|
||||
y: 145,
|
||||
name: '巴黎',
|
||||
country: '法国',
|
||||
},
|
||||
SVO: {
|
||||
x: 704,
|
||||
y: 115,
|
||||
name: '莫斯科',
|
||||
country: '俄罗斯',
|
||||
},
|
||||
OTP: {
|
||||
x: 673,
|
||||
y: 160,
|
||||
name: '布加勒斯特',
|
||||
country: '罗马尼亚',
|
||||
},
|
||||
IST: {
|
||||
x: 676,
|
||||
y: 176,
|
||||
name: '伊斯坦布尔',
|
||||
country: '土耳其',
|
||||
},
|
||||
};
|
||||
|
||||
export const aliasMapping = {
|
||||
SGP: 'SIN',
|
||||
ICN: 'SEL',
|
||||
NRT: 'TYO',
|
||||
HND: 'TYO',
|
||||
KIX: 'OSA',
|
||||
PAR: 'CDG',
|
||||
MOW: 'SVO',
|
||||
CHI: 'ORD',
|
||||
SHA: 'PVG',
|
||||
CAN: 'CKG',
|
||||
CTU: 'TFU',
|
||||
BJS: 'PEK',
|
||||
HK: 'HKG',
|
||||
MO: 'MFM',
|
||||
TW: 'TPE',
|
||||
};
|
||||
|
||||
export const countryCodeMapping = {
|
||||
JP: 'TYO',
|
||||
SG: 'SIN',
|
||||
KR: 'SEL',
|
||||
MY: 'KUL',
|
||||
VN: 'HAN',
|
||||
TR: 'IST',
|
||||
RO: 'OTP',
|
||||
LU: 'LUX',
|
||||
FR: 'CDG',
|
||||
RU: 'SVO',
|
||||
DE: 'FRA',
|
||||
NL: 'AMS',
|
||||
UK: 'LON',
|
||||
AU: 'SYD',
|
||||
US: 'LAX',
|
||||
};
|
||||
|
||||
export default codeMaps;
|
||||
9
src/layout/box.vue
Normal file
9
src/layout/box.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'LayoutBox',
|
||||
};
|
||||
</script>
|
||||
52
src/layout/components/footer.vue
Normal file
52
src/layout/components/footer.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="layout-footer">
|
||||
<div class="copyright-text">
|
||||
<span class="text">
|
||||
Powered by
|
||||
<a
|
||||
ref="nofollow"
|
||||
href="https://nezha.wiki"
|
||||
target="_blank"
|
||||
>哪吒监控</a>
|
||||
</span>
|
||||
<span class="text">
|
||||
Theme By <span
|
||||
class="nazhua"
|
||||
title="公开版本还在搓,Coming Soon ~"
|
||||
>Nazhua</span>
|
||||
{{ version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* Footer
|
||||
*/
|
||||
|
||||
const version = import.meta.env.VITE_VERSION;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-footer {
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
|
||||
.copyright-text {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.nazhua {
|
||||
color: #fa0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
153
src/layout/components/header.vue
Normal file
153
src/layout/components/header.vue
Normal file
@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div
|
||||
class="layout-header"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<div class="layer-header-container">
|
||||
<div class="left-box">
|
||||
<span
|
||||
class="site-name"
|
||||
@click="toHome"
|
||||
>{{ title }}</span>
|
||||
</div>
|
||||
<div class="right-box">
|
||||
<div
|
||||
v-if="serverCount?.total"
|
||||
class="server-count-group"
|
||||
>
|
||||
<span class="server-count server-count--total">
|
||||
<span class="text">共</span>
|
||||
<span class="value">{{ serverCount.total }}</span>
|
||||
<span class="text">台服务器</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="serverCount.online !== serverCount.total"
|
||||
class="server-count server-count--online"
|
||||
>
|
||||
<span class="text">在线</span>
|
||||
<span class="value">{{ serverCount.online }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="serverCount.offline"
|
||||
class="server-count server-count--offline"
|
||||
>
|
||||
<span class="text">离线</span>
|
||||
<span class="value">{{ serverCount.offline }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* LayoutHeader
|
||||
*/
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
} from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
} from 'vuex';
|
||||
import {
|
||||
useRoute,
|
||||
useRouter,
|
||||
} from 'vue-router';
|
||||
import config from '@/config';
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const headerStyle = computed(() => {
|
||||
const style = {};
|
||||
if (route.name === 'ServerDetail') {
|
||||
style['--layout-header-container-width'] = 'var(--detail-container-width)';
|
||||
} else {
|
||||
style['--layout-header-container-width'] = 'var(--list-container-width)';
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const serverCount = computed(() => store.state.serverCount);
|
||||
|
||||
const title = ref(config.nazhua.title);
|
||||
|
||||
function toHome() {
|
||||
if (route.name !== 'Home') {
|
||||
router.push({
|
||||
name: 'Home',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
title.value = config.nazhua.title;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
min-height: var(--layout-header-height);
|
||||
background-position: 0% 0%;
|
||||
background-image: radial-gradient(transparent 1px, rgba(#000, 0.8) 2px);
|
||||
background-size: 3px 3px;
|
||||
backdrop-filter: saturate(50%) blur(3px);
|
||||
box-shadow: 0 2px 4px rgba(#000, 0.2);
|
||||
|
||||
.site-name {
|
||||
line-height: calc(var(--layout-header-height) - 20px);
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
text-shadow: 2px 2px 4px rgba(#000, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-count-group {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.server-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #ddd;
|
||||
|
||||
&.server-count--total {
|
||||
.value {
|
||||
color: #70f3ff;
|
||||
}
|
||||
}
|
||||
&.server-count--online {
|
||||
.value {
|
||||
color: #0f0;
|
||||
}
|
||||
}
|
||||
&.server-count--offline {
|
||||
.value {
|
||||
color: #f00;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-header-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0 20px;
|
||||
width: var(--layout-header-container-width, 100%);
|
||||
margin: auto;
|
||||
padding: 10px 20px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
src/layout/main.vue
Normal file
46
src/layout/main.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="layout-group">
|
||||
<div class="layout-bg" />
|
||||
<div class="layout-main">
|
||||
<layout-header />
|
||||
<slot />
|
||||
<layout-footer />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* LayoutMain
|
||||
*/
|
||||
import LayoutHeader from './components/header.vue';
|
||||
import LayoutFooter from './components/footer.vue';
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-group {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
.layout-main {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--layout-main-bg-color);
|
||||
}
|
||||
|
||||
.layout-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background: var(--layout-bg-color) url('~@/assets/images/bg.webp') no-repeat 50% 0%;
|
||||
background-size: 100% auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
src/main.js
Normal file
7
src/main.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import customUse from './use';
|
||||
|
||||
const app = createApp(App);
|
||||
customUse(app);
|
||||
app.mount('#app');
|
||||
42
src/router/index.js
Normal file
42
src/router/index.js
Normal file
@ -0,0 +1,42 @@
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
createWebHashHistory,
|
||||
} from 'vue-router';
|
||||
import config from '@/config';
|
||||
|
||||
const constantRoutes = [{
|
||||
name: 'Home',
|
||||
path: '/',
|
||||
component: () => import('@/views/home.vue'),
|
||||
}, {
|
||||
name: 'ServerDetail',
|
||||
path: '/:serverId(\\d+)',
|
||||
component: () => import('@/views/detail.vue'),
|
||||
meta: {
|
||||
title: '节点详情',
|
||||
},
|
||||
props: true,
|
||||
}, {
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: {
|
||||
name: 'Home',
|
||||
},
|
||||
}];
|
||||
|
||||
const routerOptions = {
|
||||
history: config.nazhua.routeMode === 'h5' ? createWebHistory() : createWebHashHistory(),
|
||||
scrollBehavior: () => ({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
}),
|
||||
routes: constantRoutes,
|
||||
};
|
||||
const router = createRouter(routerOptions);
|
||||
|
||||
router.beforeResolve((to, from, next) => {
|
||||
document.title = [to?.meta?.title, config.nazhua.title].filter((i) => i).join(' - ');
|
||||
next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
114
src/store/index.js
Normal file
114
src/store/index.js
Normal file
@ -0,0 +1,114 @@
|
||||
import {
|
||||
createStore,
|
||||
} from 'vuex';
|
||||
import dayjs from 'dayjs';
|
||||
import loadNezhaConfig from '@/utils/load-nezha-config';
|
||||
|
||||
import {
|
||||
msg,
|
||||
} from '@/ws';
|
||||
|
||||
const defaultState = () => ({
|
||||
init: false,
|
||||
serverList: [],
|
||||
serverCount: {
|
||||
total: 0,
|
||||
online: 0,
|
||||
offline: 0,
|
||||
},
|
||||
});
|
||||
|
||||
function isOnline(LastActive) {
|
||||
const lastActiveTime = dayjs(LastActive)?.valueOf?.() || 0;
|
||||
if (Date.now() - lastActiveTime > 10 * 1000) {
|
||||
return -1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function handleServerCount(servers) {
|
||||
const counts = {
|
||||
total: servers.length,
|
||||
online: servers.filter((i) => i.online === 1).length,
|
||||
offline: servers.filter((i) => i.online === -1).length,
|
||||
};
|
||||
return counts;
|
||||
}
|
||||
|
||||
const store = createStore({
|
||||
state: defaultState(),
|
||||
mutations: {
|
||||
SET_SERVERS(state, servers) {
|
||||
const newServers = [...servers];
|
||||
newServers.sort((a, b) => b.DisplayIndex - a.DisplayIndex);
|
||||
state.serverList = newServers;
|
||||
state.serverCount = handleServerCount(newServers);
|
||||
state.init = true;
|
||||
},
|
||||
UPDATE_SERVERS(state, servers) {
|
||||
// 遍历新的servers 处理新的内容
|
||||
const oldServersMap = {};
|
||||
state.serverList.forEach((server) => {
|
||||
oldServersMap[server.ID] = server;
|
||||
});
|
||||
let newServers = servers.map((server) => {
|
||||
const oldItem = oldServersMap[server.ID];
|
||||
const serverItem = {
|
||||
...server,
|
||||
};
|
||||
if (oldItem?.PublicNote) {
|
||||
serverItem.PublicNote = oldItem.PublicNote;
|
||||
} else {
|
||||
serverItem.PublicNote = {};
|
||||
}
|
||||
return serverItem;
|
||||
});
|
||||
newServers = newServers.filter((server) => server);
|
||||
newServers.sort((a, b) => b.DisplayIndex - a.DisplayIndex);
|
||||
state.serverList = newServers;
|
||||
state.serverCount = handleServerCount(newServers);
|
||||
state.init = true;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* 加载服务器列表
|
||||
*/
|
||||
async loadServers({ commit }) {
|
||||
const serverConfig = await loadNezhaConfig();
|
||||
if (!serverConfig) {
|
||||
console.error('load server config failed');
|
||||
return;
|
||||
}
|
||||
const servers = serverConfig.map((i) => {
|
||||
const item = {
|
||||
...i,
|
||||
online: isOnline(i.LastActive),
|
||||
};
|
||||
return item;
|
||||
});
|
||||
commit('SET_SERVERS', servers);
|
||||
},
|
||||
/**
|
||||
* 开始监听ws消息
|
||||
*/
|
||||
watchWsMsg({
|
||||
commit,
|
||||
}) {
|
||||
msg.on('servers', (res) => {
|
||||
if (res) {
|
||||
const servers = res.map((i) => {
|
||||
const item = {
|
||||
...i,
|
||||
online: isOnline(i.LastActive),
|
||||
};
|
||||
return item;
|
||||
});
|
||||
commit('UPDATE_SERVERS', servers);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default store;
|
||||
11
src/use.js
Normal file
11
src/use.js
Normal file
@ -0,0 +1,11 @@
|
||||
import 'remixicon/fonts/remixicon.css';
|
||||
import 'flag-icons/css/flag-icons.min.css';
|
||||
import 'font-logos/assets/font-logos.css';
|
||||
import './assets/scss/base.scss';
|
||||
import router from './router';
|
||||
import store from './store';
|
||||
|
||||
export default (app) => {
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
};
|
||||
12
src/utils/custom-error.js
Normal file
12
src/utils/custom-error.js
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 自定义错误
|
||||
*/
|
||||
|
||||
class CustomError extends Error {
|
||||
constructor(msg, code) {
|
||||
super(msg);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export default CustomError;
|
||||
104
src/utils/date.js
Normal file
104
src/utils/date.js
Normal file
@ -0,0 +1,104 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 计算时长工具
|
||||
* @param {Date|Number|String} startDate 开始时间
|
||||
* @param {Date|Number|String} endDate 结束时间
|
||||
* @param {Boolean} noSub 不带子单位
|
||||
*
|
||||
* @returns {String} 时长
|
||||
* 1. 1小时以内,显示N分钟N秒
|
||||
* 2. 1小时以上,显示N小时N分钟
|
||||
* 3. 1天以上,显示N天
|
||||
*/
|
||||
export const duration = (startDate, endDate, noSub = false) => {
|
||||
const startTime = dayjs(startDate).valueOf();
|
||||
const endTime = dayjs(endDate).valueOf();
|
||||
const diff = endTime - startTime;
|
||||
|
||||
if (diff < 0) {
|
||||
return '刚刚启动';
|
||||
}
|
||||
|
||||
const second = 1000;
|
||||
const minute = second * 60;
|
||||
const hour = minute * 60;
|
||||
const day = hour * 24;
|
||||
|
||||
if (diff < minute) {
|
||||
return `${Math.floor(diff / second)}秒`;
|
||||
}
|
||||
if (diff < hour) {
|
||||
if (noSub) {
|
||||
return `${Math.floor(diff / minute)}分钟`;
|
||||
}
|
||||
return `${Math.floor(diff / minute)}分钟${Math.floor((diff % minute) / second)}秒`;
|
||||
}
|
||||
if (diff < day) {
|
||||
if (noSub) {
|
||||
return `${Math.floor(diff / hour)}小时`;
|
||||
}
|
||||
return `${Math.floor(diff / hour)}小时${Math.floor((diff % hour) / minute)}分钟`;
|
||||
}
|
||||
return `${Math.floor(diff / day)}天`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算时长,返回详细信息
|
||||
* @param {Date|Number|String} startDate 开始时间
|
||||
* @param {Date|Number|String} endDate 结束时间
|
||||
*/
|
||||
export const duration2 = (startDate, endDate) => {
|
||||
const startTime = dayjs(startDate).valueOf();
|
||||
const endTime = dayjs(endDate).valueOf();
|
||||
const diff = endTime - startTime;
|
||||
|
||||
const second = 1000;
|
||||
const minute = second * 60;
|
||||
const hour = minute * 60;
|
||||
const day = hour * 24;
|
||||
|
||||
const result = {
|
||||
days: Math.floor(diff / day),
|
||||
hours: Math.floor(diff / hour) % 24,
|
||||
minutes: Math.floor(diff / minute) % 60,
|
||||
seconds: Math.floor(diff / second) % 60,
|
||||
$symbol: {
|
||||
day: '天',
|
||||
hour: '小时',
|
||||
minute: '分钟',
|
||||
second: '秒',
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按周期月数计算下一个日期,必须大于传入的第三个参数(指定日期,为空则为当前日期)
|
||||
*
|
||||
* @param {Date|Number|String} startDate 起始日期
|
||||
* @param {Number} months 周期月份数
|
||||
* @param {Date|Number|String} specifiedDate 指定日期
|
||||
*
|
||||
* @returns {Number} 下一个日期的时间毫秒数
|
||||
*/
|
||||
export function getNextCycleTime(startDate, months, specifiedDate) {
|
||||
const start = dayjs(startDate);
|
||||
const checkDate = dayjs(specifiedDate);
|
||||
|
||||
if (!start.isValid() || months <= 0) {
|
||||
throw new Error('参数无效:请检查起始日期、周期月份数和指定日期。');
|
||||
}
|
||||
|
||||
let nextDate = start;
|
||||
|
||||
// 循环增加周期直到大于当前日期
|
||||
let whileStatus = true;
|
||||
while (whileStatus) {
|
||||
nextDate = nextDate.add(months, 'month');
|
||||
whileStatus = nextDate.valueOf() <= checkDate.valueOf();
|
||||
}
|
||||
|
||||
return nextDate.valueOf(); // 返回时间毫秒数
|
||||
}
|
||||
227
src/utils/host.js
Normal file
227
src/utils/host.js
Normal file
@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 主机匹配信息工具
|
||||
*/
|
||||
|
||||
/**
|
||||
* 匹配CPU信息
|
||||
* @param {string} text CPU信息文本
|
||||
* 示例文本:
|
||||
* Intel(R) Xeon(R) Platinum 2 Virtual Core
|
||||
* Intel Core Processor (Broadwell, IBRS) 1 Virtual Core
|
||||
* Intel(R) Xeon(R) Gold 6133 CPU @ 2.50GHz 1 Virtual Core
|
||||
* Intel(R) Xeon(R) CPU E5-2697 v3 @ 2.60GHz 1 Virtual Core
|
||||
* Intel(R) Xeon(R) Platinum 1 Virtual Core
|
||||
* AMD EPYC 7B13 64-Core Processor 1 Virtual Core
|
||||
* AMD EPYC 7B13 64-Core Processor 1 Virtual Core
|
||||
* AMD EPYC 9654 96-Core Processor 1 Virtual Core
|
||||
* AMD Ryzen 9 7950X 16-Core Processor 1 Virtual Core
|
||||
* AMD Ryzen 9 9900X 12-Core Processor 1 Virtual Core
|
||||
*
|
||||
* @returns {object} 匹配结果
|
||||
* - {string} company CPU厂商
|
||||
* - {string} model CPU型号
|
||||
* - {string} modelNum CPU型号编号
|
||||
* - {string} core CPU核心信息
|
||||
* - {string} cores CPU核心数
|
||||
*/
|
||||
export function getCPUInfo(text) {
|
||||
const cpuInfo = {
|
||||
company: '',
|
||||
model: '',
|
||||
modelNum: '',
|
||||
core: '',
|
||||
cores: '',
|
||||
};
|
||||
const companyReg = /Intel|AMD|ARM|Qualcomm|Apple|Samsung|IBM|NVIDIA/;
|
||||
// eslint-disable-next-line max-len, vue/max-len
|
||||
const modelReg = /Xeon|Threadripper|Athlon|Pentium|Celeron|Opteron|Phenom|Turion|Sempron|FX|A-Series|R-Series|EPYC|Ryzen/;
|
||||
const coresReg = /(\d+) (Virtual|Physics) Core/;
|
||||
const companyMatch = text.match(companyReg);
|
||||
const modelMatch = text.match(modelReg);
|
||||
const coresMatch = text.match(coresReg);
|
||||
if (companyMatch) {
|
||||
[cpuInfo.company] = companyMatch;
|
||||
}
|
||||
if (modelMatch) {
|
||||
[cpuInfo.model] = modelMatch;
|
||||
}
|
||||
// 匹配特定的CPU型号编号
|
||||
if (text.includes('Xeon')) {
|
||||
if (text.includes('E-')) {
|
||||
// Xeon型号
|
||||
const modelNumReg = /(E\d-\S+)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
}
|
||||
if (text.includes('Gold')) {
|
||||
// Xeon型号
|
||||
const modelNumReg = /(Gold\s\w+)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.includes('Ryzen')) {
|
||||
// 5900X 5950X 7900X 7950X 9900X 9950X
|
||||
const modelNumReg = /Ryzen.*(\d{4}X)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
}
|
||||
if (text.includes('EPYC')) {
|
||||
// 7B13 7B13 9654...
|
||||
const modelNumReg = /EPYC (\w{4})/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
}
|
||||
if (coresMatch) {
|
||||
[cpuInfo.core, cpuInfo.cores] = coresMatch;
|
||||
}
|
||||
return cpuInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算十进制存储大小
|
||||
*
|
||||
* @returns {object} 内存信息
|
||||
* - {string} t TB值
|
||||
* - {string} g GB值
|
||||
* - {string} m MB值
|
||||
* - {string} k KB值
|
||||
*/
|
||||
export function calcDecimal(memTotal) {
|
||||
const k = memTotal / 1000;
|
||||
const m = memTotal / 1000 ** 2;
|
||||
const g = memTotal / 1000 ** 3;
|
||||
const t = memTotal / 1000 ** 4;
|
||||
return {
|
||||
k,
|
||||
m,
|
||||
g,
|
||||
t,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字节大小
|
||||
* @param {number} bytes 字节数
|
||||
* @returns {object} 字节大小
|
||||
* - {number} kb KB值
|
||||
* - {number} mb MB值
|
||||
* - {number} gb GB值
|
||||
* - {number} tb TB值
|
||||
*/
|
||||
export function calcBinary(bytes) {
|
||||
const k = bytes / 1024;
|
||||
const m = k / 1024;
|
||||
const g = m / 1024;
|
||||
const t = g / 1024;
|
||||
let p = null;
|
||||
if (t > 1000) {
|
||||
p = t / 1024;
|
||||
}
|
||||
return {
|
||||
k,
|
||||
m,
|
||||
g,
|
||||
t,
|
||||
p,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算流量规格
|
||||
*/
|
||||
export function calcTransfer(bytes) {
|
||||
const stats = calcBinary(bytes);
|
||||
const result = {
|
||||
value: '',
|
||||
symbol: '',
|
||||
stats,
|
||||
};
|
||||
if (stats.t > 1) {
|
||||
result.value = (stats.t).toFixed(2) * 1;
|
||||
result.symbol = 'T';
|
||||
} else if (stats.g > 1) {
|
||||
result.value = (stats.g).toFixed(2) * 1;
|
||||
result.symbol = 'G';
|
||||
} else if (stats.m > 1) {
|
||||
result.value = (stats.m).toFixed(1) * 1;
|
||||
result.symbol = 'M';
|
||||
} else if (stats.p > 0) {
|
||||
result.value = (stats.p).toFixed(1) * 1;
|
||||
result.symbol = 'P';
|
||||
} else {
|
||||
result.value = (stats.k).toFixed(1) * 1;
|
||||
result.symbol = 'K';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统发行版本
|
||||
*/
|
||||
export function getSystemOSLabel(platform) {
|
||||
switch (platform) {
|
||||
case 'windows':
|
||||
return 'Windows';
|
||||
case 'linux':
|
||||
return 'Linux';
|
||||
case 'darwin':
|
||||
return 'MacOS';
|
||||
case 'debian':
|
||||
return 'Debian';
|
||||
case 'ubuntu':
|
||||
return 'Ubuntu';
|
||||
case 'centos':
|
||||
return 'CentOS';
|
||||
case 'fedora':
|
||||
return 'Fedora';
|
||||
case 'redhat':
|
||||
return 'RedHat';
|
||||
case 'suse':
|
||||
return 'SUSE';
|
||||
case 'gentoo':
|
||||
return 'Gentoo';
|
||||
case 'arch':
|
||||
return 'Arch';
|
||||
case 'alpine':
|
||||
return 'Alpine';
|
||||
case 'raspbian':
|
||||
return 'Raspbian';
|
||||
case 'openwrt':
|
||||
return 'OpenWRT';
|
||||
case 'freebsd':
|
||||
return 'FreeBSD';
|
||||
case 'netbsd':
|
||||
return 'NetBSD';
|
||||
case 'openbsd':
|
||||
return 'OpenBSD';
|
||||
case 'dragonfly':
|
||||
return 'DragonFly';
|
||||
case 'solaris':
|
||||
return 'Solaris';
|
||||
case 'aix':
|
||||
return 'AIX';
|
||||
case 'hpux':
|
||||
return 'HP-UX';
|
||||
case 'irix':
|
||||
return 'IRIX';
|
||||
case 'osf':
|
||||
return 'OSF';
|
||||
case 'tru64':
|
||||
return 'Tru64';
|
||||
case 'unixware':
|
||||
return 'UnixWare';
|
||||
case 'sco':
|
||||
return 'SCO';
|
||||
default:
|
||||
return platform;
|
||||
}
|
||||
}
|
||||
33
src/utils/load-nezha-config.js
Normal file
33
src/utils/load-nezha-config.js
Normal file
@ -0,0 +1,33 @@
|
||||
import config from '@/config';
|
||||
|
||||
const configReg = (type) => new RegExp(`${type} = JSON.parse\\('(.*)'\\)`);
|
||||
// 格式化数据,保证JSON.parse能够正常解析
|
||||
const unescaped = (str) => {
|
||||
let str2 = str.replace(/\\u([\d\w]{4})/gi, (match, grp) => String.fromCharCode(parseInt(grp, 16)));
|
||||
str2 = str2.replace(/\\\\r/g, '');
|
||||
str2 = str2.replace(/\\\\n/g, '');
|
||||
str2 = str2.replace(/\\\\/g, '\\');
|
||||
return str2;
|
||||
};
|
||||
export default async () => fetch(config.nazhua.nezhaPath).then((res) => res.text()).then((res) => {
|
||||
const resMatch = res?.match?.(configReg(config.nazhua.nezhaV0ConfigType));
|
||||
const configStr = resMatch?.[1];
|
||||
if (!configStr) {
|
||||
return null;
|
||||
}
|
||||
const remoteConfig = JSON.parse(unescaped(configStr));
|
||||
if (remoteConfig?.servers) {
|
||||
return remoteConfig.servers.map((i) => {
|
||||
const item = {
|
||||
...i,
|
||||
};
|
||||
try {
|
||||
item.PublicNote = JSON.parse(i.PublicNote);
|
||||
} catch {
|
||||
item.PublicNote = {};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}).catch(() => null);
|
||||
189
src/utils/request.js
Normal file
189
src/utils/request.js
Normal file
@ -0,0 +1,189 @@
|
||||
import axios from 'axios';
|
||||
import uuid from '@/utils/uuid';
|
||||
import validate from '@/utils/validate';
|
||||
|
||||
import config from '@/config';
|
||||
import CustomError from './custom-error';
|
||||
|
||||
const {
|
||||
codeField,
|
||||
dataField,
|
||||
msgField,
|
||||
okCode,
|
||||
limit = 10,
|
||||
} = config.request;
|
||||
|
||||
const requestTagMap = {};
|
||||
|
||||
/**
|
||||
* axios请求
|
||||
* @param {object} options 请求参数
|
||||
* @param {boolean} noFormat 不进行返回数据的格式化处理 网络状态200即为成功
|
||||
* @return {Promise}
|
||||
*/
|
||||
async function axiosRequest(options, noFormat) {
|
||||
return axios(options).then((res) => {
|
||||
if (res.status === 200) {
|
||||
if (noFormat) {
|
||||
return res;
|
||||
}
|
||||
if (validate.isSet(res.data[codeField]) && `${res.data[codeField]}` === `${okCode}`) {
|
||||
return res.data[dataField];
|
||||
}
|
||||
if (typeof res.data[codeField] !== 'undefined') {
|
||||
throw new CustomError(res.data[msgField], res.data[codeField]);
|
||||
}
|
||||
throw new CustomError('服务器返回内容不规范', -99);
|
||||
}
|
||||
throw new CustomError(`网络错误${res.status}`, res.status);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络请求
|
||||
*/
|
||||
class NetworkRequest {
|
||||
constructor() {
|
||||
this.tasks = [];
|
||||
this.tasking = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为Form请求
|
||||
*/
|
||||
static FormRequest = (headers) => {
|
||||
if (!headers) return false;
|
||||
const keys = Object.keys(headers);
|
||||
for (let i = 0, n = keys.length; i < n; i += 1) {
|
||||
if (keys[i].toLowerCase() === 'content-type') {
|
||||
return headers[keys[i]].includes('x-www-form-urlencoded');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加请求
|
||||
*
|
||||
* @param {string} url 请求的相对路径
|
||||
* @param {string} type 请求的Method
|
||||
* @param {object} headers Header请求参数
|
||||
* @param {object} data 请求参数
|
||||
* @param {boolean} noFormat 不进行返回数据的格式化处理 网络状态200即为成功
|
||||
* @param {boolean} defaultContentType 默认的请求方式
|
||||
* @param {Boolean} priority 优先调用请求
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
push(
|
||||
options = {},
|
||||
controller = {},
|
||||
priority = false,
|
||||
) {
|
||||
const {
|
||||
url,
|
||||
type,
|
||||
headers,
|
||||
data,
|
||||
noFormat = false,
|
||||
defaultContentType = true,
|
||||
requestTag = undefined,
|
||||
responseType,
|
||||
} = options || {};
|
||||
const {
|
||||
abortController,
|
||||
} = controller || {};
|
||||
const tag = requestTag || uuid();
|
||||
|
||||
if (requestTagMap[tag]) {
|
||||
return requestTagMap[tag];
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const defaultHeaders = {
|
||||
...config.request.headers,
|
||||
};
|
||||
if (defaultContentType === false) {
|
||||
if (NetworkRequest.FormRequest(defaultHeaders)) {
|
||||
defaultHeaders['content-type'] = 'application/json';
|
||||
} else {
|
||||
defaultHeaders['content-type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
}
|
||||
const requestOptions = [
|
||||
{
|
||||
url,
|
||||
method: type,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...headers,
|
||||
},
|
||||
data,
|
||||
signal: abortController?.signal ?? undefined,
|
||||
responseType,
|
||||
},
|
||||
noFormat,
|
||||
(res) => {
|
||||
resolve(res);
|
||||
},
|
||||
(err) => {
|
||||
reject(err);
|
||||
},
|
||||
tag,
|
||||
];
|
||||
if (priority) {
|
||||
this.tasks.unshift(requestOptions);
|
||||
} else {
|
||||
this.tasks.push(requestOptions);
|
||||
}
|
||||
this.nextTask();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一个请求任务
|
||||
*/
|
||||
nextTask() {
|
||||
if (this.tasking >= limit) {
|
||||
setTimeout(() => {
|
||||
this.nextTask();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
if (this.tasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
const [options, onFormat, success, fail, tag] = this.tasks.pop();
|
||||
// 请求未执行已被中止
|
||||
if (options?.signal?.aborted) {
|
||||
this.overTask();
|
||||
return;
|
||||
}
|
||||
requestTagMap[tag] = axiosRequest(options, onFormat);
|
||||
requestTagMap[tag].finally(() => {
|
||||
this.overTask();
|
||||
// 一秒内请求不重复
|
||||
setTimeout(() => {
|
||||
delete requestTagMap[tag];
|
||||
}, 1000);
|
||||
});
|
||||
requestTagMap[tag].then(success).catch(fail);
|
||||
this.tasking += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束请求任务
|
||||
*/
|
||||
overTask() {
|
||||
this.tasking -= 1;
|
||||
this.nextTask();
|
||||
}
|
||||
}
|
||||
|
||||
const request = new NetworkRequest();
|
||||
|
||||
export {
|
||||
NetworkRequest,
|
||||
};
|
||||
|
||||
export default (...args) => request.push(...args);
|
||||
3
src/utils/sleep.js
Normal file
3
src/utils/sleep.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default (timed = 1000) => new Promise((resolve) => {
|
||||
setTimeout(() => resolve(), timed > 0 ? timed : 30);
|
||||
});
|
||||
77
src/utils/subscribe.js
Normal file
77
src/utils/subscribe.js
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 消息订阅器
|
||||
*/
|
||||
|
||||
class MessageSubscribe {
|
||||
constructor() {
|
||||
this.subscribers = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅消息
|
||||
* @params {String} key 消息类型
|
||||
* @params {Function} callback 回调函数
|
||||
*/
|
||||
on(key, callback) {
|
||||
if (!this.subscribers[key]) {
|
||||
this.subscribers[key] = [];
|
||||
}
|
||||
this.subscribers[key].push(callback);
|
||||
if (import.meta.env.VITE_LIVE_SUBSCRIBE_DEBUG) {
|
||||
console.log('subscribers on by key:', key);
|
||||
console.log('subscribers on', this.subscribers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅一次消息
|
||||
* @params {String} key 消息类型
|
||||
* @params {Function} callback 回调函数
|
||||
*/
|
||||
once(key, callback) {
|
||||
const onceCallback = (...args) => {
|
||||
callback(...args);
|
||||
this.off(key, onceCallback);
|
||||
};
|
||||
this.on(key, onceCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
* @params {String} key 消息类型
|
||||
* @params {Function} callback 回调函数
|
||||
*/
|
||||
off(key, callback) {
|
||||
if (!this.subscribers[key]) {
|
||||
return;
|
||||
}
|
||||
const index = this.subscribers[key].indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.subscribers[key].splice(index, 1);
|
||||
}
|
||||
if (import.meta.env.VITE_LIVE_SUBSCRIBE_DEBUG) {
|
||||
console.log('subscribers off by key:', key);
|
||||
console.log('subscribers off', this.subscribers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布消息
|
||||
* @params {String} key 消息类型
|
||||
* @params {Object} data 消息数据
|
||||
*/
|
||||
emit(key, data) {
|
||||
if (!this.subscribers[key]) {
|
||||
return;
|
||||
}
|
||||
this.subscribers[key].forEach((callback) => {
|
||||
callback(data);
|
||||
});
|
||||
if (import.meta.env.VITE_LIVE_SUBSCRIBE_DEBUG) {
|
||||
console.log('subscribers emit by key:', key);
|
||||
console.log('subscribers emit', key, this.subscribers[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default MessageSubscribe;
|
||||
25
src/utils/uuid.js
Normal file
25
src/utils/uuid.js
Normal file
@ -0,0 +1,25 @@
|
||||
/* eslint-disable */
|
||||
export default () => {
|
||||
if (crypto?.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Public Domain/MIT
|
||||
// Timestamp
|
||||
let d = new Date().getTime();
|
||||
// Time in microseconds since page-load or 0 if unsupported
|
||||
let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0;
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
// random number between 0 and 16
|
||||
let r = Math.random() * 16;
|
||||
// Use timestamp until depleted
|
||||
if (d > 0) {
|
||||
r = (d + r) % 16 | 0;
|
||||
d = Math.floor(d / 16);
|
||||
} else {
|
||||
// Use microseconds since page-load if supported
|
||||
r = (d2 + r) % 16 | 0;
|
||||
d2 = Math.floor(d2 / 16);
|
||||
}
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
82
src/utils/validate.js
Normal file
82
src/utils/validate.js
Normal file
@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 校验方法
|
||||
*/
|
||||
|
||||
const validate = {
|
||||
/**
|
||||
* 判断值是否已经设置类型数据
|
||||
* null|undefined为false
|
||||
*/
|
||||
isSet(val) {
|
||||
if (
|
||||
val === null
|
||||
|| val === undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* 判断是否为空值
|
||||
* null|undefined|空字符串 绝对为空
|
||||
* 空对象、空数组根据拓展选项来控制
|
||||
*
|
||||
* @param {Any} val 验证值
|
||||
* @param {Object|Boolean} options 验证选项
|
||||
* @param {Boolean} options.allEmpty 全部验证
|
||||
* @param {Boolean} options.objectEmpty 对象验证
|
||||
* @param {Boolean} options.arrayEmpty 数组验证
|
||||
*
|
||||
* @return {Boolean} 是否为空
|
||||
*/
|
||||
isEmpty(val, options = null) {
|
||||
let allEmpty = false;
|
||||
let objectEmpty = false;
|
||||
let arrayEmpty = false;
|
||||
if (options === true) {
|
||||
allEmpty = true;
|
||||
} else {
|
||||
const emptyOptions = options || {};
|
||||
allEmpty = emptyOptions.allEmpty;
|
||||
objectEmpty = emptyOptions.objectEmpty;
|
||||
arrayEmpty = emptyOptions.arrayEmpty;
|
||||
}
|
||||
|
||||
if (
|
||||
val === null
|
||||
|| val === undefined
|
||||
|| (
|
||||
val.constructor.name === 'String'
|
||||
&& val === ''
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(allEmpty || objectEmpty)
|
||||
&& val.constructor.name === 'Object'
|
||||
&& Object.getOwnPropertyNames(val).length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(allEmpty || arrayEmpty)
|
||||
&& Array.isArray(val)
|
||||
&& val.length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* 是否为对象
|
||||
*/
|
||||
isObject(val) {
|
||||
return typeof val === 'object' && val !== null && val.constructor.name === 'Object';
|
||||
},
|
||||
hasOwn(obj, key) {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
},
|
||||
};
|
||||
|
||||
export default validate;
|
||||
35
src/utils/world-map-location.js
Normal file
35
src/utils/world-map-location.js
Normal file
@ -0,0 +1,35 @@
|
||||
import config from '@/config';
|
||||
import CODE_MAPS, {
|
||||
countryCodeMapping,
|
||||
aliasMapping,
|
||||
} from '@/data/code-maps';
|
||||
|
||||
export const ALIAS_CODE = {
|
||||
...aliasMapping,
|
||||
...countryCodeMapping,
|
||||
};
|
||||
|
||||
export const alias2code = (code) => ALIAS_CODE[code];
|
||||
|
||||
export const locationCode2Info = (code) => {
|
||||
const maps = {
|
||||
...CODE_MAPS,
|
||||
...(config.nazhua.customCodeMap || {}),
|
||||
};
|
||||
let info = maps[code];
|
||||
const aliasCode = aliasMapping[code];
|
||||
if (!info && aliasCode) {
|
||||
info = maps[aliasCode];
|
||||
}
|
||||
return info;
|
||||
};
|
||||
|
||||
export const count2size = (count) => {
|
||||
if (count < 3) {
|
||||
return 4;
|
||||
}
|
||||
if (count < 5) {
|
||||
return 6;
|
||||
}
|
||||
return 8;
|
||||
};
|
||||
363
src/views/components/server-detail/server-info-box.vue
Normal file
363
src/views/components/server-detail/server-info-box.vue
Normal file
@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="server-info-box">
|
||||
<div
|
||||
v-if="billPlanData.length"
|
||||
class="server-info-group server-info--biil-plan"
|
||||
>
|
||||
<div class="server-info-label">
|
||||
套餐
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<span class="server-info-item-group">
|
||||
<span
|
||||
v-for="item in billPlanData"
|
||||
:key="item.label"
|
||||
class="server-info-item"
|
||||
>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="server-info-item-label"
|
||||
>{{ item.label }}</span>
|
||||
<span class="server-info-item-value">{{ item.value }}</span>
|
||||
</span>
|
||||
<div
|
||||
v-if="showBuyBtn"
|
||||
class="buy-btn"
|
||||
@click.stop="toBuy"
|
||||
>
|
||||
<span class="icon">
|
||||
<span class="ri-shopping-bag-3-line" />
|
||||
</span>
|
||||
<span class="text">{{ buyBtnText }}</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-info-group server-info--cpu">
|
||||
<div class="server-info-label">
|
||||
平台
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<span
|
||||
class="cpu-info"
|
||||
:title="info?.Host?.CPU?.[0]"
|
||||
>
|
||||
<span>{{ info?.Host?.CPU?.[0] }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-info-group server-info--system-os">
|
||||
<div class="server-info-label">
|
||||
系统
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<span class="server-info-item">
|
||||
<span class="server-info-item-label">{{ systemOSLabel }}</span>
|
||||
<span
|
||||
v-if="info?.Host?.PlatformVersion"
|
||||
class="server-info-item-value"
|
||||
>
|
||||
{{ info?.Host?.PlatformVersion }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-info-group server-info--load">
|
||||
<div class="server-info-label">
|
||||
占用
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<span class="server-info-item-group">
|
||||
<span class="server-info-item process-count">
|
||||
<span class="server-info-item-label">进程数</span>
|
||||
<span class="server-info-item-value">{{ processCount }}</span>
|
||||
</span>
|
||||
<span class="server-info-item load">
|
||||
<span class="server-info-item-label">负载</span>
|
||||
<span class="server-info-item-value">
|
||||
{{ info?.State?.Load1 }},{{ info?.State?.Load5 }},{{ info?.State?.Load15 }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-info-group server-info--transfer">
|
||||
<div class="server-info-label">
|
||||
流量
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<span class="server-info-item-group">
|
||||
<span class="server-info-item transfer--in">
|
||||
<span class="server-info-item-label">入网</span>
|
||||
<span class="server-info-item-value">{{ transfer?.in?.value }}{{ transfer?.in?.symbol }}</span>
|
||||
</span>
|
||||
<span class="server-info-item transfer--out">
|
||||
<span class="server-info-item-label">出网</span>
|
||||
<span class="server-info-item-value">{{ transfer?.out?.value }}{{ transfer?.out?.symbol }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-info-group server-info--conn">
|
||||
<div class="server-info-label">
|
||||
连接
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<span class="server-info-item-group">
|
||||
<span class="server-info-item conn--tcp">
|
||||
<span class="server-info-item-label">TCP</span>
|
||||
<span class="server-info-item-value">{{ tcpConnCount }}</span>
|
||||
</span>
|
||||
<span class="server-info-item conn--tcp">
|
||||
<span class="server-info-item-label">UDP</span>
|
||||
<span class="server-info-item-value">{{ udpConnCount }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-info-group server-info--boottime">
|
||||
<div class="server-info-label">
|
||||
启动
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<span class="server-info-item runtime--boottime">
|
||||
<span class="server-info-item-value">{{ bootTime }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-info-group server-info--lasttime">
|
||||
<div class="server-info-label">
|
||||
活跃
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<span class="server-info-item runtime--lasttime">
|
||||
<span class="server-info-item-value">{{ lastActive }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="tagList?.length"
|
||||
class="server-info-group server-info--tag-list"
|
||||
>
|
||||
<div class="server-info-label">
|
||||
标签
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<div class="server-info-tag-list">
|
||||
<span
|
||||
v-for="(tag, index) in tagList"
|
||||
:key="`${tag}_${index}`"
|
||||
class="server-info-tag-item"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 服务器信息盒子
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import config from '@/config';
|
||||
import * as hostUtils from '@/utils/host';
|
||||
|
||||
import handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const buyBtnText = config.nazhua.buyBtnText || '购买';
|
||||
const showBuyBtn = computed(() => !!props.info?.PublicNote?.customData?.orderLink);
|
||||
|
||||
function toBuy() {
|
||||
window.open(props.info?.PublicNote?.customData?.orderLink);
|
||||
}
|
||||
|
||||
const {
|
||||
billAndPlan,
|
||||
} = handleServerBillAndPlan({
|
||||
props,
|
||||
});
|
||||
|
||||
const billPlanData = computed(() => ['billing', 'remainingTime', 'bandwidth', 'traffic'].map((i) => {
|
||||
if (billAndPlan.value[i]) {
|
||||
return {
|
||||
label: billAndPlan.value[i].label,
|
||||
value: billAndPlan.value[i].value,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((i) => i));
|
||||
|
||||
const tagList = computed(() => {
|
||||
const list = [];
|
||||
if (props?.info?.PublicNote?.planDataMod?.networkRoute) {
|
||||
list.push(...props.info.PublicNote.planDataMod.networkRoute.split(','));
|
||||
}
|
||||
if (props?.info?.PublicNote?.planDataMod?.extra) {
|
||||
list.push(...props.info.PublicNote.planDataMod.extra.split(','));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const systemOSLabel = computed(() => {
|
||||
if (props?.info?.Host?.Platform) {
|
||||
return hostUtils.getSystemOSLabel(props.info.Host.Platform);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const bootTime = computed(() => {
|
||||
if (props?.info?.Host?.BootTime) {
|
||||
return dayjs(props.info.Host.BootTime * 1000).format('YYYY.MM.DD HH:mm:ss');
|
||||
}
|
||||
return '-';
|
||||
});
|
||||
|
||||
const lastActive = computed(() => {
|
||||
if (props?.info?.Host?.BootTime && props?.info?.LastActive) {
|
||||
return dayjs(props.info.LastActive).format('YYYY.MM.DD HH:mm:ss');
|
||||
}
|
||||
return '-';
|
||||
});
|
||||
|
||||
/**
|
||||
* 计算流量
|
||||
*/
|
||||
const transfer = computed(() => {
|
||||
const stats = {
|
||||
in: 0,
|
||||
out: 0,
|
||||
total: 0,
|
||||
};
|
||||
if (props?.info?.State?.NetInTransfer) {
|
||||
stats.total += props.info.State.NetInTransfer;
|
||||
stats.in = props.info.State.NetInTransfer;
|
||||
}
|
||||
if (props?.info?.State?.NetOutTransfer) {
|
||||
stats.total += props.info.State.NetOutTransfer;
|
||||
stats.out = props.info.State.NetOutTransfer;
|
||||
}
|
||||
const result = {
|
||||
in: hostUtils.calcTransfer(stats.in),
|
||||
out: hostUtils.calcTransfer(stats.out),
|
||||
total: hostUtils.calcTransfer(stats.total),
|
||||
stats,
|
||||
};
|
||||
return result;
|
||||
});
|
||||
|
||||
const tcpConnCount = computed(() => props.info?.State?.TcpConnCount);
|
||||
const udpConnCount = computed(() => props.info?.State?.UdpConnCount);
|
||||
const processCount = computed(() => props.info?.State?.ProcessCount);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-info-box {
|
||||
--server-info-item-size: 24px;
|
||||
|
||||
padding: 20px;
|
||||
color: #eee;
|
||||
border-radius: 12px;
|
||||
background-image: radial-gradient(transparent 1px, rgba(#000, 0.6) 1px);
|
||||
background-size: 3px 3px;
|
||||
backdrop-filter: saturate(50%) blur(3px);
|
||||
box-shadow: 2px 4px 6px rgba(#000, 0.4);
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
background-color: rgba(#000, 0.8);
|
||||
background-image: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
@media screen and (max-width: 480px) {
|
||||
--server-info-item-size: 30px;
|
||||
}
|
||||
|
||||
.server-info-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
font-size: 14px;
|
||||
|
||||
.server-info-label {
|
||||
line-height: var(--server-info-item-size);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.server-info-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
line-height: 18px;
|
||||
text-align: right;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.server-info-item-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 12px;
|
||||
}
|
||||
|
||||
.server-info-item {
|
||||
display: flex;
|
||||
gap: 0.2em;
|
||||
}
|
||||
|
||||
.server-info-item-value {
|
||||
color: #a1eafb;
|
||||
}
|
||||
|
||||
.transfer--total {
|
||||
.server-info-item-value {
|
||||
color: #fdfdfd;
|
||||
}
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
line-height: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--list-item-buy-link-color);
|
||||
cursor: pointer;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.server-info-tag-list {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
.server-info-tag-item {
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
line-height: 18px;
|
||||
font-size: 12px;
|
||||
color: #111;
|
||||
background-color: rgba(#fff, 0.8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
241
src/views/components/server-detail/server-monitor.vue
Normal file
241
src/views/components/server-detail/server-monitor.vue
Normal file
@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="monitorData.length"
|
||||
class="server-monitor-group"
|
||||
>
|
||||
<div class="module-head-group">
|
||||
<div class="left-box">
|
||||
<span class="module-title">
|
||||
网络监控
|
||||
</span>
|
||||
</div>
|
||||
<div class="right-box">
|
||||
<div
|
||||
class="peak-shaving-group"
|
||||
title="过滤太高或太低的数据"
|
||||
@click="switchPeakShaving"
|
||||
>
|
||||
<div
|
||||
class="switch-box"
|
||||
:class="{
|
||||
active: peakShaving,
|
||||
}"
|
||||
>
|
||||
<span class="switch-dot" />
|
||||
</div>
|
||||
<span class="label-text">削峰</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<line-chart
|
||||
:cate-list="monitorChartData.cateList"
|
||||
:date-list="monitorChartData.dateList"
|
||||
:value-list="monitorChartData.valueList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 服务器监控
|
||||
*/
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import config from '@/config';
|
||||
import request from '@/utils/request';
|
||||
|
||||
import LineChart from '@/components/charts/line.vue';
|
||||
|
||||
import {
|
||||
getThreshold,
|
||||
} from '@/views/composable/server-monitor';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const peakShaving = ref(false);
|
||||
|
||||
const monitorData = ref([]);
|
||||
|
||||
const monitorChartData = computed(() => {
|
||||
const cateMap = {};
|
||||
const dateMap = {};
|
||||
monitorData.value.forEach((i) => {
|
||||
if (!cateMap[i.monitor_name]) {
|
||||
cateMap[i.monitor_name] = [];
|
||||
}
|
||||
const {
|
||||
threshold,
|
||||
mean,
|
||||
max,
|
||||
min,
|
||||
} = peakShaving.value ? getThreshold(i.avg_delay, 2) : {};
|
||||
i.created_at.forEach((o, index) => {
|
||||
if (!dateMap[o]) {
|
||||
dateMap[o] = [];
|
||||
}
|
||||
const avgDelay = i.avg_delay[index];
|
||||
if (peakShaving.value) {
|
||||
if (Math.abs(avgDelay - mean) > threshold && max / min > 2) {
|
||||
return;
|
||||
}
|
||||
if (avgDelay === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
dateMap[o].push({
|
||||
name: i.monitor_name,
|
||||
value: (avgDelay).toFixed(2) * 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
const dateList = [];
|
||||
Object.keys(dateMap).forEach((i) => {
|
||||
if (dateMap[i]?.length) {
|
||||
dateList.push(parseInt(i, 10));
|
||||
dateMap[i].forEach((o) => {
|
||||
cateMap[o.name].push(o.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
dateList.sort((a, b) => a - b);
|
||||
const cateList = [];
|
||||
const valueList = [];
|
||||
Object.keys(cateMap).forEach((i) => {
|
||||
if (cateMap[i]?.length) {
|
||||
cateList.push(i);
|
||||
}
|
||||
valueList.push({
|
||||
name: i,
|
||||
data: cateMap[i],
|
||||
});
|
||||
});
|
||||
return {
|
||||
cateList,
|
||||
dateList,
|
||||
valueList,
|
||||
};
|
||||
});
|
||||
|
||||
function switchPeakShaving() {
|
||||
peakShaving.value = !peakShaving.value;
|
||||
}
|
||||
|
||||
async function loadMonitor() {
|
||||
await request({
|
||||
url: config.nazhua.apiMonitorPath.replace('{id}', props.info.ID),
|
||||
}).then((res) => {
|
||||
if (Array.isArray(res)) {
|
||||
monitorData.value = res;
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
let loadMonitorTimer = null;
|
||||
async function setTimeLoadMonitor() {
|
||||
if (loadMonitorTimer) {
|
||||
clearTimeout(loadMonitorTimer);
|
||||
}
|
||||
await loadMonitor();
|
||||
loadMonitorTimer = setTimeout(() => {
|
||||
setTimeLoadMonitor();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeLoadMonitor();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (loadMonitorTimer) {
|
||||
clearTimeout(loadMonitorTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-monitor-group {
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
background-image: radial-gradient(transparent 1px, rgba(#000, 0.6) 1px);
|
||||
background-size: 3px 3px;
|
||||
backdrop-filter: saturate(50%) blur(3px);
|
||||
box-shadow: 2px 4px 6px rgba(#000, 0.4);
|
||||
|
||||
--line-chart-size: 270px;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
background-color: rgba(#000, 0.8);
|
||||
background-image: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.module-head-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
height: 30px;
|
||||
|
||||
.module-title {
|
||||
line-height: 30px;
|
||||
font-size: 16px;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.peak-shaving-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.switch-box {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
background: #999;
|
||||
border-radius: 10px;
|
||||
transition: backgroundColor 0.3s;
|
||||
|
||||
.switch-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: left 0.3s;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #4caf50;
|
||||
|
||||
.switch-dot {
|
||||
left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-text {
|
||||
color: #ddd;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
232
src/views/components/server-detail/server-name.vue
Normal file
232
src/views/components/server-detail/server-name.vue
Normal file
@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="server-head">
|
||||
<div class="server-flag">
|
||||
<div class="server-flag-font">
|
||||
<span
|
||||
class="fi"
|
||||
:class="'fi-' + (info?.Host?.CountryCode || 'un')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-name-and-slogan">
|
||||
<div class="server-name-group">
|
||||
<span class="server-name">
|
||||
{{ info.Name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="cpuAndMemAndDisk"
|
||||
class="cpu-mem-group"
|
||||
>
|
||||
<span
|
||||
v-if="info?.Host?.Platform"
|
||||
class="system-os-icon"
|
||||
>
|
||||
<span
|
||||
:class="'fl-' + info?.Host?.Platform"
|
||||
/>
|
||||
</span>
|
||||
<span class="core-mem">{{ cpuAndMemAndDisk }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="slogan"
|
||||
class="slogan-content"
|
||||
>
|
||||
<span>“{{ slogan }}”</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="cpuInfo"
|
||||
class="cpu-model-info"
|
||||
>
|
||||
<span
|
||||
v-if="cpuInfo.company"
|
||||
class="cpu-company"
|
||||
:class="'cpu-company--' + cpuInfo.company.toLowerCase()"
|
||||
>
|
||||
{{ cpuInfo.company }}
|
||||
</span>
|
||||
<span
|
||||
v-if="cpuInfo.model"
|
||||
class="cpu-model"
|
||||
>
|
||||
{{ cpuInfo.model }}
|
||||
</span>
|
||||
<span
|
||||
v-if="cpuInfo.modelNum"
|
||||
class="cpu-model-num"
|
||||
>
|
||||
{{ cpuInfo.modelNum }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 单节点
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import * as hostUtils from '@/utils/host';
|
||||
import handleServerInfo from '@/views/composable/server-info';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* XCore XGB
|
||||
*/
|
||||
const { cpuAndMemAndDisk } = handleServerInfo({
|
||||
props,
|
||||
});
|
||||
|
||||
const slogan = computed(() => props.info?.PublicNote?.customData?.slogan);
|
||||
const cpuInfo = computed(() => hostUtils.getCPUInfo(props.info.Host.CPU[0]));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background-image: radial-gradient(transparent 1px, rgba(#000, 0.8) 1px);
|
||||
background-size: 3px 3px;
|
||||
backdrop-filter: saturate(50%) blur(3px);
|
||||
transition: 0.3s;
|
||||
box-shadow: 2px 4px 6px rgba(#000, 0.4);
|
||||
|
||||
.server-flag {
|
||||
--flag-size: 72px;
|
||||
position: relative;
|
||||
width: calc(var(--flag-size) * 1.33333333);
|
||||
height: var(--flag-size);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.server-flag-font {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: calc(var(--flag-size) * 1.33333333);
|
||||
height: var(--flag-size);
|
||||
line-height: var(--flag-size);
|
||||
font-size: var(--flag-size);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
--flag-size: 40px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.server-name-and-slogan {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// justify-content: space-between;
|
||||
gap: 4px;
|
||||
padding: 5px 0;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.server-name-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #eee;
|
||||
|
||||
.server-name {
|
||||
line-height: 30px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.system-os-icon {
|
||||
height: 24px;
|
||||
line-height: 22px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.core-mem {
|
||||
line-height: 30px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
display: block;
|
||||
|
||||
.server-name {
|
||||
line-height: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cpu-mem-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@media screen and (max-width: 500px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.slogan-content {
|
||||
color: #ccc;
|
||||
line-height: 18px;
|
||||
font-size: 14px;
|
||||
@media screen and (max-width: 500px) {
|
||||
line-height: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.cpu-model-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 2px;
|
||||
line-height: 24px;
|
||||
color: #ddd;
|
||||
|
||||
.cpu-company {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
padding: 0 5px;
|
||||
color: #111;
|
||||
background: #e0fcff;
|
||||
|
||||
&--intel {
|
||||
text-transform: lowercase;
|
||||
color: #fff;
|
||||
background: #0068b5;
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
&--amd {
|
||||
font-weight: bold;
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.cpu-model {
|
||||
color: #e0fcff;
|
||||
}
|
||||
|
||||
.cpu-model-num {
|
||||
color: #c7eeff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
159
src/views/components/server-detail/server-status-box.vue
Normal file
159
src/views/components/server-detail/server-status-box.vue
Normal file
@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="server-status-and-real-time">
|
||||
<div
|
||||
class="server-status-group"
|
||||
:class="'status-list--' + serverStatusList.length"
|
||||
>
|
||||
<server-status-item
|
||||
v-for="item in serverStatusList"
|
||||
:key="item.type"
|
||||
:type="item.type"
|
||||
:used="item.used"
|
||||
:colors="item.colors"
|
||||
:val-text="item.valText"
|
||||
:label="item.label"
|
||||
:content="item.content"
|
||||
/>
|
||||
</div>
|
||||
<server-list-item-real-time :info="info" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 服务器状态组
|
||||
*/
|
||||
import handleServerStatus from '@/views/composable/server-status';
|
||||
|
||||
import ServerStatusItem from '@/views/components/server/server-status.vue';
|
||||
import ServerListItemRealTime from '@/views/components/server/server-real-time.vue';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
serverStatusList,
|
||||
} = handleServerStatus({
|
||||
props,
|
||||
statusListTpl: 'cpu,mem,swap,disk',
|
||||
statusListItemContent: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-status-and-real-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
background-image: radial-gradient(transparent 1px, rgba(#000, 0.6) 1px);
|
||||
background-size: 3px 3px;
|
||||
backdrop-filter: saturate(50%) blur(3px);
|
||||
box-shadow: 2px 4px 6px rgba(#000, 0.4);
|
||||
|
||||
--real-time-value-font-size: 36px;
|
||||
--real-time-text-font-size: 16px;
|
||||
--real-time-label-font-size: 16px;
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
--real-time-value-font-size: 30px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
background-color: rgba(#000, 0.8);
|
||||
background-image: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
--real-time-value-font-size: 24px;
|
||||
--real-time-text-font-size: 14px;
|
||||
--real-time-label-font-size: 14px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 320px) {
|
||||
--real-time-value-font-size: 20px;
|
||||
--real-time-text-font-size: 12px;
|
||||
--real-time-label-font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.server-status-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&.status-list--3 {
|
||||
--server-status-size: 200px;
|
||||
--server-status-val-text-font-size: 32px;
|
||||
--server-status-label-font-size: 18px;
|
||||
--server-status-content-font-size: 16px;
|
||||
}
|
||||
|
||||
&.status-list--4 {
|
||||
--server-status-size: 180px;
|
||||
--server-status-val-text-font-size: 28px;
|
||||
--server-status-label-font-size: 16px;
|
||||
--server-status-content-font-size: 16px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
// gap: 10px 20px;
|
||||
|
||||
&.status-list--4 {
|
||||
--server-status-size: 160px;
|
||||
--server-status-val-text-font-size: 26px;
|
||||
--server-status-label-font-size: 15px;
|
||||
--server-status-content-font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
&.status-list--3 {
|
||||
--server-status-size: 100px;
|
||||
--server-status-val-text-font-size: 14px;
|
||||
--server-status-label-font-size: 12px;
|
||||
--server-status-content-font-size: 12px;
|
||||
}
|
||||
|
||||
&.status-list--4 {
|
||||
padding: 0 10px;
|
||||
gap: 10px 0;
|
||||
--server-status-size: 120px;
|
||||
--server-status-val-text-font-size: 16px;
|
||||
--server-status-label-font-size: 14px;
|
||||
--server-status-content-font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
&.status-list--3 {
|
||||
--server-status-size: 90px;
|
||||
--server-status-val-text-font-size: 12px;
|
||||
--server-status-label-font-size: 12px;
|
||||
--server-status-content-font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 320px) {
|
||||
&.status-list--3 {
|
||||
--server-status-size: 90px;
|
||||
}
|
||||
&.status-list--4 {
|
||||
padding: 0;
|
||||
--server-status-size: 100px;
|
||||
--server-status-val-text-font-size: 14px;
|
||||
--server-status-label-font-size: 12px;
|
||||
--server-status-content-font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
161
src/views/components/server-list/server-list-item-bill.vue
Normal file
161
src/views/components/server-list/server-list-item-bill.vue
Normal file
@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="server-list-item-bill">
|
||||
<div class="remaining-time-info">
|
||||
<template
|
||||
v-if="billAndPlan.remainingTime"
|
||||
>
|
||||
<span class="icon">
|
||||
<span class="ri-hourglass-fill" />
|
||||
</span>
|
||||
<span
|
||||
v-if="billAndPlan.remainingTime.type !== 'infinity'"
|
||||
class="text"
|
||||
>
|
||||
<span class="text-item label-text">{{ billAndPlan.remainingTime.label }}</span>
|
||||
<span class="text-item value-text">{{ billAndPlan.remainingTime.value }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text"
|
||||
>
|
||||
<span class="text-item value-text">{{ billAndPlan.remainingTime.value }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="billing-and-order-link">
|
||||
<div
|
||||
v-if="billAndPlan.billing"
|
||||
class="billing-info"
|
||||
>
|
||||
<span class="text">
|
||||
<span class="text-item value-text">{{ billAndPlan.billing.value }}</span>
|
||||
<span class="text-item">/</span>
|
||||
<span class="text-item label-text">{{ billAndPlan.billing.cycleLabel }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showBuyBtn"
|
||||
class="buy-btn"
|
||||
@click.stop="toBuy"
|
||||
>
|
||||
<span class="icon">
|
||||
<span class="ri-shopping-bag-3-line" />
|
||||
</span>
|
||||
<span class="text">{{ buyBtnText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 套餐信息
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
import handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
billAndPlan,
|
||||
} = handleServerBillAndPlan({
|
||||
props,
|
||||
});
|
||||
|
||||
const buyBtnText = config.nazhua.buyBtnText || '购买';
|
||||
const showBuyBtn = computed(() => !!props.info?.PublicNote?.customData?.orderLink);
|
||||
|
||||
function toBuy() {
|
||||
window.open(props.info?.PublicNote?.customData?.orderLink);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-list-item-bill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
border-bottom-left-radius: var(--list-item-border-radius);
|
||||
border-bottom-right-radius: var(--list-item-border-radius);
|
||||
background: rgba(#000, 0.4);
|
||||
box-shadow: 0 2px 4px rgba(#000, 0.5);
|
||||
|
||||
.remaining-time-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 1;
|
||||
font-size: 16px;
|
||||
color: #74dbef;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 30px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.value-text {
|
||||
color: #74dbef;
|
||||
}
|
||||
}
|
||||
|
||||
.billing-and-order-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
gap: 10px;
|
||||
|
||||
.billing-info {
|
||||
line-height: 30px;
|
||||
color: var(--list-item-price-color);
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
gap: 5px;
|
||||
line-height: 1;
|
||||
font-weight: bold;
|
||||
color: var(--list-item-buy-link-color);
|
||||
border: 2px solid var(--list-item-buy-link-color);
|
||||
border-radius: 8px;
|
||||
transition: all 150ms ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #111;
|
||||
border-color: var(--list-item-buy-link-color);
|
||||
background-color: var(--list-item-buy-link-color);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
81
src/views/components/server-list/server-list-item-status.vue
Normal file
81
src/views/components/server-list/server-list-item-status.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="server-list-item-status">
|
||||
<server-status-item
|
||||
v-for="item in serverStatusList"
|
||||
:key="item.type"
|
||||
:type="item.type"
|
||||
:used="item.used"
|
||||
:colors="item.colors"
|
||||
:val-text="item.valText"
|
||||
:label="item.label"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 服务器状态盒子
|
||||
*/
|
||||
import handleServerStatus from '@/views/composable/server-status';
|
||||
import ServerStatusItem from '@/views/components/server/server-status.vue';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
serverStatusList,
|
||||
} = handleServerStatus({
|
||||
props,
|
||||
statusListTpl: 'cpu,mem,disk',
|
||||
statusListItemContent: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-list-item-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 5px;
|
||||
|
||||
--server-status-size: 120px;
|
||||
--server-status-val-text-font-size: 20px;
|
||||
--server-status-label-font-size: 14px;
|
||||
// 针对1440px以下的屏幕(新Mac笔记本或者windows缩放)
|
||||
@media screen and (max-width: 1440px) {
|
||||
padding: 0;
|
||||
--server-status-size: 110px;
|
||||
--server-status-val-text-font-size: 18px;
|
||||
--server-status-label-font-size: 14px;
|
||||
}
|
||||
// 针对1280px以下的屏幕(Mac居多)
|
||||
@media screen and (max-width: 1280px) {
|
||||
padding: 0 8px;
|
||||
--server-status-size: 100px;
|
||||
--server-status-val-text-font-size: 16px;
|
||||
--server-status-label-font-size: 12px;
|
||||
}
|
||||
// 针对1024px以下的屏幕(平板居多)
|
||||
@media screen and (max-width: 1024px) {
|
||||
// padding: 0 8px;
|
||||
--server-status-size: 120px;
|
||||
--server-status-val-text-font-size: 20px;
|
||||
--server-status-label-font-size: 16px;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
padding: 0 8px;
|
||||
--server-status-size: 100px;
|
||||
--server-status-val-text-font-size: 16px;
|
||||
--server-status-label-font-size: 12px;
|
||||
}
|
||||
@media screen and (max-width: 375px) {
|
||||
padding: 0;
|
||||
--server-status-size: 90px;
|
||||
--server-status-val-text-font-size: 14px;
|
||||
--server-status-label-font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
206
src/views/components/server-list/server-list-item.vue
Normal file
206
src/views/components/server-list/server-list-item.vue
Normal file
@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div
|
||||
class="server-list-item"
|
||||
:class="{
|
||||
'server-list-item--offline': info.online === -1,
|
||||
}"
|
||||
@click="openDetail"
|
||||
>
|
||||
<div class="server-info-group server-list-item-head">
|
||||
<div class="server-name-group left-box">
|
||||
<span
|
||||
class="server-flag"
|
||||
>
|
||||
<span
|
||||
class="fi"
|
||||
:class="'fi-' + (info?.Host?.CountryCode || 'un')"
|
||||
/>
|
||||
</span>
|
||||
<span class="server-name">
|
||||
{{ info.Name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="right-box">
|
||||
<div
|
||||
v-if="cpuAndMemAndDisk"
|
||||
class="cpu-mem-group"
|
||||
>
|
||||
<span
|
||||
v-if="info?.Host?.Platform"
|
||||
:class="'fl-' + info?.Host?.Platform"
|
||||
/>
|
||||
<span class="core-mem">{{ cpuAndMemAndDisk }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-list-item-main">
|
||||
<server-list-item-status
|
||||
:info="info"
|
||||
/>
|
||||
<server-real-time
|
||||
:info="info"
|
||||
/>
|
||||
</div>
|
||||
<server-list-item-bill
|
||||
v-if="showBill"
|
||||
:info="info"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 单节点
|
||||
*/
|
||||
|
||||
import {
|
||||
useRouter,
|
||||
} from 'vue-router';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
import handleServerInfo from '@/views/composable/server-info';
|
||||
import ServerRealTime from '@/views/components/server/server-real-time.vue';
|
||||
import ServerListItemStatus from './server-list-item-status.vue';
|
||||
import ServerListItemBill from './server-list-item-bill.vue';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
/**
|
||||
* XCore XGB
|
||||
*/
|
||||
const { cpuAndMemAndDisk } = handleServerInfo({
|
||||
props,
|
||||
});
|
||||
|
||||
function openDetail() {
|
||||
router.push({
|
||||
name: 'ServerDetail',
|
||||
params: {
|
||||
serverId: props.info.ID,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const showBill = config.nazhua.hideListItemBill !== true;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-list-item {
|
||||
--list-item-border-radius: 12px;
|
||||
width: var(--list-item-width);
|
||||
color: #fff;
|
||||
background-image: radial-gradient(transparent 1px, rgba(#000, 0.6) 1px);
|
||||
background-size: 3px 3px;
|
||||
backdrop-filter: saturate(50%) blur(3px);
|
||||
border-radius: var(--list-item-border-radius);
|
||||
transition: 0.3s;
|
||||
box-shadow: 2px 4px 6px rgba(#000, 0.4);
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
background-color: rgba(#000, 0.8);
|
||||
background-image: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.server-info-group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 15px;
|
||||
height: 50px;
|
||||
border-top-left-radius: var(--list-item-border-radius);
|
||||
border-top-right-radius: var(--list-item-border-radius);
|
||||
background: rgba(#000, 0.3);
|
||||
box-shadow: 0 2px 4px rgba(#000, 0.5);
|
||||
|
||||
&.server-list-item-head {
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-box,
|
||||
.right-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.server-flag {
|
||||
width: calc(18px * 1.5);
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
height: 30px;
|
||||
line-height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cpu-mem-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.core-mem {
|
||||
height: 30px;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&.server-list-item--offline {
|
||||
filter: grayscale(1);
|
||||
.server-info-group {
|
||||
.server-name {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-list-item-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 15px 10px;
|
||||
|
||||
--real-time-value-font-size: 24px;
|
||||
--real-time-text-font-size: 12px;
|
||||
--real-time-label-font-size: 14px;
|
||||
|
||||
@media screen and (max-width: 1280px) {
|
||||
padding: 10px 0 15px;
|
||||
|
||||
--real-time-value-font-size: 20px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
--real-time-value-font-size: 24px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
--real-time-value-font-size: 20px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 680px) {
|
||||
--real-time-value-font-size: 24px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 320px) {
|
||||
--real-time-value-font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
src/views/components/server-list/server-option-box.vue
Normal file
92
src/views/components/server-list/server-option-box.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="server-option-box">
|
||||
<div
|
||||
v-for="item in options"
|
||||
:key="item.key"
|
||||
class="server-option-item"
|
||||
:class="{
|
||||
active: activeValue === item.value,
|
||||
}"
|
||||
@click="toggleModelValue(item)"
|
||||
>
|
||||
<span class="option-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 过滤栏
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
accpetEmpty: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits([
|
||||
'update:modelValue',
|
||||
]);
|
||||
|
||||
const activeValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emits('update:modelValue', val);
|
||||
},
|
||||
});
|
||||
|
||||
function toggleModelValue(item) {
|
||||
if (activeValue.value === item.value) {
|
||||
if (props.accpetEmpty) {
|
||||
activeValue.value = '';
|
||||
}
|
||||
} else {
|
||||
activeValue.value = item.value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-option-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 var(--list-padding);
|
||||
gap: 8px;
|
||||
|
||||
.server-option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 15px;
|
||||
line-height: 1.2;
|
||||
border-radius: 6px;
|
||||
background: rgba(#000, 0.3);
|
||||
cursor: pointer;
|
||||
@media screen and (max-width: 768px) {
|
||||
background-color: rgba(#000, 0.8);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(#ff7500, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
103
src/views/components/server/server-real-time.vue
Normal file
103
src/views/components/server/server-real-time.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="server-real-time-group">
|
||||
<div
|
||||
v-for="item in serverRealTimeList"
|
||||
:key="item.key"
|
||||
class="server-real-time-item"
|
||||
:class="`server-real-time--${item.key}`"
|
||||
>
|
||||
<div class="item-content">
|
||||
<span class="item-value">{{ item?.value || '-' }}</span>
|
||||
<span class="item-symbol item-text">{{ item?.value ? item?.symbol : '' }}</span>
|
||||
</div>
|
||||
<span class="item-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 服务器数据统计
|
||||
*/
|
||||
import {
|
||||
inject,
|
||||
} from 'vue';
|
||||
import handleServerRealTime from '@/views/composable/server-real-time';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const currentTime = inject('currentTime', {
|
||||
value: Date.now(),
|
||||
});
|
||||
|
||||
const {
|
||||
serverRealTimeList,
|
||||
} = handleServerRealTime({
|
||||
props,
|
||||
currentTime,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-real-time-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.server-real-time-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.item-value {
|
||||
line-height: 1em;
|
||||
font-size: var(--real-time-value-font-size, 24px);
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
line-height: 1.3em;
|
||||
font-size: var(--real-time-text-font-size, 12px);
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
line-height: 1.2em;
|
||||
font-size: var(--real-time-label-font-size, 14px);
|
||||
color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
.server-real-time--duration {
|
||||
.item-value {
|
||||
color: #cbf1f5;
|
||||
}
|
||||
}
|
||||
.server-real-time--transfer {
|
||||
.item-value {
|
||||
color: #ffc300;
|
||||
}
|
||||
}
|
||||
.server-real-time--inSpeed {
|
||||
.item-value {
|
||||
color: #46cdcf;
|
||||
}
|
||||
}
|
||||
.server-real-time--outSpeed {
|
||||
.item-value {
|
||||
color: #abedd8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
135
src/views/components/server/server-status.vue
Normal file
135
src/views/components/server/server-status.vue
Normal file
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div
|
||||
class="server-status"
|
||||
:class="'server-status--' + type"
|
||||
>
|
||||
<div class="server-status-donut">
|
||||
<chart-donut
|
||||
:size="size"
|
||||
:used="used"
|
||||
:item-colors="colors"
|
||||
>
|
||||
<template #default>
|
||||
<div class="chart-donut-label">
|
||||
<div class="server-status-val-text">
|
||||
<span>{{ valText }}</span>
|
||||
</div>
|
||||
<div class="server-status-label">
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</chart-donut>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="content"
|
||||
class="server-status-content"
|
||||
>
|
||||
<span
|
||||
v-if="content?.default"
|
||||
class="default-content"
|
||||
>
|
||||
{{ content?.default }}
|
||||
</span>
|
||||
<span
|
||||
v-if="content?.mobile"
|
||||
class="default-mobile"
|
||||
>
|
||||
{{ content?.mobile }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 服务器状态单项
|
||||
*/
|
||||
|
||||
import ChartDonut from '@/components/charts/donut.vue';
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
used: {
|
||||
type: [Number, String],
|
||||
default: 1,
|
||||
},
|
||||
colors: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
valText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
content: {
|
||||
type: [String, Object],
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-status {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
.server-status-donut {
|
||||
--donut-box-size: var(--server-status-size);
|
||||
height: var(--server-status-size);
|
||||
}
|
||||
|
||||
.chart-donut-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.server-status-val-text {
|
||||
line-height: 1.2em;
|
||||
font-size: var(--server-status-val-text-font-size, 14px);
|
||||
color: #a1eafb;
|
||||
}
|
||||
.server-status-label {
|
||||
line-height: 1.1em;
|
||||
font-size: var(--server-status-label-font-size, 12px);
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.server-status-content {
|
||||
line-height: 1.2em;
|
||||
font-size: var(--server-status-content-font-size, 14px);
|
||||
color: #eee;
|
||||
|
||||
.default-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.default-content {
|
||||
display: none;
|
||||
}
|
||||
.default-mobile {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
164
src/views/composable/server-bill-and-plan.js
Normal file
164
src/views/composable/server-bill-and-plan.js
Normal file
@ -0,0 +1,164 @@
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import config from '@/config';
|
||||
import validate from '@/utils/validate';
|
||||
import * as dateUtils from '@/utils/date';
|
||||
|
||||
export default (params) => {
|
||||
const {
|
||||
props,
|
||||
} = params || {};
|
||||
/**
|
||||
* 账单和计划
|
||||
*/
|
||||
const billAndPlan = computed(() => {
|
||||
const obj = {
|
||||
billing: null,
|
||||
remainingTime: null,
|
||||
bandwidth: null,
|
||||
traffic: null,
|
||||
};
|
||||
if (props?.info?.PublicNote) {
|
||||
const {
|
||||
billingDataMod,
|
||||
planDataMod,
|
||||
} = props.info.PublicNote;
|
||||
let months;
|
||||
// 套餐资费
|
||||
let cycleLabel;
|
||||
if (validate.isSet(billingDataMod?.cycle)) {
|
||||
switch (billingDataMod.cycle.toLowerCase()) {
|
||||
case '月':
|
||||
case 'm':
|
||||
case 'mo':
|
||||
case 'month':
|
||||
case 'monthly':
|
||||
cycleLabel = '月';
|
||||
months = 1;
|
||||
break;
|
||||
case '年':
|
||||
case 'y':
|
||||
case 'yr':
|
||||
case 'year':
|
||||
case 'annual':
|
||||
cycleLabel = '年';
|
||||
months = 12;
|
||||
break;
|
||||
case '季':
|
||||
case 'quarterly':
|
||||
cycleLabel = '季';
|
||||
months = 3;
|
||||
break;
|
||||
case '半':
|
||||
case '半年':
|
||||
case 'h':
|
||||
case 'half':
|
||||
case 'semi-annually':
|
||||
cycleLabel = '半年';
|
||||
months = 6;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (validate.isSet(billingDataMod?.amount)) {
|
||||
let amountValue = billingDataMod.amount;
|
||||
let label;
|
||||
if (billingDataMod.amount.toString() === '-1') {
|
||||
amountValue = '按量';
|
||||
label = `每${cycleLabel}`;
|
||||
} else if (billingDataMod.amount.toString() === '0') {
|
||||
amountValue = config.nazhua.freeAmount || '免费';
|
||||
} else {
|
||||
label = `${cycleLabel}付`;
|
||||
}
|
||||
obj.billing = {
|
||||
label,
|
||||
value: amountValue,
|
||||
cycleLabel,
|
||||
months,
|
||||
};
|
||||
}
|
||||
// 剩余时间
|
||||
if (validate.isSet(billingDataMod?.endDate)) {
|
||||
const {
|
||||
endDate,
|
||||
autoRenewal,
|
||||
} = billingDataMod;
|
||||
const nowTime = new Date().getTime();
|
||||
const endTime = dayjs(endDate).valueOf();
|
||||
if (endDate.indexOf('0000-00-00') === 0) {
|
||||
obj.remainingTime = {
|
||||
label: '有效期',
|
||||
value: config.nazhua.infinityCycle || '无限',
|
||||
type: 'infinity',
|
||||
};
|
||||
} else if (autoRenewal === '1') {
|
||||
// 自动续费时间计算,cycleType 为 1 时为月,为 12 时为年
|
||||
// 判断endDate是否超过当前时间,超过则显示剩余时间
|
||||
if (endTime > nowTime) {
|
||||
const diff = dayjs(endTime).diff(dayjs(), 'day') + 1;
|
||||
obj.remainingTime = {
|
||||
label: '剩余',
|
||||
value: `${diff}天`,
|
||||
value2: diff,
|
||||
type: 'autoRenewal-endTime',
|
||||
};
|
||||
} else {
|
||||
// endDate如果早于当前时间,按照cycleType计算出超过当前时间的结束时间
|
||||
const nextTime = dateUtils.getNextCycleTime(endTime, months, nowTime);
|
||||
const diff = dayjs(nextTime).diff(dayjs(), 'day') + 1;
|
||||
obj.remainingTime = {
|
||||
label: '剩余',
|
||||
value: `${diff}天`,
|
||||
value2: diff,
|
||||
type: 'autoRenewal-nextTime',
|
||||
};
|
||||
}
|
||||
} else if (endTime > nowTime) {
|
||||
const diff = dayjs(endTime).diff(dayjs(), 'day') + 1;
|
||||
obj.remainingTime = {
|
||||
label: '剩余',
|
||||
value: `${diff}天`,
|
||||
value2: diff,
|
||||
type: 'endTime',
|
||||
};
|
||||
} else {
|
||||
obj.remainingTime = {
|
||||
label: '剩余',
|
||||
value: '已过期',
|
||||
type: 'expired',
|
||||
};
|
||||
}
|
||||
}
|
||||
// 带宽、流量
|
||||
if (planDataMod) {
|
||||
if (planDataMod.bandwidth) {
|
||||
obj.bandwidth = {
|
||||
label: '带宽',
|
||||
value: planDataMod.bandwidth,
|
||||
};
|
||||
}
|
||||
if (planDataMod.trafficVol) {
|
||||
let trafficTypeLabel = '双向';
|
||||
if (planDataMod.trafficType === '1') {
|
||||
trafficTypeLabel = '单向出';
|
||||
} else if (planDataMod.trafficType === '3') {
|
||||
trafficTypeLabel = '单向取最大';
|
||||
}
|
||||
obj.traffic = {
|
||||
label: `${trafficTypeLabel}流量`,
|
||||
value: planDataMod.trafficVol,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
|
||||
return {
|
||||
billAndPlan,
|
||||
};
|
||||
};
|
||||
43
src/views/composable/server-info.js
Normal file
43
src/views/composable/server-info.js
Normal file
@ -0,0 +1,43 @@
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import * as hostUtils from '@/utils/host';
|
||||
|
||||
export default (params) => {
|
||||
const {
|
||||
props,
|
||||
} = params || {};
|
||||
const cpuAndMemAndDisk = computed(() => {
|
||||
let cpuInfo;
|
||||
let memInfo;
|
||||
let distInfo;
|
||||
if (props.info?.Host?.CPU?.[0]) {
|
||||
cpuInfo = hostUtils.getCPUInfo(props.info.Host.CPU[0]);
|
||||
}
|
||||
if (props.info?.Host?.MemTotal) {
|
||||
memInfo = hostUtils.calcBinary(props.info.Host.MemTotal);
|
||||
}
|
||||
if (props.info?.Host?.DiskTotal) {
|
||||
distInfo = hostUtils.calcBinary(props.info.Host.DiskTotal);
|
||||
}
|
||||
const text = [];
|
||||
if (cpuInfo) {
|
||||
text.push(`${cpuInfo.cores}C`);
|
||||
}
|
||||
if (memInfo) {
|
||||
if (memInfo.m > 900) {
|
||||
text.push(`${Math.round(memInfo.g)}G`);
|
||||
} else {
|
||||
text.push(`${(memInfo.g).toFixed(1) * 1}G`);
|
||||
}
|
||||
}
|
||||
if (distInfo) {
|
||||
text.push(`${Math.ceil(distInfo.g)}G`);
|
||||
}
|
||||
return text.join('');
|
||||
});
|
||||
|
||||
return {
|
||||
cpuAndMemAndDisk,
|
||||
};
|
||||
};
|
||||
24
src/views/composable/server-monitor.js
Normal file
24
src/views/composable/server-monitor.js
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 计算数据的阈值和平均值
|
||||
*
|
||||
* @param {number[]} data - 要计算的数据数组
|
||||
* @param {number} [tolerance=2] - 容差倍数,默认值为2
|
||||
* @returns {{threshold: number, mean: number}} 返回包含阈值和平均值的对象
|
||||
* @property {number} threshold - 计算得到的阈值
|
||||
* @property {number} mean - 数据的平均值
|
||||
*/
|
||||
export function getThreshold(data, tolerance = 2) {
|
||||
const mean = data.reduce((sum, value) => sum + value, 0) / data.length;
|
||||
const variance = data.reduce((sum, value) => sum + (value - mean) ** 2, 0) / data.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
const threshold = tolerance * stdDev;
|
||||
const filteredData = data.filter((value) => value !== 0);
|
||||
const min = Math.min(...filteredData);
|
||||
const max = Math.max(...filteredData);
|
||||
return {
|
||||
threshold,
|
||||
mean,
|
||||
min,
|
||||
max,
|
||||
};
|
||||
}
|
||||
211
src/views/composable/server-real-time.js
Normal file
211
src/views/composable/server-real-time.js
Normal file
@ -0,0 +1,211 @@
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import validate from '@/utils/validate';
|
||||
import * as dateUtils from '@/utils/date';
|
||||
import * as hostUtils from '@/utils/host';
|
||||
|
||||
export default (params) => {
|
||||
const {
|
||||
props,
|
||||
currentTime,
|
||||
serverRealTimeListTpls = 'duration,transfer,inSpeed,outSpeed',
|
||||
} = params || {};
|
||||
if (!props?.info) {
|
||||
return {};
|
||||
}
|
||||
/**
|
||||
* 计算在线时长
|
||||
*/
|
||||
const duration = computed(() => {
|
||||
if (props.info?.Host?.BootTime) {
|
||||
const lastActive = dayjs(props.info.LastActive)?.valueOf?.();
|
||||
const data = dateUtils.duration2(props.info.Host.BootTime * 1000, lastActive || currentTime.value);
|
||||
if (data.days > 0) {
|
||||
return {
|
||||
value: data.days,
|
||||
symbol: data.$symbol.day,
|
||||
};
|
||||
}
|
||||
if (data.hours > 0) {
|
||||
return {
|
||||
value: data.hours,
|
||||
symbol: data.$symbol.hour,
|
||||
};
|
||||
}
|
||||
if (data.minutes > 0) {
|
||||
return {
|
||||
value: data.minutes,
|
||||
symbol: data.$symbol.minute,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: data.seconds,
|
||||
symbol: data.$symbol.second,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 计算流量
|
||||
*/
|
||||
const transfer = computed(() => {
|
||||
const stats = {
|
||||
in: null,
|
||||
out: null,
|
||||
total: null,
|
||||
};
|
||||
let total = 0;
|
||||
if (props.info?.State?.NetInTransfer) {
|
||||
total += props.info.State.NetInTransfer;
|
||||
stats.in = hostUtils.calcBinary(props.info.State.NetInTransfer);
|
||||
}
|
||||
if (props.info?.State?.NetOutTransfer) {
|
||||
total += props.info.State.NetOutTransfer;
|
||||
stats.out = hostUtils.calcBinary(props.info.State.NetOutTransfer);
|
||||
}
|
||||
stats.total = hostUtils.calcBinary(total);
|
||||
|
||||
const result = {
|
||||
value: 0,
|
||||
symbol: '',
|
||||
statType: '',
|
||||
statTypeLabel: '',
|
||||
stats,
|
||||
};
|
||||
|
||||
let ruleStat;
|
||||
ruleStat = total;
|
||||
result.statType = 'Total';
|
||||
result.statTypeLabel = '双向';
|
||||
if (props.info?.PublicNote && validate.isSet(props.info.PublicNote?.planDataMod?.trafficType)) {
|
||||
const {
|
||||
trafficType = 2,
|
||||
} = props.info.PublicNote.planDataMod;
|
||||
switch (+trafficType) {
|
||||
case 1:
|
||||
ruleStat = props.info.State.NetOutTransfer;
|
||||
result.statType = 'Out';
|
||||
result.statTypeLabel = '单向出';
|
||||
break;
|
||||
case 3:
|
||||
if (props.info?.State?.NetOutTransfer >= props.info?.State?.NetInTransfer) {
|
||||
ruleStat = props.info.State.NetOutTransfer;
|
||||
result.statType = 'MaxOut';
|
||||
result.statTypeLabel = '最大出';
|
||||
} else if (props.info?.State?.NetOutTransfer < props.info?.State?.NetInTransfer) {
|
||||
ruleStat = props.info.State.NetInTransfer;
|
||||
result.statType = 'MaxIn';
|
||||
result.statTypeLabel = '最大入';
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
const ruleStats = hostUtils.calcBinary(ruleStat);
|
||||
if (ruleStats.t > 1) {
|
||||
result.value = (ruleStats.t).toFixed(2) * 1;
|
||||
result.symbol = 'T';
|
||||
} else if (ruleStats.g > 1) {
|
||||
result.value = (ruleStats.g).toFixed(2) * 1;
|
||||
result.symbol = 'G';
|
||||
} else if (ruleStats.m > 1) {
|
||||
result.value = (ruleStats.m).toFixed(1) * 1;
|
||||
result.symbol = 'M';
|
||||
} else {
|
||||
result.value = (ruleStats.k).toFixed(1) * 1;
|
||||
result.symbol = 'K';
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* 计算入向网速
|
||||
*/
|
||||
const netInSpeed = computed(() => {
|
||||
const inSpeed = hostUtils.calcBinary(props.info?.State?.NetInSpeed || 0);
|
||||
const result = {
|
||||
value: 0,
|
||||
symbol: '',
|
||||
};
|
||||
if (inSpeed.g > 1) {
|
||||
result.value = (inSpeed.g).toFixed(1) * 1;
|
||||
result.symbol = 'G';
|
||||
} else if (inSpeed.m > 1) {
|
||||
result.value = (inSpeed.m).toFixed(1) * 1;
|
||||
result.symbol = 'M';
|
||||
} else {
|
||||
result.value = (inSpeed.k).toFixed(1) * 1;
|
||||
result.symbol = 'K';
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* 计算出向网速
|
||||
*/
|
||||
const netOutSpeed = computed(() => {
|
||||
const outSpeed = hostUtils.calcBinary(props.info?.State?.NetOutSpeed || 0);
|
||||
const result = {
|
||||
value: 0,
|
||||
symbol: '',
|
||||
};
|
||||
if (outSpeed.g > 1) {
|
||||
result.value = (outSpeed.g).toFixed(1) * 1;
|
||||
result.symbol = 'G';
|
||||
} else if (outSpeed.m > 1) {
|
||||
result.value = (outSpeed.m).toFixed(1) * 1;
|
||||
result.symbol = 'M';
|
||||
} else {
|
||||
result.value = (outSpeed.k).toFixed(1) * 1;
|
||||
result.symbol = 'K';
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const serverRealTimeList = computed(() => serverRealTimeListTpls.split(',').map((key) => {
|
||||
switch (key) {
|
||||
case 'duration':
|
||||
return {
|
||||
key,
|
||||
label: '在线',
|
||||
value: duration.value?.value,
|
||||
symbol: duration.value?.symbol,
|
||||
};
|
||||
case 'transfer':
|
||||
return {
|
||||
key,
|
||||
label: `${transfer.value.statTypeLabel}流量`,
|
||||
value: transfer.value?.value,
|
||||
symbol: transfer.value?.symbol,
|
||||
};
|
||||
case 'inSpeed':
|
||||
return {
|
||||
key,
|
||||
label: '入网',
|
||||
value: netInSpeed.value?.value,
|
||||
symbol: netInSpeed.value?.symbol,
|
||||
};
|
||||
case 'outSpeed':
|
||||
return {
|
||||
key,
|
||||
label: '出网',
|
||||
value: netOutSpeed.value?.value,
|
||||
symbol: netOutSpeed.value?.symbol,
|
||||
};
|
||||
default:
|
||||
}
|
||||
return null;
|
||||
}).filter((item) => item));
|
||||
|
||||
return {
|
||||
duration,
|
||||
transfer,
|
||||
netInSpeed,
|
||||
netOutSpeed,
|
||||
serverRealTimeList,
|
||||
};
|
||||
};
|
||||
162
src/views/composable/server-status.js
Normal file
162
src/views/composable/server-status.js
Normal file
@ -0,0 +1,162 @@
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import validate from '@/utils/validate';
|
||||
import * as hostUtils from '@/utils/host';
|
||||
|
||||
export default (params) => {
|
||||
const {
|
||||
props,
|
||||
statusListTpl = 'cpu,mem,disk',
|
||||
} = params || {};
|
||||
if (!props?.info) {
|
||||
return {};
|
||||
}
|
||||
const cpuInfo = computed(() => {
|
||||
if (props.info?.Host?.CPU) {
|
||||
return hostUtils.getCPUInfo(props.info.Host.CPU[0]);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const useMemAndTotalMem = computed(() => {
|
||||
const used = hostUtils.calcBinary(props.info?.State?.MemUsed || 0);
|
||||
const total = hostUtils.calcBinary(props.info?.Host?.MemTotal || 1);
|
||||
const usePercent = ((props.info?.State?.MemUsed / props.info?.Host?.MemTotal) * 100).toFixed(2) * 1 || 0;
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
usePercent,
|
||||
};
|
||||
});
|
||||
|
||||
const useSwapAndTotalSwap = computed(() => {
|
||||
if (!props.info?.Host?.SwapTotal || props.info?.Host?.SwapTotal === 0) {
|
||||
return null;
|
||||
}
|
||||
const used = hostUtils.calcBinary(props.info?.State?.SwapUsed || 0);
|
||||
const total = hostUtils.calcBinary(props.info?.Host?.SwapTotal || 1);
|
||||
const usePercent = ((props.info?.State?.SwapUsed / props.info?.Host?.SwapTotal) * 100).toFixed(2) * 1 || 0;
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
usePercent,
|
||||
};
|
||||
});
|
||||
|
||||
const useDiskAndTotalDisk = computed(() => {
|
||||
const used = hostUtils.calcBinary(props.info?.State?.DiskUsed || 0);
|
||||
const total = hostUtils.calcBinary(props.info?.Host?.DiskTotal || 1);
|
||||
const usePercent = ((props.info?.State?.DiskUsed / props.info?.Host?.DiskTotal) * 100).toFixed(2) * 1 || 0;
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
usePercent,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 状态列表
|
||||
*/
|
||||
const serverStatusList = computed(() => statusListTpl.split(',').map((i) => {
|
||||
switch (i) {
|
||||
case 'cpu':
|
||||
return {
|
||||
type: 'cpu',
|
||||
used: Math.max(props.info.State.CPU, 1),
|
||||
colors: {
|
||||
used: '#0088ff',
|
||||
total: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
valText: `${(props.info.State.CPU).toFixed(1) * 1}%`,
|
||||
label: 'CPU',
|
||||
content: {
|
||||
default: cpuInfo.value?.core,
|
||||
mobile: `${cpuInfo.value?.cores}C`,
|
||||
},
|
||||
};
|
||||
case 'mem':
|
||||
{
|
||||
let contentVal;
|
||||
if (useMemAndTotalMem.value.total.g > 4) {
|
||||
contentVal = `${(useMemAndTotalMem.value.total.g).toFixed(1) * 1}G`;
|
||||
} else {
|
||||
contentVal = `${Math.ceil(useMemAndTotalMem.value.total.m)}M`;
|
||||
}
|
||||
return {
|
||||
type: 'mem',
|
||||
used: Math.max(useMemAndTotalMem.value.usePercent, 1),
|
||||
colors: {
|
||||
used: '#0aa344',
|
||||
total: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
valText: `${Math.ceil(useMemAndTotalMem.value.used.m)}M`,
|
||||
label: '内存',
|
||||
content: {
|
||||
default: `运行内存${contentVal}`,
|
||||
mobile: `内存${contentVal}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'swap':
|
||||
{
|
||||
if (!useSwapAndTotalSwap.value) {
|
||||
return null;
|
||||
}
|
||||
let contentVal;
|
||||
if (useSwapAndTotalSwap.value.total.g > 4) {
|
||||
contentVal = `${(useSwapAndTotalSwap.value.total.g).toFixed(1) * 1}G`;
|
||||
} else {
|
||||
contentVal = `${Math.ceil(useSwapAndTotalSwap.value.total.m)}M`;
|
||||
}
|
||||
return {
|
||||
type: 'swap',
|
||||
used: Math.max(useSwapAndTotalSwap.value.usePercent, 1),
|
||||
colors: {
|
||||
used: '#ff8c00',
|
||||
total: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
valText: `${Math.ceil(useSwapAndTotalSwap.value.used.m)}M`,
|
||||
label: '交换',
|
||||
content: {
|
||||
default: `交换内存${contentVal}`,
|
||||
mobile: `交换${contentVal}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'disk':
|
||||
{
|
||||
let contentValue;
|
||||
if (useDiskAndTotalDisk.value.total.t >= 2) {
|
||||
contentValue = `${(useDiskAndTotalDisk.value.total.t).toFixed(1) * 1}T`;
|
||||
} else {
|
||||
contentValue = `${Math.ceil(useDiskAndTotalDisk.value.total.g)}G`;
|
||||
}
|
||||
return {
|
||||
type: 'disk',
|
||||
used: Math.max(useDiskAndTotalDisk.value.usePercent, 1),
|
||||
colors: {
|
||||
used: '#70f3ff',
|
||||
total: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
valText: `${(useDiskAndTotalDisk.value.used.g).toFixed(1) * 1}G`,
|
||||
label: '磁盘',
|
||||
content: {
|
||||
default: `磁盘容量${contentValue}`,
|
||||
mobile: `磁盘${contentValue}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
}
|
||||
return null;
|
||||
}).filter((i) => validate.isSet(i)));
|
||||
|
||||
return {
|
||||
cpuInfo,
|
||||
useMemAndTotalMem,
|
||||
useSwapAndTotalSwap,
|
||||
useDiskAndTotalDisk,
|
||||
serverStatusList,
|
||||
};
|
||||
};
|
||||
170
src/views/detail.vue
Normal file
170
src/views/detail.vue
Normal file
@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="info"
|
||||
class="detail-container"
|
||||
:class="{
|
||||
'server--offline': info?.online !== 1,
|
||||
}"
|
||||
>
|
||||
<world-map
|
||||
v-if="showWorldMap"
|
||||
:width="worldMapWidth"
|
||||
:locations="locations"
|
||||
/>
|
||||
<server-name
|
||||
:key="info.ID"
|
||||
:info="info"
|
||||
/>
|
||||
<server-status-box
|
||||
:key="info.ID"
|
||||
:info="info"
|
||||
/>
|
||||
<server-info-box
|
||||
:key="info.ID"
|
||||
:info="info"
|
||||
/>
|
||||
<server-monitor
|
||||
:key="info.ID"
|
||||
:info="info"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 单节点详情
|
||||
*/
|
||||
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
} from 'vuex';
|
||||
import {
|
||||
useRouter,
|
||||
} from 'vue-router';
|
||||
|
||||
import config from '@/config';
|
||||
import {
|
||||
alias2code,
|
||||
locationCode2Info,
|
||||
} from '@/utils/world-map-location';
|
||||
|
||||
import WorldMap from '@/components/world-map/world-map.vue';
|
||||
import ServerName from './components/server-detail/server-name.vue';
|
||||
import ServerStatusBox from './components/server-detail/server-status-box.vue';
|
||||
import ServerInfoBox from './components/server-detail/server-info-box.vue';
|
||||
import ServerMonitor from './components/server-detail/server-monitor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverId: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const worldMapWidth = ref(900);
|
||||
const info = computed(() => store.state?.serverList?.find?.((i) => +i.ID === +props.serverId));
|
||||
const dataInit = computed(() => store.state.init);
|
||||
|
||||
const locations = computed(() => {
|
||||
const arr = [];
|
||||
let aliasCode;
|
||||
let locationCode;
|
||||
if (info?.value?.PublicNote?.customData?.location) {
|
||||
aliasCode = info?.value?.PublicNote?.customData?.location;
|
||||
locationCode = info?.value?.PublicNote?.customData?.location;
|
||||
} else if (info?.value?.Host?.CountryCode) {
|
||||
aliasCode = info.value.Host.CountryCode.toUpperCase();
|
||||
}
|
||||
const code = alias2code(aliasCode) || locationCode;
|
||||
if (code) {
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
name,
|
||||
} = locationCode2Info(code) || {};
|
||||
arr.push({
|
||||
key: code,
|
||||
x,
|
||||
y,
|
||||
code,
|
||||
size: 4,
|
||||
label: `${name}`,
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
});
|
||||
|
||||
const showWorldMap = computed(() => {
|
||||
if (config.nazhua?.hideWorldMap) {
|
||||
return false;
|
||||
}
|
||||
if (config.nazhua?.hideDetailWorldMap) {
|
||||
return false;
|
||||
}
|
||||
if (info.value?.ID && locations.value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function handleWorldMapWidth() {
|
||||
worldMapWidth.value = Math.max(
|
||||
Math.min(
|
||||
document.querySelector('.detail-container')?.offsetWidth - 40,
|
||||
window.innerWidth - 40,
|
||||
900,
|
||||
),
|
||||
300, // 防止奇葩情况
|
||||
);
|
||||
}
|
||||
|
||||
watch(() => info.value, () => {
|
||||
if (info.value) {
|
||||
handleWorldMapWidth();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => dataInit.value, () => {
|
||||
if (dataInit.value && !info.value) {
|
||||
router.replace({
|
||||
name: 'Home',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (info.value) {
|
||||
handleWorldMapWidth();
|
||||
}
|
||||
window.addEventListener('resize', handleWorldMapWidth);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleWorldMapWidth);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: var(--detail-container-width);
|
||||
padding: 20px;
|
||||
margin: auto;
|
||||
|
||||
&.server--offline {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
305
src/views/home.vue
Normal file
305
src/views/home.vue
Normal file
@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div class="index-container">
|
||||
<div class="scroll-container">
|
||||
<div
|
||||
class="world-map-box"
|
||||
>
|
||||
<world-map
|
||||
v-if="showWorldMap"
|
||||
:locations="serverLocations || []"
|
||||
:width="worldMapWidth"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showFilter"
|
||||
class="fitler-group"
|
||||
>
|
||||
<div class="left-box">
|
||||
<server-option-box
|
||||
v-if="showTag && tagOptions.length"
|
||||
v-model="filterFormData.tag"
|
||||
:options="tagOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="right-box">
|
||||
<server-option-box
|
||||
v-if="onlineOptions.length"
|
||||
v-model="filterFormData.online"
|
||||
:options="onlineOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<transition-group
|
||||
name="list"
|
||||
tag="div"
|
||||
class="server-list-container"
|
||||
>
|
||||
<server-item
|
||||
v-for="item in filterServerList"
|
||||
:key="item.ID"
|
||||
:info="item"
|
||||
/>
|
||||
</transition-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 首页
|
||||
*/
|
||||
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
} from 'vuex';
|
||||
|
||||
import config from '@/config';
|
||||
import {
|
||||
alias2code,
|
||||
locationCode2Info,
|
||||
count2size,
|
||||
} from '@/utils/world-map-location';
|
||||
import uuid from '@/utils/uuid';
|
||||
|
||||
import WorldMap from '@/components/world-map/world-map.vue';
|
||||
import ServerOptionBox from './components/server-list/server-option-box.vue';
|
||||
import ServerItem from './components/server-list/server-list-item.vue';
|
||||
|
||||
const store = useStore();
|
||||
const worldMapWidth = ref();
|
||||
|
||||
const showFilter = config.nazhua.hideFilter !== true;
|
||||
const filterFormData = ref({
|
||||
tag: '',
|
||||
online: '',
|
||||
});
|
||||
// 是否显示标签
|
||||
const showTag = config.nazhua.hideTag !== true;
|
||||
|
||||
// 服务器列表
|
||||
const serverList = computed(() => store.state.serverList);
|
||||
// 服务器总数
|
||||
const serverCount = computed(() => store.state.serverCount);
|
||||
|
||||
/**
|
||||
* 解构数据
|
||||
*/
|
||||
const serverListData = computed(() => {
|
||||
const tagMap = {};
|
||||
serverList.value.forEach((i) => {
|
||||
if (i.Tag) {
|
||||
if (!tagMap[i.Tag]) {
|
||||
tagMap[i.Tag] = 0;
|
||||
}
|
||||
tagMap[i.Tag] += 1;
|
||||
}
|
||||
});
|
||||
const tags = [];
|
||||
Object.entries(tagMap).forEach(([tag, count]) => {
|
||||
tags.push({
|
||||
tag,
|
||||
count,
|
||||
});
|
||||
});
|
||||
return {
|
||||
tags,
|
||||
};
|
||||
});
|
||||
|
||||
const tagOptions = computed(() => (serverListData.value?.tags || []).map((i) => ({
|
||||
key: uuid(),
|
||||
label: i.tag,
|
||||
value: i.tag,
|
||||
})));
|
||||
|
||||
const onlineOptions = computed(() => {
|
||||
if (serverCount.value?.total !== serverCount.value?.online) {
|
||||
return [{
|
||||
key: 'online',
|
||||
label: '在线',
|
||||
value: '1',
|
||||
}, {
|
||||
key: 'offline',
|
||||
label: '离线',
|
||||
value: '-1',
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const filterServerList = computed(() => serverList.value.filter((i) => {
|
||||
const isFilterArr = [];
|
||||
if (filterFormData.value.tag) {
|
||||
isFilterArr.push(i.Tag === filterFormData.value.tag);
|
||||
}
|
||||
if (filterFormData.value.online) {
|
||||
isFilterArr.push(i.online === (filterFormData.value.online * 1));
|
||||
}
|
||||
return isFilterArr.length ? isFilterArr.every((o) => o) : true;
|
||||
}));
|
||||
|
||||
/**
|
||||
* 解构服务器列表的位置数据
|
||||
*/
|
||||
const serverLocations = computed(() => {
|
||||
const locationMap = {};
|
||||
filterServerList.value.forEach((i) => {
|
||||
if (i.online === -1) {
|
||||
return;
|
||||
}
|
||||
let aliasCode;
|
||||
let locationCode;
|
||||
if (i?.PublicNote?.customData?.location) {
|
||||
aliasCode = i.PublicNote.customData.location;
|
||||
locationCode = i.PublicNote.customData.location;
|
||||
} else if (i?.Host?.CountryCode) {
|
||||
aliasCode = i.Host.CountryCode.toUpperCase();
|
||||
}
|
||||
const code = alias2code(aliasCode) || locationCode;
|
||||
if (code) {
|
||||
if (!locationMap[code]) {
|
||||
locationMap[code] = 0;
|
||||
}
|
||||
locationMap[code] += 1;
|
||||
}
|
||||
});
|
||||
const locations = [];
|
||||
Object.entries(locationMap).forEach(([code, count]) => {
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
name,
|
||||
} = locationCode2Info(code) || {};
|
||||
if (x && y) {
|
||||
locations.push({
|
||||
key: code,
|
||||
x,
|
||||
y,
|
||||
code,
|
||||
size: count2size(count),
|
||||
label: `${name},${count}台`,
|
||||
});
|
||||
}
|
||||
});
|
||||
return locations;
|
||||
});
|
||||
|
||||
const showWorldMap = computed(() => {
|
||||
if (config.nazhua?.hideWorldMap) {
|
||||
return false;
|
||||
}
|
||||
if (config.nazhua?.hideHomeWorldMap) {
|
||||
return false;
|
||||
}
|
||||
if (serverList.value.length > 0 && serverLocations.value.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理窗口大小变化
|
||||
*/
|
||||
function handleResize() {
|
||||
worldMapWidth.value = document.querySelector('.server-list-container').clientWidth - 40;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.index-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
--list-padding: 20px;
|
||||
--list-gap-size: 20px;
|
||||
--list-item-num: 3;
|
||||
--list-item-width: calc(
|
||||
(
|
||||
var(--list-container-width)
|
||||
- (var(--list-padding) * 2)
|
||||
- (
|
||||
var(--list-gap-size)
|
||||
* (var(--list-item-num) - 1)
|
||||
)
|
||||
)
|
||||
/ var(--list-item-num)
|
||||
);
|
||||
|
||||
.scroll-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.world-map-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.server-list-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--list-gap-size);
|
||||
padding: 0 var(--list-padding);
|
||||
width: var(--list-container-width);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
// 针对1440px以下的屏幕
|
||||
@media screen and (max-width: 1440px) {
|
||||
--list-gap-size: 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
--list-item-num: 2;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 680px) {
|
||||
--list-item-num: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fitler-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 10px 20px;
|
||||
width: var(--list-container-width);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
53
src/ws/index.js
Normal file
53
src/ws/index.js
Normal file
@ -0,0 +1,53 @@
|
||||
import config from '@/config';
|
||||
import MessageSubscribe from '@/utils/subscribe';
|
||||
import WSService from './service';
|
||||
|
||||
const msg = new MessageSubscribe();
|
||||
const wsService = new WSService({
|
||||
wsUrl: config?.nazhua?.wsPath,
|
||||
onConnect: () => {
|
||||
msg.emit('connect');
|
||||
},
|
||||
onClose: () => {
|
||||
msg.emit('close');
|
||||
},
|
||||
onError: (error) => {
|
||||
msg.emit('error', error);
|
||||
},
|
||||
onMessage: (data) => {
|
||||
if (data?.now) {
|
||||
msg.emit('servers', data?.servers);
|
||||
} else {
|
||||
msg.emit('message', data);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function restart() {
|
||||
if (wsService.connected !== 0) {
|
||||
wsService.close();
|
||||
}
|
||||
wsService.active();
|
||||
}
|
||||
|
||||
export {
|
||||
wsService,
|
||||
msg,
|
||||
restart,
|
||||
};
|
||||
|
||||
export default (actived) => {
|
||||
// console.log('wsService active');
|
||||
if (wsService.connected === 1) {
|
||||
if (actived) {
|
||||
actived();
|
||||
}
|
||||
return;
|
||||
}
|
||||
wsService.active();
|
||||
msg.once('connect', () => {
|
||||
if (actived) {
|
||||
actived();
|
||||
}
|
||||
});
|
||||
};
|
||||
82
src/ws/service.js
Normal file
82
src/ws/service.js
Normal file
@ -0,0 +1,82 @@
|
||||
class WSService {
|
||||
constructor(options) {
|
||||
const {
|
||||
wsUrl,
|
||||
onConnect,
|
||||
onClose,
|
||||
onError,
|
||||
onMessage,
|
||||
onMessageError,
|
||||
} = options || {};
|
||||
|
||||
this.debug = options?.debug || false;
|
||||
|
||||
this.$wsUrl = wsUrl?.replace?.('http', 'ws');
|
||||
this.$on = {
|
||||
close: onClose || (() => {}),
|
||||
error: onError || (() => {}),
|
||||
connect: onConnect || (() => {}),
|
||||
message: onMessage || (() => {}),
|
||||
messageError: onMessageError || (() => {}),
|
||||
};
|
||||
// 0: 未连接,1: 已连接,-1: 已关闭
|
||||
this.connected = 0;
|
||||
this.ws = undefined;
|
||||
this.evt = (event) => {
|
||||
if (this.debug) {
|
||||
console.log('Message from server ', event.data);
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.$on.message(data, event);
|
||||
} catch (error) {
|
||||
console.error('socket message error', error);
|
||||
if (this.debug) {
|
||||
console.log('message', event.data);
|
||||
}
|
||||
this.$on.messageError(error, event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.connected === 1;
|
||||
}
|
||||
|
||||
active() {
|
||||
if (this.connected === 1) {
|
||||
throw new Error('已创建连接,请勿重复创建');
|
||||
}
|
||||
// 创建 WebSocket 连接
|
||||
this.ws = new WebSocket(this.$wsUrl);
|
||||
this.ws.addEventListener('open', (event) => {
|
||||
if (this.debug) {
|
||||
console.log('socket connected', event);
|
||||
}
|
||||
this.connected = 1;
|
||||
this.$on.connect(event);
|
||||
});
|
||||
this.ws.addEventListener('close', (event) => {
|
||||
if (this.debug) {
|
||||
console.log('socket closed', event);
|
||||
}
|
||||
this.connected = -1;
|
||||
this.$on.close(event);
|
||||
});
|
||||
this.ws.addEventListener('message', this.evt);
|
||||
this.ws.addEventListener('error', (event) => {
|
||||
console.error('ai-live-websocket error', event);
|
||||
this.$on.error(event);
|
||||
});
|
||||
}
|
||||
|
||||
send(data) {
|
||||
this?.ws?.send?.(JSON.stringify(data));
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws?.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
export default WSService;
|
||||
66
vite.config.js
Normal file
66
vite.config.js
Normal file
@ -0,0 +1,66 @@
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import babel from 'vite-plugin-babel';
|
||||
import eslintPlugin from 'vite-plugin-eslint';
|
||||
|
||||
dotenv.config({
|
||||
path: '.env.development.local',
|
||||
});
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
hmr: {
|
||||
overlay: false,
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.API_HOST,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: process.env.WS_HOST,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/nezha/': {
|
||||
target: process.env.NEZHA_HOST,
|
||||
changeOrigin: true,
|
||||
rewrite: (e) => (process.env.NEZHA_HOST_REPACE_PATH ? e.replace(/^\/nezha/, '') : e),
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: 'modern-compiler',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
babel({
|
||||
babelConfig: {
|
||||
babelrc: false,
|
||||
configFile: false,
|
||||
plugins: [
|
||||
'@babel/plugin-proposal-optional-chaining',
|
||||
'@babel/plugin-proposal-nullish-coalescing-operator',
|
||||
],
|
||||
},
|
||||
}),
|
||||
eslintPlugin({
|
||||
include: ['src/**/*.js', 'src/**/*.vue', 'src/*.js', 'src/*.vue'],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src/'),
|
||||
'~@': path.resolve(__dirname, './src/'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user