mirror of
https://github.com/hi2shark/nazhua.git
synced 2026-01-12 07:10:43 +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