mirror of
https://github.com/hi2shark/nazhua.git
synced 2026-01-12 23:30:42 +08:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c347cc0eb | ||
|
|
881c9a05e5 | ||
|
|
586f1dd063 | ||
|
|
93f66cb42c | ||
|
|
0b9da8fe01 | ||
|
|
90884c2730 | ||
|
|
69ab11babc | ||
|
|
33f1625ab1 | ||
|
|
015ce40586 | ||
|
|
da35150a8d | ||
|
|
cf1e7cf9fd | ||
|
|
c8fddc4803 | ||
|
|
ec3b5cb5ea | ||
|
|
640fd787a2 | ||
|
|
2d331823b9 | ||
|
|
0a33582541 | ||
|
|
9aaa5b0cc3 | ||
|
|
eed7be4b1b | ||
|
|
1b20505ef2 | ||
|
|
51197a1c05 | ||
|
|
a0e066c04f | ||
|
|
513a88d37e | ||
|
|
203b86e0e6 | ||
|
|
230bbcf7f0 | ||
|
|
2708263440 | ||
|
|
8dc1aa0154 | ||
|
|
53cd629119 | ||
|
|
3bb971479f | ||
|
|
36d5a6ce66 | ||
|
|
a24401a87c | ||
|
|
8de81431ca | ||
|
|
280387fba5 | ||
|
|
d9ef0b9b84 | ||
|
|
b16fd1ac15 | ||
|
|
d99225eca7 | ||
|
|
d898b58c15 | ||
|
|
59be8baee3 | ||
|
|
d7d4112e70 | ||
|
|
bc50b78135 | ||
|
|
48d6e5c36a | ||
|
|
842cc7d2f8 | ||
|
|
31e9f61384 | ||
|
|
d11deece54 | ||
|
|
39fc6b2497 | ||
|
|
6d3139094f | ||
|
|
873d6a5f28 | ||
|
|
3c4d7b71c2 | ||
|
|
14c83386e7 | ||
|
|
2344200815 | ||
|
|
13d66010df | ||
|
|
f446221f45 | ||
|
|
068c7e09e9 | ||
|
|
9f2c90c5fa | ||
|
|
84dc786b28 | ||
|
|
fbc3937b84 | ||
|
|
ad1b53786e | ||
|
|
3d6e25d352 | ||
|
|
17e548abb1 | ||
|
|
b6d8457649 | ||
|
|
bc5db4b5b1 | ||
|
|
0fea4ee186 | ||
|
|
dbdd1d36ee | ||
|
|
73387bdb79 | ||
|
|
2f1ca9cb0f | ||
|
|
0d43597346 | ||
|
|
25bcbbadc8 | ||
|
|
8d7c815461 | ||
|
|
25c68fa64e |
BIN
.github/images/nazhua-detail-mobile.webp
vendored
Normal file
BIN
.github/images/nazhua-detail-mobile.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
.github/images/nazhua-main.webp
vendored
Normal file
BIN
.github/images/nazhua-main.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
BIN
.github/images/nazhua-mobile.webp
vendored
Normal file
BIN
.github/images/nazhua-mobile.webp
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
.github/images/remixicon-select.jpg
vendored
Normal file
BIN
.github/images/remixicon-select.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
.github/images/vmiss-logo.jpg
vendored
Normal file
BIN
.github/images/vmiss-logo.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
.github/images/yxvm-logo.jpg
vendored
Normal file
BIN
.github/images/yxvm-logo.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@ -1,4 +1,4 @@
|
||||
FROM nginx:1.27.3
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY ./dist /home/wwwroot/html
|
||||
COPY ./nginx-default.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
199
doc/deploy.md
Normal file
199
doc/deploy.md
Normal file
@ -0,0 +1,199 @@
|
||||
# 🚀 部署指南
|
||||
|
||||
## 部署概述
|
||||
> Nazhua主题是纯前端项目,可部署在静态服务器上
|
||||
>
|
||||
> **跨域解决注重点**:
|
||||
> - **V0版本**:需解决 `/api/v1/monitor/${id}`、`/ws` 和 `/` 的跨域
|
||||
> - **V1版本**:需解决 `/api/xxx` 和 `/api/v1/ws/server` 的跨域
|
||||
>
|
||||
> 推荐使用 Nginx 或 Caddy 反向代理解决跨域问题
|
||||
|
||||
## 🐳 Docker Compose + Cloudflare Tunnels 部署
|
||||
此方案便于后续更新,只需通过 `docker compose pull` 命令即可更新主题(镜像)。
|
||||
|
||||
### 配置说明
|
||||
- **favicon.ico**:可通过挂载或配置文件指定(默认无)
|
||||
- **config.js**:需单独挂载,建议使用[配置生成器](https://hi2shark.github.io/nazhua-generator/)生成
|
||||
- **style.css**:用于自定义CSS样式,尽量保持选择器稳定
|
||||
|
||||
### 部署示例
|
||||
```yaml
|
||||
services:
|
||||
nazhua:
|
||||
image: ghcr.io/hi2shark/nazhua:latest
|
||||
container_name: nazhua
|
||||
ports:
|
||||
- 80:80
|
||||
# volumes:
|
||||
# - ./favicon.ico:/home/wwwroot/html/favicon.ico:ro # 自定义favicon图标
|
||||
# - ./config.js:/home/wwwroot/html/config.js:ro # 自定义配置文件
|
||||
# - ./style.css:/home/wwwroot/html/style.css:ro # 自定义样式文件
|
||||
environment:
|
||||
- DOMAIN=_ # 监听的域名,默认为_(监听所有)
|
||||
- NEZHA=http://nezha-dashboard.example.com/ # 可以被反向代理nezha主页地址
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### 💡 小贴士
|
||||
- 推荐使用 docker-compose 部署 Nazhua 与 Nezha Dashboard,并通过 Cloudflare Tunnels 对外提供服务
|
||||
- 如需减少内置库体积,可使用 CDN 版本镜像:`ghcr.io/hi2shark/nazhua:cdn`
|
||||
- 隐藏原面板方案:使用 Zero Trust Tunnels 部署三个容器 (Tunnels、nezha-dashboard、nazhua)
|
||||
- nazhua 通过 docker 内部地址访问 nezha-dashboard
|
||||
- Tunnels 绑定 nazhua 到公开域名
|
||||
- Tunnels 绑定 nezha-dashboard 到需要邮箱/IP验证的私密域名
|
||||
|
||||
## 🌐 自定义Web服务部署
|
||||
|
||||
### 安装步骤
|
||||
1. 在 [Releases页面](https://github.com/hi2shark/nazhua/releases) 下载最新版 `v{Nazhua版本号}-all.zip`
|
||||
2. 解压后将 `dist` 目录文件上传到Web服务目录
|
||||
|
||||
### Nginx配置示例
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name nazhua.example.com;
|
||||
client_max_body_size 1024m;
|
||||
|
||||
# 哪吒V0的WebSocket服务
|
||||
location /ws {
|
||||
proxy_pass ${NEZHA}ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $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;
|
||||
}
|
||||
|
||||
# 哪吒V1的WebSocket服务
|
||||
location /api/v1/ws/server {
|
||||
proxy_pass ${NEZHA}api/v1/ws/server;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $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 / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
root /home/wwwroot/html;
|
||||
}
|
||||
}
|
||||
```
|
||||
----
|
||||
**Tips:** V0环境下若想与面板使用同域名,下载 `v0-nazhua.zip` 并将文件上传至面板目录下的 `nazhua` 文件夹
|
||||
|
||||
----
|
||||
|
||||
## ⚙️ 配置文件
|
||||
|
||||
### config.js 配置说明
|
||||
建议使用 [Nazhua 配置生成器](https://hi2shark.github.io/nazhua-generator/) 生成配置文件。
|
||||
|
||||
```javascript
|
||||
window.$$nazhuaConfig = {
|
||||
title: '哪吒监控', // 网站标题
|
||||
footerSlogan: '不要年付!不要年付!不要年付!<span style="color: #f00;">欢迎访问Nazhua探针</span>', // 底部标语,支持html渲染
|
||||
freeAmount: '白嫖', // 免费服务的费用名称
|
||||
infinityCycle: '长期有效', // 无限周期名称
|
||||
buyBtnText: '购买', // 购买按钮文案
|
||||
buyBtnIcon: '', // 购买按钮图标,取自remixicon
|
||||
customBackgroundImage: '', // 自定义的背景图片地址
|
||||
lightBackground: true, // 启用了浅色系背景图,会强制关闭点点背景
|
||||
showFireworks: true, // 是否显示烟花,建议开启浅色系背景
|
||||
showLantern: true, // 是否显示灯笼
|
||||
enableInnerSearch: true, // 启用内部搜索
|
||||
listServerItemTypeToggle: true, // 服务器列表项类型切换
|
||||
listServerItemType: 'row', // 服务器列表项类型 card/row row列表模式移动端自动切换至card
|
||||
listServerStatusType: 'progress', // 服务器状态类型--列表
|
||||
listServerRealTimeShowLoad: true, // 列表显示服务器实时负载
|
||||
detailServerStatusType: 'progress', // 服务器状态类型--详情页
|
||||
simpleColorMode: true, // 服务器状态纯色显示
|
||||
serverStatusLinear: true, // 服务器状态渐变线性显示 - 与pureColorMode互斥
|
||||
disableSarasaTermSC: true, // 禁用Sarasa Term SC字体
|
||||
hideWorldMap: false, // 隐藏地图
|
||||
hideHomeWorldMap: false, // 隐藏首页地图
|
||||
hideDetailWorldMap: false, // 隐藏详情地图
|
||||
homeWorldMapPosition: 'top', // 首页地图位置 top/bottom
|
||||
detailWorldMapPosition: 'top', // 详情页地图位置 top/bottom
|
||||
hideNavbarServerCount: false, // 隐藏服务器数量
|
||||
hideNavbarServerStat: false, // 隐藏服务器统计
|
||||
hideListItemStatusDonut: false, // 隐藏列表项的饼图
|
||||
hideListItemStat: false, // 隐藏列表项的统计信息
|
||||
hideListItemBill: false, // 隐藏列表项的账单信息
|
||||
hideListItemLink: true, // 隐藏列表项的购买链接
|
||||
hideFilter: false, // 隐藏筛选
|
||||
hideTag: false, // 隐藏标签
|
||||
hideDotBG: true, // 隐藏框框里面的点点背景
|
||||
monitorRefreshTime: 10, // 监控刷新时间间隔,单位s(秒), 0为不刷新,为保证不频繁请求源站,最低生效值为10s
|
||||
monitorChartType: 'multi', // 监控图表类型 single/multi
|
||||
monitorChartTypeToggle: true, // 监控图表类型切换
|
||||
filterGPUKeywords: ['Virtual Display'], // 如果GPU名称中包含这些关键字,则过滤掉
|
||||
customCodeMap: {}, // 自定义的地图点信息
|
||||
nezhaVersion: 'v1', // 哪吒版本 不填写则尝试自动识别
|
||||
apiMonitorPath: '/api/v1/monitor/{id}',
|
||||
wsPath: '/ws',
|
||||
nezhaPath: '/nezha/',
|
||||
nezhaV0ConfigType: 'servers', // 哪吒v0数据读取类型
|
||||
v1ApiMonitorPath: '/api/v1/service/{id}',
|
||||
v1WsPath: '/api/v1/ws/server',
|
||||
v1ApiGroupPath: '/api/v1/server-group',
|
||||
v1ApiSettingPath: '/api/v1/setting',
|
||||
v1ApiProfilePath: '/api/v1/profile',
|
||||
v1DashboardUrl: '/dashboard', // v1版本控制台地址
|
||||
v1HideNezhaDashboardBtn: true, // v1版本导航栏控制台入口/登录按钮 在nezhaVersion为v1时有效
|
||||
routeMode: 'h5', // 路由模式
|
||||
customFavicon: '', // 自定义favicon, 填写完整的url地址
|
||||
};
|
||||
```
|
||||
|
||||
### 🎨 自定义样式
|
||||
通过修改根目录下的 `style.css` 文件实现样式定制:
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 修改颜色 */
|
||||
/* 地图上标记点的颜色 */
|
||||
--world-map-point-color: #fff;
|
||||
/* 列表项显示的价格颜色 */
|
||||
--list-item-price-color: #ff6;
|
||||
/* 购买链接的主要颜色 */
|
||||
--list-item-buy-link-color: #f00;
|
||||
}
|
||||
|
||||
/* 自定义背景图示例 */
|
||||
:root {
|
||||
/* 图片太亮时,增加背景遮罩透明度 */
|
||||
--layout-main-bg-color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
.layout-group .layout-bg {
|
||||
/* 添加!important强制背景图替换 */
|
||||
background: url(./bg.jpg) no-repeat 50% 50% !important;
|
||||
background-size: cover;
|
||||
}
|
||||
```
|
||||
120
doc/public-note.md
Normal file
120
doc/public-note.md
Normal file
@ -0,0 +1,120 @@
|
||||
# 📝 公开备注配置指南
|
||||
|
||||
[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/#/?tab=publicNote)已添加公开备注编辑器,方便大家配置公开备注
|
||||
|
||||
## 🗺️ 点阵地图节点显示
|
||||
|
||||
### 地图说明
|
||||
Nazhua采用的点阵地图是一个并非精准的变形地图,不能使用真实经纬度坐标进行换算定位,因此需要通过自定义坐标来指定位置。
|
||||
|
||||
### 配置方法
|
||||
使用[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/)获取内置的点阵地图坐标或者自定义坐标(可以在`config.js`中配置`customCodeMap`添加自定义地图点)
|
||||
在节点的公开备注对象中设置位置代码:
|
||||
```json
|
||||
{
|
||||
"customData": {
|
||||
"location": "HKG" // 位置代码
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 默认位置映射
|
||||
部分常见地区已有默认映射:
|
||||
- 中国大陆默认显示在北京(v0.4.6后添加)
|
||||
- 美国默认显示在洛杉矶
|
||||
|
||||
## 🔧 customData 字段详解
|
||||
|
||||
### 可用字段
|
||||
| 字段 | 用途 | 版本支持 |
|
||||
|------|------|---------|
|
||||
| `location` | 指定节点地理位置代码 | 全版本 |
|
||||
| `slogan` | 显示节点标语 | 全版本 |
|
||||
| `orderLink` | 购买链接地址 | 全版本 |
|
||||
| `flag` | 自定义国家/地区旗帜 | v0.6.4+ |
|
||||
| `buyBtnText` | 购买按钮文案 | v0.5.3+ |
|
||||
| `buyBtnIcon` | 购买按钮图标 | v0.5.3+ |
|
||||
|
||||
### 示例配置
|
||||
```json
|
||||
{
|
||||
"customData": {
|
||||
"location": "HKG",
|
||||
"slogan": "这是一个香港节点",
|
||||
"orderLink": "https://buy.example.com",
|
||||
"buyBtnText": "官网",
|
||||
"buyBtnIcon": "ri-gift-2-line",
|
||||
"flag": "cn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 💡 链接编码提示
|
||||
由于配置数据无法正常解析符号`&`,请使用URL编码:
|
||||
- 在线工具:[https://www.bejson.com/enc/urlencode/](https://www.bejson.com/enc/urlencode/)
|
||||
- 浏览器控制台:执行`encodeURIComponent('链接内容')`获取编码后内容
|
||||
|
||||
## 📊 原版公开备注支持
|
||||
在哪吒的主题ServerStatus迭代中,nap0o增加了一个公开备注的功能,可以给节点添加额外的展示信息
|
||||
具体字段定义参考 [https://github.com/nezhahq/nezha/pull/425](https://github.com/nezhahq/nezha/pull/425)
|
||||
Nazhua支持原版ServerStatus主题的公开备注字段,支持的字段如下:
|
||||
|
||||
### 账单信息 (billingDataMod)
|
||||
```json
|
||||
{
|
||||
"billingDataMod": {
|
||||
"startDate": "2024-10-01T00:00:00+08:00",
|
||||
"endDate": "2024-11-01T00:00:00+08:00",
|
||||
"autoRenewal": "1",
|
||||
"cycle": "月",
|
||||
"amount": "$3.99"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置信息 (planDataMod)
|
||||
```json
|
||||
{
|
||||
"planDataMod": {
|
||||
"bandwidth": "30Mbps",
|
||||
"trafficVol": "1TB/月",
|
||||
"trafficType": "1",
|
||||
"IPv4": "1",
|
||||
"IPv6": "1",
|
||||
"networkRoute": "CN2,GIA",
|
||||
"extra": "传家宝,AS9929"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 完整公开备注示例
|
||||
|
||||
```json
|
||||
{
|
||||
"billingDataMod": {
|
||||
"startDate": "2024-10-01",
|
||||
"endDate": "2024-11-01",
|
||||
"autoRenewal": "1",
|
||||
"cycle": "月",
|
||||
"amount": "$3.99"
|
||||
},
|
||||
"planDataMod": {
|
||||
"bandwidth": "30Mbps",
|
||||
"trafficVol": "1TB/月",
|
||||
"trafficType": "1",
|
||||
"IPv4": "1",
|
||||
"IPv6": "1",
|
||||
"networkRoute": "CN2,GIA",
|
||||
"extra": "传家宝,AS9929"
|
||||
},
|
||||
"customData": {
|
||||
"location": "HKG",
|
||||
"slogan": "这是一个香港节点",
|
||||
"orderLink": "https://buy.example.com",
|
||||
"buyBtnText": "官网",
|
||||
"buyBtnIcon": "ri-gift-2-line",
|
||||
"flag": "cn"
|
||||
}
|
||||
}
|
||||
```
|
||||
[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/#/?tab=publicNote)已添加公开备注编辑器,方便大家配置公开备注
|
||||
30
doc/update.md
Normal file
30
doc/update.md
Normal file
@ -0,0 +1,30 @@
|
||||
# 📝 更新日志
|
||||
|
||||
> 此处仅记录功能性更新,Bug修复不在此记录
|
||||
|
||||
## 📦 v0.6.4 更新
|
||||
- ✨ **新增**: 网络监控折线图拆分单一图表功能
|
||||
- 🌍 **新增**: 公开备注中支持自定义国家/地区旗帜 (`flag` 字段)
|
||||
- 🔄 **新增**: 支持地图在首页与详情页的上下位置切换
|
||||
|
||||
## 📦 v0.5.7 更新
|
||||
- 🖼️ **新增**: 自定义favicon支持
|
||||
|
||||
## 📦 v0.5.4 更新
|
||||
- 🔍 **新增**: 内置搜索功能,支持 `Ctrl+K` 快速打开搜索
|
||||
|
||||
## 📦 v0.5.3 更新
|
||||
- 🛒 **新增**: 支持单独设置服务器购买按钮的文案和图标
|
||||
|
||||
### 使用方法
|
||||
- `buyBtnText`: 设置购买按钮文案
|
||||
- `buyBtnIcon`: 设置购买按钮图标,支持Remixicon图标
|
||||
|
||||
### 图标配置示例
|
||||
1. 访问 [Remixicon官网](https://www.remixicon.com/)
|
||||
2. 选择并复制图标名称
|
||||
3. 在 `buyBtnIcon` 字段中填写,补齐 `ri-` 前缀
|
||||
|
||||

|
||||
|
||||
> 当前支持版本: Remixicon 4.6.0(cdn版本,受限于更新原因,支持到4.3.0)
|
||||
5187
package-lock.json
generated
Normal file
5187
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nazhua",
|
||||
"version": "0.5.2",
|
||||
"version": "0.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@ -8,15 +8,16 @@
|
||||
"build:cdn": "cross-env VITE_SARASA_TERM_SC_USE_CDN=1 VITE_USE_CDN=1 vite build",
|
||||
"build:nazhua": "cross-env VITE_BASE_PATH=/nazhua/ VITE_NEZHA_VERSION=v0 VITE_SARASA_TERM_SC_USE_CDN=1 VITE_USE_CDN=1 vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.13.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"echarts": "^5.5.1",
|
||||
"flag-icons": "^7.2.3",
|
||||
"font-logos": "^1.3.0",
|
||||
"remixicon": "^4.5.0",
|
||||
"remixicon": "^4.7.0",
|
||||
"uniqolor": "^1.1.1",
|
||||
"vue": "^3.5.12",
|
||||
"vue-echarts": "^7.0.3",
|
||||
@ -24,19 +25,19 @@
|
||||
"vuex": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.9",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@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",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/eslint-config-airbnb": "^7.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-plugin-vue": "^9.9.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"sass": "^1.81.0",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-babel": "^1.2.0",
|
||||
"vite": "^6.4.1",
|
||||
"vite-plugin-babel": "^1.3.2",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-svg-loader": "^5.1.0"
|
||||
},
|
||||
|
||||
@ -4,29 +4,39 @@ window.$$nazhuaConfig = {
|
||||
// freeAmount: '白嫖', // 免费服务的费用名称
|
||||
// infinityCycle: '长期有效', // 无限周期名称
|
||||
// buyBtnText: '购买', // 购买按钮文案
|
||||
// buyBtnIcon: '', // 购买按钮图标,取自remixicon
|
||||
// customBackgroundImage: '', // 自定义的背景图片地址
|
||||
// lightBackground: true, // 启用了浅色系背景图,会强制关闭点点背景
|
||||
// showFireworks: true, // 是否显示烟花,建议开启浅色系背景
|
||||
// showLantern: true, // 是否显示灯笼
|
||||
enableInnerSearch: true, // 启用内部搜索
|
||||
// listServerItemTypeToggle: true, // 服务器列表项类型切换
|
||||
// listServerItemType: 'row', // 服务器列表项类型 card/row row列表模式移动端自动切换至card
|
||||
listServerItemType: 'card', // 服务器列表项类型 card/row/server-status row列表模式移动端自动切换至card
|
||||
// serverStatusColumnsTpl: null, // 服务器状态列配置模板
|
||||
// listServerStatusType: 'progress', // 服务器状态类型--列表
|
||||
// listServerRealTimeShowLoad: true, // 列表显示服务器实时负载
|
||||
// detailServerStatusType: 'progress', // 服务器状态类型--详情页
|
||||
// serverStatusLinear: true, // 服务器状态渐变线性显示
|
||||
// simpleColorMode: true, // 服务器状态纯色显示
|
||||
serverStatusLinear: true, // 服务器状态渐变线性显示 - 与pureColorMode互斥
|
||||
// disableSarasaTermSC: true, // 禁用Sarasa Term SC字体
|
||||
// hideWorldMap: false, // 隐藏地图
|
||||
// hideHomeWorldMap: false, // 隐藏首页地图
|
||||
// hideDetailWorldMap: false, // 隐藏详情地图
|
||||
// homeWorldMapPosition: 'top', // 首页地图位置 top/bottom
|
||||
// detailWorldMapPosition: 'top', // 详情页地图位置 top/bottom
|
||||
// hideNavbarServerCount: false, // 隐藏服务器数量
|
||||
// hideNavbarServerStat: false, // 隐藏服务器统计
|
||||
// hideListItemStatusDonut: false, // 隐藏列表项的饼图
|
||||
// hideListItemStat: false, // 隐藏列表项的统计信息
|
||||
// hideListItemBill: false, // 隐藏列表项的账单信息
|
||||
hideListItemLink: true, // 隐藏列表项的购买链接
|
||||
// hideFilter: false, // 隐藏筛选
|
||||
// hideSort: false, // 隐藏排序
|
||||
// hideTag: false, // 隐藏标签
|
||||
// hideDotBG: true, // 隐藏框框里面的点点背景
|
||||
// monitorRefreshTime: 10, // 监控刷新时间间隔,单位s(秒), 0为不刷新,为保证不频繁请求源站,最低生效值为10s
|
||||
monitorChartType: 'multi', // 监控图表类型 single/multi
|
||||
monitorChartTypeToggle: true, // 监控图表类型切换
|
||||
// filterGPUKeywords: ['Virtual Display'], // 如果GPU名称中包含这些关键字,则过滤掉
|
||||
// customCodeMap: {}, // 自定义的地图点信息
|
||||
// nezhaVersion: 'v1', // 哪吒版本
|
||||
@ -42,4 +52,5 @@ window.$$nazhuaConfig = {
|
||||
// v1DashboardUrl: '/dashboard', // v1版本控制台地址
|
||||
// v1HideNezhaDashboardBtn: true, // v1版本导航栏控制台入口/登录按钮 在nezhaVersion为v1时有效
|
||||
// routeMode: 'h5', // 路由模式
|
||||
// customFavicon: '', // 自定义favicon, 填写完整的url地址
|
||||
};
|
||||
|
||||
299
readme.md
299
readme.md
@ -1,278 +1,74 @@
|
||||
# Nazhua
|
||||
**使用前,请务必阅读Readme的内容,对你有帮助**
|
||||
基于哪吒监控(nezha.wiki)v0版本构建的前端主题,目前兼容与v0相同数据结构的v1版本。
|
||||
~~主题有点**重**,因为内置了一个带中文的`SarasaTermSC-SemiBold`字体。~~
|
||||
~~根据不同场景,可以选择是否打包带入或者是否加载这个字体。~~
|
||||
考虑到多数国内直连用户无法访问jsdelivr,所以默认使用cdnjs的loli.net引用版本。
|
||||
同时默认关闭SarasaTermSC字体,如果需要使用,请使用Docker镜像全量包。
|
||||
|
||||
## 劝退指南 用前必读
|
||||
1. 本主题是基于哪吒监控v0版本构建的,~~不确定能否完美v1版本~~。*v0.4.3的版本已适配*
|
||||
2. 本主题是一个纯前端项目,需要解决跨域问题,通常需要一个nginx或者caddy反代请求解决跨域问题。
|
||||
3. 我不会提供任何技术支持,如果你有问题,可以提issue,但是我不保证会回答,可能询问GPT会更快。
|
||||
<div>
|
||||
<img src="./.github/images/nazhua-main.webp" style="max-height: 500px;" alt="Nazhua桌面版"/>
|
||||
<img src="./.github/images/nazhua-mobile.webp" style="max-height: 500px;" alt="Nazhua移动版"/>
|
||||
<img src="./.github/images/nazhua-detail-mobile.webp" style="max-height: 500px;" alt="Nazhua详情页"/>
|
||||
</div>
|
||||
|
||||
## V0/V1的使用区别
|
||||
### Docker版本的nazhua
|
||||
使用V1必须在`config.js`中指定`nezhaVersion`的版本为`v1`,**大小写敏感*
|
||||
默认的数据是基于V0
|
||||
### Release版本的nazhua
|
||||
V1下载最新版本[Releases](https://github.com/hi2shark/nazhua/releases)的`dist.zip`;
|
||||
V0下载最新版本[Releases](https://github.com/hi2shark/nazhua/releases)的`v0-dist.zip`;
|
||||
`v{版本}-all.zip`是包含字体的全量包。
|
||||
`v{版本}-cdn-{CDN供应方}.zip`是公共资源使用CDN引用的版本。
|
||||
## 📢 使用须知
|
||||
|
||||
## 关于点阵地图
|
||||
点阵地图是一个失真的地图,地图边际与城市位置都不是真实的经纬度坐标,因此无法通过经纬度来定位城市。
|
||||
需要在是[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/)中,拾取点阵地图上的坐标,然后在`config.js`中配置`customCodeMap`来自定义地图点信息。
|
||||
如何指定节点的地理位置?
|
||||
在哪吒监控后台,给节点的公开备注对象中,添加一个`customData`对象,并指定`location`的代码;
|
||||
关于都有哪些内置的地理位置代码,需要在[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/)中查看。
|
||||
示例
|
||||
```json
|
||||
{
|
||||
"customData": {
|
||||
"location": "HKG"
|
||||
}
|
||||
}
|
||||
```
|
||||
对于几个我常见的国别位置,添加了默认映射位置,会自动显示在地图上。
|
||||
Tips: 中国大陆地区默认在首都:北京(该映射在0.4.6后补充)
|
||||
Tips: 美国默认在最常买的位置:洛杉矶
|
||||
**使用前,请务必阅读本文档,对您的部署会有很大帮助**
|
||||
|
||||
## 关于节点slogan和购买链接
|
||||
同时,这个`customData`中还可以添加一项`slogan`和`orderLink`字符串,分别用于显示节点的标语和购买链接。
|
||||
```json
|
||||
{
|
||||
"customData": {
|
||||
"location": "HKG",
|
||||
"slogan": "这是一个香港节点",
|
||||
"orderLink": "https://buy.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
Tips:
|
||||
由于配置数据获取的方式特殊,无法正常解析符号`&`,建议在[https://www.bejson.com/enc/urlencode/](https://www.bejson.com/enc/urlencode/)进行编码后,将encodeURIComponent编码内容添加到orderLink中。
|
||||
当然,你也可以通过浏览器的console(控制台),执行`encodeURIComponent('链接内容')`,获取编码后的内容。
|
||||
- 基于哪吒监控(nezha.wiki)v0版本构建的前端主题,兼容v1版本数据结构
|
||||
- 考虑到国内用户访问需求,默认使用cdnjs的loli.net作为CDN引用源
|
||||
- 如需使用SarasaTermSC字体,请选择Docker镜像全量包进行部署
|
||||
|
||||
## 对于公开备注的支持
|
||||
在哪吒的主题ServerStatus迭代中,nap0o增加了一个公开备注的功能,可以给节点添加额外的展示信息
|
||||
具体字段定义参考 [https://github.com/nezhahq/nezha/pull/425](https://github.com/nezhahq/nezha/pull/425)
|
||||
Nazhua对这个支持大概在90%左右,参与数据处理了的字段如下:
|
||||
```json
|
||||
{
|
||||
"billingDataMod": {
|
||||
"startDate": "2024-10-01T00:00:00+08:00",
|
||||
"endDate": "2024-11-01T00:00:00+08:00",
|
||||
"autoRenewal": "1",
|
||||
"cycle": "月",
|
||||
"amount": "$3.99"
|
||||
},
|
||||
"planDataMod": {
|
||||
"bandwidth": "30Mbps",
|
||||
"trafficVol": "1TB/月",
|
||||
"trafficType": "1",
|
||||
"IPv4": "1",
|
||||
"IPv6": "1",
|
||||
"networkRoute": "CN2,GIA",
|
||||
"extra": "传家宝,AS9929"
|
||||
}
|
||||
}
|
||||
```
|
||||
~~其中IPv4、IPv6暂未参与到处理中,后续可能会支持。~~
|
||||
- 都有显示标签:双栈IP;
|
||||
- 单IPv4显示标签:仅IPv4;
|
||||
- 单IPv6显示标签:仅IPv6;
|
||||
## 🚀 部署指南
|
||||
|
||||
## 数据来源
|
||||
1-0. 公开的全量配置,其中包括“公开备注”(PublicNote),来自探针主页上暴露的服务器节点列表配置信息。此处是根据正则匹配的方式,获取到的节点列表。在主题项目中,默认将访问`/nezha/`的指向此处。
|
||||
2-0. 实时数据,v0来自公开的ws服务接口,`/ws`。
|
||||
2-1. 实时数据/全量数据,v1来自公开的ws服务接口,`/api/v1/ws/server`。
|
||||
3-0. 监控数据,v0来自公开的api接口,`/api/v1/monitor/${id}`。
|
||||
3-1. 监控数据,v1来自公开的api接口,`/api/v1/service/${id}`。
|
||||
4-0. 分组数据,v0来自服务器节点列表的`Tag`字段匹配。
|
||||
4-1. 分组数据,v1来自公开的api接口,`/api/v1/server-group`。
|
||||
**推荐使用Docker Compose + Cloudflare Tunnels部署Nazhua**
|
||||
|
||||
## 部署
|
||||
Nazhua主题是一个纯前端项目,可以部署在纯静态服务器上;
|
||||
v0需要解决`/api/v1/monitor/${id}`监控数据、`/ws`WS服务和`/`主页的跨域访问。
|
||||
v1需要解决`/api/xxx`等数据接口、`/api/v1/ws/server`WS服务的跨域访问。
|
||||
通常来说,你需要一个nginx或者caddy反代请求解决跨域问题。
|
||||
👉 [详细部署文档](./doc/deploy.md)
|
||||
|
||||
### Docker Compose + Cloudflare Tunnels部署
|
||||
**请关注备注中的提示内容**
|
||||
```yaml
|
||||
services:
|
||||
nazhua:
|
||||
image: ghcr.io/hi2shark/nazhua:latest
|
||||
container_name: nazhua
|
||||
ports:
|
||||
- 80:80
|
||||
# volumes:
|
||||
# - ./favicon.ico:/home/wwwroot/html/favicon.ico:ro # 自定义favicon图标
|
||||
# - ./config.js:/home/wwwroot/html/config.js:ro # 自定义配置文件
|
||||
# - ./style.css:/home/wwwroot/html/style.css:ro # 自定义样式文件
|
||||
environment:
|
||||
- DOMAIN=_ # 监听的域名,默认为_(监听所有)
|
||||
- NEZHA=http://nezha-dashboard.example.com/ # 可以被反代nezha主页地址
|
||||
restart: unless-stopped
|
||||
```
|
||||
建议通过docker-compose部署服务,然后通过cloudflare的tunnels向外提供服务,可以不用自己配置https证书。
|
||||
**再次提示,哪吒V1的必须设置config.js中的nezhaVersion为v1**
|
||||
**如果不想加载完整的内置库,可以使用cdn引用镜像**
|
||||
例如:`ghcr.io/hi2shark/nazhua:latest`替换为`ghcr.io/hi2shark/nazhua:cdn`
|
||||
Nazhua提供了丰富的配置选项:
|
||||
- 支持点阵地图显示/隐藏
|
||||
- 首页风格切换等多种个性化设置
|
||||
|
||||
>如果你想隐藏原面板,只暴露nazhua出来,你可以用Zero Trust的Tunnels;
|
||||
>三个容器:Tunnels、nezha-dashboard、nazhua
|
||||
>nazhua用docker内的地址访问nezha-dashboard,然后Tunnels绑定nazhua给公开访问的域名
|
||||
>Tunnels绑定nezha-dashboard到私密域名,需要邮箱|IP等匹配的才能访问
|
||||
配置方式:
|
||||
- **V1内置版本**:使用[配置生成器](https://hi2shark.github.io/nazhua-generator/)生成配置,填入控制台自定义代码
|
||||
- **Docker部署**:手动配置`config.js`文件(包括v0版本)
|
||||
|
||||
### Nginx配置示例
|
||||
```nginx
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
## 🗺️ 节点位置配置
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name nazhua.example.com;
|
||||
client_max_body_size 1024m;
|
||||
要在地图上显示节点位置,需在公开备注中指定`location`字段
|
||||
|
||||
# 哪吒V0的WebSocket服务
|
||||
location /ws {
|
||||
proxy_pass ${NEZHA}ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $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;
|
||||
}
|
||||
👉 [公开备注配置文档](./doc/public-note.md)
|
||||
|
||||
# 哪吒V1的WebSocket服务
|
||||
location /api/v1/ws/server {
|
||||
proxy_pass ${NEZHA}api/v1/ws/server;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $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;
|
||||
}
|
||||
👉 [功能更新记录](./doc/update.md)
|
||||
|
||||
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 / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
root /home/wwwroot/html;
|
||||
}
|
||||
}
|
||||
```
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://www.vmiss.com" target="_blank" title="VMISS,加拿大企业,打造全球优质优化线路。提供香港、日本、韩国、美国、英国的云服务器">
|
||||
<img src="./.github/images/vmiss-logo.jpg" width="200px;" alt="VMISS"/>
|
||||
</a>
|
||||
<br />
|
||||
<strong>VMISS</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 自定义配置
|
||||
可以通过修改根目录下的`config.js`文件来自定义配置
|
||||
例如:(*参考内容在文档上不一定是最新,具体参考public/config.js或者[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/)*)
|
||||
```javascript
|
||||
window.$$nazhuaConfig = {
|
||||
title: '哪吒监控', // 网站标题
|
||||
freeAmount: '白嫖', // 免费服务的费用名称
|
||||
infinityCycle: '长期有效', // 无限周期名称
|
||||
buyBtnText: '购买', // 购买按钮文案
|
||||
customBackgroundImage: '', // 自定义的背景图片地址
|
||||
lightBackground: true, // 启用了浅色系背景图,会强制关闭点点背景
|
||||
listServerItemType: 'row', // 服务器列表项类型 card/row row列表模式目前不兼容移动端
|
||||
listServerStatusType: 'progress', // 服务器状态类型--列表
|
||||
listServerRealTimeShowLoad: false, // 列表显示服务器实时负载
|
||||
detailServerStatusType: 'progress', // 服务器状态类型--详情页
|
||||
serverStatusLinear: true, // 服务器状态渐变线性显示
|
||||
disableSarasaTermSC: false, // 禁用Sarasa Term SC字体
|
||||
hideWorldMap: false, // 隐藏地图
|
||||
hideHomeWorldMap: false, // 隐藏首页地图
|
||||
hideDetailWorldMap: false, // 隐藏详情地图
|
||||
hideNavbarServerCount: false, // 隐藏服务器数量
|
||||
hideNavbarServerStat: false, // 隐藏服务器统计
|
||||
hideListItemStatusDonut: false, // 隐藏列表项的饼图
|
||||
hideListItemStat: false, // 隐藏列表项的统计信息
|
||||
hideListItemBill: false, // 隐藏列表项的账单信息
|
||||
hideFilter: false, // 隐藏筛选
|
||||
hideTag: false, // 隐藏标签
|
||||
hideDotBG: true, // 隐藏框框里面的点点背景
|
||||
monitorRefreshTime: 10, // 监控刷新时间间隔,单位s(秒), 0为不刷新,为保证不频繁请求源站,最低生效值为10s
|
||||
filterGPUKeywords: ['Virtual Display'], // 如果GPU名称中包含这些关键字,则过滤掉
|
||||
customCodeMap: {}, // 自定义的地图点信息
|
||||
nezhaVersion: 'v1', // 哪吒版本
|
||||
apiMonitorPath: '/api/v1/monitor/{id}',
|
||||
wsPath: '/ws',
|
||||
nezhaPath: '/nezha/',
|
||||
nezhaV0ConfigType: 'servers', // 哪吒v0数据读取类型
|
||||
v1ApiMonitorPath: '/api/v1/service/{id}',
|
||||
v1WsPath: '/api/v1/ws/server',
|
||||
v1ApiGroupPath: '/api/v1/server-group',
|
||||
v1ApiSettingPath: '/api/v1/setting',
|
||||
v1ApiProfilePath: '/api/v1/profile',
|
||||
v1DashboardUrl: '/dashboard', // v1版本控制台地址
|
||||
v1HideNezhaDashboardBtn: true, // v1版本导航栏控制台入口/登录按钮 在nezhaVersion为v1时有效
|
||||
routeMode: 'h5', // 路由模式
|
||||
};
|
||||
```
|
||||
可以通过[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/)快速生成config.js配置文件
|
||||
## 💻 开发者指南
|
||||
|
||||
通过修改根目录下的`style.css`文件来自定义样式
|
||||
例如:
|
||||
```css
|
||||
:root {
|
||||
/* 修改颜色 */
|
||||
/* 地图上标记点的颜色 */
|
||||
--world-map-point-color: #fff;
|
||||
/* 列表项显示的价格颜色 */
|
||||
--list-item-price-color: #ff6;
|
||||
/* 购买链接的主要颜色 */
|
||||
--list-item-buy-link-color: #f00;
|
||||
}
|
||||
```
|
||||
自定义背景图的实例:
|
||||
```css
|
||||
:root {
|
||||
/* 图片太亮了,需要图片前面的前景色(也是背景色)更暗一些 */
|
||||
--layout-main-bg-color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
/* 自定义背景图 */
|
||||
.layout-group .layout-bg {
|
||||
/* 添加important强制背景图替换,此处的替换设计不是很优雅,后期会改进 */
|
||||
background: url(./bg.jpg) no-repeat 50% 50% !important;
|
||||
background-size: cover;
|
||||
}
|
||||
```
|
||||
`./bg.jpg` 这个是图片地址,可以替换为外链图片;也可以把背景图片放到项目里面去,通常是docker的volumes映射,根据你自己的实际情况来。
|
||||
### 环境配置
|
||||
|
||||
在`.env.development.local`中配置以下变量:
|
||||
|
||||
## 二次开发提示
|
||||
`.env.development.local`配置变量
|
||||
```bash
|
||||
#### Sarasa Term SC字体的配置
|
||||
#### Sarasa Term SC字体设置
|
||||
# VITE_DISABLE_SARASA_TERM_SC=1
|
||||
# VITE_SARASA_TERM_SC_USE_CDN=1
|
||||
|
||||
#### 引用库的CDN配置
|
||||
#### CDN配置
|
||||
# VITE_USE_CDN=1
|
||||
# VITE_CDN_LIB_TYPE=jsdelivr # jsdelivr | cdnjs | loli
|
||||
|
||||
#### 哪吒的默认版本控制
|
||||
# VITE_NEZHA_VERSION=v1 # v0 | v0
|
||||
#### 哪吒版本控制
|
||||
# VITE_NEZHA_VERSION=v1 # v0 | v1
|
||||
|
||||
#### 本地开发设置
|
||||
# PROXY_WS_HOST= # 本地开发时,可以代理WS服务的地址,启用后,自动转发至 {PROXY_WS_HOST}/proxy?wsPath={WS_HOST}
|
||||
@ -281,3 +77,12 @@ window.$$nazhuaConfig = {
|
||||
##### 仅限v0版本
|
||||
# NEZHA_HOST= # 本地开发时,代理的哪吒主页地址
|
||||
```
|
||||
|
||||
### 数据来源参考
|
||||
|
||||
| 数据类型 | V0版本 | V1版本 |
|
||||
|---------|--------|--------|
|
||||
| 全量配置 | 公开备注(PublicNote):通过正则匹配节点列表,默认访问`/nezha/` | - |
|
||||
| 实时数据 | WS接口:`/ws` | WS接口:`/api/v1/ws/server` |
|
||||
| 监控数据 | API接口:`/api/v1/monitor/${id}` | API接口:`/api/v1/service/${id}` |
|
||||
| 分组数据 | 服务器节点列表的`Tag`字段匹配 | API接口:`/api/v1/server-group` |
|
||||
|
||||
29
src/App.vue
29
src/App.vue
@ -15,6 +15,7 @@ import {
|
||||
watch,
|
||||
provide,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute } from 'vue-router';
|
||||
@ -24,6 +25,7 @@ import config, {
|
||||
import sleep from '@/utils/sleep';
|
||||
import LayoutMain from './layout/main.vue';
|
||||
|
||||
import { WS_CONNECTION_STATUS } from './ws/service';
|
||||
import activeWebsocketService, {
|
||||
wsService,
|
||||
restart,
|
||||
@ -39,13 +41,19 @@ provide('currentTime', currentTime);
|
||||
|
||||
/**
|
||||
* 刷新当前时间
|
||||
* 使用 requestAnimationFrame 持续更新时间,但只在秒级变化时更新值以减少不必要的响应式更新
|
||||
*/
|
||||
let lastUpdateTime = 0;
|
||||
function refreshTime() {
|
||||
currentTime.value = Date.now();
|
||||
setTimeout(() => {
|
||||
refreshTime();
|
||||
}, 1000);
|
||||
const now = Date.now();
|
||||
// 只在秒级变化时更新,减少响应式更新频率
|
||||
if (Math.floor(now / 1000) !== Math.floor(lastUpdateTime / 1000)) {
|
||||
currentTime.value = now;
|
||||
lastUpdateTime = now;
|
||||
}
|
||||
window.requestAnimationFrame(refreshTime);
|
||||
}
|
||||
refreshTime();
|
||||
|
||||
// 是否为Windows系统
|
||||
const isWindows = /windows|win32/i.test(navigator.userAgent);
|
||||
@ -109,17 +117,22 @@ onMounted(async () => {
|
||||
console.log('ws connected');
|
||||
store.dispatch('watchWsMsg');
|
||||
});
|
||||
window.addEventListener('focus', () => {
|
||||
const handleFocus = () => {
|
||||
// ws在离开焦点后出现断连,尝试重新连接
|
||||
// 暂定仅针对-1状态进行重连
|
||||
if ([-1].includes(wsService.connected)) {
|
||||
// 仅针对已关闭状态进行重连
|
||||
if (wsService.connected === WS_CONNECTION_STATUS.CLOSED) {
|
||||
restart();
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('focus', handleFocus);
|
||||
/**
|
||||
* 激活websocket服务
|
||||
*/
|
||||
activeWebsocketService();
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
|
||||
@ -16,18 +16,35 @@
|
||||
|
||||
--world-map-point-color: #fff143;
|
||||
|
||||
--duration-color: #cbf1f5;
|
||||
--duration-color: #89c3eb;
|
||||
--transfer-color: #f9ed69;
|
||||
--transfer-in-color: var(--transfer-color);
|
||||
--transfer-out-color: #90f2ff;
|
||||
--net-speed-color: #90f2ff;
|
||||
--net-speed-in-color: #f5b199;
|
||||
--net-speed-out-color: #89c3eb;
|
||||
--conn-color: #90f2ff;
|
||||
--conn-tcp-color: #89c3eb;
|
||||
--conn-udp-color: #2ca9e1;
|
||||
--load-color: #90f2ff;
|
||||
--process-color: #f5b199;
|
||||
--cpu-text-color: #89c3eb;
|
||||
--mem-text-color: #2ca9e1;
|
||||
--disk-text-color: #90f2ff;
|
||||
--swap-text-color: #f5b199;
|
||||
|
||||
--list-item-price-color: #eee;
|
||||
--list-item-buy-link-color: #ffc300;
|
||||
--list-item-buy-link-color-hover: #ff9900;
|
||||
--public-note-tag-color: #ddd;
|
||||
--public-note-tag-bg: linear-gradient(125deg, #8f94fb, #4e54c8);
|
||||
--public-note-tag-color: #ccc;
|
||||
--public-note-tag-bg: linear-gradient(125deg, #676ef7, #41459c);
|
||||
|
||||
--option-high-color: #ff7500;
|
||||
--option-high-color-active: rgba(255, 177, 0, 0.75);
|
||||
|
||||
--server-status-value-color: #a1eafb;
|
||||
--server-status-label-color: #ddd;
|
||||
--server-status-content-color: #eee;
|
||||
|
||||
// 针对1440px以下的屏幕
|
||||
@media screen and (max-width: 1440px) {
|
||||
@ -54,3 +71,27 @@
|
||||
--detail-container-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
body.simple-color-mode {
|
||||
|
||||
--world-map-point-color: #cbf1f5;
|
||||
|
||||
--simple-color: #ccc;
|
||||
--duration-color: var(--simple-color);
|
||||
--transfer-color: var(--simple-color);
|
||||
--transfer-in-color: var(--transfer-color);
|
||||
--transfer-out-color: var(--simple-color);
|
||||
--net-speed-in-color: var(--simple-color);
|
||||
--net-speed-out-color: var(--simple-color);
|
||||
|
||||
--list-item-price-color: #eee;
|
||||
--list-item-buy-link-color: var(--simple-color);
|
||||
--list-item-buy-link-color-hover: draken(#cbf1f5, 10%);
|
||||
--public-note-tag-color: #eee;
|
||||
--public-note-tag-bg: transparent;
|
||||
|
||||
--option-high-color: rgb(93, 122, 126);
|
||||
--option-high-color-active: rgba(93, 122, 126, 0.75);
|
||||
|
||||
--server-status-value-color: var(--simple-color);
|
||||
}
|
||||
|
||||
@ -35,80 +35,86 @@ function handleColor(color) {
|
||||
return color;
|
||||
}
|
||||
|
||||
export default (used, total, itemColors, size = 100) => ({
|
||||
angleAxis: {
|
||||
max: total, // 满分
|
||||
// 隐藏刻度线
|
||||
axisLine: {
|
||||
show: false,
|
||||
export default (used, total, itemColors, size = 100) => {
|
||||
const isLinear = (
|
||||
(config.nazhua.serverStatusLinear || config.nazhua.lightBackground)
|
||||
&& !config.nazhua.simpleColorMode
|
||||
);
|
||||
return {
|
||||
angleAxis: {
|
||||
max: total, // 满分
|
||||
// 隐藏刻度线
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
radiusAxis: {
|
||||
type: 'category',
|
||||
// 隐藏刻度线
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
polar: {
|
||||
center: ['50%', '50%'],
|
||||
radius: ['50%', '100%'],
|
||||
},
|
||||
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,
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: [{
|
||||
value: used,
|
||||
}],
|
||||
itemStyle: {
|
||||
color: typeof itemColors === 'string' ? itemColors : handleColor(itemColors?.used),
|
||||
borderRadius: 5,
|
||||
shadowColor: (() => {
|
||||
if (config.nazhua.serverStatusLinear) {
|
||||
return 'rgba(0, 0, 0, 0.5)';
|
||||
}
|
||||
if (config.nazhua.lightBackground) {
|
||||
return 'rgba(0, 0, 0, 0.2)';
|
||||
}
|
||||
return undefined;
|
||||
})(),
|
||||
shadowBlur: isLinear ? 10 : undefined,
|
||||
},
|
||||
coordinateSystem: 'polar',
|
||||
cursor: 'default',
|
||||
roundCap: true,
|
||||
barWidth: Math.ceil((size / 100) * 10),
|
||||
barGap: '-100%', // 两环重叠
|
||||
z: 10,
|
||||
}, {
|
||||
type: 'bar',
|
||||
data: [{
|
||||
value: total,
|
||||
}],
|
||||
itemStyle: {
|
||||
color: handleColor(itemColors?.total) || 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
coordinateSystem: 'polar',
|
||||
cursor: 'default',
|
||||
barWidth: Math.ceil((size / 100) * 10),
|
||||
barGap: '-100%', // 两环重叠
|
||||
z: 5,
|
||||
}],
|
||||
itemStyle: {
|
||||
color: typeof itemColors === 'string' ? itemColors : handleColor(itemColors?.used),
|
||||
borderRadius: 5,
|
||||
shadowColor: (() => {
|
||||
if (config.nazhua.serverStatusLinear) {
|
||||
return 'rgba(0, 0, 0, 0.5)';
|
||||
}
|
||||
if (config.nazhua.lightBackground) {
|
||||
return 'rgba(0, 0, 0, 0.2)';
|
||||
}
|
||||
return undefined;
|
||||
})(),
|
||||
shadowBlur: (config.nazhua.serverStatusLinear || config.nazhua.lightBackground) ? 10 : undefined,
|
||||
},
|
||||
coordinateSystem: 'polar',
|
||||
cursor: 'default',
|
||||
roundCap: true,
|
||||
barWidth: Math.ceil((size / 100) * 10),
|
||||
barGap: '-100%', // 两环重叠
|
||||
z: 10,
|
||||
}, {
|
||||
type: 'bar',
|
||||
data: [{
|
||||
value: total,
|
||||
}],
|
||||
itemStyle: {
|
||||
color: handleColor(itemColors?.total) || 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
coordinateSystem: 'polar',
|
||||
cursor: 'default',
|
||||
barWidth: Math.ceil((size / 100) * 10),
|
||||
barGap: '-100%', // 两环重叠
|
||||
z: 5,
|
||||
}],
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@ -3,7 +3,6 @@ import { SVGRenderer } from 'echarts/renderers';
|
||||
import { LineChart } from 'echarts/charts';
|
||||
import {
|
||||
TooltipComponent,
|
||||
// LegendComponent,
|
||||
GridComponent,
|
||||
DataZoomComponent,
|
||||
} from 'echarts/components';
|
||||
@ -15,17 +14,17 @@ use([
|
||||
SVGRenderer,
|
||||
LineChart,
|
||||
TooltipComponent,
|
||||
// LegendComponent,
|
||||
GridComponent,
|
||||
DataZoomComponent,
|
||||
]);
|
||||
|
||||
export default (
|
||||
cateList,
|
||||
dateList,
|
||||
valueList,
|
||||
mode = 'dark',
|
||||
) => {
|
||||
export default (options) => {
|
||||
const {
|
||||
dateList,
|
||||
valueList,
|
||||
mode = 'dark',
|
||||
connectNulls = true,
|
||||
} = options || {};
|
||||
const fontFamily = config.nazhua.disableSarasaTermSC === true ? undefined : 'Sarasa Term SC';
|
||||
const option = {
|
||||
darkMode: mode === 'dark',
|
||||
@ -39,7 +38,7 @@ export default (
|
||||
let res = `<p style="font-weight: bold; color: #ff6;">${time}</p>`;
|
||||
if (params.length < 10) {
|
||||
params.forEach((i) => {
|
||||
res += `${i.marker} ${i.seriesName}: ${i.value[1]}ms<br>`;
|
||||
res += i.value[1] ? `${i.marker} ${i.seriesName}: ${i.value[1]}ms<br>` : '';
|
||||
});
|
||||
} else {
|
||||
res += '<table>';
|
||||
@ -48,7 +47,9 @@ export default (
|
||||
if (index % 2 === 0) {
|
||||
res += '<tr>';
|
||||
}
|
||||
res += `<td style="padding: 0 4px;">${i.marker} ${i.seriesName}: ${i.value[1]}ms</td>`;
|
||||
res += i.value[1]
|
||||
? `<td style="padding: 0 4px;">${i.marker} ${i.seriesName}: ${i.value[1]}ms</td>`
|
||||
: '<td style="padding: 0 4px;"></td>';
|
||||
if (index % 2 === 1) {
|
||||
res += '</tr>';
|
||||
trEnd = true;
|
||||
@ -69,23 +70,6 @@ export default (
|
||||
fontSize: 14,
|
||||
},
|
||||
},
|
||||
// legend: {
|
||||
// show: false,
|
||||
// data: cateList.map((i) => ({
|
||||
// name: i.name,
|
||||
// itemStyle: {
|
||||
// color: i.color,
|
||||
// },
|
||||
// lineStyle: {
|
||||
// color: i.color,
|
||||
// },
|
||||
// })),
|
||||
// textStyle: {
|
||||
// color: mode === 'dark' ? '#ddd' : '#222',
|
||||
// fontFamily,
|
||||
// fontSize: 14,
|
||||
// },
|
||||
// },
|
||||
grid: {
|
||||
top: 10,
|
||||
left: 5,
|
||||
@ -128,7 +112,7 @@ export default (
|
||||
...i,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
connectNulls: true,
|
||||
connectNulls,
|
||||
legendHoverLink: false,
|
||||
symbol: 'none',
|
||||
})),
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
<div
|
||||
v-if="option"
|
||||
class="line-box"
|
||||
:style="boxStyle"
|
||||
>
|
||||
<v-chart
|
||||
ref="chartRef"
|
||||
@ -25,10 +26,6 @@ import VChart from 'vue-echarts';
|
||||
import lineChart from './line';
|
||||
|
||||
const props = defineProps({
|
||||
cateList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
dateList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@ -41,19 +38,30 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
connectNulls: {
|
||||
type: [Boolean, String],
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const chartRef = ref();
|
||||
const option = computed(() => {
|
||||
if (props.cateList && props.dateList && props.valueList) {
|
||||
return lineChart(
|
||||
props.cateList,
|
||||
props.dateList,
|
||||
props.valueList,
|
||||
);
|
||||
if (props.dateList && props.valueList) {
|
||||
return lineChart({
|
||||
dateList: props.dateList,
|
||||
valueList: props.valueList,
|
||||
connectNulls: props.connectNulls,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const boxStyle = computed(() => {
|
||||
const style = {};
|
||||
if (props.size > 0) {
|
||||
style.height = `${props.size}px`;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
function handleResize() {
|
||||
chartRef.value?.resize?.();
|
||||
|
||||
292
src/components/popover.vue
Normal file
292
src/components/popover.vue
Normal file
@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="popover-trigger"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@focusin="handleFocusIn"
|
||||
@focusout="handleFocusOut"
|
||||
@click="handleTriggerClick"
|
||||
>
|
||||
<slot name="trigger" />
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="isShow"
|
||||
ref="popoverRef"
|
||||
class="popover"
|
||||
:style="[popoverStyle, { zIndex: currentZIndex }]"
|
||||
>
|
||||
<template v-if="$slots.title || title">
|
||||
<div class="popover-body">
|
||||
{{ title }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="popover-body">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
组件名称:Popover
|
||||
|
||||
组件说明:
|
||||
该组件在移动端与 PC 端提供不同的交互模式,通过 "hover" 或 "click" 来触发显示或隐藏提示浮层。
|
||||
若设置 unique 属性,则在显示新浮层的同时会隐藏其他已显示的浮层。
|
||||
|
||||
使用示例:
|
||||
<Popover title="示例标题" trigger="click">
|
||||
<template #trigger>
|
||||
<button>点击触发</button>
|
||||
</template>
|
||||
这是 Popover 的内容
|
||||
</Popover>
|
||||
|
||||
Props:
|
||||
- visible (Boolean,默认 false)
|
||||
Popover 的可见状态,可供外部进行手动控制。
|
||||
- title (String,默认 '')
|
||||
Popover 的标题文本,如不传则展示默认内容插槽。
|
||||
- trigger (String,默认 'hover')
|
||||
触发模式,可选值为 "hover" 或 "click"。
|
||||
- unique (Boolean,默认 true)
|
||||
如果为 true,则在显示当前 Popover 时会自动隐藏其他已显示的 Popover。
|
||||
|
||||
方法说明:
|
||||
- handleMouseEnter()
|
||||
当鼠标移入触发元素时,若 trigger 为 hover,会显示 Popover。
|
||||
- handleMouseLeave()
|
||||
当鼠标移出触发元素时,若 trigger 为 hover,会隐藏 Popover。
|
||||
- handleTriggerClick(e)
|
||||
当在移动端或 trigger 为 click 时,点击触发元素会切换 Popover 显示状态,并在移动端下自动延时隐藏。
|
||||
- handleFocusIn()
|
||||
当触发元素获得焦点时,若触发方式为 hover,会显示 Popover。
|
||||
- handleFocusOut()
|
||||
当触发元素失去焦点时,若触发方式为 hover,会隐藏 Popover。
|
||||
|
||||
注意事项:
|
||||
- 在移动端会根据窗口宽度做适配,通过 document 监听点击事件和窗口大小变化来控制显示与关闭。
|
||||
- 当 visible 通过外部控制时,非移动端能手动实现 Popover 的显隐。
|
||||
*/
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { getNextZIndex } from '../utils/zIndexManager';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'hover',
|
||||
validator: (value) => ['hover', 'click'].includes(value),
|
||||
},
|
||||
unique: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 移除全局 Symbol 相关代码
|
||||
// 添加静态 z-index 计数器
|
||||
// const baseZIndex = 1000;
|
||||
// let zIndexCounter = baseZIndex;
|
||||
|
||||
const popoverRef = ref(null);
|
||||
const position = ref({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const isMobile = ref(window.innerWidth < 600);
|
||||
const isShow = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const currentZIndex = ref(1000);
|
||||
|
||||
// 移除 getCurrentPopover 和 setCurrentPopover 函数
|
||||
|
||||
// 更新移动端位置
|
||||
const updateMobilePosition = () => {
|
||||
if (!triggerRef.value) return;
|
||||
const rect = triggerRef.value.getBoundingClientRect();
|
||||
position.value = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height,
|
||||
};
|
||||
};
|
||||
|
||||
// 修改显示逻辑
|
||||
const updateShow = (value) => {
|
||||
if (value) {
|
||||
currentZIndex.value = getNextZIndex();
|
||||
}
|
||||
isShow.value = value;
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile.value && props.trigger === 'hover') {
|
||||
updateShow(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!isMobile.value && props.trigger === 'hover') {
|
||||
updateShow(false);
|
||||
}
|
||||
};
|
||||
|
||||
let autoCloseTimer;
|
||||
const handleTriggerClick = (e) => {
|
||||
if (props.trigger === 'click' || isMobile.value) {
|
||||
e.stopPropagation();
|
||||
updateShow(!isShow.value);
|
||||
if (isShow.value && isMobile.value) {
|
||||
if (autoCloseTimer) {
|
||||
clearTimeout(autoCloseTimer);
|
||||
}
|
||||
autoCloseTimer = setTimeout(() => {
|
||||
isShow.value = false;
|
||||
}, 5 * 1000);
|
||||
updateMobilePosition();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusIn = () => {
|
||||
if (!isMobile.value && props.trigger === 'hover') {
|
||||
isShow.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusOut = () => {
|
||||
if (!isMobile.value && props.trigger === 'hover') {
|
||||
isShow.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 修改点击事件处理
|
||||
const handleDocumentClick = (e) => {
|
||||
if (isShow.value && !triggerRef.value?.contains(e.target) && !popoverRef.value?.contains(e.target)) {
|
||||
isShow.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePosition = (e) => {
|
||||
if (isMobile.value || !isShow.value) return;
|
||||
position.value = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const popoverStyle = computed(() => {
|
||||
if (isMobile.value) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: '10vh',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
};
|
||||
}
|
||||
|
||||
const { x, y } = position.value;
|
||||
const rect = popoverRef.value?.getBoundingClientRect();
|
||||
const offset = 15; // 修改为20px偏移量
|
||||
|
||||
let left = x + offset;
|
||||
let top = y + offset;
|
||||
|
||||
if (rect) {
|
||||
// 防止超出右边界
|
||||
if (left + rect.width > window.innerWidth) {
|
||||
left = x - rect.width - offset;
|
||||
}
|
||||
// 防止超出下边界
|
||||
if (top + rect.height > window.innerHeight) {
|
||||
top = y - rect.height - offset;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth < 600;
|
||||
};
|
||||
|
||||
// 监听visible属性变化
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!isMobile.value) {
|
||||
updateShow(newVal);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (isMobile.value || props.trigger === 'click') {
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
}
|
||||
if (!isMobile.value) {
|
||||
document.addEventListener('mousemove', updatePosition);
|
||||
}
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isMobile.value || props.trigger === 'click') {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
}
|
||||
if (!isMobile.value) {
|
||||
document.removeEventListener('mousemove', updatePosition);
|
||||
}
|
||||
window.removeEventListener('resize', handleResize);
|
||||
// 移除全局 Popover 相关的清理代码
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popover-trigger {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popover {
|
||||
background: rgba(#000, 0.8);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||
// 移除固定的 z-index
|
||||
max-width: 300px;
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(251, 255, 217, 0.15);
|
||||
}
|
||||
|
||||
.popover-body {
|
||||
line-height: 1.4;
|
||||
font-size: 14px;
|
||||
// 允许换行
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
29
src/components/server-flag.vue
Normal file
29
src/components/server-flag.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<span
|
||||
class="server-flag"
|
||||
>
|
||||
<span
|
||||
class="fi"
|
||||
:class="'fi-' + lastFlag"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const lastFlag = computed(() => {
|
||||
let flag = props.info?.Host?.CountryCode || 'un';
|
||||
if (props.info?.PublicNote?.customData?.flag) {
|
||||
flag = props.info.PublicNote.customData.flag;
|
||||
}
|
||||
return flag.toLowerCase();
|
||||
});
|
||||
</script>
|
||||
@ -33,10 +33,46 @@ if (config.nazhua.nezhaVersion) {
|
||||
config.init = true;
|
||||
}
|
||||
|
||||
function handle$$serverStatus() {
|
||||
if (window.$$serverStatus) {
|
||||
config.nazhua.listServerItemType = 'server-status';
|
||||
config.nazhua.homeWorldMapPosition = 'bottom';
|
||||
}
|
||||
}
|
||||
handle$$serverStatus();
|
||||
|
||||
function setColorMode() {
|
||||
if (config.nazhua.simpleColorMode) {
|
||||
document.body.classList.add('simple-color-mode');
|
||||
} else {
|
||||
document.body.classList.remove('simple-color-mode');
|
||||
}
|
||||
}
|
||||
setColorMode();
|
||||
|
||||
/**
|
||||
* 替换网站图标
|
||||
*/
|
||||
function replaceFavicon() {
|
||||
if (config.nazhua.customFavicon) {
|
||||
const link = document.querySelector("link[rel*='icon']");
|
||||
link.type = 'image/x-icon';
|
||||
link.rel = 'shortcut icon';
|
||||
link.href = config.nazhua.customFavicon;
|
||||
}
|
||||
}
|
||||
replaceFavicon();
|
||||
|
||||
/**
|
||||
* 合并自定义配置
|
||||
*/
|
||||
export function mergeNazhuaConfig(customConfig) {
|
||||
Object.keys(customConfig).forEach((key) => {
|
||||
config.nazhua[key] = customConfig[key];
|
||||
});
|
||||
replaceFavicon();
|
||||
setColorMode();
|
||||
handle$$serverStatus();
|
||||
}
|
||||
// 暴露合并配置方法
|
||||
window.$mergeNazhuaConfig = mergeNazhuaConfig;
|
||||
|
||||
@ -77,6 +77,12 @@ const codeMaps = {
|
||||
name: '吉隆坡',
|
||||
country: '马来西亚',
|
||||
},
|
||||
BKK: {
|
||||
name: '曼谷',
|
||||
country: '泰国',
|
||||
x: 985,
|
||||
y: 296,
|
||||
},
|
||||
HAN: {
|
||||
x: 998,
|
||||
y: 274,
|
||||
@ -89,6 +95,24 @@ const codeMaps = {
|
||||
name: '胡志明市',
|
||||
country: '越南',
|
||||
},
|
||||
BOM: {
|
||||
name: '孟买',
|
||||
country: '印度',
|
||||
x: 874,
|
||||
y: 284,
|
||||
},
|
||||
DEL: {
|
||||
name: '新德里',
|
||||
country: '印度',
|
||||
x: 886,
|
||||
y: 246,
|
||||
},
|
||||
DXB: {
|
||||
name: '迪拜',
|
||||
country: '阿联酋',
|
||||
x: 794.5,
|
||||
y: 252,
|
||||
},
|
||||
LAX: {
|
||||
x: 95,
|
||||
y: 207,
|
||||
@ -137,6 +161,12 @@ const codeMaps = {
|
||||
name: '纽约',
|
||||
country: '美国',
|
||||
},
|
||||
IAD: {
|
||||
name: '阿什本',
|
||||
country: 'US',
|
||||
x: 265,
|
||||
y: 186,
|
||||
},
|
||||
DFW: {
|
||||
x: 172,
|
||||
y: 211,
|
||||
@ -251,6 +281,30 @@ const codeMaps = {
|
||||
name: '布加勒斯特',
|
||||
country: '罗马尼亚',
|
||||
},
|
||||
SOF: {
|
||||
name: '索菲亚',
|
||||
country: '保加利亚',
|
||||
x: 662.5,
|
||||
y: 167,
|
||||
},
|
||||
VNO: {
|
||||
name: '维尔纽斯',
|
||||
country: '立陶宛',
|
||||
x: 657.5,
|
||||
y: 110.5,
|
||||
},
|
||||
OSL: {
|
||||
name: '奥斯陆',
|
||||
country: '挪威',
|
||||
x: 615.5,
|
||||
y: 93,
|
||||
},
|
||||
RBA: {
|
||||
name: '拉巴特',
|
||||
country: '摩洛哥',
|
||||
x: 545,
|
||||
y: 212,
|
||||
},
|
||||
IST: {
|
||||
x: 676,
|
||||
y: 176,
|
||||
@ -275,6 +329,7 @@ export const aliasMapping = {
|
||||
HK: 'HKG',
|
||||
MO: 'MFM',
|
||||
TW: 'TPE',
|
||||
ASH: 'IAD',
|
||||
};
|
||||
|
||||
export const countryCodeMapping = {
|
||||
@ -284,6 +339,9 @@ export const countryCodeMapping = {
|
||||
KR: 'SEL',
|
||||
MY: 'KUL',
|
||||
VN: 'HAN',
|
||||
IN: 'DEL',
|
||||
TH: 'BKK',
|
||||
AE: 'DXB',
|
||||
TR: 'IST',
|
||||
RO: 'OTP',
|
||||
LU: 'LUX',
|
||||
@ -302,6 +360,10 @@ export const countryCodeMapping = {
|
||||
IT: 'MXP',
|
||||
ES: 'MAD',
|
||||
PL: 'WAW',
|
||||
BG: 'SOF',
|
||||
LT: 'VNO',
|
||||
NO: 'OSL',
|
||||
MA: 'RBA',
|
||||
};
|
||||
|
||||
export default codeMaps;
|
||||
|
||||
59
src/layout/components/dashboard-btn.vue
Normal file
59
src/layout/components/dashboard-btn.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div
|
||||
class="nezha-user-info-group"
|
||||
>
|
||||
<a
|
||||
:href="dashboardUrl"
|
||||
class="dashboard-url"
|
||||
:title="userLogin ? '访问管理后台' : '登录管理后台'"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
'ri-dashboard-3-line': userLogin,
|
||||
'ri-user-line': !userLogin,
|
||||
}"
|
||||
/>
|
||||
<span>{{ userLogin ? '管理后台' : '登录' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 控制台入口
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
} from 'vuex';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const userLogin = computed(() => store.state.profile?.username);
|
||||
const dashboardUrl = computed(() => config.nazhua.v1DashboardUrl || '/dashboard');
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nezha-user-info-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0 20px;
|
||||
|
||||
.dashboard-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0 5px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #ff9a00;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -54,6 +54,7 @@ const store = useStore();
|
||||
const footerSlogan = computed(() => decodeURIComponent(config.nazhua?.footerSlogan || ''));
|
||||
|
||||
const dynamicContentRef = ref();
|
||||
const executedScripts = ref(new Set()); // 记录已执行的脚本,避免重复执行
|
||||
|
||||
const dynamicContent = computed(() => {
|
||||
if (store.state.setting?.config?.custom_code) {
|
||||
@ -69,24 +70,69 @@ const dynamicContent = computed(() => {
|
||||
const executeScripts = () => {
|
||||
nextTick(() => {
|
||||
if (!dynamicContentRef.value) return;
|
||||
|
||||
const scripts = dynamicContentRef.value.querySelectorAll('script');
|
||||
|
||||
scripts.forEach((script) => {
|
||||
const newScript = document.createElement('script');
|
||||
newScript.type = 'text/javascript';
|
||||
if (script.src) {
|
||||
newScript.src = script.src; // 拷贝外部脚本的 src
|
||||
} else {
|
||||
newScript.textContent = script.textContent; // 拷贝内联脚本
|
||||
try {
|
||||
// 生成脚本唯一标识,避免重复执行
|
||||
const scriptIdentifier = script.src || script.textContent || '';
|
||||
if (!scriptIdentifier || executedScripts.value.has(scriptIdentifier)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newScript = document.createElement('script');
|
||||
newScript.type = script.type || 'text/javascript';
|
||||
|
||||
// 复制所有相关属性
|
||||
if (script.async !== undefined) newScript.async = script.async;
|
||||
if (script.defer !== undefined) newScript.defer = script.defer;
|
||||
if (script.crossOrigin) newScript.crossOrigin = script.crossOrigin;
|
||||
if (script.integrity) newScript.integrity = script.integrity;
|
||||
if (script.noModule !== undefined) newScript.noModule = script.noModule;
|
||||
if (script.referrerPolicy) newScript.referrerPolicy = script.referrerPolicy;
|
||||
|
||||
if (script.src) {
|
||||
// 外部脚本:监听加载完成事件
|
||||
newScript.src = script.src;
|
||||
newScript.onload = () => {
|
||||
executedScripts.value.add(scriptIdentifier);
|
||||
};
|
||||
newScript.onerror = (error) => {
|
||||
console.error('Failed to load external script:', script.src, error);
|
||||
};
|
||||
document.body.appendChild(newScript);
|
||||
} else {
|
||||
// 内联脚本:直接执行
|
||||
newScript.textContent = script.textContent;
|
||||
document.body.appendChild(newScript);
|
||||
executedScripts.value.add(scriptIdentifier);
|
||||
// 内联脚本执行后可以安全移除
|
||||
document.body.removeChild(newScript);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error executing dynamic script:', error);
|
||||
}
|
||||
document.body.appendChild(newScript);
|
||||
document.body.removeChild(newScript); // 可选:移除以保持整洁
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
watch(dynamicContent, () => {
|
||||
if (dynamicContent.value) {
|
||||
executeScripts();
|
||||
// 清理已执行脚本的记录(当内容变化时)
|
||||
const cleanupScripts = () => {
|
||||
executedScripts.value.clear();
|
||||
};
|
||||
|
||||
watch(dynamicContent, (newVal, oldVal) => {
|
||||
// 内容变化时,清理旧的执行记录
|
||||
if (newVal !== oldVal) {
|
||||
cleanupScripts();
|
||||
}
|
||||
|
||||
if (newVal) {
|
||||
// 确保 DOM 已更新
|
||||
nextTick(() => {
|
||||
executeScripts();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -12,110 +12,15 @@
|
||||
>{{ title }}</span>
|
||||
</div>
|
||||
<div class="right-box">
|
||||
<div
|
||||
v-if="serverCount?.total && showServerCount"
|
||||
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>
|
||||
<template v-if="serverCount.online !== serverCount.total">
|
||||
<span
|
||||
class="server-count server-count--online"
|
||||
>
|
||||
<span class="text">在线</span>
|
||||
<span class="value">{{ serverCount.online }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="server-count server-count--offline"
|
||||
>
|
||||
<span class="text">离线</span>
|
||||
<span class="value">{{ serverCount.offline }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="serverStat && showServerStat"
|
||||
class="server-stat-group"
|
||||
>
|
||||
<div
|
||||
v-if="serverStat.transfer"
|
||||
class="server-stat server-stat--transfer"
|
||||
>
|
||||
<span class="server-stat-label">
|
||||
<span class="text">流量</span>
|
||||
</span>
|
||||
<div class="server-stat-content">
|
||||
<span class="server-stat-item server-stat-item--in">
|
||||
<span class="ri-download-line" />
|
||||
<span class="text-value">
|
||||
{{ serverStat.transfer.inData.value }}
|
||||
</span>
|
||||
<span class="text-unit">
|
||||
{{ serverStat.transfer.inData.unit }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="server-stat-item server-stat-item--out">
|
||||
<span class="ri-upload-line" />
|
||||
<span class="text-value">
|
||||
{{ serverStat.transfer.outData.value }}
|
||||
</span>
|
||||
<span class="text-unit">
|
||||
{{ serverStat.transfer.outData.unit }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="serverStat.netSpeed"
|
||||
class="server-stat server-stat--net-speed"
|
||||
>
|
||||
<span class="server-stat-label">
|
||||
<span class="text">网速</span>
|
||||
</span>
|
||||
<div class="server-stat-content">
|
||||
<span class="server-stat-item server-stat-item--in">
|
||||
<span class="ri-arrow-down-line" />
|
||||
<span class="text-value">
|
||||
{{ serverStat.netSpeed.inData.value }}
|
||||
</span>
|
||||
<span class="text-unit">
|
||||
{{ serverStat.netSpeed.inData.unit }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="server-stat-item server-stat-item--out">
|
||||
<span class="ri-arrow-up-line" />
|
||||
<span class="text-value">
|
||||
{{ serverStat.netSpeed.outData.value }}
|
||||
</span>
|
||||
<span class="text-unit">
|
||||
{{ serverStat.netSpeed.outData.unit }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
<server-count
|
||||
v-if="showServerCount"
|
||||
/>
|
||||
<server-stat
|
||||
v-if="showServerStat"
|
||||
/>
|
||||
<dashboard-btn
|
||||
v-if="showDashboardBtn"
|
||||
class="nezha-user-info-group"
|
||||
>
|
||||
<a
|
||||
:href="dashboardUrl"
|
||||
class="dashboard-url"
|
||||
:title="userLogin ? '访问管理后台' : '登录管理后台'"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
'ri-dashboard-3-line': userLogin,
|
||||
'ri-user-line': !userLogin,
|
||||
}"
|
||||
/>
|
||||
<span>{{ userLogin ? '管理后台' : '登录' }}</span>
|
||||
</a>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -128,18 +33,17 @@
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
} from 'vuex';
|
||||
import {
|
||||
useRoute,
|
||||
useRouter,
|
||||
} from 'vue-router';
|
||||
import * as hostUtils from '@/utils/host';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
const store = useStore();
|
||||
import ServerCount from './server-count.vue';
|
||||
import ServerStat from './server-stat.vue';
|
||||
import DashboardBtn from './dashboard-btn.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@ -156,105 +60,8 @@ const headerStyle = computed(() => {
|
||||
});
|
||||
|
||||
const showServerCount = computed(() => config.nazhua.hideNavbarServerCount !== true);
|
||||
const serverCount = computed(() => store.state.serverCount);
|
||||
|
||||
const showServerStat = computed(() => config.nazhua.hideNavbarServerStat !== true);
|
||||
const serverStat = computed(() => {
|
||||
const transfer = {
|
||||
in: 0,
|
||||
inData: {
|
||||
value: 0,
|
||||
unit: '',
|
||||
},
|
||||
out: 0,
|
||||
outData: {
|
||||
value: 0,
|
||||
unit: '',
|
||||
},
|
||||
};
|
||||
const netSpeed = {
|
||||
in: 0,
|
||||
inData: {
|
||||
value: 0,
|
||||
unit: '',
|
||||
},
|
||||
out: 0,
|
||||
outData: {
|
||||
value: 0,
|
||||
unit: '',
|
||||
},
|
||||
};
|
||||
if (store.state.serverList.length) {
|
||||
store.state.serverList.forEach((server) => {
|
||||
if (server.online === 1 && server.State) {
|
||||
transfer.in += server.State.NetInTransfer;
|
||||
transfer.out += server.State.NetOutTransfer;
|
||||
netSpeed.in += server.State.NetInSpeed;
|
||||
netSpeed.out += server.State.NetOutSpeed;
|
||||
}
|
||||
});
|
||||
}
|
||||
const calcInTransfer = hostUtils.calcBinary(transfer.in);
|
||||
if (calcInTransfer.t > 1) {
|
||||
transfer.inData.value = (calcInTransfer.t).toFixed(1) * 1;
|
||||
transfer.inData.unit = 'T';
|
||||
} else if (calcInTransfer.g > 1) {
|
||||
transfer.inData.value = (calcInTransfer.g).toFixed(1) * 1;
|
||||
transfer.inData.unit = 'G';
|
||||
} else if (calcInTransfer.m > 1) {
|
||||
transfer.inData.value = (calcInTransfer.m).toFixed(1) * 1;
|
||||
transfer.inData.unit = 'M';
|
||||
} else {
|
||||
transfer.inData.value = calcInTransfer.value;
|
||||
transfer.inData.unit = 'K';
|
||||
}
|
||||
const calcOutTransfer = hostUtils.calcBinary(transfer.out);
|
||||
if (calcOutTransfer.t > 1) {
|
||||
transfer.outData.value = (calcOutTransfer.t).toFixed(1) * 1;
|
||||
transfer.outData.unit = 'T';
|
||||
} else if (calcOutTransfer.g > 1) {
|
||||
transfer.outData.value = (calcOutTransfer.g).toFixed(1) * 1;
|
||||
transfer.outData.unit = 'G';
|
||||
} else if (calcOutTransfer.m > 1) {
|
||||
transfer.outData.value = (calcOutTransfer.m).toFixed(1) * 1;
|
||||
transfer.outData.unit = 'M';
|
||||
} else {
|
||||
transfer.outData.value = calcOutTransfer.value;
|
||||
transfer.outData.unit = 'K';
|
||||
}
|
||||
const calcNetInSpeed = hostUtils.calcBinary(netSpeed.in);
|
||||
if (calcNetInSpeed.t > 1) {
|
||||
netSpeed.inData.value = (calcNetInSpeed.t).toFixed(1) * 1;
|
||||
netSpeed.inData.unit = 'T';
|
||||
} else if (calcNetInSpeed.g > 1) {
|
||||
netSpeed.inData.value = (calcNetInSpeed.g).toFixed(1) * 1;
|
||||
netSpeed.inData.unit = 'G';
|
||||
} else if (calcNetInSpeed.m > 1) {
|
||||
netSpeed.inData.value = (calcNetInSpeed.m).toFixed(1) * 1;
|
||||
netSpeed.inData.unit = 'M';
|
||||
} else {
|
||||
netSpeed.inData.value = (calcNetInSpeed.k).toFixed(1) * 1;
|
||||
netSpeed.inData.unit = 'K';
|
||||
}
|
||||
const calcNetOutSpeed = hostUtils.calcBinary(netSpeed.out);
|
||||
if (calcNetOutSpeed.t > 1) {
|
||||
netSpeed.outData.value = (calcNetOutSpeed.t).toFixed(1) * 1;
|
||||
netSpeed.outData.unit = 'T';
|
||||
} else if (calcNetOutSpeed.g > 1) {
|
||||
netSpeed.outData.value = (calcNetOutSpeed.g).toFixed(1) * 1;
|
||||
netSpeed.outData.unit = 'G';
|
||||
} else if (calcNetOutSpeed.m > 1) {
|
||||
netSpeed.outData.value = (calcNetOutSpeed.m).toFixed(1) * 1;
|
||||
netSpeed.outData.unit = 'M';
|
||||
} else {
|
||||
netSpeed.outData.value = (calcNetOutSpeed.k).toFixed(1) * 1;
|
||||
netSpeed.outData.unit = 'K';
|
||||
}
|
||||
return {
|
||||
transfer,
|
||||
netSpeed,
|
||||
};
|
||||
});
|
||||
|
||||
const title = computed(() => config.nazhua.title);
|
||||
|
||||
@ -287,8 +94,6 @@ const showDashboardBtn = computed(() => [
|
||||
config.nazhua.nezhaVersion === 'v1',
|
||||
config.nazhua.v1HideNezhaDashboardBtn !== true,
|
||||
].every((item) => item));
|
||||
const userLogin = computed(() => store.state.profile?.username);
|
||||
const dashboardUrl = computed(() => config.nazhua.v1DashboardUrl || '/dashboard');
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@ -324,39 +129,6 @@ const dashboardUrl = computed(() => config.nazhua.v1DashboardUrl || '/dashboard'
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-count-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.server-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #ddd;
|
||||
line-height: 30px;
|
||||
|
||||
.value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.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;
|
||||
@ -376,91 +148,5 @@ const dashboardUrl = computed(() => config.nazhua.v1DashboardUrl || '/dashboard'
|
||||
gap: 0 20px;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.server-stat-group {
|
||||
min-width: 160px;
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 28px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
gap: 10px;
|
||||
|
||||
.server-stat-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.server-stat-content {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-stat {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
line-height: 16px;
|
||||
font-size: 12px;
|
||||
|
||||
.server-stat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.server-stat-item {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.server-stat--transfer {
|
||||
.server-stat-item--in {
|
||||
.text-value {
|
||||
color: var(--transfer-in-color);
|
||||
}
|
||||
}
|
||||
|
||||
.server-stat-item--out {
|
||||
.text-value {
|
||||
color: var(--transfer-out-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-stat--net-speed {
|
||||
.server-stat-item--in {
|
||||
.text-value {
|
||||
color: var(--net-speed-in-color);
|
||||
}
|
||||
}
|
||||
|
||||
.server-stat-item--out {
|
||||
.text-value {
|
||||
color: var(--net-speed-out-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nezha-user-info-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0 20px;
|
||||
|
||||
.dashboard-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0 5px;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #ff9a00;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
332
src/layout/components/search-box.vue
Normal file
332
src/layout/components/search-box.vue
Normal file
@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<transition name="fadeIn">
|
||||
<div
|
||||
v-if="show"
|
||||
class="search-box-background"
|
||||
@click="closeSearchBox"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="fadeIn">
|
||||
<div
|
||||
v-if="show"
|
||||
class="search-box-group"
|
||||
>
|
||||
<div class="search-box">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model.trim="searchWord"
|
||||
type="text"
|
||||
placeholder="可搜索服务器名称、标签、系统、国别代码"
|
||||
class="search-box-input"
|
||||
@input="onSearchInput"
|
||||
@keydown.enter="onSearchInput"
|
||||
@blur="onSearchInput"
|
||||
/>
|
||||
<span
|
||||
v-if="searchWord"
|
||||
class="clear-btn"
|
||||
@click="clearSearchWord"
|
||||
>
|
||||
<i class="clear-icon ri-close-fill" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="result-server-list-container">
|
||||
<div class="search-list">
|
||||
<search-list-item
|
||||
v-for="item in searchResult"
|
||||
:key="item.ID"
|
||||
:info="item"
|
||||
@open-detail="openDetail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div
|
||||
class="search-active-btn"
|
||||
@click="activeSearchBox"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="ri-search-eye-line" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 搜索盒子
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
} from 'vuex';
|
||||
import {
|
||||
useRouter,
|
||||
} from 'vue-router';
|
||||
|
||||
import SearchListItem from './search-list-item.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const serverList = computed(() => store.state.serverList);
|
||||
|
||||
const show = ref(false);
|
||||
const searchWord = ref('');
|
||||
const searchResult = ref([]);
|
||||
const searchInputRef = ref(null);
|
||||
|
||||
let handleSearchTimer = null;
|
||||
function handleSearch() {
|
||||
if (handleSearchTimer) {
|
||||
clearTimeout(handleSearchTimer);
|
||||
}
|
||||
if (!searchWord.value) {
|
||||
searchResult.value = [...serverList.value];
|
||||
return;
|
||||
}
|
||||
handleSearchTimer = setTimeout(() => {
|
||||
handleSearchTimer = null;
|
||||
searchResult.value = serverList.value.filter((item) => {
|
||||
{
|
||||
const matched = item.Name.toLowerCase().includes(searchWord.value.toLowerCase());
|
||||
if (matched) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (item?.PublicNote?.planDataMod) {
|
||||
const {
|
||||
networkRoute = '',
|
||||
extra = '',
|
||||
} = item.PublicNote.planDataMod;
|
||||
return [
|
||||
networkRoute.toLowerCase().includes(searchWord.value.toLowerCase()),
|
||||
extra.toLowerCase().includes(searchWord.value.toLowerCase()),
|
||||
(item.Host.Platform || '').toLowerCase().includes(searchWord.value.toLowerCase()),
|
||||
(item.Host.CountryCode || '').toLowerCase().includes(searchWord.value.toLowerCase()),
|
||||
].some((match) => match);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function clearSearchWord() {
|
||||
searchWord.value = '';
|
||||
searchResult.value = [...serverList.value];
|
||||
}
|
||||
|
||||
function activeSearchBox() {
|
||||
searchWord.value = '';
|
||||
searchResult.value = [...serverList.value];
|
||||
show.value = true;
|
||||
// 锁定页面滚动
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 聚焦到搜索框
|
||||
setTimeout(() => {
|
||||
searchInputRef.value.focus();
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function closeSearchBox() {
|
||||
show.value = false;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function openDetail(info) {
|
||||
router.push({
|
||||
name: 'ServerDetail',
|
||||
params: {
|
||||
serverId: info.ID,
|
||||
},
|
||||
});
|
||||
closeSearchBox();
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
if (show.value) {
|
||||
closeSearchBox();
|
||||
} else {
|
||||
activeSearchBox();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscKey(event) {
|
||||
if (!show.value) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
closeSearchBox();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 监听按下快捷键 Ctrl+K 打开搜索框
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
// 监听按下 Esc 关闭搜索框
|
||||
window.addEventListener('keydown', handleEscKey);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keydown', handleEscKey);
|
||||
if (handleSearchTimer) {
|
||||
clearTimeout(handleSearchTimer);
|
||||
handleSearchTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-box-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.search-box-group {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 150px;
|
||||
z-index: 1010;
|
||||
transform: translate(-50%, 0);
|
||||
width: 600px;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(#000, 0.9);
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
width: auto;
|
||||
top: 100px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
padding: 20px;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-right: 40px;
|
||||
border-radius: 20px;
|
||||
background: #eee;
|
||||
|
||||
.search-box-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 15px;
|
||||
color: #234;
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
|
||||
.clear-icon {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-list {
|
||||
margin-top: 10px;
|
||||
height: 300px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
@media screen and (max-width: 640px) {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-active-btn {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 10;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(#000, 0.7);
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
|
||||
.icon {
|
||||
line-height: 1;
|
||||
font-size: 24px;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.fadeIn-enter-active,
|
||||
.fadeIn-leave-active {
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
.fadeIn-enter-from,
|
||||
.fadeIn-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
104
src/layout/components/search-list-item.vue
Normal file
104
src/layout/components/search-list-item.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div
|
||||
class="search-list-item"
|
||||
@click="openDetail"
|
||||
>
|
||||
<div class="server-name">
|
||||
{{ info.Name }}
|
||||
</div>
|
||||
<div class="server-tag-list">
|
||||
<span
|
||||
v-for="(tagItem, index) in tagList"
|
||||
:key="`${tagItem}_${index}`"
|
||||
class="tag-item"
|
||||
:class="{
|
||||
'has-sarasa-term': $hasSarasaTerm && config.nazhua.disableSarasaTermSC !== true,
|
||||
}"
|
||||
>
|
||||
{{ tagItem }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 搜索后的单条展示
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits([
|
||||
'open-detail',
|
||||
]);
|
||||
|
||||
const tagList = computed(() => {
|
||||
const list = [];
|
||||
const {
|
||||
networkRoute,
|
||||
extra,
|
||||
} = props?.info?.PublicNote?.planDataMod || {};
|
||||
if (networkRoute) {
|
||||
list.push(...networkRoute.split(','));
|
||||
}
|
||||
if (extra) {
|
||||
list.push(...extra.split(','));
|
||||
}
|
||||
// 列表最多显示3个标签
|
||||
return list.slice(0, 3);
|
||||
});
|
||||
|
||||
function openDetail() {
|
||||
emits('open-detail', props.info);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-list-item {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
.server-name {
|
||||
flex: 1;
|
||||
line-height: 30px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-tag-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
|
||||
.tag-item {
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
line-height: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--public-note-tag-color);
|
||||
background: var(--public-note-tag-bg);
|
||||
text-shadow: 1px 1px 2px rgba(#000, 0.2);
|
||||
border-radius: 4px;
|
||||
|
||||
&.has-sarasa-term {
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
src/layout/components/server-count.vue
Normal file
79
src/layout/components/server-count.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<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>
|
||||
<template v-if="serverCount.online !== serverCount.total">
|
||||
<span
|
||||
class="server-count server-count--online"
|
||||
>
|
||||
<span class="text">在线</span>
|
||||
<span class="value">{{ serverCount.online }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="server-count server-count--offline"
|
||||
>
|
||||
<span class="text">离线</span>
|
||||
<span class="value">{{ serverCount.offline }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 服务器数量
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
} from 'vuex';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const serverCount = computed(() => store.state.serverCount);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-count-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.server-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #ddd;
|
||||
line-height: 30px;
|
||||
|
||||
.value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.server-count--total {
|
||||
.value {
|
||||
color: #70f3ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.server-count--online {
|
||||
.value {
|
||||
color: #0f0;
|
||||
}
|
||||
}
|
||||
|
||||
&.server-count--offline {
|
||||
.value {
|
||||
color: #f00;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
254
src/layout/components/server-stat.vue
Normal file
254
src/layout/components/server-stat.vue
Normal file
@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="serverStat"
|
||||
class="server-stat-group"
|
||||
>
|
||||
<div
|
||||
v-if="serverStat.transfer"
|
||||
class="server-stat server-stat--transfer"
|
||||
>
|
||||
<span class="server-stat-label">
|
||||
<span class="text">流量</span>
|
||||
</span>
|
||||
<div class="server-stat-content">
|
||||
<span class="server-stat-item server-stat-item--in">
|
||||
<span class="ri-download-line" />
|
||||
<span class="text-value">
|
||||
{{ serverStat.transfer.inData.value }}
|
||||
</span>
|
||||
<span class="text-unit">
|
||||
{{ serverStat.transfer.inData.unit }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="server-stat-item server-stat-item--out">
|
||||
<span class="ri-upload-line" />
|
||||
<span class="text-value">
|
||||
{{ serverStat.transfer.outData.value }}
|
||||
</span>
|
||||
<span class="text-unit">
|
||||
{{ serverStat.transfer.outData.unit }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="serverStat.netSpeed"
|
||||
class="server-stat server-stat--net-speed"
|
||||
>
|
||||
<span class="server-stat-label">
|
||||
<span class="text">网速</span>
|
||||
</span>
|
||||
<div class="server-stat-content">
|
||||
<span class="server-stat-item server-stat-item--in">
|
||||
<span class="ri-arrow-down-line" />
|
||||
<span class="text-value">
|
||||
{{ serverStat.netSpeed.inData.value }}
|
||||
</span>
|
||||
<span class="text-unit">
|
||||
{{ serverStat.netSpeed.inData.unit }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="server-stat-item server-stat-item--out">
|
||||
<span class="ri-arrow-up-line" />
|
||||
<span class="text-value">
|
||||
{{ serverStat.netSpeed.outData.value }}
|
||||
</span>
|
||||
<span class="text-unit">
|
||||
{{ serverStat.netSpeed.outData.unit }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 服务器统计
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
} from 'vuex';
|
||||
import * as hostUtils from '@/utils/host';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const serverStat = computed(() => {
|
||||
const transfer = {
|
||||
in: 0,
|
||||
inData: {
|
||||
value: 0,
|
||||
unit: '',
|
||||
},
|
||||
out: 0,
|
||||
outData: {
|
||||
value: 0,
|
||||
unit: '',
|
||||
},
|
||||
};
|
||||
const netSpeed = {
|
||||
in: 0,
|
||||
inData: {
|
||||
value: 0,
|
||||
unit: '',
|
||||
},
|
||||
out: 0,
|
||||
outData: {
|
||||
value: 0,
|
||||
unit: '',
|
||||
},
|
||||
};
|
||||
if (store.state.serverList.length) {
|
||||
store.state.serverList.forEach((server) => {
|
||||
if (server.online === 1 && server.State) {
|
||||
if (typeof server.State.NetInTransfer === 'number') {
|
||||
transfer.in += server.State.NetInTransfer;
|
||||
}
|
||||
if (typeof server.State.NetOutTransfer === 'number') {
|
||||
transfer.out += server.State.NetOutTransfer;
|
||||
}
|
||||
if (typeof server.State.NetInSpeed === 'number') {
|
||||
netSpeed.in += server.State.NetInSpeed;
|
||||
}
|
||||
if (typeof server.State.NetOutSpeed === 'number') {
|
||||
netSpeed.out += server.State.NetOutSpeed;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const calcInTransfer = hostUtils.calcBinary(transfer.in);
|
||||
if (calcInTransfer.t > 1) {
|
||||
transfer.inData.value = (calcInTransfer.t).toFixed(1) * 1;
|
||||
transfer.inData.unit = 'T';
|
||||
} else if (calcInTransfer.g > 1) {
|
||||
transfer.inData.value = (calcInTransfer.g).toFixed(1) * 1;
|
||||
transfer.inData.unit = 'G';
|
||||
} else if (calcInTransfer.m > 1) {
|
||||
transfer.inData.value = (calcInTransfer.m).toFixed(1) * 1;
|
||||
transfer.inData.unit = 'M';
|
||||
} else {
|
||||
transfer.inData.value = calcInTransfer.value;
|
||||
transfer.inData.unit = 'K';
|
||||
}
|
||||
const calcOutTransfer = hostUtils.calcBinary(transfer.out);
|
||||
if (calcOutTransfer.t > 1) {
|
||||
transfer.outData.value = (calcOutTransfer.t).toFixed(1) * 1;
|
||||
transfer.outData.unit = 'T';
|
||||
} else if (calcOutTransfer.g > 1) {
|
||||
transfer.outData.value = (calcOutTransfer.g).toFixed(1) * 1;
|
||||
transfer.outData.unit = 'G';
|
||||
} else if (calcOutTransfer.m > 1) {
|
||||
transfer.outData.value = (calcOutTransfer.m).toFixed(1) * 1;
|
||||
transfer.outData.unit = 'M';
|
||||
} else {
|
||||
transfer.outData.value = calcOutTransfer.value;
|
||||
transfer.outData.unit = 'K';
|
||||
}
|
||||
const calcNetInSpeed = hostUtils.calcBinary(netSpeed.in);
|
||||
if (calcNetInSpeed.t > 1) {
|
||||
netSpeed.inData.value = (calcNetInSpeed.t).toFixed(1) * 1;
|
||||
netSpeed.inData.unit = 'T';
|
||||
} else if (calcNetInSpeed.g > 1) {
|
||||
netSpeed.inData.value = (calcNetInSpeed.g).toFixed(1) * 1;
|
||||
netSpeed.inData.unit = 'G';
|
||||
} else if (calcNetInSpeed.m > 1) {
|
||||
netSpeed.inData.value = (calcNetInSpeed.m).toFixed(1) * 1;
|
||||
netSpeed.inData.unit = 'M';
|
||||
} else {
|
||||
netSpeed.inData.value = (calcNetInSpeed.k).toFixed(1) * 1;
|
||||
netSpeed.inData.unit = 'K';
|
||||
}
|
||||
const calcNetOutSpeed = hostUtils.calcBinary(netSpeed.out);
|
||||
if (calcNetOutSpeed.t > 1) {
|
||||
netSpeed.outData.value = (calcNetOutSpeed.t).toFixed(1) * 1;
|
||||
netSpeed.outData.unit = 'T';
|
||||
} else if (calcNetOutSpeed.g > 1) {
|
||||
netSpeed.outData.value = (calcNetOutSpeed.g).toFixed(1) * 1;
|
||||
netSpeed.outData.unit = 'G';
|
||||
} else if (calcNetOutSpeed.m > 1) {
|
||||
netSpeed.outData.value = (calcNetOutSpeed.m).toFixed(1) * 1;
|
||||
netSpeed.outData.unit = 'M';
|
||||
} else {
|
||||
netSpeed.outData.value = (calcNetOutSpeed.k).toFixed(1) * 1;
|
||||
netSpeed.outData.unit = 'K';
|
||||
}
|
||||
return {
|
||||
transfer,
|
||||
netSpeed,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-stat-group {
|
||||
min-width: 160px;
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 28px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
gap: 10px;
|
||||
|
||||
.server-stat-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.server-stat-content {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.server-stat {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
line-height: 16px;
|
||||
font-size: 12px;
|
||||
|
||||
.server-stat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.server-stat-item {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.server-stat--transfer {
|
||||
.server-stat-item--in {
|
||||
.text-value {
|
||||
color: var(--transfer-in-color);
|
||||
}
|
||||
}
|
||||
|
||||
.server-stat-item--out {
|
||||
.text-value {
|
||||
color: var(--transfer-out-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-stat--net-speed {
|
||||
.server-stat-item--in {
|
||||
.text-value {
|
||||
color: var(--net-speed-in-color);
|
||||
}
|
||||
}
|
||||
|
||||
.server-stat-item--out {
|
||||
.text-value {
|
||||
color: var(--net-speed-out-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -11,8 +11,12 @@
|
||||
<layout-header />
|
||||
<slot />
|
||||
<layout-footer />
|
||||
|
||||
<search-box
|
||||
v-if="enableInnerSearch"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="config.nazhua.showFireworks">
|
||||
<template v-if="showFireworks">
|
||||
<fireworks />
|
||||
</template>
|
||||
<template v-if="config.nazhua.showLantern">
|
||||
@ -25,12 +29,19 @@
|
||||
/**
|
||||
* LayoutMain
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onUnmounted,
|
||||
} from 'vue';
|
||||
import config from '@/config';
|
||||
import Fireworks from '@/components/fireworks.vue';
|
||||
import Lantern from '@/components/lantern.vue';
|
||||
import LayoutHeader from './components/header.vue';
|
||||
import LayoutFooter from './components/footer.vue';
|
||||
import SearchBox from './components/search-box.vue';
|
||||
|
||||
const windowWidth = ref(window.innerWidth);
|
||||
|
||||
const layoutGroupStyle = computed(() => {
|
||||
const style = {};
|
||||
@ -48,6 +59,30 @@ const layoutBGStyle = computed(() => {
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const showFireworks = computed(() => {
|
||||
if (windowWidth.value < 800) {
|
||||
return false;
|
||||
}
|
||||
return config.nazhua.showFireworks;
|
||||
});
|
||||
|
||||
const enableInnerSearch = computed(() => {
|
||||
if (typeof config.nazhua.enableInnerSearch === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
return config.nazhua.enableInnerSearch;
|
||||
});
|
||||
|
||||
const handleResize = () => {
|
||||
windowWidth.value = window.innerWidth;
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
12
src/load.js
12
src/load.js
@ -17,7 +17,7 @@ function useCdnCss(item) {
|
||||
if (['cdnjs', 'loli'].includes(cdnType)) {
|
||||
cssUrl = item.cdnjs;
|
||||
if (cdnType === 'loli') {
|
||||
cssUrl = cssUrl.replace('https://cdnjs.cloudflare.com/', 'https://cdn.loli.net/');
|
||||
cssUrl = cssUrl.replace('https://cdnjs.cloudflare.com/', 'https://cdnjs.loli.net/');
|
||||
}
|
||||
}
|
||||
const cdnStylesheet = document.createElement('link');
|
||||
@ -30,16 +30,16 @@ function useCdnCss(item) {
|
||||
if (import.meta.env.VITE_USE_CDN) {
|
||||
Object.entries({
|
||||
remixicon: {
|
||||
jsdelivr: 'https://cdn.jsdelivr.net/npm/remixicon@4.5.0/fonts/remixicon.css',
|
||||
cdnjs: 'https://cdnjs.cloudflare.com/ajax/libs/remixicon/4.5.0/remixicon.css',
|
||||
jsdelivr: 'https://cdn.jsdelivr.net/npm/remixicon@4.7.0/fonts/remixicon.css',
|
||||
cdnjs: 'https://cdnjs.cloudflare.com/ajax/libs/remixicon/4.2.0/remixicon.css',
|
||||
},
|
||||
flagIcons: {
|
||||
jsdelivr: 'https://cdn.jsdelivr.net/npm/flag-icons/css/flag-icons.min.css',
|
||||
jsdelivr: 'https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css',
|
||||
cdnjs: 'https://cdnjs.cloudflare.com/ajax/libs/flag-icons/7.2.3/css/flag-icons.min.css',
|
||||
},
|
||||
fontLogos: {
|
||||
jsdelivr: 'https://cdn.jsdelivr.net/npm/font-logos/assets/font-logos.css',
|
||||
cdnjs: 'https://cdnjs.cloudflare.com/ajax/libs/font-logos/1.3.0/assets/font-logos.css',
|
||||
jsdelivr: 'https://cdn.jsdelivr.net/npm/font-logos@1.3.0/assets/font-logos.css',
|
||||
cdnjs: 'https://cdnjs.cloudflare.com/ajax/libs/font-logos/1.2.0/font-logos.css',
|
||||
},
|
||||
}).forEach(([, item]) => {
|
||||
useCdnCss(item);
|
||||
|
||||
@ -5,11 +5,15 @@ import store from './store';
|
||||
import config from './config';
|
||||
|
||||
import DotDotBox from './components/dot-dot-box.vue';
|
||||
import Popover from './components/popover.vue';
|
||||
import ServerFlag from './components/server-flag.vue';
|
||||
|
||||
export default (app) => {
|
||||
app.use(router);
|
||||
app.use(store);
|
||||
app.component('DotDotBox', DotDotBox);
|
||||
app.component('Popover', Popover);
|
||||
app.component('ServerFlag', ServerFlag);
|
||||
|
||||
app.config.globalProperties.$hasSarasaTerm = !import.meta.env.VITE_DISABLE_SARASA_TERM_SC;
|
||||
app.config.globalProperties.$config = config;
|
||||
|
||||
@ -46,68 +46,146 @@ export function getCPUInfo(text = '') {
|
||||
[cpuInfo.model] = modelMatch;
|
||||
}
|
||||
if (text.includes('Ryzen')) {
|
||||
// 5900X 5950X 7900X 7950X 9900X 9950X
|
||||
const modelNumReg = /Ryzen.*(\d{4}X)/;
|
||||
// 匹配各种Ryzen型号:
|
||||
// - 标准型号: 5900X, 5950X, 7900X, 7950X, 9900X, 9950X
|
||||
// - 普通型号: 3600, 5600, 7600
|
||||
// - G系列APU: 5700G, 3400G
|
||||
// - XT系列: 3600XT, 5600XT
|
||||
// - 移动版: 4800U, 5800H, 6800HS
|
||||
const modelNumReg = /Ryzen\s*(?:\d|(?:TR))\s*(?:\d{4}(?:[A-Z]{1,2})?)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
cpuInfo.modelNum = modelNumMatch[0].replace(/Ryzen\s*(?:\d|(?:TR))\s*/, '');
|
||||
} else {
|
||||
// 备用正则表达式,尝试匹配其他可能的格式
|
||||
const altModelNumReg = /Ryzen.*?(\d{3,4}(?:[A-Z]{0,2}))/;
|
||||
const altModelNumMatch = text.match(altModelNumReg);
|
||||
if (altModelNumMatch) {
|
||||
[, cpuInfo.modelNum] = altModelNumMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.includes('EPYC')) {
|
||||
// 7B13 7B13 9654...
|
||||
const modelNumReg = /EPYC (\w{4})/;
|
||||
// 匹配各种EPYC型号:
|
||||
// - 第一代: 7001系列 (7351, 7551, 7601)
|
||||
// - 第二代: 7002系列 (7252, 7542, 7742)
|
||||
// - 第三代: 7003系列 (7313, 7543, 7763)
|
||||
// - 第四代: 9004系列 (9124, 9354, 9654)
|
||||
// - 特殊系列: 7Fxx, 7Hxx, 7Bxx (7F72, 7H12, 7B13)
|
||||
const modelNumReg = /EPYC\s+(\d[A-Z0-9]{2,4})/i;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
} else {
|
||||
// 备用匹配,处理可能的其他格式
|
||||
const altModelNumReg = /EPYC.*?(\d{4,5}[A-Z]?)/i;
|
||||
const altModelNumMatch = text.match(altModelNumReg);
|
||||
if (altModelNumMatch) {
|
||||
[, cpuInfo.modelNum] = altModelNumMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 匹配特定的CPU型号编号
|
||||
if (text.includes('Xeon')) {
|
||||
// 匹配所有Xeon处理器系列
|
||||
// - E系列: E3, E5, E7等
|
||||
// - 金属系列: Platinum, Gold, Silver, Bronze
|
||||
// - 数字系列: W-1290, D-1653N等
|
||||
// - 扩展名系列: L, X, M, D等(如X7560, L5640)
|
||||
if (text.includes(' E')) {
|
||||
// Xeon型号
|
||||
const modelNumReg = /(E\d-\w+)/;
|
||||
const modelNumReg = /(E\d-\d{4}(?:\s?v\d)?)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
}
|
||||
if (text.includes('Gold')) {
|
||||
// Xeon型号
|
||||
const modelNumReg = /(Gold\s\w+)/;
|
||||
} else if (text.includes('Platinum')) {
|
||||
const modelNumReg = /(?:Platinum\s+)(\d{4}(?:\w)?)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
} else if (text.includes('Gold')) {
|
||||
const modelNumReg = /(?:Gold\s+)(\d{4}(?:\w)?)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
} else if (text.includes('Silver')) {
|
||||
const modelNumReg = /(?:Silver\s+)(\d{4}(?:\w)?)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
} else if (text.includes('Bronze')) {
|
||||
const modelNumReg = /(?:Bronze\s+)(\d{4}(?:\w)?)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
} else {
|
||||
// 通用Xeon型号匹配
|
||||
const genericXeonReg = /Xeon(?:\(R\))?\s+(?:\w+-)?((?:W|D)?-?\d{4,5}(?:\w)?)/;
|
||||
const genericMatch = text.match(genericXeonReg);
|
||||
if (genericMatch) {
|
||||
[, cpuInfo.modelNum] = genericMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.includes('Core(TM)')) {
|
||||
const modelNumReg = /Core\(TM\) (\w+-\w+)/;
|
||||
|
||||
if (text.includes('Core')) {
|
||||
if (text.includes('Core(TM)')) {
|
||||
// 匹配如 Core(TM) i7-10700K 等格式
|
||||
const modelNumReg = /Core\(TM\)\s+(\w\d+-\w+)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
} else {
|
||||
// 匹配如 Core i9-12900K, Core i5-13600K 等格式
|
||||
const coreReg = /Core\s+(i[3579]-\d{4,5}(?:\w+)?)/i;
|
||||
const coreMatch = text.match(coreReg);
|
||||
if (coreMatch) {
|
||||
[, cpuInfo.modelNum] = coreMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('Celeron')) {
|
||||
const modelNumReg = /Celeron(?:\(R\))?\s+(\w+\d+(?:\w+)?)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
}
|
||||
if (text.includes('Celeron(R)')) {
|
||||
const modelNumReg = /Celeron\(R\) (\w+)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
}
|
||||
if (text.includes('Pentium(R)')) {
|
||||
const modelNumReg = /Pentium\(R\) (\w+)/;
|
||||
|
||||
if (text.includes('Pentium')) {
|
||||
const modelNumReg = /Pentium(?:\(R\))?\s+(\w+\d+(?:\w+)?)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('Intel(R) N')) {
|
||||
const modelNumReg = /Intel\(R\) (N\d+)/;
|
||||
const modelNumReg = /Intel\(R\)\s+(N\d+(?:\w+)?)/;
|
||||
const modelNumMatch = text.match(modelNumReg);
|
||||
if (modelNumMatch) {
|
||||
[, cpuInfo.modelNum] = modelNumMatch;
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配Apple M系列芯片
|
||||
if (text.includes('Apple') && text.match(/M\d/)) {
|
||||
// 匹配各种Apple Silicon M系列芯片:
|
||||
// - 基本型号: M1, M2, M3等
|
||||
// - 变种型号: M1 Pro, M2 Max, M3 Ultra等
|
||||
const appleChipReg = /Apple\s+(?:Silicon\s+)?M(\d+(?:\s+(?:Pro|Max|Ultra|Extreme))?)/i;
|
||||
const appleChipMatch = text.match(appleChipReg);
|
||||
if (appleChipMatch) {
|
||||
[, cpuInfo.modelNum] = appleChipMatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (coresMatch) {
|
||||
[cpuInfo.core, cpuInfo.cores] = coresMatch;
|
||||
}
|
||||
|
||||
@ -35,7 +35,13 @@ export default async () => fetch(getNezhaConfigUrl()).then((res) => res.text()).
|
||||
if (!configStr) {
|
||||
return null;
|
||||
}
|
||||
const remoteConfig = JSON.parse(unescaped(configStr));
|
||||
let remoteConfig;
|
||||
try {
|
||||
remoteConfig = JSON.parse(unescaped(configStr));
|
||||
} catch (error) {
|
||||
console.error('Failed to parse nezha config:', error);
|
||||
return null;
|
||||
}
|
||||
if (remoteConfig?.servers) {
|
||||
remoteConfig.servers = remoteConfig.servers.map((i) => {
|
||||
const item = {
|
||||
@ -43,7 +49,8 @@ export default async () => fetch(getNezhaConfigUrl()).then((res) => res.text()).
|
||||
};
|
||||
try {
|
||||
item.PublicNote = JSON.parse(i.PublicNote);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse PublicNote for server:', i.ID || i.id, error);
|
||||
item.PublicNote = {};
|
||||
}
|
||||
return item;
|
||||
@ -51,7 +58,10 @@ export default async () => fetch(getNezhaConfigUrl()).then((res) => res.text()).
|
||||
return remoteConfig;
|
||||
}
|
||||
return null;
|
||||
}).catch(() => null);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load nezha config:', error);
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取标签列表
|
||||
|
||||
@ -21,7 +21,10 @@ export const loadServerGroup = async () => request({
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}).catch(() => null);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load server group:', error);
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载网站配置
|
||||
@ -37,7 +40,10 @@ export const loadSetting = async () => request({
|
||||
return res.data?.data || {};
|
||||
}
|
||||
return null;
|
||||
}).catch(() => null);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load setting:', error);
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载个人信息
|
||||
@ -53,4 +59,7 @@ export const loadProfile = async (check) => request({
|
||||
return res.data?.data || {};
|
||||
}
|
||||
return null;
|
||||
}).catch(() => null);
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load profile:', error);
|
||||
return null;
|
||||
});
|
||||
|
||||
@ -100,7 +100,11 @@ export default function (v1Data) {
|
||||
v0Data[key] = Mapping.each(magics[$magic[1]], v1Data);
|
||||
if (key === 'State') {
|
||||
// 修复Load1、Load5、Load15字段为空时的问题
|
||||
['Load1', 'Load5', 'Load15'].forEach((k) => {
|
||||
[
|
||||
'Load1', 'Load5', 'Load15',
|
||||
'NetInTransfer', 'NetOutTransfer',
|
||||
'NetInSpeed', 'NetOutSpeed',
|
||||
].forEach((k) => {
|
||||
if (!validate.isSet(v0Data[key][k])) {
|
||||
v0Data[key][k] = 0;
|
||||
}
|
||||
@ -118,10 +122,11 @@ export default function (v1Data) {
|
||||
try {
|
||||
v0Data.PublicNote = JSON.parse(v1Data.public_note);
|
||||
} catch (e) {
|
||||
v1Data.PublicNote = null;
|
||||
console.warn('Failed to parse public_note for server:', v1Data.id, e);
|
||||
v0Data.PublicNote = null;
|
||||
}
|
||||
} else {
|
||||
v1Data.PublicNote = null;
|
||||
v0Data.PublicNote = null;
|
||||
}
|
||||
return v0Data;
|
||||
}
|
||||
|
||||
13
src/utils/zIndexManager.js
Normal file
13
src/utils/zIndexManager.js
Normal file
@ -0,0 +1,13 @@
|
||||
const BASE_Z_INDEX = 1000;
|
||||
let zIndexCounter = BASE_Z_INDEX;
|
||||
|
||||
export const getNextZIndex = () => {
|
||||
zIndexCounter += 1;
|
||||
return zIndexCounter;
|
||||
};
|
||||
|
||||
export const getCurrentZIndex = () => zIndexCounter;
|
||||
|
||||
export const resetZIndex = () => {
|
||||
zIndexCounter = BASE_Z_INDEX;
|
||||
};
|
||||
@ -68,16 +68,45 @@
|
||||
</div>
|
||||
<div class="server-info-content">
|
||||
<div class="server-info-item-group">
|
||||
<span
|
||||
<template
|
||||
v-for="(ttItem, ttIndex) in temperatureData.list"
|
||||
:key="`${info.ID}_temperature_${ttIndex}`"
|
||||
class="server-info-item"
|
||||
:class="`temperature--${ttItem.type}`"
|
||||
:title="ttItem?.title || ''"
|
||||
>
|
||||
<span class="server-info-item-label">{{ ttItem.label }}</span>
|
||||
<span class="server-info-item-value">{{ ttItem.value }}</span>
|
||||
</span>
|
||||
<popover :title="ttItem?.title || (`${ttItem.label}: ${ttItem.value}`)">
|
||||
<template #trigger>
|
||||
<span
|
||||
class="server-info-item"
|
||||
:class="`temperature--${ttItem.type}`"
|
||||
>
|
||||
<span class="server-info-item-icon">
|
||||
<i
|
||||
v-if="ttItem.type === 'cpu' || ttItem.label.toLowerCase().includes('cpu')"
|
||||
class="ri-cpu-line"
|
||||
/>
|
||||
<i
|
||||
v-else-if="ttItem.type === 'gpu' || ttItem.label.toLowerCase().includes('gpu')"
|
||||
class="ri-gamepad-line"
|
||||
/>
|
||||
<i
|
||||
v-else-if="ttItem.type === 'nvme' || ttItem.label.toLowerCase().includes('nvme')"
|
||||
class="ri-hard-drive-3-line"
|
||||
/>
|
||||
<i
|
||||
v-else-if="ttItem.type === 'motherboard'"
|
||||
class="ri-instance-line"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="ri-temp-hot-line"
|
||||
/>
|
||||
</span>
|
||||
<span class="server-info-item-value">
|
||||
{{ ttItem.value }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</popover>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -231,7 +260,7 @@
|
||||
@click.stop="toBuy"
|
||||
>
|
||||
<span class="icon">
|
||||
<span class="ri-shopping-bag-3-line" />
|
||||
<span :class="buyBtnIcon" />
|
||||
</span>
|
||||
<span class="text">{{ buyBtnText }}</span>
|
||||
</div>
|
||||
@ -260,7 +289,18 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const buyBtnText = computed(() => config.nazhua.buyBtnText || '购买');
|
||||
const buyBtnIcon = computed(() => {
|
||||
if (props.info?.PublicNote?.customData?.buyBtnIcon) {
|
||||
return props.info?.PublicNote?.customData?.buyBtnIcon;
|
||||
}
|
||||
return config.nazhua.buyBtnIcon || 'ri-shopping-bag-3-line';
|
||||
});
|
||||
const buyBtnText = computed(() => {
|
||||
if (props.info?.PublicNote?.customData?.buyBtnText) {
|
||||
return props.info?.PublicNote?.customData?.buyBtnText;
|
||||
}
|
||||
return config.nazhua.buyBtnText || '购买';
|
||||
});
|
||||
const showBuyBtn = computed(() => !!props.info?.PublicNote?.customData?.orderLink);
|
||||
|
||||
function toBuy() {
|
||||
@ -305,95 +345,157 @@ const temperatureData = computed(() => {
|
||||
const acpitz = [];
|
||||
const coretemp_package_id = [];
|
||||
const coretemp_core = [];
|
||||
const nvme = [];
|
||||
const k10temp = [];
|
||||
const amdgpu = [];
|
||||
const other = [];
|
||||
|
||||
// 温度数据分类处理
|
||||
props.info.State.Temperatures.forEach((item) => {
|
||||
if (item.Name.indexOf('acpitz') === 0) {
|
||||
acpitz.push(item.Temperature);
|
||||
const name = item.Name.toLowerCase();
|
||||
const temp = item.Temperature;
|
||||
|
||||
if (name.startsWith('acpitz')) {
|
||||
acpitz.push(temp);
|
||||
return;
|
||||
}
|
||||
if (item.Name.indexOf('coretemp_package_id_') === 0) {
|
||||
const coreIndex = parseInt(item.Name.replace('coretemp_package_id_', ''), 10);
|
||||
if (name.startsWith('coretemp_package_id_')) {
|
||||
const coreIndex = parseInt(name.replace('coretemp_package_id_', ''), 10);
|
||||
coretemp_package_id.push({
|
||||
index: coreIndex,
|
||||
value: `${item.Temperature}℃`,
|
||||
value: temp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (item.Name.indexOf('coretemp_core_') === 0) {
|
||||
const coreIndex = parseInt(item.Name.replace('coretemp_core_', ''), 10);
|
||||
if (name.startsWith('coretemp_core_')) {
|
||||
const coreIndex = parseInt(name.replace('coretemp_core_', ''), 10);
|
||||
coretemp_core.push({
|
||||
index: coreIndex,
|
||||
value: item.Temperature,
|
||||
value: temp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (name.includes('nvme')) {
|
||||
nvme.push({
|
||||
name: item.Name,
|
||||
value: temp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (name.includes('k10temp')) {
|
||||
k10temp.push({
|
||||
name: item.Name,
|
||||
value: temp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (name.includes('amdgpu')) {
|
||||
amdgpu.push({
|
||||
name: item.Name,
|
||||
value: temp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (name.includes('motherboard') || name.includes('mainboard') || name.includes('board')) {
|
||||
other.push({
|
||||
label: '主板',
|
||||
value: temp,
|
||||
type: 'motherboard',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// console.log(item);
|
||||
other.push({
|
||||
label: item.Name,
|
||||
value: `${item.Temperature}℃`,
|
||||
value: temp,
|
||||
type: 'other',
|
||||
});
|
||||
});
|
||||
|
||||
// 主板温度处理
|
||||
if (acpitz.length) {
|
||||
const acpitzMean = (acpitz.reduce((a, b) => a + b, 0) / acpitz.length).toFixed(1);
|
||||
data.push({
|
||||
label: '主板',
|
||||
value: `${acpitz[0]}℃`,
|
||||
type: 'acpitz',
|
||||
value: `${acpitzMean}℃`,
|
||||
title: acpitz.map((i, index) => `传感器${index + 1}: ${parseFloat(i).toFixed(1)}℃`).join('\n'),
|
||||
type: 'motherboard',
|
||||
});
|
||||
if (acpitz.length) {
|
||||
const acpitzMean = (acpitz.reduce((a, b) => a + b, 0) / acpitz.length).toFixed(1) * 1;
|
||||
}
|
||||
|
||||
// CPU温度处理
|
||||
if (coretemp_package_id.length || coretemp_core.length) {
|
||||
const temps = [];
|
||||
const details = [];
|
||||
|
||||
// 处理 CPU 温度
|
||||
if (coretemp_package_id.length) {
|
||||
const cpuTemps = coretemp_package_id.map((i) => `${parseFloat(i.value).toFixed(1)}℃`);
|
||||
temps.push(cpuTemps.join(', '));
|
||||
details.push(...coretemp_package_id.map((i) => `CPU.${i.index + 1}: ${parseFloat(i.value).toFixed(1)}℃`));
|
||||
}
|
||||
|
||||
// 处理核心温度
|
||||
if (coretemp_core.length) {
|
||||
const coreMean = (coretemp_core.reduce((a, b) => a + b.value, 0) / coretemp_core.length).toFixed(1);
|
||||
temps.push(`${parseFloat(coreMean).toFixed(1)}℃`);
|
||||
details.push(...coretemp_core.map((i) => `核心${i.index + 1}: ${parseFloat(i.value).toFixed(1)}℃`));
|
||||
}
|
||||
|
||||
data.push({
|
||||
label: 'CPU',
|
||||
value: temps.join(' / '),
|
||||
title: details.join('\n'),
|
||||
type: 'cpu',
|
||||
});
|
||||
}
|
||||
|
||||
// AMD CPU温度处理
|
||||
if (k10temp.length) {
|
||||
const tctl = k10temp.find((i) => i.name.includes('tctl'));
|
||||
if (tctl) {
|
||||
data.push({
|
||||
label: '主板平均',
|
||||
value: `${acpitzMean}℃`,
|
||||
title: acpitz.map((i, index) => `传感器${index + 1}: ${i}℃`).join('\n'),
|
||||
type: 'acpitz-mean',
|
||||
label: 'AMD CPU',
|
||||
value: `${parseFloat(tctl.value).toFixed(1)}℃`,
|
||||
title: k10temp.map((i) => `${i.name}: ${parseFloat(i.value).toFixed(1)}℃`).join('\n'),
|
||||
type: 'cpu',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (coretemp_package_id.length) {
|
||||
data.push({
|
||||
label: 'CPU温度',
|
||||
value: coretemp_package_id.map((i) => i.value).join(', '),
|
||||
title: coretemp_package_id.length > 1
|
||||
? coretemp_package_id.map((i) => `CPU.${i.index + 1}: ${i.value}`).join('\n')
|
||||
: '',
|
||||
type: 'coretemp-package',
|
||||
});
|
||||
}
|
||||
if (coretemp_core.length) {
|
||||
const coretempCoreMean = (coretemp_core.reduce((a, b) => a + b.value, 0) / coretemp_core.length).toFixed(1) * 1;
|
||||
data.push({
|
||||
label: '核心平均',
|
||||
value: `${coretempCoreMean}℃`,
|
||||
title: coretemp_core.map((i) => `核心${i.index + 1}: ${i.value}℃`).join('\n'),
|
||||
type: 'coretemp-core',
|
||||
});
|
||||
// 最高温度的核心
|
||||
let max;
|
||||
let maxCore;
|
||||
coretemp_core.forEach((i) => {
|
||||
if (max === undefined || i.value > max) {
|
||||
max = i.value;
|
||||
maxCore = i.index;
|
||||
}
|
||||
});
|
||||
// 当最高温度的核心温度比平均温度高 20% 时,显示
|
||||
if (max / coretempCoreMean > 1.2) {
|
||||
|
||||
// AMD GPU温度处理
|
||||
if (amdgpu.length) {
|
||||
const edge = amdgpu.find((i) => i.name.includes('edge'));
|
||||
if (edge) {
|
||||
data.push({
|
||||
label: `最热核心.${maxCore + 1}`,
|
||||
value: `${max}℃`,
|
||||
type: 'coretemp-max-core',
|
||||
label: 'AMD GPU',
|
||||
value: `${parseFloat(edge.value).toFixed(1)}℃`,
|
||||
title: amdgpu.map((i) => `${i.name}: ${parseFloat(i.value).toFixed(1)}℃`).join('\n'),
|
||||
type: 'gpu',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (other.length) {
|
||||
data.push({
|
||||
type: 'other',
|
||||
label: '其它',
|
||||
value: '...',
|
||||
title: other.map((i) => `${i.label}: ${i.value}`).join('\n'),
|
||||
});
|
||||
|
||||
// NVME温度处理
|
||||
if (nvme.length) {
|
||||
const composite = nvme.find((i) => i.name.includes('composite'));
|
||||
if (composite) {
|
||||
data.push({
|
||||
label: 'NVME',
|
||||
value: `${parseFloat(composite.value).toFixed(1)}℃`,
|
||||
title: nvme.map((i) => `${i.name}: ${parseFloat(i.value).toFixed(1)}℃`).join('\n'),
|
||||
type: 'nvme',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 其他温度处理
|
||||
other.forEach((i) => {
|
||||
data.push({
|
||||
label: i.label,
|
||||
value: `${parseFloat(i.value).toFixed(1)}℃`,
|
||||
type: i.type || 'other',
|
||||
});
|
||||
});
|
||||
}
|
||||
return {
|
||||
list: data,
|
||||
@ -543,6 +645,17 @@ const processCount = computed(() => props.info?.State?.ProcessCount);
|
||||
.server-info-item {
|
||||
display: flex;
|
||||
gap: 0.2em;
|
||||
align-items: center;
|
||||
|
||||
.server-info-item-icon {
|
||||
width: 24px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.server-info-item-value {
|
||||
@ -567,6 +680,17 @@ const processCount = computed(() => props.info?.State?.ProcessCount);
|
||||
}
|
||||
}
|
||||
|
||||
.server-info--temperature {
|
||||
.server-info-item {
|
||||
.server-info-item-label {
|
||||
max-width: 4.5em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-info--order-link {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
<dot-dot-box
|
||||
v-if="monitorData.length"
|
||||
class="server-monitor-group"
|
||||
:class="{
|
||||
'chart-type--multi': config.nazhua.monitorChartTypeToggle && monitorChartType === 'multi',
|
||||
'chart-type--single': config.nazhua.monitorChartTypeToggle && monitorChartType === 'single',
|
||||
}"
|
||||
padding="16px 20px"
|
||||
>
|
||||
<div class="module-head-group">
|
||||
@ -11,6 +15,22 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="right-box">
|
||||
<div
|
||||
v-if="config.nazhua.monitorChartTypeToggle"
|
||||
class="chart-type-switch-group"
|
||||
title="监控折线图是否聚合"
|
||||
@click="switchChartType"
|
||||
>
|
||||
<span class="label-text">聚合</span>
|
||||
<div
|
||||
class="switch-box"
|
||||
:class="{
|
||||
active: monitorChartType === 'multi',
|
||||
}"
|
||||
>
|
||||
<span class="switch-dot" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="refresh-data-group"
|
||||
title="是否自动刷新"
|
||||
@ -66,46 +86,101 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="monitor-cate-group">
|
||||
<div
|
||||
v-for="cateItem in monitorChartData.cateList"
|
||||
:key="cateItem.id"
|
||||
class="monitor-cate-item"
|
||||
:class="{
|
||||
disabled: showCates[cateItem.id] === false,
|
||||
}"
|
||||
:style="{
|
||||
'--cate-color': cateItem.color,
|
||||
}"
|
||||
:title="cateItem.title"
|
||||
@click="toggleShowCate(cateItem.id)"
|
||||
>
|
||||
<span class="cate-legend" />
|
||||
<span
|
||||
class="cate-name"
|
||||
<template v-if="monitorChartType === 'single'">
|
||||
<div class="monitor-chart-group">
|
||||
<div
|
||||
v-for="(cateItem, index) in monitorChartData.cateList"
|
||||
:key="cateItem.id"
|
||||
class="monitor-chart-item"
|
||||
>
|
||||
{{ cateItem.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="cateItem.avg !== 0"
|
||||
class="cate-avg-ms"
|
||||
>
|
||||
{{ cateItem.avg }}ms
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="cate-avg-ms"
|
||||
>
|
||||
-ms
|
||||
</span>
|
||||
<div class="cate-name-box">
|
||||
<popover :title="cateItem.title">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="monitor-cate-item"
|
||||
:class="{
|
||||
disabled: showCates[cateItem.id] === false,
|
||||
}"
|
||||
:style="{
|
||||
'--cate-color': cateItem.color,
|
||||
}"
|
||||
>
|
||||
<span class="cate-legend" />
|
||||
<span
|
||||
class="cate-name"
|
||||
>
|
||||
{{ cateItem.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="cateItem.avg !== 0"
|
||||
class="cate-avg-ms"
|
||||
>
|
||||
{{ cateItem.avg }}ms
|
||||
</span>
|
||||
<span
|
||||
v-if="cateItem.over !== 0"
|
||||
class="cate-over-rate"
|
||||
>
|
||||
{{ cateItem.over }}%
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</popover>
|
||||
</div>
|
||||
<line-chart
|
||||
:date-list="monitorChartData.dateList"
|
||||
:value-list="[monitorChartData.valueList[index]]"
|
||||
:size="240"
|
||||
:connect-nulls="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="monitor-cate-group">
|
||||
<template
|
||||
v-for="cateItem in monitorChartData.cateList"
|
||||
:key="cateItem.id"
|
||||
>
|
||||
<popover :title="cateItem.title">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="monitor-cate-item"
|
||||
:class="{
|
||||
disabled: showCates[cateItem.id] === false,
|
||||
}"
|
||||
:style="{
|
||||
'--cate-color': cateItem.color,
|
||||
}"
|
||||
@click="toggleShowCate(cateItem.id)"
|
||||
@touchstart="handleTouchStart(cateItem.id)"
|
||||
@touchend="handleTouchEnd(cateItem.id)"
|
||||
@touchmove="handleTouchMove(cateItem.id)"
|
||||
>
|
||||
<span class="cate-legend" />
|
||||
<span
|
||||
class="cate-name"
|
||||
>
|
||||
{{ cateItem.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="cateItem.avg !== 0"
|
||||
class="cate-avg-ms"
|
||||
>
|
||||
{{ cateItem.avg }}ms
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</popover>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<line-chart
|
||||
:cate-list="monitorChartData.cateList"
|
||||
:date-list="monitorChartData.dateList"
|
||||
:value-list="monitorChartData.valueList"
|
||||
/>
|
||||
<line-chart
|
||||
:date-list="monitorChartData.dateList"
|
||||
:value-list="monitorChartData.valueList"
|
||||
:connect-nulls="false"
|
||||
/>
|
||||
</template>
|
||||
</dot-dot-box>
|
||||
</template>
|
||||
|
||||
@ -160,14 +235,35 @@ const minutes = [{
|
||||
label: '24小时',
|
||||
value: 1440,
|
||||
}];
|
||||
const refreshData = ref(true);
|
||||
const peakShaving = ref(false);
|
||||
const localData = {
|
||||
peakShaving: window.localStorage.getItem('nazhua_monitor_peak_shaving'),
|
||||
refreshData: window.localStorage.getItem('nazhua_monitor_refresh_data'),
|
||||
chartType: window.localStorage.getItem('nazhua_monitor_chart_type'),
|
||||
};
|
||||
localData.peakShaving = validate.isSet(localData.peakShaving) ? localData.peakShaving === 'true' : false;
|
||||
localData.refreshData = validate.isSet(localData.refreshData) ? localData.refreshData === 'true' : true;
|
||||
|
||||
const peakShaving = ref(localData.peakShaving);
|
||||
const refreshData = ref(localData.refreshData);
|
||||
const showCates = ref({});
|
||||
|
||||
const monitorData = ref([]);
|
||||
const longPressTimer = ref(null);
|
||||
|
||||
const now = ref(Date.now());
|
||||
const accpetShowTime = computed(() => now.value - (minute.value * 60 * 1000));
|
||||
const chartType = validate.isSet(localData.chartType)
|
||||
? ref(localData.chartType)
|
||||
: ref(config.nazhua.monitorChartType === 'single' ? 'single' : 'multi');
|
||||
const monitorChartType = computed(() => {
|
||||
if (config.nazhua.monitorChartTypeToggle) {
|
||||
return chartType.value;
|
||||
}
|
||||
return config.nazhua.monitorChartType;
|
||||
});
|
||||
|
||||
// 服务器时间(后面来自接口)
|
||||
const nowServerTime = computed(() => store.state.serverTime || Date.now());
|
||||
// const nowServerTime = computed(() => Date.now());
|
||||
// console.log(store.state.serverTime);
|
||||
const acceptShowTime = computed(() => (Math.floor(nowServerTime.value / 60000) - minute.value) * 60000);
|
||||
|
||||
const minuteActiveArrowStyle = computed(() => {
|
||||
const index = minutes.findIndex((i) => i.value === minute.value);
|
||||
@ -186,98 +282,162 @@ const monitorChartData = computed(() => {
|
||||
* - valueList {Array}: 包含以下内容的对象列表:
|
||||
* - name {String}: 监控名称。
|
||||
* - data {Array}: [时间戳, 平均延迟] 对的数组。
|
||||
*
|
||||
* 该函数执行以下步骤:
|
||||
* 1. 遍历监控数据以分类和过滤平均延迟。
|
||||
* 2. 如果启用了削峰,则应用削峰以过滤异常值。
|
||||
* 3. 构建监控名称到其各自时间戳和平均延迟的映射。
|
||||
* 4. 将映射转换为监控名称、时间戳和平均延迟数据的列表。
|
||||
* 5. 删除重复的时间戳并对其进行排序。
|
||||
*/
|
||||
const cateList = [];
|
||||
const cateMap = {};
|
||||
const dateSet = new Set();
|
||||
let valueList = [];
|
||||
monitorData.value.forEach((i) => {
|
||||
const dateMap = {};
|
||||
if (!cateMap[i.monitor_name]) {
|
||||
cateMap[i.monitor_name] = {
|
||||
id: i.monitor_id,
|
||||
dateMap,
|
||||
avgs: [],
|
||||
const dateMap = new Map();
|
||||
const {
|
||||
monitor_name,
|
||||
monitor_id,
|
||||
created_at,
|
||||
avg_delay,
|
||||
} = i;
|
||||
if (!cateMap[monitor_name]) {
|
||||
cateMap[monitor_name] = {
|
||||
id: monitor_id,
|
||||
};
|
||||
}
|
||||
const showAvgDelay = [];
|
||||
const showCreateTime = i.created_at.filter((o, index) => {
|
||||
const status = o >= accpetShowTime.value;
|
||||
const cateDelayMap = new Map();
|
||||
const cateAcceptTimeMap = new Map();
|
||||
const cateCreateTime = new Set();
|
||||
|
||||
// 实际数据的最早时间戳
|
||||
let earliestTimestamp = nowServerTime.value;
|
||||
created_at.forEach((time, index) => {
|
||||
if (time < earliestTimestamp) {
|
||||
earliestTimestamp = time;
|
||||
}
|
||||
const status = time >= acceptShowTime.value;
|
||||
|
||||
// 允许显示的数据,记录到cateAcceptTime
|
||||
if (status) {
|
||||
showAvgDelay.push(i.avg_delay[index]);
|
||||
if (import.meta.env.VITE_MONITOR_DEBUG === '1' && cateAcceptTimeMap.has(time)) {
|
||||
console.log(`${monitor_name} ${time} 重复,值对比: ${avg_delay[index]} vs ${cateAcceptTimeMap.get(time)}`);
|
||||
}
|
||||
cateAcceptTimeMap.set(time, avg_delay[index]);
|
||||
}
|
||||
return status;
|
||||
});
|
||||
if (import.meta.env.VITE_MONITOR_DEBUG === '1') {
|
||||
console.log(`${monitor_name} created_at`, earliestTimestamp);
|
||||
console.log(`${monitor_name} created_at`, JSON.parse(JSON.stringify(created_at)));
|
||||
console.log(`${monitor_name} avg_delay`, JSON.parse(JSON.stringify(avg_delay)));
|
||||
}
|
||||
|
||||
// 允许显示的最早时间戳,用于生成显示时间范围内的数据
|
||||
const actualStartTime = Math.max(
|
||||
acceptShowTime.value,
|
||||
earliestTimestamp,
|
||||
);
|
||||
|
||||
// 显示时间范围内的分钟数
|
||||
const allMintues = Math.floor((Date.now() - actualStartTime) / 60000);
|
||||
|
||||
// 合成分钟数据
|
||||
for (let j = 0; j < allMintues; j += 1) {
|
||||
const time = actualStartTime + j * 60000;
|
||||
// 记录创建时间
|
||||
cateCreateTime.add(time);
|
||||
// 记录延迟数据
|
||||
const timeProp = cateAcceptTimeMap.get(time);
|
||||
cateDelayMap.set(time, timeProp ?? undefined);
|
||||
}
|
||||
|
||||
// 计算削峰阈值
|
||||
const {
|
||||
threshold,
|
||||
mean,
|
||||
max,
|
||||
min,
|
||||
} = peakShaving.value ? getThreshold(showAvgDelay, 2) : {};
|
||||
showCreateTime.forEach((o, index) => {
|
||||
if (dateMap[o]) {
|
||||
return;
|
||||
}
|
||||
const avgDelay = showAvgDelay[index];
|
||||
median,
|
||||
tolerancePercent,
|
||||
} = peakShaving.value ? getThreshold(Array.from(cateDelayMap.values())) : {};
|
||||
|
||||
// 合成分钟数据
|
||||
cateCreateTime.values().forEach((time) => {
|
||||
const avgDelay = cateDelayMap.get(time) * 1;
|
||||
|
||||
// 只对有效的延迟值进行削峰判断
|
||||
if (peakShaving.value) {
|
||||
if (avgDelay === 0) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(avgDelay - mean) > threshold && max / min > 2) {
|
||||
// 削峰过滤:根据中位数和动态容差百分比判断异常值
|
||||
const threshold = median * tolerancePercent;
|
||||
// 当偏离中位数超过阈值时,视为异常值
|
||||
if (Math.abs(avgDelay - median) > threshold) {
|
||||
dateMap.set(time, null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dateMap[o] = (avgDelay).toFixed(2) * 1;
|
||||
// 无数据或无效数据的情况,设置为undefined
|
||||
if (Number.isNaN(avgDelay)) {
|
||||
dateMap.set(time, undefined);
|
||||
} else {
|
||||
dateMap.set(time, (avgDelay).toFixed(2) * 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
let dateList = [];
|
||||
let valueList = [];
|
||||
const cateList = [];
|
||||
Object.keys(cateMap).forEach((i) => {
|
||||
const {
|
||||
id,
|
||||
dateMap,
|
||||
avgs,
|
||||
} = cateMap[i];
|
||||
Object.entries(dateMap).forEach(([key, value]) => {
|
||||
const time = parseInt(key, 10);
|
||||
avgs.push([time, value]);
|
||||
dateList.push(time);
|
||||
|
||||
const lineData = [];
|
||||
const validatedData = [];
|
||||
const overValidatedData = [];
|
||||
let delayTotal = 0;
|
||||
dateMap.forEach((val, key) => {
|
||||
const time = parseInt(key, 10); // 时间戳
|
||||
lineData.push([time, val || null]);
|
||||
if (val) {
|
||||
dateSet.add(time);
|
||||
validatedData.push([time, val]);
|
||||
delayTotal += val;
|
||||
}
|
||||
if (val !== undefined) {
|
||||
overValidatedData.push([time, val]);
|
||||
}
|
||||
});
|
||||
const color = getLineColor(id);
|
||||
if (avgs.length) {
|
||||
|
||||
if (import.meta.env.VITE_MONITOR_DEBUG === '1') {
|
||||
cateMap[monitor_name].origin = {
|
||||
cateCreateTime,
|
||||
cateDelayMap,
|
||||
cateAcceptTimeMap,
|
||||
dateMap,
|
||||
lineData,
|
||||
validatedData,
|
||||
overValidatedData,
|
||||
delayTotal,
|
||||
};
|
||||
}
|
||||
|
||||
const id = monitor_id;
|
||||
// 计算平均延迟
|
||||
const avgDelay = delayTotal / validatedData.length || 0;
|
||||
|
||||
if (lineData && lineData.length) {
|
||||
if (!validate.hasOwn(showCates.value, id)) {
|
||||
showCates.value[id] = true;
|
||||
}
|
||||
// 计算平均延迟和成功率
|
||||
const validAvgs = avgs.filter((a) => a[1] !== 0);
|
||||
const avg = validAvgs.reduce((a, b) => a + b[1], 0) / validAvgs.length;
|
||||
const over = avgs.filter((a) => a[1] !== 0).length / avgs.length;
|
||||
const color = getLineColor(id);
|
||||
// 成功率 = 有效数据点 / 所有数据点
|
||||
const over = overValidatedData.length > 0 ? overValidatedData.length / lineData.length : 0;
|
||||
const validRate = 1 - ((validatedData.length > 0 && overValidatedData.length > 0)
|
||||
? validatedData.length / overValidatedData.length : 0);
|
||||
const cateItem = {
|
||||
id,
|
||||
name: i,
|
||||
name: monitor_name,
|
||||
color,
|
||||
avg: avg.toFixed(2) * 1,
|
||||
avg: avgDelay.toFixed(2) * 1,
|
||||
over: (over * 100).toFixed(2) * 1,
|
||||
validRate: (validRate * 100).toFixed(2) * 1,
|
||||
};
|
||||
if (Number.isNaN(cateItem.avg)) {
|
||||
cateItem.avg = 0;
|
||||
}
|
||||
const titles = [
|
||||
cateItem.name,
|
||||
cateItem.avg === 0 ? '' : `平均延迟:${cateItem.avg}ms`,
|
||||
`成功率:${cateItem.over}%`,
|
||||
];
|
||||
if (peakShaving.value) {
|
||||
titles.push(`削峰率: ${cateItem.validRate}%`);
|
||||
}
|
||||
cateItem.title = titles.filter((s) => s).join('\n');
|
||||
cateList.push(cateItem);
|
||||
valueList.push({
|
||||
id,
|
||||
name: i,
|
||||
data: avgs,
|
||||
name: monitor_name,
|
||||
data: lineData,
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
@ -287,9 +447,15 @@ const monitorChartData = computed(() => {
|
||||
});
|
||||
}
|
||||
});
|
||||
// 去重
|
||||
dateList = Array.from(new Set(dateList)).sort((a, b) => a - b);
|
||||
|
||||
const dateList = Array.from(dateSet).sort((a, b) => a - b);
|
||||
valueList = valueList.filter((i) => showCates.value[i.id]);
|
||||
|
||||
if (import.meta.env.VITE_MONITOR_DEBUG === '1') {
|
||||
window._cateMap = cateMap;
|
||||
console.log(window._cateMap);
|
||||
console.log(dateList, cateList, valueList);
|
||||
}
|
||||
return {
|
||||
dateList,
|
||||
cateList,
|
||||
@ -299,21 +465,50 @@ const monitorChartData = computed(() => {
|
||||
|
||||
function switchPeakShaving() {
|
||||
peakShaving.value = !peakShaving.value;
|
||||
window.localStorage.setItem('nazhua_monitor_peak_shaving', peakShaving.value);
|
||||
}
|
||||
|
||||
function switchRefresh() {
|
||||
refreshData.value = !refreshData.value;
|
||||
window.localStorage.setItem('nazhua_monitor_refresh_data', refreshData.value);
|
||||
}
|
||||
|
||||
function switchChartType() {
|
||||
chartType.value = chartType.value === 'single' ? 'multi' : 'single';
|
||||
window.localStorage.setItem('nazhua_monitor_chart_type', chartType.value);
|
||||
}
|
||||
|
||||
function toggleMinute(value) {
|
||||
now.value = store.state.serverTime || Date.now();
|
||||
minute.value = value;
|
||||
}
|
||||
|
||||
function toggleShowCate(id) {
|
||||
if (window.innerWidth < 768) {
|
||||
return;
|
||||
}
|
||||
showCates.value[id] = !showCates.value[id];
|
||||
}
|
||||
|
||||
function handleTouchStart(id) {
|
||||
longPressTimer.value = setTimeout(() => {
|
||||
showCates.value[id] = !showCates.value[id];
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value);
|
||||
longPressTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove() {
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value);
|
||||
longPressTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMonitor() {
|
||||
await request({
|
||||
url: (
|
||||
@ -327,7 +522,6 @@ async function loadMonitor() {
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
now.value = store.state.serverTime || Date.now();
|
||||
}
|
||||
|
||||
let loadMonitorTimer = null;
|
||||
@ -368,6 +562,62 @@ onUnmounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
.server-monitor-group {
|
||||
--line-chart-size: 300px;
|
||||
|
||||
&.chart-type--single {
|
||||
--line-chart-size: 240px;
|
||||
}
|
||||
|
||||
.monitor-cate-item {
|
||||
--cate-item-height: 28px;
|
||||
--cate-item-font-size: 14px;
|
||||
--cate-color: #fff;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: var(--cate-item-width);
|
||||
height: var(--cate-item-height);
|
||||
gap: 6px;
|
||||
padding: 0 6px;
|
||||
font-size: var(--cate-item-font-size);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cate-legend {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
background: var(--cate-color);
|
||||
}
|
||||
|
||||
.cate-name {
|
||||
// flex: 1;
|
||||
height: var(--cate-item-height);
|
||||
line-height: calc(var(--cate-item-height) + 2px);
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.cate-avg-ms {
|
||||
height: var(--cate-item-height);
|
||||
line-height: calc(var(--cate-item-height) + 2px);
|
||||
text-align: right;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cate-over-rate {
|
||||
height: var(--cate-item-height);
|
||||
line-height: calc(var(--cate-item-height) + 2px);
|
||||
text-align: right;
|
||||
color: #fffbd8;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
filter: grayscale(1) brightness(0.8);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.module-head-group {
|
||||
@ -377,6 +627,12 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
position: sticky;
|
||||
top: var(--layout-header-height);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.module-title {
|
||||
width: max-content;
|
||||
height: 30px;
|
||||
@ -393,7 +649,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.peak-shaving-group,
|
||||
.refresh-data-group {
|
||||
.refresh-data-group,
|
||||
.chart-type-switch-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@ -521,59 +778,30 @@ onUnmounted(() => {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
// justify-content: center;
|
||||
gap: var(--gap-size);
|
||||
margin-right: calc(var(--gap-size) * -1);
|
||||
}
|
||||
|
||||
.monitor-cate-item {
|
||||
// --cate-item-width: calc(20% - var(--gap-size));
|
||||
--cate-item-height: 28px;
|
||||
--cate-item-font-size: 14px;
|
||||
--cate-color: #fff;
|
||||
.monitor-chart-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 0;
|
||||
|
||||
.monitor-chart-item {
|
||||
width: 50%;
|
||||
height: calc(var(--line-chart-size) + 28px);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.monitor-chart-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cate-name-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: var(--cate-item-width);
|
||||
height: var(--cate-item-height);
|
||||
gap: 6px;
|
||||
padding: 0 6px;
|
||||
font-size: var(--cate-item-font-size);
|
||||
// background: rgba(#fff, 0.2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
.cate-legend {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
// border-radius: 50%;
|
||||
// width: 6px;
|
||||
// height: calc(var(--cate-item-height) - 10px);
|
||||
// margin-left: -6px;
|
||||
background: var(--cate-color);
|
||||
}
|
||||
|
||||
.cate-name {
|
||||
// flex: 1;
|
||||
height: var(--cate-item-height);
|
||||
line-height: calc(var(--cate-item-height) + 2px);
|
||||
// text-overflow: ellipsis;
|
||||
// white-space: nowrap;
|
||||
// overflow: hidden;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.cate-avg-ms {
|
||||
// width: 55px;
|
||||
height: var(--cate-item-height);
|
||||
line-height: calc(var(--cate-item-height) + 2px);
|
||||
text-align: right;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -3,13 +3,8 @@
|
||||
class="server-head"
|
||||
padding="16px"
|
||||
>
|
||||
<div class="server-flag">
|
||||
<div class="server-flag-font">
|
||||
<span
|
||||
class="fi"
|
||||
:class="'fi-' + (info?.Host?.CountryCode || 'un')"
|
||||
/>
|
||||
</div>
|
||||
<div class="server-flag-box">
|
||||
<server-flag :info="info" />
|
||||
</div>
|
||||
<div class="server-name-and-slogan">
|
||||
<div class="server-name-group">
|
||||
@ -97,7 +92,7 @@ const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconCl
|
||||
gap: 12px;
|
||||
transition: 0.3s;
|
||||
|
||||
.server-flag {
|
||||
.server-flag-box {
|
||||
--flag-size: 72px;
|
||||
position: relative;
|
||||
width: calc(var(--flag-size) * 1.33333333);
|
||||
@ -105,7 +100,7 @@ const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconCl
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.server-flag-font {
|
||||
.server-flag {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@ -211,10 +206,17 @@ const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconCl
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--amd {
|
||||
font-weight: bold;
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
&--apple {
|
||||
font-weight: 600;
|
||||
font-family: PingFang SC, Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.cpu-model {
|
||||
@ -224,6 +226,17 @@ const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconCl
|
||||
.cpu-model-num {
|
||||
color: #c7eeff;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
padding-left: 0;
|
||||
margin-top: -7px;
|
||||
line-height: 16px;
|
||||
.cpu-company {
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
padding: 0 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -63,7 +63,7 @@
|
||||
@click.stop="toBuy"
|
||||
>
|
||||
<span class="icon">
|
||||
<span class="ri-shopping-bag-3-line" />
|
||||
<span :class="buyBtnIcon" />
|
||||
</span>
|
||||
<span class="text">{{ buyBtnText }}</span>
|
||||
</div>
|
||||
@ -96,8 +96,24 @@ const {
|
||||
props,
|
||||
});
|
||||
|
||||
const buyBtnText = computed(() => config.nazhua.buyBtnText || '购买');
|
||||
const showBuyBtn = computed(() => !!props.info?.PublicNote?.customData?.orderLink);
|
||||
const buyBtnIcon = computed(() => {
|
||||
if (props.info?.PublicNote?.customData?.buyBtnIcon) {
|
||||
return props.info?.PublicNote?.customData?.buyBtnIcon;
|
||||
}
|
||||
return config.nazhua.buyBtnIcon || 'ri-shopping-bag-3-line';
|
||||
});
|
||||
const buyBtnText = computed(() => {
|
||||
if (props.info?.PublicNote?.customData?.buyBtnText) {
|
||||
return props.info?.PublicNote?.customData?.buyBtnText;
|
||||
}
|
||||
return config.nazhua.buyBtnText || '购买';
|
||||
});
|
||||
const showBuyBtn = computed(() => {
|
||||
if (config.nazhua.hideListItemLink === true) {
|
||||
return false;
|
||||
}
|
||||
return !!props.info?.PublicNote?.customData?.orderLink;
|
||||
});
|
||||
|
||||
function toBuy() {
|
||||
const decodeUrl = decodeURIComponent(props.info?.PublicNote?.customData?.orderLink);
|
||||
@ -146,12 +162,24 @@ const show = computed(() => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
height: 40px;
|
||||
border-bottom-left-radius: var(--list-item-border-radius);
|
||||
border-bottom-right-radius: var(--list-item-border-radius);
|
||||
background: rgba(#000, 0.3);
|
||||
box-shadow: 0 -2px 4px rgba(#000, 0.5);
|
||||
|
||||
--list-item-bill-height: 40px;
|
||||
--list-item-bill-font-size: 14px;
|
||||
--list-item-bill-icon-font-size: 16px;
|
||||
|
||||
height: var(--list-item-bill-height);
|
||||
font-size: var(--list-item-bill-font-size);
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
--list-item-bill-height: 30px;
|
||||
--list-item-bill-font-size: 12px;
|
||||
--list-item-bill-icon-font-size: 14px;
|
||||
}
|
||||
|
||||
&.dot-dot-box--hide {
|
||||
box-shadow: none;
|
||||
border-top: 1px solid rgba(#ddd, 0.1);
|
||||
@ -170,23 +198,27 @@ const show = computed(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: calc(var(--list-item-bill-height) * 0.75);
|
||||
height: calc(var(--list-item-bill-height) * 0.75);
|
||||
line-height: 1;
|
||||
font-size: 16px;
|
||||
font-size: var(--list-item-bill-icon-font-size);
|
||||
color: #74dbef;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 30px;
|
||||
line-height: var(--list-item-bill-height);
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.value-text {
|
||||
color: #74dbef;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
padding-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="server-list-item-status"
|
||||
:class="'type--' + componentName"
|
||||
:class="classNames"
|
||||
>
|
||||
<component
|
||||
:is="componentMaps[componentName]"
|
||||
@ -21,6 +21,10 @@
|
||||
* 服务器状态盒子
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
import handleServerStatus from '@/views/composable/server-status';
|
||||
@ -39,10 +43,13 @@ const componentMaps = {
|
||||
progress: ServerStatusProgress,
|
||||
};
|
||||
|
||||
const componentName = [
|
||||
'donut',
|
||||
'progress',
|
||||
].includes(config.nazhua.listServerStatusType) ? config.nazhua.listServerStatusType : 'donut';
|
||||
const componentName = computed(() => {
|
||||
const name = [
|
||||
'donut',
|
||||
'progress',
|
||||
].includes(config.nazhua.listServerStatusType) ? config.nazhua.listServerStatusType : 'donut';
|
||||
return config.nazhua.listServerItemType === 'server-status' ? 'progress' : name;
|
||||
});
|
||||
|
||||
const {
|
||||
serverStatusList,
|
||||
@ -51,6 +58,13 @@ const {
|
||||
statusListTpl: 'cpu,mem,disk',
|
||||
statusListItemContent: false,
|
||||
});
|
||||
|
||||
const classNames = computed(() => {
|
||||
const names = {};
|
||||
names[`type--${componentName.value}`] = true;
|
||||
names[`len--${serverStatusList.value?.length}`] = true;
|
||||
return names;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@ -63,11 +77,16 @@ const {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
|
||||
--progress-bar-width: calc(50% - 5px);
|
||||
--progress-bar-height: 20px;
|
||||
|
||||
@media screen and (max-width: 350px) {
|
||||
@media screen and (max-width: 400px) {
|
||||
--progress-bar-height: 16px;
|
||||
padding: 0 15px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
&.len--3 {
|
||||
--progress-bar-width: calc((100% - 20px) / 3);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,14 +15,7 @@
|
||||
@click="openDetail"
|
||||
>
|
||||
<div class="server-name-group left-box">
|
||||
<span
|
||||
class="server-flag"
|
||||
>
|
||||
<span
|
||||
class="fi"
|
||||
:class="'fi-' + (info?.Host?.CountryCode || 'un')"
|
||||
/>
|
||||
</span>
|
||||
<server-flag :info="info" />
|
||||
<span class="server-name">
|
||||
{{ info.Name }}
|
||||
</span>
|
||||
@ -97,8 +90,8 @@ const { cpuAndMemAndDisk } = handleServerInfo({
|
||||
const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconClassName(props.info?.Host?.Platform));
|
||||
|
||||
const serverRealTimeListTpls = computed(() => {
|
||||
if (config.nazhua?.listServerRealTimeShowLoad) {
|
||||
return 'duration,load,transfer,speeds';
|
||||
if (config.nazhua?.listServerRealTimeShowLoad || config.nazhua.listServerItemType === 'server-status') {
|
||||
return 'D-A-T,T-A-U,L-A-P,I-A-O';
|
||||
}
|
||||
return 'duration,transfer,inSpeed,outSpeed';
|
||||
});
|
||||
@ -121,12 +114,12 @@ function openDetail() {
|
||||
transition: 0.3s;
|
||||
|
||||
.server-info-group {
|
||||
--list-item-head-height: 50px;
|
||||
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);
|
||||
@ -135,6 +128,7 @@ function openDetail() {
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
cursor: default;
|
||||
--list-item-head-height: 40px;
|
||||
}
|
||||
|
||||
&.dot-dot-box--hide {
|
||||
@ -145,6 +139,7 @@ function openDetail() {
|
||||
&.server-list-item-head {
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
height: var(--list-item-head-height, 50px);
|
||||
}
|
||||
|
||||
.left-box,
|
||||
@ -203,6 +198,8 @@ function openDetail() {
|
||||
--real-time-text-font-size: 12px;
|
||||
--real-time-label-font-size: 14px;
|
||||
|
||||
font-size: var(--real-time-label-font-size);
|
||||
|
||||
@media screen and (max-width: 1280px) {
|
||||
padding: 10px 0 15px;
|
||||
|
||||
@ -217,8 +214,12 @@ function openDetail() {
|
||||
--real-time-value-font-size: 20px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 680px) {
|
||||
@media screen and (max-width: 720px) {
|
||||
--real-time-value-font-size: 24px;
|
||||
--real-time-text-font-size: 12px;
|
||||
--real-time-label-font-size: 12px;
|
||||
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 320px) {
|
||||
|
||||
@ -23,7 +23,10 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="item-text item-value">{{ value }}</span>
|
||||
<span class="item-text item-unit">{{ unit }}</span>
|
||||
<span
|
||||
v-if="unit"
|
||||
class="item-text item-unit"
|
||||
>{{ unit }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@ -184,6 +187,12 @@ const columnStyle = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
&--speeds {
|
||||
.item-value {
|
||||
color: var(--net-speed-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--inSpeed {
|
||||
.item-value {
|
||||
color: var(--net-speed-in-color);
|
||||
@ -207,5 +216,23 @@ const columnStyle = computed(() => {
|
||||
color: var(--list-item-price-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--tcp {
|
||||
.item-value {
|
||||
color: var(--conn-tcp-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--udp {
|
||||
.item-value {
|
||||
color: var(--conn-udp-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--conns {
|
||||
.item-value {
|
||||
color: var(--conn-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -62,7 +62,12 @@ const {
|
||||
props,
|
||||
});
|
||||
|
||||
const buyBtnText = computed(() => config.nazhua.buyBtnText || '购买');
|
||||
const buyBtnText = computed(() => {
|
||||
if (props.info?.PublicNote?.customData?.buyBtnText) {
|
||||
return props.info?.PublicNote?.customData?.buyBtnText;
|
||||
}
|
||||
return config.nazhua.buyBtnText || '购买';
|
||||
});
|
||||
const showBuyBtn = computed(() => !!props.info?.PublicNote?.customData?.orderLink);
|
||||
|
||||
function toBuy() {
|
||||
|
||||
@ -9,14 +9,7 @@
|
||||
@click="openDetail"
|
||||
>
|
||||
<div class="list-column-item list-column-item--server-flag">
|
||||
<span
|
||||
class="server-flag"
|
||||
>
|
||||
<span
|
||||
class="fi"
|
||||
:class="'fi-' + (info?.Host?.CountryCode || 'un')"
|
||||
/>
|
||||
</span>
|
||||
<server-flag :info="info" />
|
||||
</div>
|
||||
<div class="list-column-item list-column-item--server-name">
|
||||
<span
|
||||
@ -48,7 +41,7 @@
|
||||
<server-list-item-real-time
|
||||
v-if="$config.nazhua.hideListItemStat !== true"
|
||||
:info="info"
|
||||
server-real-time-list-tpls="load,inSpeed,outSpeed,transfer,duration"
|
||||
server-real-time-list-tpls="load,conns,speeds,transfer,duration"
|
||||
/>
|
||||
<server-list-item-bill
|
||||
v-if="$config.nazhua.hideListItemBill !== true"
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
:class="{
|
||||
'server-list--row': showListRow,
|
||||
'server-list--card': showListCard,
|
||||
'server-list--status': showListByServerStatus,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
@ -17,6 +18,7 @@
|
||||
:class="{
|
||||
'server-list--row': showListRow,
|
||||
'server-list--card': showListCard,
|
||||
'server-list--status': showListByServerStatus,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
@ -41,6 +43,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showListByServerStatus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -94,6 +100,18 @@ defineProps({
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.server-list-container.server-list--status {
|
||||
--list-padding: 20px;
|
||||
--list-gap-size: 12px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--list-gap-size);
|
||||
width: var(--list-container-width);
|
||||
padding: 0 var(--list-padding);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
class="server-option-box"
|
||||
:class="{
|
||||
'server-option-box--light-background': lightBackground,
|
||||
'server-option-box--mobile-hide': !mobileShow,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
@ -48,7 +49,11 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
accpetEmpty: {
|
||||
acceptEmpty: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
mobileShow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
@ -69,7 +74,7 @@ const activeValue = computed({
|
||||
|
||||
function toggleModelValue(item) {
|
||||
if (activeValue.value === item.value) {
|
||||
if (props.accpetEmpty) {
|
||||
if (props.acceptEmpty) {
|
||||
activeValue.value = '';
|
||||
}
|
||||
} else {
|
||||
@ -85,6 +90,12 @@ function toggleModelValue(item) {
|
||||
padding: 0 var(--list-padding);
|
||||
gap: 8px;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
&--mobile-hide {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.server-option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -122,13 +133,13 @@ function toggleModelValue(item) {
|
||||
@media screen and (min-width: 768px) {
|
||||
&:hover {
|
||||
.option-label {
|
||||
color: #ff7500;
|
||||
color: var(--option-high-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(#ff7500, 0.75);
|
||||
background: var(--option-high-color-active);
|
||||
|
||||
.option-label {
|
||||
color: #fff;
|
||||
@ -146,7 +157,7 @@ function toggleModelValue(item) {
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(#ff7500, 0.75);
|
||||
background: var(--option-high-color-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
341
src/views/components/server-list/server-sort-box.vue
Normal file
341
src/views/components/server-list/server-sort-box.vue
Normal file
@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<div
|
||||
class="server-sort-box"
|
||||
:class="{
|
||||
'server-sort-box--light-background': lightBackground,
|
||||
'server-sort-box--mobile-hide': !mobileShow,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="sort-select-wrapper"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<div class="sort-select-selected">
|
||||
<span class="sort-select-selected-value">{{ selectedLabel }}</span>
|
||||
<span
|
||||
class="sort-select-selected-icon"
|
||||
@click.stop="toggleOrder"
|
||||
>
|
||||
<span
|
||||
v-if="activeValue.order === 'desc'"
|
||||
class="ri-arrow-down-line"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="ri-arrow-up-line"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下拉菜单 -->
|
||||
<Teleport to="body">
|
||||
<server-sort-dropdown-menu
|
||||
ref="dropdownMenuRef"
|
||||
:visible="isDropdownOpen"
|
||||
:options="options"
|
||||
:active-value="activeValue.prop"
|
||||
:dropdown-style="dropdownStyle"
|
||||
:light-background="lightBackground"
|
||||
:is-mobile="isMobile"
|
||||
@select="handleSelectItem"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 过滤栏
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
ref,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
} from 'vue';
|
||||
import config from '@/config';
|
||||
import ServerSortDropdownMenu from './server-sort-dropdown-menu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
prop: 'DisplayIndex',
|
||||
order: 'desc',
|
||||
}),
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
acceptEmpty: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
mobileShow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits([
|
||||
'update:modelValue',
|
||||
'change',
|
||||
]);
|
||||
|
||||
const lightBackground = computed(() => config.nazhua.lightBackground);
|
||||
|
||||
// 设备检测(用于判断是否小屏,小屏时居中显示)
|
||||
const isMobile = ref(window.innerWidth < 768);
|
||||
|
||||
// PC端下拉菜单相关
|
||||
const isDropdownOpen = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const dropdownMenuRef = ref(null);
|
||||
const dropdownStyle = ref({});
|
||||
|
||||
const activeValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
emits('update:modelValue', val);
|
||||
emits('change', val);
|
||||
},
|
||||
});
|
||||
|
||||
// 获取当前选中项的label
|
||||
const selectedLabel = computed(() => {
|
||||
const selectedOption = props.options.find((opt) => opt.value === activeValue.value.prop);
|
||||
return selectedOption ? selectedOption.label : '排序';
|
||||
});
|
||||
|
||||
// 更新下拉菜单位置
|
||||
function updateDropdownPosition() {
|
||||
if (!triggerRef.value || !dropdownMenuRef.value) return;
|
||||
|
||||
// 使用 nextTick 确保 DOM 已更新
|
||||
nextTick(() => {
|
||||
const dropdownRef = dropdownMenuRef.value?.dropdownRef;
|
||||
|
||||
if (!dropdownRef) return;
|
||||
|
||||
// 小屏设备:居中显示
|
||||
if (isMobile.value) {
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
visibility: 'visible',
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// 大屏设备:相对定位
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect();
|
||||
|
||||
// 先设置一个初始位置,确保元素在视口中可见
|
||||
let top = triggerRect.bottom + 8;
|
||||
let { left } = triggerRect;
|
||||
|
||||
// 设置初始位置
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
visibility: 'hidden', // 先隐藏,避免闪烁
|
||||
};
|
||||
|
||||
// 再次使用 nextTick 确保样式已应用
|
||||
nextTick(() => {
|
||||
const dropdownRect = dropdownRef.getBoundingClientRect();
|
||||
|
||||
// 防止超出右边界
|
||||
if (left + dropdownRect.width > window.innerWidth) {
|
||||
left = window.innerWidth - dropdownRect.width - 10;
|
||||
}
|
||||
|
||||
// 防止超出下边界,如果超出则向上展开
|
||||
if (top + dropdownRect.height > window.innerHeight) {
|
||||
top = triggerRect.top - dropdownRect.height - 8;
|
||||
}
|
||||
|
||||
// 防止超出左边界
|
||||
if (left < 10) {
|
||||
left = 10;
|
||||
}
|
||||
|
||||
// 更新最终位置并显示
|
||||
dropdownStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
visibility: 'visible',
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 切换下拉菜单显示状态
|
||||
function toggleDropdown(event) {
|
||||
event.stopPropagation(); // 阻止事件冒泡,防止立即被 handleDocumentClick 关闭
|
||||
isDropdownOpen.value = !isDropdownOpen.value;
|
||||
if (isDropdownOpen.value) {
|
||||
nextTick(() => {
|
||||
updateDropdownPosition();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 切换升序/降序
|
||||
function toggleOrder(event) {
|
||||
event.stopPropagation(); // 阻止事件冒泡,避免触发下拉菜单
|
||||
if (!activeValue.value.prop) return; // 如果没有选中排序字段,则不切换
|
||||
|
||||
activeValue.value = {
|
||||
prop: activeValue.value.prop,
|
||||
order: activeValue.value.order === 'desc' ? 'asc' : 'desc',
|
||||
};
|
||||
emits('change', activeValue.value);
|
||||
}
|
||||
|
||||
// PC端选择项
|
||||
function handleSelectItem(item) {
|
||||
if (activeValue.value.prop === item.value) {
|
||||
if (props.acceptEmpty) {
|
||||
activeValue.value = {
|
||||
prop: '',
|
||||
order: 'desc',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
activeValue.value = {
|
||||
prop: item.value,
|
||||
order: activeValue.value.order || 'desc',
|
||||
};
|
||||
}
|
||||
isDropdownOpen.value = false;
|
||||
emits('change', activeValue.value);
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
function handleDocumentClick(event) {
|
||||
if (!isDropdownOpen.value) return;
|
||||
|
||||
const dropdownRef = dropdownMenuRef.value?.dropdownRef;
|
||||
|
||||
if (
|
||||
triggerRef.value
|
||||
&& !triggerRef.value.contains(event.target)
|
||||
&& dropdownRef
|
||||
&& !dropdownRef.contains(event.target)
|
||||
) {
|
||||
isDropdownOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口resize处理
|
||||
function handleResize() {
|
||||
isMobile.value = window.innerWidth < 768;
|
||||
|
||||
// 如果下拉菜单打开,更新位置
|
||||
if (isDropdownOpen.value) {
|
||||
nextTick(() => {
|
||||
updateDropdownPosition();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-sort-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 var(--list-padding);
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
&--mobile-hide {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// PC端触发元素
|
||||
.sort-select-wrapper {
|
||||
position: relative;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-select-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 15px;
|
||||
line-height: 1.2;
|
||||
border-radius: 6px;
|
||||
background: rgba(#000, 0.3);
|
||||
transition: all 0.3s linear;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 3px;
|
||||
background-color: rgba(#000, 0.8);
|
||||
}
|
||||
|
||||
.sort-select-selected-value {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sort-select-selected-icon {
|
||||
margin-left: 8px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s linear;
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(#fff, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(#fff, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PC端浅色背景样式
|
||||
&--light-background {
|
||||
.sort-select-selected {
|
||||
background: rgba(#000, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
145
src/views/components/server-list/server-sort-dropdown-menu.vue
Normal file
145
src/views/components/server-list/server-sort-dropdown-menu.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="visible"
|
||||
ref="dropdownRef"
|
||||
class="server-sort-select-dropdown"
|
||||
:class="{
|
||||
'server-sort-select-dropdown--light-background': lightBackground,
|
||||
'server-sort-select-dropdown--mobile': isMobile,
|
||||
}"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<div class="sort-select-options">
|
||||
<div
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
class="server-sort-item"
|
||||
:class="{
|
||||
active: activeValue === item.value,
|
||||
}"
|
||||
:title="item?.title || false"
|
||||
@click.stop="handleSelect(item, $event)"
|
||||
>
|
||||
<span class="option-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dropdownStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
lightBackground: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['select']);
|
||||
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
function handleSelect(item, event) {
|
||||
event.stopPropagation();
|
||||
emits('select', item);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
dropdownRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-sort-select-dropdown {
|
||||
z-index: 500;
|
||||
background: rgba(#000, 0.8);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
min-width: 150px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
// 小屏居中显示样式
|
||||
&--mobile {
|
||||
min-width: 280px;
|
||||
max-width: 90vw;
|
||||
max-height: 70vh;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.sort-select-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.server-sort-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 15px;
|
||||
line-height: 1.2;
|
||||
border-radius: 6px;
|
||||
background: rgba(#000, 0.3);
|
||||
transition: all 0.3s linear;
|
||||
cursor: pointer;
|
||||
|
||||
.option-label {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s linear;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.option-label {
|
||||
color: var(--option-high-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--option-high-color-active);
|
||||
|
||||
.option-label {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 浅色背景样式
|
||||
.server-sort-select-dropdown--light-background {
|
||||
.server-sort-item {
|
||||
background: rgba(#000, 0.5);
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.8);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--option-high-color-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
142
src/views/components/server-list/server-status/main.vue
Normal file
142
src/views/components/server-list/server-status/main.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<dot-dot-box
|
||||
v-if="tableData"
|
||||
border-radius="6px"
|
||||
class="server-status"
|
||||
>
|
||||
<table class="server-status-table">
|
||||
<thead class="server-status-table-header">
|
||||
<tr class="server-status-table-header-row">
|
||||
<template
|
||||
v-for="column in tableData.columnProps"
|
||||
:key="`th_${column.prop}`"
|
||||
>
|
||||
<template v-if="['billing', 'remainingTime'].includes(column.prop)">
|
||||
<server-status-th
|
||||
v-if="tableData.showBilling && column.prop === 'billing'"
|
||||
:column="column"
|
||||
/>
|
||||
<server-status-th
|
||||
v-if="tableData.showRemainingTime && column.prop === 'remainingTime'"
|
||||
:column="column"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<server-status-th
|
||||
:column="column"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="server-status-table-body">
|
||||
<tr
|
||||
v-for="itemData in tableData.list"
|
||||
:key="itemData.info.ID"
|
||||
class="server-status-table-body-row"
|
||||
:class="{
|
||||
'server-status-table-body-row--offline': itemData.info?.online === -1,
|
||||
'server-status-table-body-row--online': itemData.info?.online === 1,
|
||||
[`server-item--${itemData.info?.ID}`]: true,
|
||||
}"
|
||||
@click="openDetail(itemData.info)"
|
||||
>
|
||||
<template
|
||||
v-for="column in itemData.columnData"
|
||||
:key="`td_${itemData.info?.ID}_${column.prop}`"
|
||||
>
|
||||
<template v-if="['billing', 'remainingTime'].includes(column.prop)">
|
||||
<server-status-td
|
||||
v-if="tableData.showBilling && column.prop === 'billing'"
|
||||
:column="column"
|
||||
/>
|
||||
<server-status-td
|
||||
v-if="tableData.showRemainingTime && column.prop === 'remainingTime'"
|
||||
:column="column"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<server-status-td
|
||||
:column="column"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</dot-dot-box>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* ServerStatus风格的列表
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import {
|
||||
useRouter,
|
||||
} from 'vue-router';
|
||||
|
||||
import config from '@/config';
|
||||
|
||||
import {
|
||||
handleServerListColumn,
|
||||
} from './server-status';
|
||||
|
||||
import ServerStatusTh from './table/th.vue';
|
||||
import ServerStatusTd from './table/td.vue';
|
||||
|
||||
const props = defineProps({
|
||||
serverList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const tableData = computed(() => {
|
||||
const result = handleServerListColumn(props.serverList, config.nazhua.serverStatusColumnsTpl);
|
||||
return result;
|
||||
});
|
||||
|
||||
function openDetail(info) {
|
||||
router.push({
|
||||
name: 'ServerDetail',
|
||||
params: {
|
||||
serverId: info.ID,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-status {
|
||||
--server-status-cell-padding: 0 5px;
|
||||
--server-status-td-height: 32px;
|
||||
|
||||
--progress-bar-height: 18px;
|
||||
}
|
||||
.server-status-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
.server-status-table-body-row {
|
||||
@media screen and (min-width: 1025px) {
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: background-color 500ms ease-in-out;
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
&--offline td:not(.server-status-td--status) {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="conn-group">
|
||||
<div class="conn--tcp">
|
||||
{{ tcpConnCount }}
|
||||
</div>
|
||||
<div class="split-line">
|
||||
|
|
||||
</div>
|
||||
<div class="conn--udp">
|
||||
{{ udpConnCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 连接信息
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
realTimeData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const tcpConnCount = computed(() => {
|
||||
const { item } = props.realTimeData?.conns || {};
|
||||
const { value } = item?.data?.tcp || {};
|
||||
return value || '-';
|
||||
});
|
||||
const udpConnCount = computed(() => {
|
||||
const { item } = props.realTimeData?.conns || {};
|
||||
const { value } = item?.data?.udp || {};
|
||||
return value || '-';
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.conn-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
.conn--tcp {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: var(--conn-tcp-color);
|
||||
}
|
||||
|
||||
.conn--udp {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
color: var(--conn-udp-color);
|
||||
}
|
||||
|
||||
.split-line {
|
||||
width: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="country-content">
|
||||
<server-flag :info="info" />
|
||||
<span class="country-label">
|
||||
{{ countryLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 地区信息
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const countryLabel = computed(() => props.info?.Host?.CountryCode?.toUpperCase() || 'UN');
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.country-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="net-speed-group">
|
||||
<div class="net-speed--in">
|
||||
{{ inSpeed }}
|
||||
</div>
|
||||
<div class="split-line">
|
||||
|
|
||||
</div>
|
||||
<div class="net-speed--out">
|
||||
{{ outSpeed }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 网速信息
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
realTimeData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const inSpeed = computed(() => {
|
||||
const { item } = props.realTimeData?.speeds || {};
|
||||
if (item?.data?.in) {
|
||||
const { value, unit } = item.data.in;
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
return '-';
|
||||
});
|
||||
const outSpeed = computed(() => {
|
||||
const { item } = props.realTimeData?.speeds || {};
|
||||
if (item?.data?.out) {
|
||||
const { value, unit } = item.data.out;
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
return '-';
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.net-speed-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
.net-speed--in {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: var(--net-speed-in-color);
|
||||
}
|
||||
|
||||
.net-speed--out {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
color: var(--net-speed-out-color);
|
||||
}
|
||||
|
||||
.split-line {
|
||||
width: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="status-icon-box">
|
||||
<div
|
||||
class="status-icon"
|
||||
:class="{
|
||||
online: info.online === 1,
|
||||
offline: info.online === -1,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 状态图标
|
||||
*/
|
||||
defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.status-icon-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-icon.online {
|
||||
background-image: linear-gradient(rgba(77, 133, 58, 1) 0, rgba(54, 126, 54, 1) 100%);
|
||||
}
|
||||
|
||||
.status-icon.offline {
|
||||
background-image: linear-gradient(rgba(155, 37, 34, 1) 0, rgba(161, 38, 35, 1) 100%);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="system-os-content">
|
||||
<span class="system-icon">
|
||||
<span :class="platformLogoIconClassName" />
|
||||
</span>
|
||||
<span class="system-label">
|
||||
{{ systemOSLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 系统信息
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
import * as hostUtils from '@/utils/host';
|
||||
|
||||
const props = defineProps({
|
||||
info: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconClassName(props.info?.Host?.Platform));
|
||||
const systemOSLabel = computed(() => hostUtils.getSystemOSLabel(props.info?.Host?.Platform, true));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.system-os-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="transfer-group">
|
||||
<div class="transfer--in">
|
||||
{{ transferIn }}
|
||||
</div>
|
||||
<div class="split-line">
|
||||
|
|
||||
</div>
|
||||
<div class="transfer--out">
|
||||
{{ transferOut }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 流量信息
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
realTimeData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const transferIn = computed(() => {
|
||||
const { item } = props.realTimeData?.transfer || {};
|
||||
if (item?.data?.in) {
|
||||
const { value, unit } = item.data.in;
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
return '-';
|
||||
});
|
||||
const transferOut = computed(() => {
|
||||
const { item } = props.realTimeData?.transfer || {};
|
||||
if (item?.data?.out) {
|
||||
const { value, unit } = item.data.out;
|
||||
return `${value}${unit}`;
|
||||
}
|
||||
return '-';
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.transfer-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
.transfer--in {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
color: var(--transfer-in-color);
|
||||
}
|
||||
|
||||
.transfer--out {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
color: var(--transfer-out-color);
|
||||
}
|
||||
|
||||
.split-line {
|
||||
width: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
446
src/views/components/server-list/server-status/server-status.js
Normal file
446
src/views/components/server-list/server-status/server-status.js
Normal file
@ -0,0 +1,446 @@
|
||||
/**
|
||||
* ServerStatus风格的列表列配置
|
||||
*/
|
||||
import {
|
||||
h,
|
||||
} from 'vue';
|
||||
|
||||
// import * as hostUtils from '@/utils/host';
|
||||
import handleServerStatus from '@/views/composable/server-status';
|
||||
import handleServerInfo from '@/views/composable/server-info';
|
||||
import handleServerRealTime from '@/views/composable/server-real-time';
|
||||
import handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';
|
||||
|
||||
import ServerStatusProgress from '@/views/components/server/server-status-progress.vue';
|
||||
import StatusIcon from '@/views/components/server-list/server-status/server-info/status-icon.vue';
|
||||
import SystemOS from '@/views/components/server-list/server-status/server-info/system-os.vue';
|
||||
import Country from '@/views/components/server-list/server-status/server-info/country.vue';
|
||||
import NetSpeed from '@/views/components/server-list/server-status/server-info/net-speed.vue';
|
||||
import Transfer from '@/views/components/server-list/server-status/server-info/transfer.vue';
|
||||
import Conns from '@/views/components/server-list/server-status/server-info/conns.vue';
|
||||
|
||||
const COLUMN_MAP = Object.freeze({
|
||||
status: {
|
||||
label: '状态',
|
||||
width: 40,
|
||||
},
|
||||
name: {
|
||||
label: '名称',
|
||||
minWidth: 100,
|
||||
align: 'left',
|
||||
},
|
||||
config: {
|
||||
label: '规格',
|
||||
width: 80,
|
||||
align: 'left',
|
||||
},
|
||||
system: {
|
||||
label: '系统',
|
||||
width: 90,
|
||||
align: 'left',
|
||||
},
|
||||
country: {
|
||||
label: '地区',
|
||||
width: 60,
|
||||
align: 'left',
|
||||
},
|
||||
duration: {
|
||||
label: '在线',
|
||||
width: 60,
|
||||
align: 'left',
|
||||
},
|
||||
load: {
|
||||
label: '负载',
|
||||
width: 45,
|
||||
align: 'center',
|
||||
},
|
||||
speeds: {
|
||||
label: '网速',
|
||||
width: 122,
|
||||
align: 'center',
|
||||
},
|
||||
inSpeed: {
|
||||
label: '入网',
|
||||
width: 60,
|
||||
align: 'left',
|
||||
},
|
||||
outSpeed: {
|
||||
label: '出网',
|
||||
width: 60,
|
||||
align: 'left',
|
||||
},
|
||||
transfer: {
|
||||
label: '流量',
|
||||
width: 122,
|
||||
align: 'center',
|
||||
},
|
||||
inTransfer: {
|
||||
label: '入网流量',
|
||||
width: 60,
|
||||
align: 'left',
|
||||
},
|
||||
outTransfer: {
|
||||
label: '出网流量',
|
||||
width: 60,
|
||||
align: 'left',
|
||||
},
|
||||
conns: {
|
||||
label: '连接',
|
||||
width: 72,
|
||||
align: 'center',
|
||||
},
|
||||
tcp: {
|
||||
label: 'TCP',
|
||||
width: 40,
|
||||
align: 'left',
|
||||
},
|
||||
udp: {
|
||||
label: 'UDP',
|
||||
width: 40,
|
||||
align: 'left',
|
||||
},
|
||||
cpu: {
|
||||
label: 'CPU',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
cpuText: {
|
||||
valProp: 'cpu',
|
||||
label: 'CPU',
|
||||
width: 40,
|
||||
align: 'center',
|
||||
},
|
||||
mem: {
|
||||
label: '内存',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
memText: {
|
||||
valProp: 'mem',
|
||||
label: '内存',
|
||||
width: 40,
|
||||
align: 'center',
|
||||
},
|
||||
swap: {
|
||||
label: '交换',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
swapText: {
|
||||
valProp: 'swap',
|
||||
label: '交换',
|
||||
width: 40,
|
||||
align: 'center',
|
||||
},
|
||||
disk: {
|
||||
label: '硬盘',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
diskText: {
|
||||
valProp: 'disk',
|
||||
label: '硬盘',
|
||||
width: 40,
|
||||
align: 'center',
|
||||
},
|
||||
billing: {
|
||||
label: '价格',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
},
|
||||
remainingTime: {
|
||||
label: '剩余',
|
||||
width: 70,
|
||||
align: 'right',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 默认列配置
|
||||
*/
|
||||
// eslint-disable-next-line max-len, vue/max-len
|
||||
const DEFAULT_COLUMNS = 'status,name,country,system,config,duration,speeds,transfer,load,cpu,mem,disk,billing,remainingTime';
|
||||
|
||||
/**
|
||||
* 需要实时更新的数据
|
||||
*/
|
||||
const RELD_TIME_DATA = [
|
||||
'speeds', 'inSpeed', 'outSpeed',
|
||||
'transfer', 'inTransfer', 'outTransfer',
|
||||
'conns', 'tcp', 'udp',
|
||||
'duration', 'load',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取列配置
|
||||
* @param {string} columnsTpls 列配置模板
|
||||
* @returns {Object} 列配置
|
||||
* @property {Array} columns 列配置
|
||||
*/
|
||||
export const getColumnPropsConfig = (tpls = DEFAULT_COLUMNS) => {
|
||||
const tplList = tpls.split(',');
|
||||
const columnList = [];
|
||||
tplList.forEach((tpl) => {
|
||||
if (COLUMN_MAP[tpl]) {
|
||||
columnList.push({
|
||||
prop: tpl,
|
||||
...COLUMN_MAP[tpl],
|
||||
});
|
||||
}
|
||||
});
|
||||
return columnList;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将服务器数据转换为表格数据
|
||||
* @param {Object} server 服务器数据
|
||||
* @returns {Object} 表格数据
|
||||
*/
|
||||
export const handleServerItemData = (params) => {
|
||||
const {
|
||||
column,
|
||||
server,
|
||||
realTimeData,
|
||||
progressData,
|
||||
billAndPlan,
|
||||
} = params || {};
|
||||
switch (column.prop) {
|
||||
case 'status':
|
||||
return {
|
||||
type: 'component',
|
||||
component: h(StatusIcon, { info: server }),
|
||||
originalData: params,
|
||||
};
|
||||
case 'name':
|
||||
return {
|
||||
type: 'text',
|
||||
value: server.Name,
|
||||
originalData: params,
|
||||
};
|
||||
case 'config':
|
||||
{
|
||||
const { cpuAndMemAndDisk } = handleServerInfo({
|
||||
props: {
|
||||
info: server,
|
||||
},
|
||||
originalData: params,
|
||||
});
|
||||
return {
|
||||
type: 'text',
|
||||
value: cpuAndMemAndDisk,
|
||||
originalData: params,
|
||||
};
|
||||
}
|
||||
case 'system':
|
||||
return {
|
||||
type: 'component',
|
||||
component: h(SystemOS, { info: server }),
|
||||
originalData: params,
|
||||
};
|
||||
case 'country':
|
||||
return {
|
||||
type: 'component',
|
||||
component: h(Country, { info: server }),
|
||||
originalData: params,
|
||||
};
|
||||
case 'speeds':
|
||||
return {
|
||||
type: 'component',
|
||||
component: h(NetSpeed, { realTimeData }),
|
||||
originalData: params,
|
||||
};
|
||||
case 'transfer':
|
||||
return {
|
||||
type: 'component',
|
||||
component: h(Transfer, { realTimeData }),
|
||||
originalData: params,
|
||||
};
|
||||
case 'conns':
|
||||
return {
|
||||
type: 'component',
|
||||
component: h(Conns, { realTimeData }),
|
||||
originalData: params,
|
||||
};
|
||||
case 'cpu':
|
||||
case 'mem':
|
||||
case 'disk':
|
||||
case 'swap':
|
||||
{
|
||||
const progressItem = progressData[column.prop];
|
||||
return {
|
||||
type: 'component',
|
||||
component: h(ServerStatusProgress, {
|
||||
type: column.prop,
|
||||
used: progressItem?.used || 0,
|
||||
colors: progressItem?.colors || {},
|
||||
valText: progressItem?.valPercent || '',
|
||||
}),
|
||||
originalData: params,
|
||||
};
|
||||
}
|
||||
case 'cpuText':
|
||||
case 'memText':
|
||||
case 'diskText':
|
||||
case 'swapText':
|
||||
{
|
||||
const progressItem = progressData[column.valProp];
|
||||
return {
|
||||
prop: column.prop,
|
||||
type: 'text',
|
||||
value: parseFloat(progressItem?.used || 0).toFixed(1),
|
||||
unit: '%',
|
||||
text: progressItem?.valPercent || '',
|
||||
originalData: params,
|
||||
};
|
||||
}
|
||||
case 'billing':
|
||||
{
|
||||
const item = billAndPlan?.value?.billing;
|
||||
const texts = [];
|
||||
if (item?.value) {
|
||||
texts.push(item.value || '-');
|
||||
}
|
||||
if (item?.cycleLabel) {
|
||||
texts.push(item.cycleLabel);
|
||||
}
|
||||
return {
|
||||
prop: column.prop,
|
||||
type: 'text',
|
||||
text: texts.length ? texts.join('/') : '-',
|
||||
originalData: params,
|
||||
};
|
||||
}
|
||||
case 'remainingTime':
|
||||
{
|
||||
const item = billAndPlan?.value?.remainingTime;
|
||||
return {
|
||||
prop: column.prop,
|
||||
type: 'text',
|
||||
text: item?.value || '-',
|
||||
originalData: params,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
if (RELD_TIME_DATA.includes(column.prop) && realTimeData[column.prop]) {
|
||||
const item = realTimeData[column.prop];
|
||||
return {
|
||||
prop: column.prop,
|
||||
type: 'text',
|
||||
text: item?.text,
|
||||
value: item?.value,
|
||||
unit: item?.unit,
|
||||
originalData: params,
|
||||
};
|
||||
}
|
||||
return {
|
||||
prop: column.prop,
|
||||
type: 'text',
|
||||
value: '-',
|
||||
originalData: params,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将服务器数据转换为表格数据
|
||||
* @param {Object} server 服务器数据
|
||||
* @param {Array} columns 列配置
|
||||
* @returns {Array} 表格数据
|
||||
*/
|
||||
export const handleServerListColumn = (serverList, columnTpls = DEFAULT_COLUMNS) => {
|
||||
const columnProps = getColumnPropsConfig(columnTpls);
|
||||
const tpls = columnProps.map((column) => column.valProp || column.prop).join(',');
|
||||
const hasBilling = columnTpls.includes('billing');
|
||||
const hasRemainingTime = columnTpls.includes('remainingTime');
|
||||
let showBilling = false;
|
||||
let showRemainingTime = false;
|
||||
const list = serverList.map((server) => {
|
||||
// 负载\网速\流量\在线等
|
||||
const realTimeResult = handleServerRealTime({
|
||||
props: {
|
||||
info: server,
|
||||
},
|
||||
serverRealTimeListTpls: tpls,
|
||||
});
|
||||
const realTimeData = {};
|
||||
realTimeResult?.serverRealTimeList?.value?.forEach?.((item) => {
|
||||
if (item.show) {
|
||||
const text = [item.value];
|
||||
if (item.unit) {
|
||||
text.push(item.unit);
|
||||
}
|
||||
realTimeData[item.key] = {
|
||||
value: item.value,
|
||||
unit: item.unit,
|
||||
text: text.join(''),
|
||||
item,
|
||||
};
|
||||
} else {
|
||||
realTimeData[item.key] = {
|
||||
text: '-',
|
||||
item,
|
||||
};
|
||||
}
|
||||
});
|
||||
// CPU\内存\硬盘\交换 进度条
|
||||
const {
|
||||
serverStatusList,
|
||||
} = handleServerStatus({
|
||||
props: {
|
||||
info: server,
|
||||
},
|
||||
statusListTpl: tpls,
|
||||
statusListItemContent: false,
|
||||
});
|
||||
const progressData = {};
|
||||
serverStatusList.value?.forEach?.((item) => {
|
||||
progressData[item.type] = item;
|
||||
});
|
||||
let billAndPlan = null;
|
||||
if (hasBilling || hasRemainingTime) {
|
||||
const result = handleServerBillAndPlan({
|
||||
props: {
|
||||
info: server,
|
||||
},
|
||||
});
|
||||
billAndPlan = result.billAndPlan;
|
||||
if (billAndPlan?.value?.billing) {
|
||||
showBilling = true;
|
||||
}
|
||||
if (billAndPlan?.value?.remainingTime) {
|
||||
showRemainingTime = true;
|
||||
}
|
||||
}
|
||||
|
||||
const columnData = [];
|
||||
columnProps.forEach((columnItem) => {
|
||||
columnData.push({
|
||||
...columnItem,
|
||||
data: handleServerItemData({
|
||||
column: columnItem,
|
||||
server,
|
||||
realTimeData,
|
||||
progressData,
|
||||
billAndPlan,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
info: server,
|
||||
columnData,
|
||||
computedData: {
|
||||
realTimeData,
|
||||
progressData,
|
||||
billAndPlan,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
list,
|
||||
columnProps,
|
||||
showBilling,
|
||||
showRemainingTime,
|
||||
};
|
||||
};
|
||||
209
src/views/components/server-list/server-status/table/td.vue
Normal file
209
src/views/components/server-list/server-status/table/td.vue
Normal file
@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<td
|
||||
class="server-status-td server-status-body-td"
|
||||
:class="columnClass"
|
||||
:style="columnStyle"
|
||||
>
|
||||
<div
|
||||
class="server-status-td-content"
|
||||
:class="'server-status-td-content--' + tdContent.prop"
|
||||
>
|
||||
<template
|
||||
v-if="tdContent.type === 'text'"
|
||||
>
|
||||
<span
|
||||
v-if="isSet(tdContent.value)"
|
||||
class="text--value"
|
||||
>
|
||||
{{ tdContent.value }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isSet(tdContent.unit)"
|
||||
class="text--unit"
|
||||
>
|
||||
{{ tdContent.unit }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!isSet(tdContent.value) && isSet(tdContent.text)"
|
||||
class="text"
|
||||
>
|
||||
{{ tdContent.text }}
|
||||
</span>
|
||||
</template>
|
||||
<template
|
||||
v-if="tdContent.type === 'component'"
|
||||
>
|
||||
<component :is="tdContent.component" />
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 自定义TD组件
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
column: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
// 计算css的长度单位
|
||||
const getCssLengthUnit = (value) => {
|
||||
if (typeof value === 'number') {
|
||||
return `${value}px`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const columnClass = computed(() => {
|
||||
const className = {
|
||||
[`server-status-td--${props.column.prop}`]: true,
|
||||
};
|
||||
if (props.column.align) {
|
||||
className[`server-status-td--align-${props.column.align}`] = true;
|
||||
}
|
||||
return className;
|
||||
});
|
||||
|
||||
const columnStyle = computed(() => {
|
||||
const style = {};
|
||||
if (props.column.width) {
|
||||
style.width = getCssLengthUnit(props.column.width);
|
||||
}
|
||||
if (props.column.minWidth) {
|
||||
style.minWidth = getCssLengthUnit(props.column.minWidth);
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const tdContent = computed(() => {
|
||||
if (['text', 'component'].includes(props.column.data.type)) {
|
||||
return props.column.data;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
function isSet(value) {
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-status-td {
|
||||
height: var(--server-status-td-height);
|
||||
padding: var(--server-status-cell-padding);
|
||||
|
||||
--td-content-justify-content: center;
|
||||
|
||||
&--align-center {
|
||||
--td-content-justify-content: center;
|
||||
}
|
||||
&--align-right {
|
||||
--td-content-justify-content: flex-end;
|
||||
}
|
||||
&--align-left {
|
||||
--td-content-justify-content: flex-start;
|
||||
}
|
||||
|
||||
.server-status-td-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: var(--td-content-justify-content);
|
||||
width: 100%;
|
||||
line-height: var(--server-status-td-height);
|
||||
|
||||
&--transfer {
|
||||
.text--value {
|
||||
color: var(--transfer-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--inTransfer {
|
||||
.text--value {
|
||||
color: var(--transfer-in-color);
|
||||
}
|
||||
}
|
||||
&--outTransfer {
|
||||
.text--value {
|
||||
color: var(--transfer-out-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--inSpeed {
|
||||
.text--value {
|
||||
color: var(--net-speed-in-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--outSpeed {
|
||||
.text--value {
|
||||
color: var(--net-speed-out-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--tcp {
|
||||
.text--value {
|
||||
color: var(--conn-tcp-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--udp {
|
||||
.text--value {
|
||||
color: var(--conn-udp-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--load {
|
||||
.text--value {
|
||||
color: var(--load-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--duration {
|
||||
.text--value {
|
||||
color: var(--duration-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--cpuText {
|
||||
.text--value {
|
||||
color: var(--cpu-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--memText {
|
||||
.text--value {
|
||||
color: var(--mem-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--swapText {
|
||||
.text--value {
|
||||
color: var(--swap-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--diskText {
|
||||
.text--value {
|
||||
color: var(--disk-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--billing {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--remainingTime {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
72
src/views/components/server-list/server-status/table/th.vue
Normal file
72
src/views/components/server-list/server-status/table/th.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<th
|
||||
class="server-status-th"
|
||||
:class="columnClass"
|
||||
:style="columnStyle"
|
||||
>
|
||||
{{ column.label }}
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 自定义TH组件
|
||||
*/
|
||||
|
||||
import {
|
||||
computed,
|
||||
} from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
column: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
// 计算css的长度单位
|
||||
const getCssLengthUnit = (value) => {
|
||||
if (typeof value === 'number') {
|
||||
return `${value}px`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const columnClass = computed(() => {
|
||||
const className = {};
|
||||
if (props.column.align) {
|
||||
className[`server-status-th--align-${props.column.align}`] = true;
|
||||
}
|
||||
return className;
|
||||
});
|
||||
|
||||
const columnStyle = computed(() => {
|
||||
const style = {};
|
||||
if (props.column.width) {
|
||||
style.width = getCssLengthUnit(props.column.width);
|
||||
}
|
||||
if (props.column.minWidth) {
|
||||
style.minWidth = getCssLengthUnit(props.column.minWidth);
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.server-status-th {
|
||||
padding: var(--server-status-cell-padding);
|
||||
|
||||
text-align: center;
|
||||
|
||||
&--align-center {
|
||||
text-align: center;
|
||||
}
|
||||
&--align-right {
|
||||
text-align: right;
|
||||
}
|
||||
&--align-left {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -22,13 +22,19 @@
|
||||
</span>
|
||||
<span class="item-content-sub-content">
|
||||
<span class="item-value">{{ subItem.show ? subItem?.value : '-' }}</span>
|
||||
<span class="item-unit item-text">{{ subItem.show ? subItem?.unit : '' }}</span>
|
||||
<span
|
||||
v-if="subItem.show"
|
||||
class="item-unit item-text"
|
||||
>{{ subItem?.unit }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="item-value">{{ item.show ? item?.value : '-' }}</span>
|
||||
<span class="item-unit item-text">{{ item.show ? item?.unit : '' }}</span>
|
||||
<span
|
||||
v-if="item.show"
|
||||
class="item-unit item-text"
|
||||
>{{ item?.unit }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<span
|
||||
@ -120,29 +126,53 @@ const {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 0.2em;
|
||||
}
|
||||
|
||||
--real-time-label-line-height: calc(var(--real-time-label-font-size, 14px) * 1.8);
|
||||
|
||||
.item-content-sub-label {
|
||||
height: var(--real-time-label-line-height);
|
||||
line-height: var(--real-time-label-line-height);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-content-sub-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-value {
|
||||
line-height: 1em;
|
||||
font-size: var(--real-time-label-font-size, 14px);
|
||||
}
|
||||
|
||||
.item-text {
|
||||
line-height: 1em;
|
||||
font-size: var(--real-time-label-font-size, 14px);
|
||||
}
|
||||
|
||||
.item-value,
|
||||
.item-text,
|
||||
.item-label {
|
||||
line-height: 1em;
|
||||
height: var(--real-time-label-line-height);
|
||||
line-height: var(--real-time-label-line-height);
|
||||
font-size: var(--real-time-label-font-size, 14px);
|
||||
}
|
||||
|
||||
.item-content-sub-item--L-A-P-load {
|
||||
.item-value {
|
||||
color: var(--load-color);
|
||||
}
|
||||
}
|
||||
.item-content-sub-item--L-A-P-process {
|
||||
.item-value {
|
||||
color: var(--process-color);
|
||||
}
|
||||
}
|
||||
|
||||
.item-content-sub-item--D-A-T-duration {
|
||||
.item-value {
|
||||
color: var(--duration-color);
|
||||
}
|
||||
}
|
||||
.item-content-sub-item--D-A-T-transfer {
|
||||
.item-value {
|
||||
color: var(--transfer-color);
|
||||
}
|
||||
}
|
||||
|
||||
.item-content-sub-item--speeds-in {
|
||||
.item-value {
|
||||
color: var(--net-speed-in-color);
|
||||
@ -153,6 +183,17 @@ const {
|
||||
color: var(--net-speed-out-color);
|
||||
}
|
||||
}
|
||||
|
||||
.item-content-sub-item--conn-tcp {
|
||||
.item-value {
|
||||
color: var(--conn-tcp-color);
|
||||
}
|
||||
}
|
||||
.item-content-sub-item--conn-udp {
|
||||
.item-value {
|
||||
color: var(--conn-udp-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -114,18 +114,18 @@ defineProps({
|
||||
.server-status-val-text {
|
||||
line-height: 1.2em;
|
||||
font-size: var(--server-status-val-text-font-size, 14px);
|
||||
color: #a1eafb;
|
||||
color: var(--server-status-value-color);
|
||||
}
|
||||
.server-status-label {
|
||||
line-height: 1.1em;
|
||||
font-size: var(--server-status-label-font-size, 12px);
|
||||
color: #ddd;
|
||||
color: var(--server-status-label-color);
|
||||
}
|
||||
|
||||
.server-status-content {
|
||||
line-height: 1.2em;
|
||||
font-size: var(--server-status-content-font-size, 14px);
|
||||
color: #eee;
|
||||
color: var(--server-status-content-color);
|
||||
|
||||
.default-mobile {
|
||||
display: none;
|
||||
|
||||
@ -12,7 +12,10 @@
|
||||
class="progress-bar-label"
|
||||
:title="label + '使用' + used + '%'"
|
||||
>
|
||||
<span class="server-status-label">
|
||||
<span
|
||||
v-if="label"
|
||||
class="server-status-label"
|
||||
>
|
||||
{{ label }}:
|
||||
</span>
|
||||
<span class="server-status-val-text">
|
||||
@ -96,13 +99,13 @@ const progressStyle = computed(() => {
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
flex: none;
|
||||
width: calc(50% - 5px);
|
||||
width: var(--progress-bar-width, calc(50% - 5px));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 350px) {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
// @media screen and (max-width: 350px) {
|
||||
// flex: none;
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
.progress-bar-box {
|
||||
position: relative;
|
||||
|
||||
@ -1,33 +1,75 @@
|
||||
import uniqolor from 'uniqolor';
|
||||
|
||||
/**
|
||||
* 计算数据的阈值和平均值
|
||||
* 计算数据的统计信息,使用截尾中位数作为基准值
|
||||
* 根据平均延迟的不同范围,使用不同的容差百分比进行削峰
|
||||
*
|
||||
* @param {number[]} data - 要计算的数据数组
|
||||
* @param {number} [tolerance=2] - 容差倍数,默认值为2
|
||||
* @returns {{threshold: number, mean: number}} 返回包含阈值和平均值的对象
|
||||
* @property {number} threshold - 计算得到的阈值
|
||||
* @property {number} mean - 数据的平均值
|
||||
* @returns {{median: number, tolerancePercent: number, min: number, max: number}}
|
||||
* 返回包含统计信息的对象
|
||||
* @property {number} median - 截尾中位数(去掉极端值后的中位数)
|
||||
* @property {number} tolerancePercent - 根据中位数计算的容差百分比
|
||||
* @property {number} min - 最小值
|
||||
* @property {number} max - 最大值
|
||||
*/
|
||||
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;
|
||||
// 过滤掉值为0的数据
|
||||
const filteredData = data.filter((value) => value !== 0);
|
||||
// 计算过滤后数据的最小值
|
||||
const min = Math.min(...filteredData);
|
||||
// 计算过滤后数据的最大值
|
||||
const max = Math.max(...filteredData);
|
||||
// 返回包含阈值、平均值、最小值和最大值的对象
|
||||
export function getThreshold(data) {
|
||||
// 过滤掉null和0的数据,只对有效延迟值计算统计量
|
||||
const filteredData = data.filter((value) => value !== 0 && value !== null);
|
||||
|
||||
if (filteredData.length === 0) {
|
||||
return {
|
||||
median: 0,
|
||||
tolerancePercent: 0.2,
|
||||
min: 0,
|
||||
max: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 排序数据
|
||||
const sortedData = [...filteredData].sort((a, b) => Math.ceil(a) - Math.ceil(b));
|
||||
const len = sortedData.length;
|
||||
|
||||
// 计算需要裁剪的数量(10%)
|
||||
const trimCount = Math.floor(len * 0.1);
|
||||
|
||||
// 用于计算中位数的数据:如果10%的数量>=1,则去掉最大和最小的10%
|
||||
let dataForMedian;
|
||||
if (trimCount >= 1) {
|
||||
// 截尾:去掉最小的10%和最大的10%
|
||||
dataForMedian = sortedData.slice(trimCount, len - trimCount);
|
||||
} else {
|
||||
// 数据量太少,不裁剪
|
||||
dataForMedian = sortedData;
|
||||
}
|
||||
|
||||
// 计算截尾中位数
|
||||
const medianLen = dataForMedian.length;
|
||||
const median = medianLen % 2 === 0
|
||||
? (dataForMedian[medianLen / 2 - 1] + dataForMedian[medianLen / 2]) / 2
|
||||
: dataForMedian[Math.floor(medianLen / 2)];
|
||||
|
||||
// 根据中位数确定容差百分比,延迟越小容差越大
|
||||
let tolerancePercent;
|
||||
if (median <= 10) {
|
||||
tolerancePercent = 0.50; // 50%
|
||||
} else if (median <= 30) {
|
||||
tolerancePercent = 0.35; // 35%
|
||||
} else if (median <= 50) {
|
||||
tolerancePercent = 0.25; // 25%
|
||||
} else if (median <= 100) {
|
||||
tolerancePercent = 0.20; // 20%
|
||||
} else {
|
||||
tolerancePercent = 0.15; // 15%
|
||||
}
|
||||
|
||||
const min = sortedData[0];
|
||||
const max = sortedData[len - 1];
|
||||
|
||||
// console.log(min, max, median, sortedData);
|
||||
|
||||
return {
|
||||
threshold,
|
||||
mean,
|
||||
median,
|
||||
tolerancePercent,
|
||||
min,
|
||||
max,
|
||||
};
|
||||
|
||||
@ -128,7 +128,13 @@ export default (params) => {
|
||||
value: 0,
|
||||
unit: '',
|
||||
};
|
||||
if (inStats.g > 1) {
|
||||
if (inStats.p > 1) {
|
||||
result.value = (inStats.p).toFixed(1) * 1;
|
||||
result.unit = 'P';
|
||||
} else if (inStats.t > 1) {
|
||||
result.value = (inStats.t).toFixed(1) * 1;
|
||||
result.unit = 'T';
|
||||
} else if (inStats.g > 1) {
|
||||
result.value = (inStats.g).toFixed(1) * 1;
|
||||
result.unit = 'G';
|
||||
} else if (inStats.m > 1) {
|
||||
@ -147,7 +153,13 @@ export default (params) => {
|
||||
value: 0,
|
||||
unit: '',
|
||||
};
|
||||
if (outStats.g > 1) {
|
||||
if (outStats.p > 1) {
|
||||
result.value = (outStats.p).toFixed(1) * 1;
|
||||
result.unit = 'P';
|
||||
} else if (outStats.t > 1) {
|
||||
result.value = (outStats.t).toFixed(1) * 1;
|
||||
result.unit = 'T';
|
||||
} else if (outStats.g > 1) {
|
||||
result.value = (outStats.g).toFixed(1) * 1;
|
||||
result.unit = 'G';
|
||||
} else if (outStats.m > 1) {
|
||||
@ -221,6 +233,18 @@ export default (params) => {
|
||||
value: transfer.value?.value,
|
||||
unit: transfer.value?.unit,
|
||||
show: validate.isSet(transfer.value?.value),
|
||||
data: {
|
||||
in: {
|
||||
value: inTransfer.value?.value,
|
||||
unit: inTransfer.value?.unit,
|
||||
show: validate.isSet(inTransfer.value?.value),
|
||||
},
|
||||
out: {
|
||||
value: outTransfer.value?.value,
|
||||
unit: outTransfer.value?.unit,
|
||||
show: validate.isSet(outTransfer.value?.value),
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'inTransfer':
|
||||
return {
|
||||
@ -255,6 +279,94 @@ export default (params) => {
|
||||
show: validate.isSet(netOutSpeed.value?.value),
|
||||
};
|
||||
case 'speeds':
|
||||
return {
|
||||
key,
|
||||
label: '网速',
|
||||
value: [
|
||||
`${netInSpeed.value?.value}${netInSpeed.value?.unit}`,
|
||||
`${netOutSpeed.value?.value}${netOutSpeed.value?.unit}`,
|
||||
].join('|'),
|
||||
show: validate.isSet(netInSpeed.value?.value) && validate.isSet(netOutSpeed.value?.value),
|
||||
data: {
|
||||
in: {
|
||||
value: netInSpeed.value?.value,
|
||||
unit: netInSpeed.value?.unit,
|
||||
show: validate.isSet(netInSpeed.value?.value),
|
||||
},
|
||||
out: {
|
||||
value: netOutSpeed.value?.value,
|
||||
unit: netOutSpeed.value?.unit,
|
||||
show: validate.isSet(netOutSpeed.value?.value),
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'load':
|
||||
return {
|
||||
key,
|
||||
label: '负载',
|
||||
value: (props.info.State?.Load1 || 0).toFixed(2),
|
||||
show: validate.isSet(props.info.State?.Load1),
|
||||
};
|
||||
case 'loads':
|
||||
{
|
||||
const loads = [];
|
||||
loads.push((props.info.State?.Load1 || 0).toFixed(2));
|
||||
loads.push((props.info.State?.Load5 || 0).toFixed(2));
|
||||
loads.push((props.info.State?.Load15 || 0).toFixed(2));
|
||||
return {
|
||||
key,
|
||||
label: '负载',
|
||||
value: loads.join(','),
|
||||
show: loads.some((load) => validate.isSet(load)),
|
||||
data: {
|
||||
load1: {
|
||||
value: (props.info.State?.Load1 || 0).toFixed(2),
|
||||
show: validate.isSet(props.info.State?.Load1),
|
||||
},
|
||||
load5: {
|
||||
value: (props.info.State?.Load5 || 0).toFixed(2),
|
||||
show: validate.isSet(props.info.State?.Load5),
|
||||
},
|
||||
load15: {
|
||||
value: (props.info.State?.Load15 || 0).toFixed(2),
|
||||
show: validate.isSet(props.info.State?.Load15),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'conns':
|
||||
return {
|
||||
key,
|
||||
label: '连接',
|
||||
value: `${props.info.State?.TcpConnCount || 0}|${props.info.State?.UdpConnCount || 0}`,
|
||||
show: true,
|
||||
data: {
|
||||
tcp: {
|
||||
value: props.info.State?.TcpConnCount || 0,
|
||||
show: validate.isSet(props.info.State?.TcpConnCount),
|
||||
},
|
||||
udp: {
|
||||
value: props.info.State?.UdpConnCount || 0,
|
||||
show: validate.isSet(props.info.State?.UdpConnCount),
|
||||
},
|
||||
},
|
||||
};
|
||||
case 'tcp':
|
||||
return {
|
||||
key,
|
||||
label: 'TCP',
|
||||
value: props.info.State?.TcpConnCount || 0,
|
||||
show: validate.isSet(props.info.State?.TcpConnCount),
|
||||
};
|
||||
case 'udp':
|
||||
return {
|
||||
key,
|
||||
label: 'UDP',
|
||||
value: props.info.State?.UdpConnCount || 0,
|
||||
show: validate.isSet(props.info.State?.UdpConnCount),
|
||||
};
|
||||
// 入网和出网
|
||||
case 'I-A-O':
|
||||
return {
|
||||
key,
|
||||
label: '网速',
|
||||
@ -276,13 +388,71 @@ export default (params) => {
|
||||
],
|
||||
show: validate.isSet(netInSpeed.value?.value) && validate.isSet(netOutSpeed.value?.value),
|
||||
};
|
||||
case 'load':
|
||||
// 负载和进程
|
||||
case 'L-A-P':
|
||||
return {
|
||||
key,
|
||||
label: '负载',
|
||||
value: (props.info.State?.Load1 || 0).toFixed(2),
|
||||
unit: '',
|
||||
show: validate.isSet(props.info.State?.Load1),
|
||||
values: [
|
||||
{
|
||||
key: 'load',
|
||||
label: '负载',
|
||||
value: (props.info.State?.Load1 || 0).toFixed(2),
|
||||
show: validate.isSet(props.info.State?.Load1),
|
||||
},
|
||||
{
|
||||
key: 'process',
|
||||
label: '进程',
|
||||
value: props.info.State?.ProcessCount || 0,
|
||||
show: validate.isSet(props.info.State?.ProcessCount),
|
||||
},
|
||||
],
|
||||
show: validate.isSet(props.info.State?.Load1) || validate.isSet(props.info.State?.ProcessCount),
|
||||
};
|
||||
// 连接 TCP和UDP
|
||||
case 'T-A-U':
|
||||
return {
|
||||
key,
|
||||
label: '连接',
|
||||
values: [
|
||||
{
|
||||
key: 'tcp',
|
||||
label: 'TCP',
|
||||
value: (props.info.State?.TcpConnCount || 0).toString().padEnd(3, ' '),
|
||||
show: validate.isSet(props.info.State?.TcpConnCount),
|
||||
},
|
||||
{
|
||||
key: 'udp',
|
||||
label: 'UDP',
|
||||
value: (props.info.State?.UdpConnCount || 0).toString().padEnd(3, ' '),
|
||||
show: validate.isSet(props.info.State?.UdpConnCount),
|
||||
},
|
||||
],
|
||||
show: validate.isSet(props.info.State?.TcpConnCount) || validate.isSet(props.info.State?.UdpConnCount),
|
||||
};
|
||||
// 在线和流量
|
||||
case 'D-A-T':
|
||||
return {
|
||||
key,
|
||||
label: '统计',
|
||||
values: [
|
||||
{
|
||||
key: 'duration',
|
||||
label: '在线',
|
||||
value: duration.value?.value,
|
||||
unit: duration.value?.unit,
|
||||
show: validate.isSet(duration.value?.value),
|
||||
},
|
||||
{
|
||||
key: 'transfer',
|
||||
label: '流量',
|
||||
title: `${transfer.value.statTypeLabel}流量`,
|
||||
value: transfer.value?.value,
|
||||
unit: transfer.value?.unit,
|
||||
show: validate.isSet(transfer.value?.value),
|
||||
},
|
||||
],
|
||||
show: validate.isSet(duration.value?.value) || validate.isSet(transfer.value?.value),
|
||||
};
|
||||
default:
|
||||
}
|
||||
|
||||
128
src/views/composable/server-sort.js
Normal file
128
src/views/composable/server-sort.js
Normal file
@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 服务器排序选项
|
||||
*/
|
||||
export const serverSortOptions = () => [{
|
||||
label: '排序值',
|
||||
value: 'DisplayIndex',
|
||||
}, {
|
||||
label: '主机名称',
|
||||
value: 'Name',
|
||||
}, {
|
||||
label: '国家地区',
|
||||
value: 'Host.CountryCode',
|
||||
}, {
|
||||
label: '系统平台',
|
||||
value: 'Host.Platform',
|
||||
}, {
|
||||
label: '在线时长',
|
||||
value: 'Host.BootTime',
|
||||
}, {
|
||||
label: '入网速度',
|
||||
value: 'State.NetInSpeed',
|
||||
}, {
|
||||
label: '出网速度',
|
||||
value: 'State.NetOutSpeed',
|
||||
}, {
|
||||
label: '入网流量',
|
||||
value: 'State.NetInTransfer',
|
||||
}, {
|
||||
label: '出网流量',
|
||||
value: 'State.NetOutTransfer',
|
||||
}, {
|
||||
label: '合计流量',
|
||||
value: '$.TotalTransfer',
|
||||
}, {
|
||||
label: 'TCP连接',
|
||||
value: 'State.TcpConnCount',
|
||||
}, {
|
||||
label: 'UDP连接',
|
||||
value: 'State.UdpConnCount',
|
||||
}, {
|
||||
label: '总连接数',
|
||||
value: '$.TotalConnCount',
|
||||
}, {
|
||||
label: '1分钟负载',
|
||||
value: 'State.Load1',
|
||||
}, {
|
||||
label: 'CPU占用',
|
||||
value: 'State.CPU',
|
||||
}, {
|
||||
label: '核心数量',
|
||||
value: '$.CPU',
|
||||
}, {
|
||||
label: '内存占用',
|
||||
value: 'State.MemUsed',
|
||||
}, {
|
||||
label: '内存大小',
|
||||
value: 'Host.MemTotal',
|
||||
}, {
|
||||
label: '交换占用',
|
||||
value: 'State.SwapUsed',
|
||||
}, {
|
||||
label: '交换大小',
|
||||
value: 'Host.SwapTotal',
|
||||
}, {
|
||||
label: '硬盘占用',
|
||||
value: 'State.DiskUsed',
|
||||
}, {
|
||||
label: '硬盘大小',
|
||||
value: 'Host.DiskTotal',
|
||||
}];
|
||||
|
||||
/**
|
||||
* 服务器排序处理
|
||||
*/
|
||||
export function serverSortHandler(a, b, sortby, order) {
|
||||
let aValue;
|
||||
let bValue;
|
||||
const hasDot = sortby.includes('.');
|
||||
if (!hasDot) {
|
||||
aValue = a[sortby];
|
||||
bValue = b[sortby];
|
||||
} else {
|
||||
const [sortby1, sortby2] = sortby.split('.');
|
||||
if (sortby1 !== '$') {
|
||||
switch (sortby2) {
|
||||
case 'BootTime':
|
||||
{
|
||||
const currentTime = Date.now();
|
||||
aValue = currentTime - a.Host.BootTime * 1000;
|
||||
bValue = currentTime - b.Host.BootTime * 1000;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
aValue = a[sortby1][sortby2];
|
||||
bValue = b[sortby1][sortby2];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch (sortby2) {
|
||||
case 'TotalTransfer':
|
||||
{
|
||||
aValue = a.State.NetInTransfer + a.State.NetOutTransfer;
|
||||
bValue = b.State.NetInTransfer + b.State.NetOutTransfer;
|
||||
break;
|
||||
}
|
||||
case 'TotalConnCount':
|
||||
{
|
||||
aValue = a.State.TcpConnCount + a.State.UdpConnCount;
|
||||
bValue = b.State.TcpConnCount + b.State.UdpConnCount;
|
||||
break;
|
||||
}
|
||||
case 'CPU':
|
||||
{
|
||||
aValue = a.Host.CPU.length;
|
||||
bValue = b.Host.CPU.length;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
if (order === 'desc') {
|
||||
return bValue - aValue;
|
||||
}
|
||||
return aValue - bValue;
|
||||
}
|
||||
@ -5,6 +5,32 @@ import config from '@/config';
|
||||
import validate from '@/utils/validate';
|
||||
import * as hostUtils from '@/utils/host';
|
||||
|
||||
function getColor(type, mode) {
|
||||
const colors = {
|
||||
cpu: {
|
||||
linear: ['#0088FF', '#72B7FF'],
|
||||
default: '#0088FF',
|
||||
simple: '#007B43',
|
||||
},
|
||||
mem: {
|
||||
linear: ['#2B6939', '#0AA344'],
|
||||
default: '#0AA344',
|
||||
simple: '#007B43',
|
||||
},
|
||||
swap: {
|
||||
linear: ['#FF8C00', '#F38100'],
|
||||
default: '#FF8C00',
|
||||
simple: '#007B43',
|
||||
},
|
||||
disk: {
|
||||
linear: ['#00848F', '#70F3FF'],
|
||||
default: '#70F3FF',
|
||||
simple: '#007B43',
|
||||
},
|
||||
};
|
||||
return colors[type][mode];
|
||||
}
|
||||
|
||||
export default (params) => {
|
||||
const {
|
||||
props,
|
||||
@ -15,7 +41,15 @@ export default (params) => {
|
||||
}
|
||||
|
||||
const lightBackground = computed(() => config.nazhua.lightBackground);
|
||||
const serverStatusLinear = computed(() => config.nazhua.serverStatusLinear || lightBackground.value);
|
||||
const serverStatusColorMode = computed(() => {
|
||||
if (config.nazhua.simpleColorMode) {
|
||||
return 'simple';
|
||||
}
|
||||
if (config.nazhua.serverStatusLinear || lightBackground.value) {
|
||||
return 'linear';
|
||||
}
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const cpuInfo = computed(() => {
|
||||
if (props.info?.Host?.CPU?.[0]) {
|
||||
@ -69,7 +103,7 @@ export default (params) => {
|
||||
case 'cpu':
|
||||
{
|
||||
const CoresVal = cpuInfo.value?.cores ? `${cpuInfo.value?.cores}C` : '-';
|
||||
const usedColor = serverStatusLinear.value ? ['#0088FF', '#72B7FF'] : '#0088FF';
|
||||
const usedColor = getColor('cpu', serverStatusColorMode.value);
|
||||
const valPercent = `${(props.info.State?.CPU || 0).toFixed(1) * 1}%`;
|
||||
const valText = valPercent;
|
||||
return {
|
||||
@ -102,7 +136,7 @@ export default (params) => {
|
||||
} else {
|
||||
contentVal = `${Math.ceil(useMemAndTotalMem.value.total.m)}M`;
|
||||
}
|
||||
const usedColor = serverStatusLinear.value ? ['#2B6939', '#0AA344'] : '#0AA344';
|
||||
const usedColor = getColor('mem', serverStatusColorMode.value);
|
||||
return {
|
||||
type: 'mem',
|
||||
used: useMemAndTotalMem.value.usePercent,
|
||||
@ -136,7 +170,7 @@ export default (params) => {
|
||||
} else {
|
||||
contentVal = `${Math.ceil(useSwapAndTotalSwap.value.total.m)}M`;
|
||||
}
|
||||
const usedColor = serverStatusLinear.value ? ['#FF8C00', '#F38100'] : '#FF8C00';
|
||||
const usedColor = getColor('swap', serverStatusColorMode.value);
|
||||
return {
|
||||
type: 'swap',
|
||||
used: useSwapAndTotalSwap.value.usePercent,
|
||||
@ -167,7 +201,7 @@ export default (params) => {
|
||||
} else {
|
||||
contentValue = `${Math.ceil(useDiskAndTotalDisk.value.total.g)}G`;
|
||||
}
|
||||
const usedColor = serverStatusLinear.value ? ['#00848F', '#70F3FF'] : '#70F3FF';
|
||||
const usedColor = getColor('disk', serverStatusColorMode.value);
|
||||
return {
|
||||
type: 'disk',
|
||||
used: useDiskAndTotalDisk.value.usePercent,
|
||||
|
||||
@ -6,11 +6,12 @@
|
||||
'server--offline': info?.online !== 1,
|
||||
}"
|
||||
>
|
||||
<world-map
|
||||
v-if="showWorldMap"
|
||||
:width="worldMapWidth"
|
||||
:locations="locations"
|
||||
/>
|
||||
<template v-if="showWorldMap && worldMapPosition === 'top'">
|
||||
<world-map
|
||||
:width="worldMapWidth"
|
||||
:locations="locations"
|
||||
/>
|
||||
</template>
|
||||
<server-name
|
||||
:key="`${info.ID}_name`"
|
||||
:info="info"
|
||||
@ -27,6 +28,12 @@
|
||||
:key="`${info.ID}_monitor`"
|
||||
:info="info"
|
||||
/>
|
||||
<template v-if="showWorldMap && worldMapPosition === 'bottom'">
|
||||
<world-map
|
||||
:width="worldMapWidth"
|
||||
:locations="locations"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -119,6 +126,13 @@ const showWorldMap = computed(() => {
|
||||
return true;
|
||||
});
|
||||
|
||||
const worldMapPosition = computed(() => {
|
||||
if (Object.keys(config.nazhua).includes('detailWorldMapPosition')) {
|
||||
return config.nazhua.detailWorldMapPosition;
|
||||
}
|
||||
return 'top';
|
||||
});
|
||||
|
||||
function handleWorldMapWidth() {
|
||||
worldMapWidth.value = Math.max(
|
||||
Math.min(
|
||||
|
||||
@ -1,31 +1,40 @@
|
||||
<template>
|
||||
<div class="index-container">
|
||||
<div
|
||||
class="index-container"
|
||||
:class="indexContainerClass"
|
||||
>
|
||||
<div class="scroll-container">
|
||||
<div
|
||||
class="world-map-box"
|
||||
v-if="worldMapPosition === 'top' && showWorldMap"
|
||||
class="world-map-box top-world-map"
|
||||
>
|
||||
<world-map
|
||||
v-if="showWorldMap"
|
||||
:locations="serverLocations || []"
|
||||
:width="worldMapWidth"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showFilter"
|
||||
class="fitler-group"
|
||||
class="filter-group"
|
||||
:class="{
|
||||
'list-is-row': showListRow,
|
||||
'list-is-card': showListCard,
|
||||
'list-is--row': showListRow,
|
||||
'list-is--card': showListCard,
|
||||
'list-is--server-status': showListRowByServerStatus,
|
||||
}"
|
||||
>
|
||||
<div class="left-box">
|
||||
<server-option-box
|
||||
v-if="showTag && tagOptions.length"
|
||||
v-if="showTag && serverGroupOptions.length"
|
||||
v-model="filterFormData.tag"
|
||||
:options="tagOptions"
|
||||
:options="serverGroupOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="right-box">
|
||||
<server-sort-box
|
||||
v-if="showSort"
|
||||
v-model="sortData"
|
||||
:options="sortOptions"
|
||||
/>
|
||||
<server-option-box
|
||||
v-if="onlineOptions.length"
|
||||
v-model="filterFormData.online"
|
||||
@ -35,10 +44,12 @@
|
||||
v-if="config.nazhua.listServerItemTypeToggle"
|
||||
v-model="listType"
|
||||
:options="listTypeOptions"
|
||||
:accpet-empty="false"
|
||||
:accept-empty="false"
|
||||
:mobile-show="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 列表模式 -->
|
||||
<server-list-warp
|
||||
v-if="showListRow"
|
||||
:show-transition="showTransition"
|
||||
@ -50,6 +61,17 @@
|
||||
:info="item"
|
||||
/>
|
||||
</server-list-warp>
|
||||
<!-- ServerStatus模式 -->
|
||||
<server-list-warp
|
||||
v-if="showListRowByServerStatus"
|
||||
:show-transition="showTransition"
|
||||
:show-list-by-server-status="showListRowByServerStatus"
|
||||
>
|
||||
<server-status-main
|
||||
:server-list="filterServerList.list"
|
||||
/>
|
||||
</server-list-warp>
|
||||
<!-- 卡片模式 -->
|
||||
<server-list-warp
|
||||
v-if="showListCard"
|
||||
:show-transition="showTransition"
|
||||
@ -61,6 +83,15 @@
|
||||
:info="item"
|
||||
/>
|
||||
</server-list-warp>
|
||||
<div
|
||||
v-if="worldMapPosition === 'bottom' && showWorldMap"
|
||||
class="world-map-box bottom-world-map"
|
||||
>
|
||||
<world-map
|
||||
:locations="serverLocations || []"
|
||||
:width="worldMapWidth"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -79,6 +110,7 @@ import {
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
nextTick,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
@ -95,9 +127,16 @@ import validate from '@/utils/validate';
|
||||
|
||||
import WorldMap from '@/components/world-map/world-map.vue';
|
||||
import ServerOptionBox from './components/server-list/server-option-box.vue';
|
||||
import ServerSortBox from './components/server-list/server-sort-box.vue';
|
||||
import ServerListWarp from './components/server-list/server-list-warp.vue';
|
||||
import ServerCardItem from './components/server-list/card/server-list-item.vue';
|
||||
import ServerRowItem from './components/server-list/row/server-list-item.vue';
|
||||
import ServerStatusMain from './components/server-list/server-status/main.vue';
|
||||
|
||||
import {
|
||||
serverSortOptions,
|
||||
serverSortHandler,
|
||||
} from './composable/server-sort';
|
||||
|
||||
const store = useStore();
|
||||
const worldMapWidth = ref();
|
||||
@ -110,6 +149,10 @@ const showTransition = computed(() => {
|
||||
if (config.nazhua.forceTransition) {
|
||||
return true;
|
||||
}
|
||||
// 安卓设备不开启 -> 部分安卓浏览器渲染动画会卡顿
|
||||
if (window.navigator.userAgent.includes('Android')) {
|
||||
return false;
|
||||
}
|
||||
// 服务器数量小于7时,不开启
|
||||
return store.state.serverList.length < 7;
|
||||
});
|
||||
@ -122,16 +165,39 @@ const showListRow = computed(() => {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const showListRowByServerStatus = computed(() => {
|
||||
if (windowWidth.value > 1024) {
|
||||
if (config.nazhua.listServerItemTypeToggle) {
|
||||
return listType.value === 'server-status';
|
||||
}
|
||||
return config.nazhua.listServerItemType === 'server-status';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const showListCard = computed(() => {
|
||||
if (windowWidth.value > 1024) {
|
||||
if (config.nazhua.listServerItemTypeToggle) {
|
||||
return listType.value !== 'row';
|
||||
return listType.value === 'card';
|
||||
}
|
||||
return config.nazhua.listServerItemType !== 'row';
|
||||
return config.nazhua.listServerItemType === 'card';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const indexContainerClass = computed(() => {
|
||||
const className = {};
|
||||
if (showListRow.value) {
|
||||
className['list-is--row'] = true;
|
||||
}
|
||||
if (showListCard.value) {
|
||||
className['list-is--card'] = true;
|
||||
}
|
||||
if (showListRowByServerStatus.value) {
|
||||
className['list-is--server-status'] = true;
|
||||
}
|
||||
return className;
|
||||
});
|
||||
|
||||
const showFilter = computed(() => config.nazhua.hideFilter !== true);
|
||||
const filterFormData = ref({
|
||||
tag: '',
|
||||
@ -153,13 +219,21 @@ const showTag = computed(() => {
|
||||
const serverList = computed(() => store.state.serverList);
|
||||
// 服务器总数
|
||||
const serverCount = computed(() => store.state.serverCount);
|
||||
|
||||
const tagOptions = computed(() => store.state.serverGroup.map((i) => ({
|
||||
key: uuid(),
|
||||
label: i.name,
|
||||
value: i.name,
|
||||
title: `${i.servers.length}台`,
|
||||
})));
|
||||
// 分组标签
|
||||
const serverGroupOptions = computed(() => {
|
||||
const options = [];
|
||||
store.state.serverGroup.forEach((i) => {
|
||||
if (i.servers && i.servers.length > 0) {
|
||||
options.push({
|
||||
key: uuid(),
|
||||
label: i.name,
|
||||
value: i.name,
|
||||
title: `${i.servers.length}台`,
|
||||
});
|
||||
}
|
||||
});
|
||||
return options;
|
||||
});
|
||||
|
||||
const onlineOptions = computed(() => {
|
||||
if (serverCount.value?.total !== serverCount.value?.online) {
|
||||
@ -178,18 +252,47 @@ const onlineOptions = computed(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
/**
|
||||
* 筛选离线时,离线数量变为0时,自动清空在线筛选
|
||||
*/
|
||||
watch(() => serverCount.value, () => {
|
||||
if (filterFormData.value.online === '-1' && serverCount.value.offline === 0) {
|
||||
filterFormData.value.online = '';
|
||||
}
|
||||
if (filterFormData.value.online === '1' && serverCount.value.online === 0) {
|
||||
filterFormData.value.online = '';
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const listTypeOptions = computed(() => [{
|
||||
key: 'card',
|
||||
label: '卡片',
|
||||
label: '卡片模式',
|
||||
value: 'card',
|
||||
icon: 'ri-gallery-view-2',
|
||||
}, {
|
||||
key: 'row',
|
||||
label: '列表',
|
||||
label: '列表模式',
|
||||
value: 'row',
|
||||
icon: 'ri-list-view',
|
||||
}, {
|
||||
key: 'server-status',
|
||||
label: 'ServerStatus模式',
|
||||
value: 'server-status',
|
||||
icon: 'ri-server-line',
|
||||
}]);
|
||||
|
||||
/**
|
||||
* 排序处理
|
||||
*/
|
||||
const showSort = computed(() => config.nazhua.hideSort !== true);
|
||||
const sortData = ref({
|
||||
prop: 'DisplayIndex',
|
||||
order: 'desc',
|
||||
});
|
||||
const sortOptions = computed(() => serverSortOptions());
|
||||
|
||||
const filterServerList = computed(() => {
|
||||
const fields = {};
|
||||
const locationMap = {};
|
||||
@ -224,7 +327,7 @@ const filterServerList = computed(() => {
|
||||
if (validate.isSet(planDataMod?.bandwidth)) {
|
||||
fields.bandwidth = true;
|
||||
}
|
||||
if (validate.isSet(customData?.orderLink)) {
|
||||
if (validate.isSet(customData?.orderLink) && config.nazhua.hideListItemLink !== true) {
|
||||
fields.orderLink = true;
|
||||
}
|
||||
}
|
||||
@ -250,6 +353,7 @@ const filterServerList = computed(() => {
|
||||
|
||||
return true;
|
||||
});
|
||||
list.sort((a, b) => serverSortHandler(a, b, sortData.value.prop, sortData.value.order));
|
||||
return {
|
||||
fields,
|
||||
list,
|
||||
@ -297,11 +401,21 @@ const showWorldMap = computed(() => {
|
||||
return true;
|
||||
});
|
||||
|
||||
const worldMapPosition = computed(() => {
|
||||
if (Object.keys(config.nazhua).includes('homeWorldMapPosition')) {
|
||||
return config.nazhua.homeWorldMapPosition;
|
||||
}
|
||||
return 'top';
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理窗口大小变化
|
||||
*/
|
||||
function handleResize() {
|
||||
worldMapWidth.value = document.querySelector('.server-list-container').clientWidth - 40;
|
||||
const serverListContainer = document.querySelector('.server-list-container');
|
||||
if (serverListContainer) {
|
||||
worldMapWidth.value = serverListContainer.clientWidth - 40;
|
||||
}
|
||||
windowWidth.value = window.innerWidth;
|
||||
}
|
||||
|
||||
@ -338,6 +452,7 @@ onActivated(() => {
|
||||
.index-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.scroll-container {
|
||||
display: flex;
|
||||
@ -352,9 +467,27 @@ onActivated(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bottom-world-map {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
&.list-is--server-status {
|
||||
--list-container-width: 1300px;
|
||||
|
||||
// 针对1440px以下的屏幕
|
||||
@media screen and (max-width: 1440px) {
|
||||
--list-container-width: 1300px;
|
||||
}
|
||||
|
||||
// 针对1280px以下的屏幕
|
||||
@media screen and (max-width: 1280px) {
|
||||
--list-container-width: 1200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fitler-group {
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
|
||||
@ -2,7 +2,7 @@ import config from '@/config';
|
||||
import MessageSubscribe from '@/utils/subscribe';
|
||||
import v1TransformV0 from '@/utils/transform-v1-2-v0';
|
||||
|
||||
import WSService from './service';
|
||||
import WSService, { WS_CONNECTION_STATUS } from './service';
|
||||
|
||||
/**
|
||||
* 获取不同版本的WebSocket路径
|
||||
@ -50,7 +50,7 @@ const wsService = new WSService({
|
||||
});
|
||||
|
||||
function restart() {
|
||||
if (wsService.connected !== 0) {
|
||||
if (wsService.connected !== WS_CONNECTION_STATUS.DISCONNECTED) {
|
||||
wsService.close();
|
||||
}
|
||||
wsService.active();
|
||||
@ -63,7 +63,7 @@ export {
|
||||
};
|
||||
|
||||
export default (actived) => {
|
||||
if (wsService.connected === 2) {
|
||||
if (wsService.connected === WS_CONNECTION_STATUS.CONNECTED) {
|
||||
if (actived) {
|
||||
actived();
|
||||
}
|
||||
@ -75,7 +75,7 @@ export default (actived) => {
|
||||
}
|
||||
});
|
||||
// 如果已经连接中,则不再连接
|
||||
if (wsService.connected === 1) {
|
||||
if (wsService.connected === WS_CONNECTION_STATUS.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
wsService.active();
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
// WebSocket 连接状态常量
|
||||
export const WS_CONNECTION_STATUS = {
|
||||
DISCONNECTED: 0, // 未连接
|
||||
CONNECTING: 1, // 连接中
|
||||
CONNECTED: 2, // 已连接
|
||||
CLOSED: -1, // 已关闭
|
||||
};
|
||||
|
||||
class WSService {
|
||||
constructor(options) {
|
||||
const {
|
||||
@ -23,16 +31,15 @@ class WSService {
|
||||
messageError: onMessageError || (() => {}),
|
||||
};
|
||||
|
||||
// 单例模式 遇到重复的ws服务,不再允许建立新的ws消息处理,如果遇到问题,等待用户自行刷新页面(破罐子破摔解决方法)
|
||||
// 单例模式:防止重复创建 WebSocket 连接
|
||||
// 如果检测到已有实例,触发错误回调并返回,避免资源浪费
|
||||
if (WSService.instance) {
|
||||
// 抛出错误,防止重复创建 WebSocket 连接
|
||||
this.$on.error(new Error('WebSocket connection already exists'));
|
||||
return;
|
||||
}
|
||||
|
||||
WSService.instance = this;
|
||||
// 0: 未连接,1: 连接中,2: 已连接,-1: 已关闭
|
||||
this.connected = 0;
|
||||
this.connected = WS_CONNECTION_STATUS.DISCONNECTED;
|
||||
this.ws = undefined;
|
||||
this.evt = (event) => {
|
||||
if (this.debug) {
|
||||
@ -52,18 +59,18 @@ class WSService {
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.connected === 2;
|
||||
return this.connected === WS_CONNECTION_STATUS.CONNECTED;
|
||||
}
|
||||
|
||||
active() {
|
||||
// 如果已经连接中或已连接,则不再连接
|
||||
if (this.connected > 0) {
|
||||
if (this.connected > WS_CONNECTION_STATUS.DISCONNECTED) {
|
||||
console.warn('WebSocket connection already exists or is connecting');
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为正在连接中
|
||||
this.connected = 1;
|
||||
this.connected = WS_CONNECTION_STATUS.CONNECTING;
|
||||
|
||||
// 创建 WebSocket 连接
|
||||
this.ws = new WebSocket(this.$wsUrl);
|
||||
@ -71,14 +78,14 @@ class WSService {
|
||||
if (this.debug) {
|
||||
console.log('socket connected', event);
|
||||
}
|
||||
this.connected = 2;
|
||||
this.connected = WS_CONNECTION_STATUS.CONNECTED;
|
||||
this.$on.connect(event);
|
||||
});
|
||||
this.ws.addEventListener('close', (event) => {
|
||||
if (this.debug) {
|
||||
console.log('socket closed', event);
|
||||
}
|
||||
this.connected = -1;
|
||||
this.connected = WS_CONNECTION_STATUS.CLOSED;
|
||||
WSService.instance = null; // 清除实例引用
|
||||
this.$on.close(event);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user