This commit is contained in:
hi2hi 2024-12-03 15:14:56 +00:00
commit e2e186329e
66 changed files with 15168 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_VERSION=0.3.0

3
.eslintignore Normal file
View File

@ -0,0 +1,3 @@
build/*.js
public
dist

98
.eslintrc.cjs Normal file
View 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
View 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
View 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
View 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>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 B

0
public/style.css Normal file
View File

104
readme.md Normal file
View 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
View 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>

Binary file not shown.

Binary file not shown.

BIN
src/assets/images/bg.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 240 KiB

164
src/assets/scss/base.scss Normal file
View 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;
}

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

View 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,
}],
});

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

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

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

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

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

@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script>
export default {
name: 'LayoutBox',
};
</script>

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

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

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

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

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

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

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

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

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

View 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(Macwindows)
@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>

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

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

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

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

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

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

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

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

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

2976
yarn.lock Normal file

File diff suppressed because it is too large Load Diff