Compare commits

...

111 Commits

Author SHA1 Message Date
hi2hi
5c347cc0eb 🚀 0.9.0 2025-12-10 10:48:21 +08:00
hi2hi
881c9a05e5 🛠️ 增强代码健壮性,添加错误处理和状态常量 2025-12-10 10:47:07 +08:00
hi2hi
586f1dd063 新增列表的排序功能 2025-12-09 17:27:19 +08:00
hi2hi
93f66cb42c 🪄 优化不同尺寸下的server-status处理;移动端环境下的server-status替代card模式采用尽可能的最小化处理 2025-12-09 01:38:03 +08:00
hi2hi
0b9da8fe01 🪄 重建组件路径 2025-12-09 00:40:51 +08:00
hi2hi
90884c2730 🪄 优化表格的显示 2025-12-09 00:08:40 +08:00
hi2hi
69ab11babc ServerStatus风格的表格模式 2025-12-08 23:57:28 +08:00
hi2hi
33f1625ab1 💥 重新优化监控表的计算;修复之前百分百成功的错误计算 2025-12-08 13:53:39 +08:00
hi2hi
015ce40586 🚀 Bump version to 0.8.0 2025-11-11 11:47:06 +08:00
hi2hi
da35150a8d 🪄 优化削峰的计算,尽量切掉与平均值不匹配的数据 2025-11-11 11:38:04 +08:00
hi2hi
cf1e7cf9fd 🛠️ 新增内置位置 2025-11-11 10:39:20 +08:00
hi2hi
c8fddc4803 🪄 尽量避免开启削峰影响成功率的波动 2025-11-10 18:03:57 +08:00
hi2hi
ec3b5cb5ea 🪄 尝试优化异步加载的配置执行,使其生效 2025-11-10 17:55:13 +08:00
hi2hi
640fd787a2 🪄 修正换行问题 2025-11-10 17:15:34 +08:00
hi2hi
2d331823b9 🚀 0.7.0 2025-08-12 13:55:53 +08:00
hi2hi
0a33582541 监控图表添加断线显示 2025-08-12 13:17:01 +08:00
hi2hi
9aaa5b0cc3 🪄 调整列表模式的数据列 2025-07-25 17:17:56 +08:00
hi2hi
eed7be4b1b 列表页添加连接数的显示 2025-07-25 17:08:07 +08:00
hi2hi
1b20505ef2 🐳 更新Dockerfile,使用nginx:1.27-alpine替代nginx:1.27.3 2025-06-25 16:04:29 +08:00
hi2hi
51197a1c05 🚀 0.6.6 2025-04-20 16:56:22 +08:00
hi2hi
a0e066c04f 添加离线数量为0时自动清空在线筛选的功能 2025-04-20 15:35:26 +08:00
hi2hi
513a88d37e 🐛 修正boolean值被转为字符串后,识别失败 2025-04-20 15:31:03 +08:00
hi2hi
203b86e0e6 update public note 2025-04-17 13:18:06 +08:00
hi2hi
230bbcf7f0 🚀 0.6.5 2025-04-12 18:46:56 +08:00
hi2hi
2708263440 新增网络监控状态切换的本地化存储 2025-04-12 01:44:01 +08:00
hi2hi
8dc1aa0154 📄 update readme 2025-04-11 18:09:11 +08:00
hi2hi
53cd629119 📄 整理文档 2025-04-11 11:22:13 +08:00
hi2hi
3bb971479f 📄 整理文档 2025-04-11 11:14:04 +08:00
hi2hi
36d5a6ce66 0.6.4 2025-04-10 12:08:56 +08:00
hi2hi
a24401a87c 新增网络监控折线图拆分为单独图表功能 2025-04-10 12:04:31 +08:00
hi2hi
8de81431ca 增加自定义的地区flag支持 2025-04-10 10:20:22 +08:00
hi2hi
280387fba5 新增地图的位置选择 2025-04-07 13:33:20 +08:00
hi2hi
d9ef0b9b84 0.6.3 2025-04-01 11:37:06 +08:00
hi2hi
b16fd1ac15 🐛 fix: 修正分组下不存在服务器时,servers对象不存在导致分组异常问题 2025-04-01 11:36:18 +08:00
hi2hi
d99225eca7 0.6.2 2025-03-28 17:03:08 +08:00
hi2hi
d898b58c15 优化CPU信息提取逻辑,增加对多种型号的正则匹配,支持Apple M系列芯片识别;更新服务器名称组件样式,新增Apple平台样式支持 2025-03-28 17:02:50 +08:00
hi2hi
59be8baee3 🚀 0.6.1 2025-03-25 16:36:03 +08:00
hi2hi
d7d4112e70 增加触摸事件处理,支持长按切换分类显示,优化移动端用户体验 2025-03-25 16:34:59 +08:00
hi2hi
bc50b78135 优化服务器信息框,新增温度数据分类处理及图标显示 2025-03-25 16:17:01 +08:00
hi2hi
48d6e5c36a 新增配置选项隐藏服务器列表项购买链接 2025-03-02 04:10:02 +00:00
hi2hi
842cc7d2f8 🚀 0.6.0 2025-02-26 04:41:25 +00:00
hi2hi
31e9f61384 🪄 部分安卓浏览器渲染动画会卡顿 2025-02-26 04:40:01 +00:00
hi2hi
d11deece54 新增简单色系模式 2025-02-26 04:08:39 +00:00
hi2hi
39fc6b2497 📄 update readme 2025-02-14 08:55:25 +00:00
hi2hi
6d3139094f 更新 CDN 资源版本 2025-02-12 07:35:09 +00:00
hi2hi
873d6a5f28 🚀 0.5.8 2025-02-12 06:50:46 +00:00
hi2hi
3c4d7b71c2 💥 修正cdn库不可用问题 2025-02-12 06:50:19 +00:00
hi2hi
14c83386e7 🚀 0.5.7 2025-02-06 07:16:57 +00:00
hi2hi
2344200815 🪄 添加自定义 favicon 功能,支持在配置中设置网站图标 2025-02-06 06:04:39 +00:00
hi2hi
13d66010df 🪄 添加 Popover 组件,用于显示动态渲染的提示框,支持移动端与 PC 端不同的交互模式 2025-02-06 06:00:11 +00:00
hi2hi
f446221f45 🚀 0.5.6 2025-01-27 03:31:35 +00:00
hi2hi
068c7e09e9 🪄 移动端不显示烟花 2025-01-27 02:35:53 +00:00
hi2hi
9f2c90c5fa 🚀 0.5.5 2025-01-24 04:46:31 +00:00
hi2hi
84dc786b28 🪄 兼容 macOS 上使用 Command + K 快捷键打开搜索框 2025-01-24 04:46:11 +00:00
hi2hi
fbc3937b84 🚀 0.5.4 2025-01-24 04:12:53 +00:00
hi2hi
ad1b53786e 🪄 优化移动端效果 2025-01-24 04:12:07 +00:00
hi2hi
3d6e25d352 新增内置搜索功能 2025-01-24 04:09:00 +00:00
hi2hi
17e548abb1 🪄 优化v1数据中,没上报内容为空的情况 2025-01-24 02:43:56 +00:00
hi2hi
b6d8457649 拆分组件 2025-01-24 02:36:33 +00:00
hi2hi
bc5db4b5b1 🐛 修复服务器状态计算中的类型检查,确保传输和速度数据的安全累加 2025-01-24 02:24:59 +00:00
hi2hi
0fea4ee186 update readme 2025-01-22 02:58:43 +00:00
hi2hi
dbdd1d36ee update readme 2025-01-22 02:17:14 +00:00
hi2hi
73387bdb79 update readme 2025-01-22 02:09:43 +00:00
hi2hi
2f1ca9cb0f update readme 2025-01-21 10:16:24 +00:00
hi2hi
0d43597346 🚀 0.5.3 2025-01-21 10:13:47 +00:00
hi2hi
25bcbbadc8 🪄 优化服务器详情中的温度显示,将所有模块显示出来 2025-01-21 10:13:19 +00:00
hi2hi
8d7c815461 支持针对单个服务器设置购买按钮的文案和图标 2025-01-21 10:05:05 +00:00
hi2hi
25c68fa64e 📄 update readme 2025-01-20 13:26:52 +00:00
hi2hi
85ff1a9844 🚀 0.5.2 2025-01-20 13:16:36 +00:00
hi2hi
2a05809c9c 新增网站底部slogan设置 2025-01-20 13:16:10 +00:00
hi2hi
86b45b5f2a 🚀 0.5.1 2024-12-31 07:51:59 +00:00
hi2hi
3ede341f3d 让AI写了一个烟花和新年快乐的灯笼 2024-12-31 07:51:53 +00:00
hi2hi
d3e549cad0 🎨 修复交互样式 2024-12-31 06:53:55 +00:00
hi2hi
1f7e87c28d 🎨 遗漏的换行未处理 2024-12-31 06:44:07 +00:00
hi2hi
26de335304 🚀 0.5.0 2024-12-31 06:19:05 +00:00
hi2hi
efbf38738f 添加卡片与列表的切换设置,优化相关样式 2024-12-31 06:18:18 +00:00
hi2hi
9d301b9681 🪄 判断服务器数量决定是否才有过渡效果,数量多了会卡顿 2024-12-31 05:56:36 +00:00
hi2hi
786d6c0a87 匹配一些超长系统发行版本,比如Windows的server版本 2024-12-30 14:29:25 +00:00
hi2hi
bdbd083d45 🚀 0.4.27 2024-12-30 09:44:06 +00:00
hi2hi
2910c2bf41 claw全面开启ipv6,嗯,添加一下ipv4\ipv6的标签显示 2024-12-30 09:43:39 +00:00
hi2hi
bcfc53b784 🚀 0.4.26 2024-12-30 05:07:26 +00:00
hi2hi
963c06dfce 💥 兼容setting接口的新版本,影响内容site_name与custom_code 2024-12-30 05:04:03 +00:00
hi2hi
718b0138b0 🚀 0.4.25 2024-12-23 04:52:55 +00:00
hi2hi
c835cec98c 🎨 无SarasaTerm字体,部分字体样式加粗 2024-12-23 04:52:37 +00:00
hi2hi
61fe45927e 🐛 修复页面标题异常 2024-12-23 04:46:12 +00:00
hi2hi
7a2e22c30c 🚀 0.4.24 2024-12-21 08:57:49 +00:00
hi2hi
93c825bf33 💥 修改打包规则,默认版本禁用字体,css与图表走全量 2024-12-21 08:55:55 +00:00
hi2hi
1974cb4e63 🚀 0.4.23 2024-12-20 16:46:08 +00:00
hi2hi
bf30e14c30 新增自定义背景图片
 新增浅色系背景设定,适配自定义背景图片
🪄 修改图表的渲染方式为SVG
🔧 优化页面路由切换的状态记录
2024-12-20 16:43:48 +00:00
hi2hi
582b367088 🚀 0.4.22 2024-12-18 04:04:52 +00:00
hi2hi
d10386e6e9 row模式在移动端切换为card模式显示 2024-12-18 04:04:23 +00:00
hi2hi
836fddf860 🚀 0.4.21
- 更新readme
 - 调整配置
2024-12-18 02:40:32 +00:00
hi2hi
68bc396ea5 🎨 更新服务器列表样式并为购买链接添加悬停效果
- 为样式中的购买链接添加了新的悬停颜色。
- 调整了服务器列表组件中各列的宽度。
- 从首页视图布局中移除了不必要的内边距。
- 修复了服务器列表项宽度属性中的拼写错误。
- 引入了字段宽度映射,以更好地控制实时项目的布局。
2024-12-18 02:31:50 +00:00
hi2hi
3501483af0 新增行列的单独组件 2024-12-18 02:31:50 +00:00
hi2hi
5ec81d7616 新增"行"模式的服务器列表 2024-12-18 02:31:50 +00:00
hi2hi
26ed6e0722 🐛 设置months的默认值为1,即默认月付 2024-12-18 02:18:14 +00:00
hi2hi
f8cef00bfd 🚀 0.4.20 2024-12-14 04:04:28 +00:00
hi2hi
ead834dd95 🪄 优化设置now“当前时间”的时机;多项数据监控列表弹出层优化; 2024-12-14 04:03:47 +00:00
hi2hi
23e0c515e5 💥修复getColor死循环 2024-12-14 03:52:54 +00:00
hi2hi
482e29579a 🐛 哪吒v1的load1\load5\load15为0不返回,需要配置默认值 2024-12-13 16:49:18 +00:00
hi2hi
b235be5fa1 🚀 0.4.19 2024-12-13 03:03:49 +00:00
hi2hi
91bc02e553 🎨 调整备注标签颜色 2024-12-13 03:03:39 +00:00
hi2hi
97e6e0e7a7 🎨 调整监控组件的阴影效果 2024-12-13 03:03:39 +00:00
hi2hi
18df1e3cec 🐛 图形阴影仅在渐变色下有效 2024-12-13 03:03:39 +00:00
hi2hi
825c655185 🎨 调整移动端的提示框位置和样式 2024-12-13 03:03:39 +00:00
hi2hi
69f7031c0d 添加服务器状态渐变显示支持 2024-12-13 03:03:39 +00:00
hi2hi
6a92673e3a 更新城市坐标并添加新城市信息 2024-12-13 02:09:08 +00:00
hi2hi
8a53dcbb0f 🚀 0.4.18 2024-12-12 17:55:29 +00:00
hi2hi
95d1d72cc7 🎨 调整标签颜色;控制列表最多显示5个标签; 2024-12-12 17:55:06 +00:00
hi2hi
a321ce2f69 读取节点名称,设置页面标题 2024-12-12 17:54:23 +00:00
hi2hi
5a771a4932 🐛 平均值计算剔除0 2024-12-12 17:42:06 +00:00
87 changed files with 12407 additions and 4351 deletions

View File

@ -66,6 +66,7 @@ module.exports = {
'no-param-reassign': 'off',
'no-underscore-dangle': 'off',
'no-unsafe-optional-chaining': 'off',
'max-classes-per-file': 'off',
'max-len': ['warn', 120],
'vue/max-len': ['warn', 120],
'object-property-newline': ['error', {

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -49,7 +49,7 @@ jobs:
env:
VITE_SARASA_TERM_SC_USE_CDN: '1'
VITE_USE_CDN: '1'
VITE_CDN_LIB_TYPE: 'jsdelivr'
VITE_CDN_LIB_TYPE: 'loli'
run: npm run build
- name: 构建CDN引用版本的Docker镜像

View File

@ -43,7 +43,7 @@ jobs:
run: |
echo "#### Changes" > release_notes.md
git log -20 --pretty=format:"- %s" >> release_notes.md
echo -e "\n-----------\n哪吒V1请下载dist.zip\n哪吒V0请下载v0-dist.zip\n哪吒V0/nazhua/子目录需求请下载v0-nazhua.zip\nv${{ steps.determine_version.outputs.version }}-all.zip是全量包\nv${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip是jsdelivr引用版\nv${{ steps.determine_version.outputs.version }}-cdn-loli.zip是cdnjs的loli.net引用版" >> release_notes.md
echo -e "\n-----------\n哪吒V1请下载dist.zip\n哪吒V0请下载v0-dist.zip\n哪吒V0/nazhua/子目录需求请下载v0-nazhua.zip\nv${{ steps.determine_version.outputs.version }}-all.zip是包含字体的全量包\nv${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip是jsdelivr引用版\nv${{ steps.determine_version.outputs.version }}-cdn-loli.zip是cdnjs的loli.net引用版" >> release_notes.md
- name: Install dependencies
run: npm install
@ -77,7 +77,7 @@ jobs:
- name: 构建自动版 - loli(CDNJS)引用版本
env:
VITE_SARASA_TERM_SC_USE_CDN: '1'
VITE_DISABLE_SARASA_TERM_SC: '1'
VITE_USE_CDN: '1'
VITE_CDN_LIB_TYPE: 'loli'
run: npm run build
@ -89,9 +89,7 @@ jobs:
env:
VITE_BASE_PATH: '/nazhua/'
VITE_NEZHA_VERSION: 'v0'
VITE_SARASA_TERM_SC_USE_CDN: '1'
VITE_USE_CDN: '1'
VITE_CDN_LIB_TYPE: 'jsdelivr'
VITE_DISABLE_SARASA_TERM_SC: '1'
run: npm run build
- name: 打包v0-nazhua.zip
@ -100,9 +98,7 @@ jobs:
- name: 构建哪吒v0版本
env:
VITE_NEZHA_VERSION: 'v0'
VITE_SARASA_TERM_SC_USE_CDN: '1'
VITE_USE_CDN: '1'
VITE_CDN_LIB_TYPE: 'jsdelivr'
VITE_DISABLE_SARASA_TERM_SC: '1'
run: npm run build
- name: 打包v0-dist.zip
@ -111,9 +107,7 @@ jobs:
- name: 构建哪吒v1版本
env:
VITE_NEZHA_VERSION: 'v1'
VITE_SARASA_TERM_SC_USE_CDN: '1'
VITE_USE_CDN: '1'
VITE_CDN_LIB_TYPE: 'jsdelivr'
VITE_DISABLE_SARASA_TERM_SC: '1'
run: npm run build
- name: 打包dist.zip

View File

@ -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
View 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
View 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
View 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使用方法](../.github/images/remixicon-select.jpg)
> 当前支持版本: Remixicon 4.6.0cdn版本受限于更新原因支持到4.3.0

5187
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "nazhua",
"version": "0.4.17",
"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"
},

View File

@ -1,24 +1,42 @@
window.$$nazhuaConfig = {
// title: '哪吒监控', // 网站标题
// footerSlogan: '不要年付!不要年付!不要年付!<span style="color: #f00;">欢迎访问Nazhua探针</span>',
// freeAmount: '白嫖', // 免费服务的费用名称
// infinityCycle: '长期有效', // 无限周期名称
// buyBtnText: '购买', // 购买按钮文案
// buyBtnIcon: '', // 购买按钮图标取自remixicon
// customBackgroundImage: '', // 自定义的背景图片地址
// lightBackground: true, // 启用了浅色系背景图,会强制关闭点点背景
// showFireworks: true, // 是否显示烟花,建议开启浅色系背景
// showLantern: true, // 是否显示灯笼
enableInnerSearch: true, // 启用内部搜索
// listServerItemTypeToggle: true, // 服务器列表项类型切换
listServerItemType: 'card', // 服务器列表项类型 card/row/server-status row列表模式移动端自动切换至card
// serverStatusColumnsTpl: null, // 服务器状态列配置模板
// listServerStatusType: 'progress', // 服务器状态类型--列表
// listServerRealTimeShowLoad: false, // 列表显示服务器实时负载
// listServerRealTimeShowLoad: true, // 列表显示服务器实时负载
// detailServerStatusType: 'progress', // 服务器状态类型--详情页
// disableSarasaTermSC: false, // 禁用Sarasa Term SC字体
// 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', // 哪吒版本
@ -34,4 +52,5 @@ window.$$nazhuaConfig = {
// v1DashboardUrl: '/dashboard', // v1版本控制台地址
// v1HideNezhaDashboardBtn: true, // v1版本导航栏控制台入口/登录按钮 在nezhaVersion为v1时有效
// routeMode: 'h5', // 路由模式
// customFavicon: '', // 自定义favicon, 填写完整的url地址
};

286
readme.md
View File

@ -1,265 +1,74 @@
# Nazhua
**使用前请务必阅读Readme的内容对你有帮助**
基于哪吒监控(nezha.wiki)v0版本构建的前端主题目前兼容与v0相同数据结构的v1版本。
~~主题有点**重**,因为内置了一个带中文的`SarasaTermSC-SemiBold`字体。~~
根据不同场景,可以选择是否打包带入或者是否加载这个字体。
## 劝退指南 用前必读
1. 本主题是基于哪吒监控v0版本构建的不确定能否完美v1版本。*20241206的版本已适配*
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-{版本}-all.zip`或`v0-{版本}-cdn-{CDN供应方}.zip`;
## 📢 使用须知
## 关于点阵地图
点阵地图是一个失真的地图,地图边际与城市位置都不是真实的经纬度坐标,因此无法通过经纬度来定位城市。
需要在是[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",
"networkRoute": "CN2,GIA",
"extra": "传家宝,AS9929"
}
}
```
其中IPv4、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: '购买', // 购买按钮文案
listServerStatusType: 'progress', // 服务器状态类型--列表
listServerRealTimeShowLoad: false, // 列表显示服务器实时负载
detailServerStatusType: 'progress', // 服务器状态类型--详情页
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: false, // 隐藏框框里面的点点背景
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: false, // 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}
@ -268,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` |

View File

@ -1,6 +1,10 @@
<template>
<layout-main>
<router-view />
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</layout-main>
</template>
@ -11,6 +15,7 @@ import {
watch,
provide,
onMounted,
onUnmounted,
} from 'vue';
import { useStore } from 'vuex';
import { useRoute } from 'vue-router';
@ -20,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,
@ -35,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);
@ -105,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) => {

View File

@ -16,17 +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;
--public-note-tag-color: #ddd;
--public-note-tag-bg: #6a7efc;
--list-item-buy-link-color-hover: #ff9900;
--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) {
@ -53,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);
}

View File

@ -1,5 +1,5 @@
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { SVGRenderer } from 'echarts/renderers';
import {
BarChart,
} from 'echarts/charts';
@ -7,13 +7,40 @@ import {
PolarComponent,
} from 'echarts/components';
import config from '@/config';
use([
CanvasRenderer,
SVGRenderer,
BarChart,
PolarComponent,
]);
export default (used, total, itemColors, size = 100) => ({
function handleColor(color) {
if (Array.isArray(color)) {
return {
type: 'linear',
x: 1,
y: 1,
x2: 0,
y2: 0,
colorStops: [{
offset: 0,
color: color[0], // 0% 处的颜色
}, {
offset: 1,
color: color[1], // 100% 处的颜色
}],
};
}
return color;
}
export default (used, total, itemColors, size = 100) => {
const isLinear = (
(config.nazhua.serverStatusLinear || config.nazhua.lightBackground)
&& !config.nazhua.simpleColorMode
);
return {
angleAxis: {
max: total, // 满分
// 隐藏刻度线
@ -56,11 +83,18 @@ export default (used, total, itemColors, size = 100) => ({
value: used,
}],
itemStyle: {
color: typeof itemColors === 'string' ? itemColors : itemColors?.used,
color: typeof itemColors === 'string' ? itemColors : handleColor(itemColors?.used),
borderRadius: 5,
shadowColor: 'rgba(0, 0, 0, 0.2)',
shadowBlur: 10,
shadowOffsetY: 2,
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',
@ -74,7 +108,7 @@ export default (used, total, itemColors, size = 100) => ({
value: total,
}],
itemStyle: {
color: itemColors?.total || 'rgba(255, 255, 255, 0.2)',
color: handleColor(itemColors?.total) || 'rgba(255, 255, 255, 0.2)',
},
coordinateSystem: 'polar',
cursor: 'default',
@ -82,4 +116,5 @@ export default (used, total, itemColors, size = 100) => ({
barGap: '-100%', // 两环重叠
z: 5,
}],
});
};
};

View File

@ -1,9 +1,8 @@
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { SVGRenderer } from 'echarts/renderers';
import { LineChart } from 'echarts/charts';
import {
TooltipComponent,
// LegendComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components';
@ -12,20 +11,20 @@ import dayjs from 'dayjs';
import config from '@/config';
use([
CanvasRenderer,
SVGRenderer,
LineChart,
TooltipComponent,
// LegendComponent,
GridComponent,
DataZoomComponent,
]);
export default (
cateList,
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',
@ -36,10 +35,31 @@ export default (
},
formatter: (params) => {
const time = dayjs(parseInt(params[0].axisValue, 10)).format('YYYY.MM.DD HH:mm');
let res = `${time}<br>`;
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>';
let trEnd = false;
params.forEach((i, index) => {
if (index % 2 === 0) {
res += '<tr>';
}
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;
}
});
if (!trEnd) {
res += '</tr>';
}
res += '</table>';
}
return res;
},
backgroundColor: mode === 'dark' ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
@ -50,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,
@ -109,7 +112,7 @@ export default (
...i,
type: 'line',
smooth: true,
connectNulls: true,
connectNulls,
legendHoverLink: false,
symbol: 'none',
})),

View File

@ -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?.();

View File

@ -2,7 +2,7 @@
<div
class="dot-dot-box"
:class="{
'dot-dot-box--hide': $config.nazhua?.hideDotBG === true,
'dot-dot-box--hide': hideDotBG,
}"
:style="boxStyle"
>
@ -16,6 +16,7 @@
*/
import { computed } from 'vue';
import config from '@/config';
const props = defineProps({
borderRadius: {
@ -32,6 +33,10 @@ const props = defineProps({
},
});
const lightBackground = computed(() => config.nazhua.lightBackground);
const hideDotBG = computed(() => lightBackground.value || config.nazhua?.hideDotBG === true);
const boxStyle = computed(() => {
const style = {};
if (props.borderRadius) {
@ -68,9 +73,14 @@ const boxStyle = computed(() => {
backdrop-filter: saturate(50%) blur(3px);
&--hide {
background-color: rgba(#000, 0.8);
background-color: rgba(#000, 0.5);
background-image: none;
backdrop-filter: none;
transition: all 0.3s linear;
&:hover {
background-color: rgba(#000, 0.8);
}
}
@media screen and (max-width: 768px) {

View File

@ -0,0 +1,158 @@
<template>
<canvas
ref="canvas"
class="fireworks-canvas"
/>
</template>
<script setup>
import {
ref,
onMounted,
onUnmounted,
} from 'vue';
const canvas = ref(null);
let ctx = null;
let particles = [];
let rockets = [];
let animationFrameId = null;
class Particle {
constructor(x, y, color) {
this.x = x;
this.y = y;
this.color = color;
this.velocity = {
x: (Math.random() - 0.5) * 8,
y: (Math.random() - 0.5) * 12 - 8,
};
this.alpha = 1;
this.decay = 0.02;
}
draw() {
ctx.beginPath();
ctx.arc(this.x, this.y, 2, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${this.color}, ${this.alpha})`;
ctx.fill();
}
update() {
this.velocity.y += 0.1;
this.x += this.velocity.x;
this.y += this.velocity.y;
this.alpha -= this.decay;
}
}
function createFirework(x, y) {
const colors = [
'255, 0, 0',
'0, 255, 0',
'0, 0, 255',
'255, 255, 0',
'255, 0, 255',
'0, 255, 255',
];
const color = colors[Math.floor(Math.random() * colors.length)];
for (let i = 0; i < 80; i += 1) {
particles.push(new Particle(x, y, color));
}
}
class Rocket {
constructor() {
this.x = Math.random() * canvas.value.width;
this.y = canvas.value.height;
this.targetY = canvas.value.height * 0.5;
this.speed = 15;
this.trail = [];
this.maxTrailLength = 5;
}
draw() {
//
ctx.beginPath();
this.trail.forEach((pos, index) => {
ctx.fillStyle = `rgba(255, 200, 0, ${index / this.trail.length})`;
ctx.fillRect(pos.x, pos.y, 2, 2);
});
//
ctx.fillStyle = 'rgba(255, 220, 0, 1)';
ctx.fillRect(this.x, this.y, 3, 3);
}
update() {
this.trail.push({
x: this.x,
y: this.y,
});
if (this.trail.length > this.maxTrailLength) {
this.trail.shift();
}
this.y -= this.speed;
if (this.y <= this.targetY) {
createFirework(this.x, this.y);
return false;
}
return true;
}
}
function animate() {
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
//
rockets = rockets.filter((rocket) => {
rocket.draw();
return rocket.update();
});
//
particles = particles.filter((particle) => particle.alpha > 0);
particles.forEach((particle) => {
particle.draw();
particle.update();
});
//
if (Math.random() < 0.03 && rockets.length < 3) {
rockets.push(new Rocket());
}
animationFrameId = requestAnimationFrame(animate);
}
function resizeCanvas() {
if (canvas.value) {
canvas.value.width = window.innerWidth;
canvas.value.height = window.innerHeight;
}
}
onMounted(() => {
ctx = canvas.value.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
animate();
});
onUnmounted(() => {
window.removeEventListener('resize', resizeCanvas);
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
});
</script>
<style scoped>
.fireworks-canvas {
position: fixed;
top: 0;
left: 0;
z-index: 8;
pointer-events: none;
}
</style>

257
src/components/lantern.vue Normal file
View File

@ -0,0 +1,257 @@
<template>
<div class="lantern-container">
<div class="lantern-group right-group">
<div class="deng-box">
<div class="deng">
<div class="xian" />
<div class="deng-a">
<div class="deng-b">
<div class="deng-t"></div>
</div>
</div>
<div class="shui shui-a">
<div class="shui-c" />
<div class="shui-b" />
</div>
</div>
</div>
<div class="deng-box deng-box--2">
<div class="deng">
<div class="xian" />
<div class="deng-a">
<div class="deng-b">
<div class="deng-t"></div>
</div>
</div>
<div class="shui shui-a">
<div class="shui-c" />
<div class="shui-b" />
</div>
</div>
</div>
</div>
<div class="lantern-group left-group">
<div class="deng-box">
<div class="deng">
<div class="xian" />
<div class="deng-a">
<div class="deng-b">
<div class="deng-t"></div>
</div>
</div>
<div class="shui shui-a">
<div class="shui-c" />
<div class="shui-b" />
</div>
</div>
</div>
<div class="deng-box deng-box--2">
<div class="deng">
<div class="xian" />
<div class="deng-a">
<div class="deng-b">
<div class="deng-t"></div>
</div>
</div>
<div class="shui shui-a">
<div class="shui-c" />
<div class="shui-b" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
//
// AI
</script>
<style lang="scss" scoped>
.lantern-container {
position: fixed;
top: calc(var(--layout-header-height) + 5px);
width: 100%;
z-index: 50;
pointer-events: none;
}
.lantern-group {
position: fixed;
top: 70px;
animation: swing 3s infinite ease-in-out;
transform-origin: 50% -10px;
&.left-group {
left: 40px;
animation-delay: -1.5s;
.deng-box:nth-child(2) {
margin-top: -12px;
.deng {
animation: swing-extra 2s infinite ease-in-out;
animation-delay: -0.5s;
}
}
}
&.right-group {
right: 30px;
animation-delay: -0.5s;
.deng-box:nth-child(2) {
.deng {
animation: swing-extra 2s infinite ease-in-out;
animation-delay: -1s;
}
}
}
.deng {
animation: none;
}
.deng-box {
position: relative;
top: -40px;
&:first-child {
z-index: 2;
.deng {
margin-bottom: 23px;
}
}
&:nth-child(2) {
z-index: 1;
}
}
}
.deng {
position: relative;
width: 120px;
height: 90px;
margin: 50px;
background: rgba(216, 0, 15, 0.8);
border-radius: 50% 50%;
transform-origin: 50% -100px;
animation: swing 3s infinite ease-in-out;
box-shadow: -5px 5px 50px 4px rgba(250, 108, 0, 1);
}
.deng-a {
width: 100px;
height: 90px;
background: rgba(216, 0, 15, 0.1);
margin: 12px 8px 8px 10px;
border-radius: 50% 50%;
border: 2px solid #dc8f03;
}
.deng-b {
width: 45px;
height: 90px;
background: rgba(216, 0, 15, 0.1);
margin: -4px 8px 8px 26px;
border-radius: 50% 50%;
border: 2px solid #dc8f03;
}
.xian {
position: absolute;
top: -20px;
left: 60px;
width: 2px;
height: 20px;
background: #dc8f03;
}
.shui-a {
position: relative;
width: 5px;
height: 20px;
margin: -5px 0 0 59px;
transform-origin: 50% -45px;
background: #ffa500;
border-radius: 0 0 5px 5px;
}
.shui-b {
position: absolute;
top: 14px;
left: -2px;
width: 10px;
height: 10px;
background: #dc8f03;
border-radius: 50%;
}
.shui-c {
position: absolute;
top: 18px;
left: -2px;
width: 10px;
height: 35px;
background: #ffa500;
border-radius: 0 0 0 5px;
}
.deng:before {
position: absolute;
top: -7px;
left: 29px;
height: 12px;
width: 60px;
content: " ";
display: block;
z-index: 999;
border-radius: 5px 5px 0 0;
border: solid 1px #dc8f03;
background: linear-gradient(to right, #dc8f03, #ffa500, #dc8f03, #ffa500, #dc8f03);
}
.deng:after {
position: absolute;
bottom: -7px;
left: 10px;
height: 12px;
width: 60px;
content: " ";
display: block;
margin-left: 20px;
border-radius: 0 0 5px 5px;
border: solid 1px #dc8f03;
background: linear-gradient(to right, #dc8f03, #ffa500, #dc8f03, #ffa500, #dc8f03);
}
.deng-t {
font-family: 华文行楷, Arial, Lucida Grande, Tahoma, sans-serif;
font-size: 3.2rem;
color: #ffd000;
line-height: 85px;
text-align: center;
margin-left: -5px;
}
@keyframes swing {
0% { transform: rotate(-6deg) }
50% { transform: rotate(6deg) }
100% { transform: rotate(-6deg) }
}
@keyframes swing-extra {
0% { transform: rotate(-3deg) }
50% { transform: rotate(3deg) }
100% { transform: rotate(-3deg) }
}
@media screen and (max-width: 1024px) {
.lantern-container {
display: none;
}
}
</style>

292
src/components/popover.vue Normal file
View 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>

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

View File

@ -1,6 +1,9 @@
<template>
<div
class="world-map-group"
:class="{
'world-map-group--light-background': lightBackground,
}"
:style="mapStyle"
>
<div class="world-map-img" />
@ -39,6 +42,7 @@ import {
computed,
watch,
} from 'vue';
import config from '@/config';
import validate from '@/utils/validate';
import WorldMapPoint from './world-map-point.vue';
@ -63,37 +67,46 @@ const props = defineProps({
},
});
const lightBackground = computed(() => config.nazhua.lightBackground);
const boxPadding = computed(() => (lightBackground.value ? 20 : 0));
// 1280:621
const computedSize = computed(() => {
// padding
const adjustedWidth = Number(props.width) - (boxPadding.value * 2);
const adjustedHeight = Number(props.height) - (boxPadding.value * 2);
if (!validate.isEmpty(props.width) && !validate.isEmpty(props.height)) {
return {
width: 1280,
height: 621,
};
}
const width = Number(props.width);
const height = Number(props.height);
if (!validate.isEmpty(props.width) && validate.isEmpty(props.height)) {
return {
width,
height: Math.ceil((621 / 1280) * width),
width: adjustedWidth,
height: Math.ceil((621 / 1280) * adjustedWidth),
};
}
if (validate.isEmpty(props.width) && !validate.isEmpty(props.height)) {
return {
width: Math.ceil((1280 / 621) * height),
height,
width: Math.ceil((1280 / 621) * adjustedHeight),
height: adjustedHeight,
};
}
if (width / height > 1280 / 621) {
if (adjustedWidth / adjustedHeight > 1280 / 621) {
return {
width: Math.ceil(height * (1280 / 621)),
height,
width: Math.ceil(adjustedHeight * (1280 / 621)),
height: adjustedHeight,
};
}
return {
width,
height: Math.ceil(width * (621 / 1280)),
width: adjustedWidth,
height: Math.ceil(adjustedWidth * (621 / 1280)),
};
});
@ -118,8 +131,8 @@ function computeMapPoints() {
const points = props.locations.map((i) => {
const item = {
key: i.key,
left: (computedSize.value.width / 1280) * i.x,
top: (computedSize.value.height / 621) * i.y,
left: (computedSize.value.width / 1280) * i.x + boxPadding.value,
top: (computedSize.value.height / 621) * i.y + boxPadding.value,
size: i.size || 4,
label: i.label,
servers: i.servers,
@ -190,7 +203,7 @@ const tipsContentStyle = computed(() => {
style.left = `${activeTipsXY.value.x}px`;
style.transform = 'translate(-50%, 20px)';
} else {
style.bottom = '10px';
style.bottom = '4px';
style.left = '50%';
style.transform = 'translate(-50%, 0)';
}
@ -219,6 +232,30 @@ function handlePointTap(e) {
height: var(--world-map-height, 621px);
position: relative;
&--light-background {
padding: 20px;
background: rgba(#000, 0.6);
border-radius: 12px;
box-sizing: content-box;
transition: background-color 0.3s linear;
.world-map-img {
opacity: 1;
}
&:hover {
background: rgba(#000, 0.9);
}
@media screen and (max-width: 768px) {
background: rgba(#000, 0.8);
&:hover {
background: rgba(#000, 0.8);
}
}
}
.world-map-img {
width: var(--world-map-width, 1280px);
height: var(--world-map-height, 621px);
@ -232,11 +269,11 @@ function handlePointTap(e) {
padding: 5px 10px;
border-radius: 5px;
line-height: 20px;
white-space: pre;
color: #eee;
background: rgba(#000, 0.8);
box-shadow: 1px 4px 8px rgba(#303841, 0.4);
z-index: 100;
white-space: pre;
//
&::before {
@ -250,6 +287,15 @@ function handlePointTap(e) {
border-bottom-color: rgba(#000, 0.8);
transform: translateX(-50%);
}
@media screen and (max-width: 500px) {
line-height: 16px;
font-size: 12px;
&::before {
display: none;
}
}
}
}

View File

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

View File

@ -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,
@ -120,8 +144,8 @@ const codeMaps = {
country: '美国',
},
MIA: {
x: 243,
y: 244,
x: 237,
y: 249,
name: '迈阿密',
country: '美国',
},
@ -137,12 +161,54 @@ const codeMaps = {
name: '纽约',
country: '美国',
},
IAD: {
name: '阿什本',
country: 'US',
x: 265,
y: 186,
},
DFW: {
x: 172,
y: 211,
name: '达拉斯',
country: '美国',
},
ATL: {
x: 225,
y: 205,
name: '亚特兰大',
country: '美国',
},
HNL: {
x: 28,
y: 270,
name: '檀香山',
country: '美国',
},
YYZ: {
x: 267,
y: 161,
name: '多伦多',
country: '加拿大',
},
MEX: {
x: 158,
y: 280,
name: '墨西哥城',
country: '墨西哥',
},
SCQ: {
x: 289,
y: 513,
name: '圣地亚哥',
country: '智利',
},
GRU: {
x: 370,
y: 473,
name: '圣保罗',
country: '巴西',
},
SYD: {
x: 1167,
y: 519,
@ -167,6 +233,12 @@ const codeMaps = {
name: '法兰克福',
country: '德国',
},
BER: {
x: 620,
y: 130,
name: '柏林',
country: '德国',
},
LUX: {
x: 591,
y: 140,
@ -179,6 +251,24 @@ const codeMaps = {
name: '巴黎',
country: '法国',
},
WAW: {
name: '华沙',
country: '波兰',
x: 649,
y: 123,
},
MAD: {
name: '马德里',
country: '西班牙',
x: 554,
y: 180,
},
MXP: {
name: '米兰',
country: '意大利',
x: 604,
y: 153,
},
SVO: {
x: 704,
y: 115,
@ -191,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,
@ -215,6 +329,7 @@ export const aliasMapping = {
HK: 'HKG',
MO: 'MFM',
TW: 'TPE',
ASH: 'IAD',
};
export const countryCodeMapping = {
@ -224,6 +339,9 @@ export const countryCodeMapping = {
KR: 'SEL',
MY: 'KUL',
VN: 'HAN',
IN: 'DEL',
TH: 'BKK',
AE: 'DXB',
TR: 'IST',
RO: 'OTP',
LU: 'LUX',
@ -235,6 +353,17 @@ export const countryCodeMapping = {
GB: 'LON',
AU: 'SYD',
US: 'LAX',
CA: 'YYZ',
MX: 'MEX',
CL: 'SCQ',
BR: 'GRU',
IT: 'MXP',
ES: 'MAD',
PL: 'WAW',
BG: 'SOF',
LT: 'VNO',
NO: 'OSL',
MA: 'RBA',
};
export default codeMaps;

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

View File

@ -1,5 +1,11 @@
<template>
<div class="layout-footer">
<div
v-if="footerSlogan"
class="footer-slogan"
>
<div v-html="footerSlogan" />
</div>
<div class="copyright-text">
<span class="text">
Powered by
@ -40,12 +46,20 @@ import {
nextTick,
} from 'vue';
import { useStore } from 'vuex';
import config from '@/config';
const version = import.meta.env.VITE_APP_VERSION;
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) {
return store.state.setting.config.custom_code;
}
if (store.state.setting?.custom_code) {
return store.state.setting.custom_code;
}
@ -56,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);
document.body.removeChild(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);
}
});
});
};
watch(dynamicContent, () => {
if (dynamicContent.value) {
//
const cleanupScripts = () => {
executedScripts.value.clear();
};
watch(dynamicContent, (newVal, oldVal) => {
//
if (newVal !== oldVal) {
cleanupScripts();
}
if (newVal) {
// DOM
nextTick(() => {
executeScripts();
});
}
});
@ -90,6 +149,16 @@ onMounted(() => {
font-size: 12px;
color: #ccc;
.footer-slogan {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 5px;
font-size: 14px;
color: #fff;
}
.copyright-text {
display: flex;
justify-content: center;

View File

@ -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
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,
}"
<server-count
v-if="showServerCount"
/>
<server-stat
v-if="showServerStat"
/>
<dashboard-btn
v-if="showDashboardBtn"
/>
<span>{{ userLogin ? '管理后台' : '登录' }}</span>
</a>
</div>
</div>
</div>
</div>
@ -128,21 +33,22 @@
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();
const lightBackground = computed(() => config.nazhua.lightBackground);
const headerStyle = computed(() => {
const style = {};
if (route.name === 'ServerDetail') {
@ -154,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);
@ -267,6 +76,9 @@ const headerClass = computed(() => {
if (showServerCount.value) {
classes.push('layout-header--show-server-count');
}
if (lightBackground.value) {
classes.push('layout-header--light-background');
}
return classes;
});
@ -282,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>
@ -304,43 +114,21 @@ const dashboardUrl = computed(() => config.nazhua.v1DashboardUrl || '/dashboard'
}
}
&--light-background {
background-color: rgba(#000, 0.7);
background-image: none;
backdrop-filter: none;
}
.site-name {
line-height: calc(var(--layout-header-height) - 20px);
font-size: 24px;
font-weight: bold;
color: #fff;
text-shadow: 2px 2px 4px rgba(#000, 0.5);
cursor: pointer;
}
.server-count-group {
display: flex;
gap: 10px;
.server-count {
display: flex;
align-items: center;
gap: 3px;
color: #ddd;
line-height: 30px;
&.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;
@ -360,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>

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

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

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

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

View File

@ -1,11 +1,27 @@
<template>
<div class="layout-group">
<div class="layout-bg" />
<div
class="layout-group"
:style="layoutGroupStyle"
>
<div
class="layout-bg"
:style="layoutBGStyle"
/>
<div class="layout-main">
<layout-header />
<slot />
<layout-footer />
<search-box
v-if="enableInnerSearch"
/>
</div>
<template v-if="showFireworks">
<fireworks />
</template>
<template v-if="config.nazhua.showLantern">
<lantern />
</template>
</div>
</template>
@ -13,8 +29,60 @@
/**
* LayoutMain
*/
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 = {};
if (config.nazhua.lightBackground) {
style['--layout-main-bg-color'] = 'rgba(20, 30, 40, 0.2)';
}
return style;
});
const layoutBGStyle = computed(() => {
const style = {};
if (config.nazhua.customBackgroundImage) {
style.background = `url(${config.nazhua.customBackgroundImage}) 50% 50%`;
style.backgroundSize = 'cover';
}
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>

View File

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

View File

@ -4,6 +4,7 @@ import {
createWebHashHistory,
} from 'vue-router';
import config from '@/config';
import pageTitle from '@/utils/page-title';
const constantRoutes = [{
name: 'Home',
@ -26,16 +27,25 @@ const constantRoutes = [{
const routerOptions = {
history: config.nazhua.routeMode === 'h5' ? createWebHistory() : createWebHashHistory(),
scrollBehavior: () => ({
scrollBehavior: (to, from, savedPosition) => {
if (savedPosition) {
return savedPosition;
}
return {
top: 0,
behavior: 'smooth',
}),
};
},
routes: constantRoutes,
};
const router = createRouter(routerOptions);
router.beforeResolve((to, from, next) => {
document.title = [to?.meta?.title, config.nazhua.title].filter((i) => i).join(' - ');
if (to?.meta?.title) {
pageTitle(to?.meta?.title);
} else if (to.name === 'Home') {
pageTitle(config.nazhua.title);
}
next();
});

View File

@ -21,6 +21,7 @@ const defaultState = () => ({
serverTime: 0,
serverGroup: [],
serverList: [],
serverListColumnWidths: {},
serverCount: {
total: 0,
online: 0,
@ -92,6 +93,9 @@ const store = createStore({
SET_SETTING(state, setting) {
state.setting = setting;
},
SET_SERVER_LIST_COLUMN_WIDTHS(state, widths) {
state.serverListColumnWidths = widths;
},
},
actions: {
/**
@ -114,7 +118,7 @@ const store = createStore({
commit('SET_SETTING', res);
// 如果自定义配置没有设置title使用站点名称
if (!window.$$nazhuaConfig.title) {
config.nazhua.title = res.site_name;
config.nazhua.title = res.config?.site_name || res.site_name;
if (route?.name === 'Home' || !route) {
document.title = config.nazhua.title;
}
@ -184,6 +188,33 @@ const store = createStore({
}
});
},
/**
* 设置服务器列表行宽度
*/
setServerListColumnWidths({
commit,
state,
}, data) {
const newWidths = {
...state.serverListColumnWidths,
...data,
};
commit('SET_SERVER_LIST_COLUMN_WIDTHS', newWidths);
},
setServerListColumnWidth({
commit,
state,
}, data) {
const newWidths = {
...state.serverListColumnWidths,
};
if (newWidths[data.prop]) {
newWidths[data.prop] = Math.max(newWidths[data.prop], data.width);
} else {
newWidths[data.prop] = data.width;
}
commit('SET_SERVER_LIST_COLUMN_WIDTHS', newWidths);
},
},
});

View File

@ -5,11 +5,16 @@ 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;
};

View File

@ -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')) {
if (text.includes('Core(TM)')) {
const modelNumReg = /Core\(TM\) (\w+-\w+)/;
// 匹配如 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;
}
@ -212,8 +290,13 @@ export function getPlatformLogoIconClassName(platform) {
/**
* 获取系统发行版本
*/
export function getSystemOSLabel(platform) {
switch (platform) {
export function getSystemOSLabel(platform, short = false) {
const platformStr = (platform || '').toLowerCase();
// 匹配一些超长系统发行版本
if (short && platformStr.includes('windows')) {
return 'Windows';
}
switch (platformStr) {
case 'windows':
return 'Windows';
case 'linux':

View File

@ -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;
});
/**
* 获取标签列表

View File

@ -21,12 +21,16 @@ export const loadServerGroup = async () => request({
});
}
return null;
}).catch(() => null);
}).catch((error) => {
console.error('Failed to load server group:', error);
return null;
});
/**
* 加载网站配置
*
* 暂时只使用site_name
* 暂时只使用site_name\custom_code
* 哪吒v1.4.9之后上面的参数调整至data.config
*/
export const loadSetting = async () => request({
url: config.nazhua.v1ApiSettingPath,
@ -36,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;
});
/**
* 加载个人信息
@ -52,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;
});

6
src/utils/page-title.js Normal file
View File

@ -0,0 +1,6 @@
import config from '@/config';
export default (...args) => {
const titles = [...new Set([...args, config.nazhua.title])].filter((i) => i);
document.title = titles.join(' - ');
};

View File

@ -2,6 +2,7 @@
* V1版数据加载
*/
import store from '@/store';
import validate from '@/utils/validate';
import { Mapping } from '@/utils/object-mapping';
/**
@ -97,6 +98,18 @@ export default function (v1Data) {
break;
case '_$mapping':
v0Data[key] = Mapping.each(magics[$magic[1]], v1Data);
if (key === 'State') {
// 修复Load1、Load5、Load15字段为空时的问题
[
'Load1', 'Load5', 'Load15',
'NetInTransfer', 'NetOutTransfer',
'NetInSpeed', 'NetOutSpeed',
].forEach((k) => {
if (!validate.isSet(v0Data[key][k])) {
v0Data[key][k] = 0;
}
});
}
break;
default:
break;
@ -109,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;
}

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

View File

@ -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}`"
>
<popover :title="ttItem?.title || (`${ttItem.label}: ${ttItem.value}`)">
<template #trigger>
<span
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 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>
@ -212,6 +241,9 @@
v-for="(tag, index) in tagList"
:key="`${tag}_${index}`"
class="server-info-tag-item"
:class="{
'has-sarasa-term': $hasSarasaTerm && config.nazhua.disableSarasaTermSC !== true,
}"
>
{{ tag }}
</span>
@ -228,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>
@ -257,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() {
@ -302,96 +345,158 @@ 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',
});
if (acpitz.length) {
const acpitzMean = (acpitz.reduce((a, b) => a + b, 0) / acpitz.length).toFixed(1) * 1;
data.push({
label: '主板平均',
value: `${acpitzMean}`,
title: acpitz.map((i, index) => `传感器${index + 1}: ${i}`).join('\n'),
type: 'acpitz-mean',
title: acpitz.map((i, index) => `传感器${index + 1}: ${parseFloat(i).toFixed(1)}`).join('\n'),
type: 'motherboard',
});
}
}
// CPU
if (coretemp_package_id.length || coretemp_core.length) {
const temps = [];
const details = [];
// 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',
});
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 coretempCoreMean = (coretemp_core.reduce((a, b) => a + b.value, 0) / coretemp_core.length).toFixed(1) * 1;
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: '核心平均',
value: `${coretempCoreMean}`,
title: coretemp_core.map((i) => `核心${i.index + 1}: ${i.value}`).join('\n'),
type: 'coretemp-core',
label: 'CPU',
value: temps.join(' / '),
title: details.join('\n'),
type: 'cpu',
});
//
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 CPU
if (k10temp.length) {
const tctl = k10temp.find((i) => i.name.includes('tctl'));
if (tctl) {
data.push({
label: `最热核心.${maxCore + 1}`,
value: `${max}`,
type: 'coretemp-max-core',
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 (other.length) {
// AMD GPU
if (amdgpu.length) {
const edge = amdgpu.find((i) => i.name.includes('edge'));
if (edge) {
data.push({
type: 'other',
label: '其它',
value: '...',
title: other.map((i) => `${i.label}: ${i.value}`).join('\n'),
label: 'AMD GPU',
value: `${parseFloat(edge.value).toFixed(1)}`,
title: amdgpu.map((i) => `${i.name}: ${parseFloat(i.value).toFixed(1)}`).join('\n'),
type: 'gpu',
});
}
}
// 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,
};
@ -415,11 +520,24 @@ const billPlanData = computed(() => ['billing', 'remainingTime', 'bandwidth', 't
const tagList = computed(() => {
const list = [];
if (props?.info?.PublicNote?.planDataMod?.networkRoute) {
list.push(...props.info.PublicNote.planDataMod.networkRoute.split(','));
const {
networkRoute,
extra,
IPv4,
IPv6,
} = props?.info?.PublicNote?.planDataMod || {};
if (networkRoute) {
list.push(...networkRoute?.split?.(','));
}
if (props?.info?.PublicNote?.planDataMod?.extra) {
list.push(...props.info.PublicNote.planDataMod.extra.split(','));
if (extra) {
list.push(...extra?.split?.(','));
}
if (IPv4 === '1' && IPv6 === '1') {
list.push('双栈IP');
} else if (IPv4 === '1') {
list.push('仅IPv4');
} else if (IPv6 === '1') {
list.push('仅IPv6');
}
return list;
});
@ -527,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 {
@ -551,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;
}
@ -592,11 +732,16 @@ const processCount = computed(() => props.info?.State?.ProcessCount);
.server-info-tag-item {
height: 18px;
padding: 0 5px 0 6px;
line-height: 20px;
line-height: 18px;
font-size: 12px;
color: var(--public-note-tag-color);
background-color: var(--public-note-tag-bg);
background: var(--public-note-tag-bg);
text-shadow: 1px 1px 2px rgba(#000, 0.2);
border-radius: 4px;
&.has-sarasa-term {
line-height: 20px;
}
}
}
}

View File

@ -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,10 +86,17 @@
</div>
</div>
<div class="monitor-cate-group">
<template v-if="monitorChartType === 'single'">
<div class="monitor-chart-group">
<div
v-for="cateItem in monitorChartData.cateList"
v-for="(cateItem, index) in monitorChartData.cateList"
:key="cateItem.id"
class="monitor-chart-item"
>
<div class="cate-name-box">
<popover :title="cateItem.title">
<template #trigger>
<div
class="monitor-cate-item"
:class="{
disabled: showCates[cateItem.id] === false,
@ -77,8 +104,6 @@
:style="{
'--cate-color': cateItem.color,
}"
:title="cateItem.title"
@click="toggleShowCate(cateItem.id)"
>
<span class="cate-legend" />
<span
@ -93,19 +118,69 @@
{{ cateItem.avg }}ms
</span>
<span
v-else
class="cate-avg-ms"
v-if="cateItem.over !== 0"
class="cate-over-rate"
>
-ms
{{ 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>
<line-chart
:cate-list="monitorChartData.cateList"
:date-list="monitorChartData.dateList"
:value-list="monitorChartData.valueList"
:connect-nulls="false"
/>
</template>
</dot-dot-box>
</template>
@ -160,17 +235,36 @@ 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 accpetShowTime = computed(() => {
const now = store.state.serverTime || Date.now();
return now - (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);
return {
@ -188,94 +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;
});
const {
threshold,
mean,
max,
min,
} = peakShaving.value ? getThreshold(showAvgDelay, 2) : {};
showCreateTime.forEach((o, index) => {
if (dateMap[o]) {
return;
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 avgDelay = showAvgDelay[index];
//
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 {
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);
}
});
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]);
}
});
let dateList = [];
let valueList = [];
const cateList = [];
Object.keys(cateMap).forEach((i) => {
const {
id,
if (import.meta.env.VITE_MONITOR_DEBUG === '1') {
cateMap[monitor_name].origin = {
cateCreateTime,
cateDelayMap,
cateAcceptTimeMap,
dateMap,
avgs,
} = cateMap[i];
Object.entries(dateMap).forEach(([key, value]) => {
const time = parseInt(key, 10);
avgs.push([time, value]);
dateList.push(time);
});
const color = getLineColor(id);
if (avgs.length) {
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 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: (avgs.reduce((a, b) => a + b[1], 0) / avgs.length).toFixed(2) * 1,
over: ((avgs.filter((o) => o[1] > 0).length / avgs.length) * 100).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,
},
@ -285,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,
@ -297,10 +465,17 @@ 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) {
@ -308,9 +483,32 @@ function toggleMinute(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: (
@ -364,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 {
@ -373,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;
@ -389,7 +649,8 @@ onUnmounted(() => {
}
.peak-shaving-group,
.refresh-data-group {
.refresh-data-group,
.chart-type-switch-group {
display: flex;
align-items: center;
gap: 4px;
@ -423,7 +684,7 @@ onUnmounted(() => {
.switch-dot {
left: 16px;
box-shadow: 1px 1px 2px #000;
box-shadow: 1px 1px 2px rgba(#000, 0.4);
}
}
}
@ -476,7 +737,7 @@ onUnmounted(() => {
align-items: center;
// padding: 0 10px;
height: var(--minute-item-height);
background: rgba(#fff, 0.1);
background: rgba(#fff, 0.2);
border-radius: calc(var(--minute-item-height) / 2);
.minute-item {
@ -493,7 +754,7 @@ onUnmounted(() => {
&.active {
color: #fff;
text-shadow: 1px 1px 2px #000;
text-shadow: 1px 1px 2px rgba(#000, 0.6);
}
}
@ -517,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>

View File

@ -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%;
@ -144,6 +139,7 @@ const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconCl
.server-name {
line-height: 30px;
font-size: 24px;
font-weight: bold;
color: #fff;
}
@ -156,6 +152,7 @@ const platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconCl
.core-mem {
line-height: 30px;
font-size: 16px;
font-weight: bold;
}
@media screen and (max-width: 500px) {
@ -209,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 {
@ -222,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>

View File

@ -36,6 +36,9 @@
v-for="(tagItem, index) in tagList"
:key="`${tagItem}_${index}`"
class="tag-item"
:class="{
'has-sarasa-term': $hasSarasaTerm && config.nazhua.disableSarasaTermSC !== true,
}"
>
{{ tagItem }}
</span>
@ -60,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>
@ -93,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);
@ -103,13 +122,27 @@ function toBuy() {
const tagList = computed(() => {
const list = [];
if (props?.info?.PublicNote?.planDataMod?.networkRoute) {
list.push(...props.info.PublicNote.planDataMod.networkRoute.split(','));
const {
networkRoute,
extra,
IPv4,
IPv6,
} = props?.info?.PublicNote?.planDataMod || {};
if (networkRoute) {
list.push(...networkRoute.split(','));
}
if (props?.info?.PublicNote?.planDataMod?.extra) {
list.push(...props.info.PublicNote.planDataMod.extra.split(','));
if (extra) {
list.push(...extra.split(','));
}
return list;
if (IPv4 === '1' && IPv6 === '1') {
list.push('双栈IP');
} else if (IPv4 === '1') {
list.push('仅IPv4');
} else if (IPv6 === '1') {
list.push('仅IPv6');
}
// 5
return list.slice(0, 5);
});
const show = computed(() => {
@ -129,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);
@ -153,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 {
@ -183,11 +232,16 @@ const show = computed(() => {
.tag-item {
height: 18px;
padding: 0 4px;
line-height: 20px;
line-height: 18px;
font-size: 12px;
color: var(--public-note-tag-color);
background-color: var(--public-note-tag-bg);
background: var(--public-note-tag-bg);
text-shadow: 1px 1px 2px rgba(#000, 0.2);
border-radius: 4px;
&.has-sarasa-term {
line-height: 20px;
}
}
}

View File

@ -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 = [
const componentName = computed(() => {
const name = [
'donut',
'progress',
].includes(config.nazhua.listServerStatusType) ? config.nazhua.listServerStatusType : 'donut';
].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);
}
}

View File

@ -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,
@ -167,6 +162,7 @@ function openDetail() {
height: 30px;
line-height: 32px;
font-size: 14px;
font-weight: bold;
}
.cpu-mem-group {
@ -178,6 +174,7 @@ function openDetail() {
.core-mem {
height: 30px;
line-height: 32px;
font-weight: bold;
}
}
@ -201,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;
@ -215,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) {

View File

@ -0,0 +1,238 @@
<template>
<div
class="list-column"
:class="`list-column--${prop}`"
:style="columnStyle"
>
<div
ref="columnContentRef"
class="list-column-content"
>
<span class="item-label">{{ label }}</span>
<div class="item-content">
<template v-if="slotContent">
<slot />
</template>
<template v-if="slotValue">
<span class="item-text item-value">
<slot name="value" />
</span>
<span class="item-text item-unit">
<slot name="unit" />
</span>
</template>
<template v-else>
<span class="item-text item-value">{{ value }}</span>
<span
v-if="unit"
class="item-text item-unit"
>{{ unit }}</span>
</template>
</div>
</div>
</div>
</template>
<script setup>
/**
* 服务器信息列表列
*/
import {
computed,
ref,
onMounted,
onBeforeUnmount,
} from 'vue';
import {
useStore,
} from 'vuex';
const props = defineProps({
prop: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
value: {
type: [String, Number],
default: '',
},
unit: {
type: String,
default: '',
},
width: {
type: [String, Number],
default: null,
},
slotContent: {
type: [String, Boolean],
default: false,
},
slotValue: {
type: [String, Boolean],
default: false,
},
});
const store = useStore();
const columnContentRef = ref(null);
let resizeObserver = null;
const columnWidth = computed(() => store.state?.serverListColumnWidths?.[props.prop]);
onMounted(() => {
if (columnContentRef.value) {
resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
let { width } = entry.contentRect;
width = Math.ceil(width);
store.dispatch('setServerListColumnWidth', {
prop: props.prop,
width: width > 40 ? width : 40,
});
});
});
resizeObserver.observe(columnContentRef.value);
}
});
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
const columnStyle = computed(() => {
const style = {};
if (props.width) {
const width = parseInt(props.width, 10);
if (Number.isNaN(width) === false) {
style.width = `${width}px`;
}
} else if (columnWidth.value > 0) {
style.width = `${columnWidth.value}px`;
}
return style;
});
</script>
<style lang="scss" scoped>
.list-column {
--list-column-label-height: 16px;
--list-column-value-height: 24px;
position: relative;
width: auto;
height: calc(var(--list-column-label-height) + var(--list-column-value-height) + 10px);
.list-column-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: max-content;
height: var(--list-item-height);
.item-label {
padding-top: 6px; //
line-height: var(--list-column-label-height);
font-size: 12px;
color: #bbb;
}
.item-content {
line-height: var(--list-column-value-height);
font-size: 14px;
}
}
&--duration {
.item-value {
color: var(--duration-color);
}
}
&--load {
.item-value {
color: var(--load-color);
}
}
&--transfer {
.item-value {
color: var(--transfer-color);
}
}
&--inTransfer {
.item-value {
color: var(--transfer-in-color);
}
}
&--outTransfer {
.item-value {
color: var(--transfer-out-color);
}
}
&--speeds {
.item-value {
color: var(--net-speed-color);
}
}
&--inSpeed {
.item-value {
color: var(--net-speed-in-color);
}
}
&--outSpeed {
.item-value {
color: var(--net-speed-out-color);
}
}
&--remaining-time {
.value-text {
color: #74dbef;
}
}
&--billing {
.value-text {
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>

View File

@ -0,0 +1,88 @@
<template>
<server-list-column
v-if="extraFields?.remainingTime"
prop="remaining-time"
label="剩余"
:value="billAndPlan?.remainingTime?.value || '-'"
/>
<server-list-column
v-if="extraFields?.billing"
prop="billing"
label="费用"
:value="billAndPlan?.billing?.value || '-'"
/>
<server-list-column
v-if="extraFields?.orderLink"
prop="order-link"
label="链接"
:slot-content="true"
>
<span
v-if="showBuyBtn"
class="order-link"
@click="toBuy"
>
{{ buyBtnText }}
</span>
<span v-else>-</span>
</server-list-column>
</template>
<script setup>
/**
* 套餐信息
*/
import {
inject,
computed,
} from 'vue';
import config from '@/config';
import handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';
import ServerListColumn from './server-list-column.vue';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const filterServerList = inject('filterServerList', {
value: null,
});
const extraFields = computed(() => filterServerList.value?.fields || {});
const {
billAndPlan,
} = handleServerBillAndPlan({
props,
});
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() {
const decodeUrl = decodeURIComponent(props.info?.PublicNote?.customData?.orderLink);
window.open(decodeUrl, '_blank');
}
</script>
<style lang="scss" scoped>
.order-link {
color: var(--list-item-buy-link-color);
cursor: pointer;
&:hover {
color: var(--list-item-buy-link-color-hover);
}
}
</style>

View File

@ -0,0 +1,45 @@
<template>
<server-list-column
v-for="item in serverRealTimeList"
:key="item.key"
:prop="item.key"
:label="item.label"
:value="item.show ? item?.value : '-'"
:unit="item.show ? item?.unit : ''"
/>
</template>
<script setup>
/**
* 服务器数据统计
*/
import {
inject,
} from 'vue';
import handleServerRealTime from '@/views/composable/server-real-time';
import ServerListColumn from './server-list-column.vue';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
serverRealTimeListTpls: {
type: String,
default: undefined,
},
});
const currentTime = inject('currentTime', {
value: Date.now(),
});
const {
serverRealTimeList,
} = handleServerRealTime({
props,
currentTime,
serverRealTimeListTpls: props.serverRealTimeListTpls,
});
</script>

View File

@ -0,0 +1,143 @@
<template>
<div
class="server-list-item-status-progress"
:class="'server-status--' + type"
:title="valPercent"
>
<span class="progress-label">
{{ label }}
</span>
<div class="progress-bar">
<div class="progress-bar-box">
<div
class="progress-bar-inner"
:style="progressStyle"
/>
<span
class="progress-bar-used"
>
{{ valText }}
</span>
</div>
</div>
</div>
</template>
<script setup>
/**
* 服务器状态进度调单项
*/
import {
computed,
} from 'vue';
const props = defineProps({
type: {
type: String,
default: '',
},
size: {
type: Number,
default: 100,
},
used: {
type: [Number, String],
default: 1,
},
colors: {
type: Object,
default: () => ({}),
},
valText: {
type: String,
default: '',
},
valPercent: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
content: {
type: [String, Object],
default: '',
},
});
const progressStyle = computed(() => {
const style = {};
style.width = `${Math.min(props.used, 100)}%`;
const color = typeof props.colors === 'string' ? props.colors : props.colors?.used;
if (color) {
if (Array.isArray(color)) {
style.background = `linear-gradient(-35deg, ${color.join(',')})`;
} else {
style.backgroundColor = color;
}
}
return style;
});
</script>
<style lang="scss" scoped>
.server-list-item-status-progress {
--progress-label-height: 16px;
--progress-bar-height: 24px;
--progress-bar-box-height: 14px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: var(--list-item-height);
.progress-label {
padding-top: 6px; //
line-height: var(--progress-label-height);
font-size: 12px;
color: #ccc;
}
.progress-bar {
display: flex;
align-items: center;
width: 100%;
height: var(--progress-bar-height);
}
.progress-bar-box {
position: relative;
width: 100%;
height: var(--progress-bar-box-height);
background: rgba(255, 255, 255, 0.2);
border-radius: calc(var(--progress-bar-box-height) / 2);
overflow: hidden;
}
.progress-bar-inner {
position: absolute;
top: 0;
left: 0;
bottom: 0;
background-color: #08f;
border-radius: calc(var(--progress-bar-box-height) / 2);
box-shadow: 2px 0 2px rgba(#000, 0.2);
}
.progress-bar-used {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
line-height: var(--progress-bar-box-height);
font-size: 12px;
text-align: center;
text-shadow: 1px 1px 2px rgba(#000, 0.8), 0 0 1px rgba(#fff, 0.5);
cursor: default;
}
}
</style>

View File

@ -0,0 +1,71 @@
<template>
<div
v-for="item in serverStatusList"
:key="item.type"
class="list-column-item list-column-item--status"
:class="`list-column-item--status-${componentName} list-column-item--status-type-${item.type}`"
>
<component
:is="componentMaps[componentName]"
:type="item.type"
:used="item.used"
:colors="item.colors"
:val-text="item.valPercent"
:val-percent="`${item.label}使用${item.valText}`"
:label="item.label"
/>
</div>
</template>
<script setup>
/**
* 服务器状态盒子
*/
import config from '@/config';
import handleServerStatus from '@/views/composable/server-status';
import ServerStatusDonut from '@/views/components/server/server-status-donut.vue';
import ServerStatusProgress from './server-list-item-status-progress.vue';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const componentMaps = {
donut: ServerStatusDonut,
progress: ServerStatusProgress,
};
const componentName = [
'donut',
'progress',
].includes(config.nazhua.listServerStatusType) ? config.nazhua.listServerStatusType : 'donut';
const {
serverStatusList,
} = handleServerStatus({
props,
statusListTpl: 'cpu,mem,disk',
statusListItemContent: false,
});
</script>
<style lang="scss" scoped>
.list-column-item {
&--status-progress {
width: 72px;
padding: 0 3px;
}
&--status-donut {
--server-status-size: 66px;
--server-status-label-scale: 0.8;
--server-status-val-text-font-size: 16px;
--server-status-label-font-size: 12px;
}
}
</style>

View File

@ -0,0 +1,168 @@
<template>
<dot-dot-box
border-radius="var(--list-item-border-radius)"
padding="var(--list-item-padding)"
class="server-list-row-item"
:class="{
'server-list-row-item--offline': info.online === -1,
}"
@click="openDetail"
>
<div class="list-column-item list-column-item--server-flag">
<server-flag :info="info" />
</div>
<div class="list-column-item list-column-item--server-name">
<span
class="server-name"
:title="info.Name"
>
{{ info.Name }}
</span>
</div>
<server-list-column
prop="server-flag"
label="地区"
:value="info?.Host?.CountryCode?.toUpperCase() || 'UN'"
/>
<server-list-column
prop="server-system"
label="系统"
:value="platformSystemLabel || '-'"
/>
<server-list-column
prop="cpu-mem"
label="配置"
:value="cpuAndMemAndDisk || '-'"
/>
<server-list-item-status
v-if="$config.nazhua.hideListItemStatusDonut !== true"
:info="info"
/>
<server-list-item-real-time
v-if="$config.nazhua.hideListItemStat !== true"
:info="info"
server-real-time-list-tpls="load,conns,speeds,transfer,duration"
/>
<server-list-item-bill
v-if="$config.nazhua.hideListItemBill !== true"
:info="info"
/>
</dot-dot-box>
</template>
<script setup>
/**
* 单节点
*/
import {
computed,
} from 'vue';
import {
useRouter,
} from 'vue-router';
import * as hostUtils from '@/utils/host';
import handleServerInfo from '@/views/composable/server-info';
import ServerListColumn from './server-list-column.vue';
import ServerListItemStatus from './server-list-item-status.vue';
import ServerListItemRealTime from './server-list-item-real-time.vue';
import ServerListItemBill from './server-list-item-bill.vue';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const router = useRouter();
/**
* XCore XGB
*/
const { cpuAndMemAndDisk } = handleServerInfo({
props,
});
const platformSystemLabel = computed(() => hostUtils.getSystemOSLabel(props.info?.Host?.Platform, true));
function openDetail() {
router.push({
name: 'ServerDetail',
params: {
serverId: props.info.ID,
},
});
}
</script>
<style lang="scss" scoped>
.server-list-row-item {
--list-item-height: 64px;
--list-item-border-radius: 8px;
--list-item-gap: 0;
--list-item-padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--list-item-height);
gap: var(--list-item-gap);
transition: 0.3s;
&--offline {
filter: grayscale(1);
}
@media (max-width: 1280px) {
--list-item-padding: 0 10px;
}
}
.list-column-item {
display: flex;
align-items: center;
overflow: hidden;
&--server-flag {
--server-flag-size: 24px;
width: calc(var(--server-flag-size) * 1.5);
.server-flag {
width: calc(var(--server-flag-size) * 1.5);
height: var(--server-flag-size);
line-height: var(--server-flag-size);
font-size: var(--server-flag-size);
}
@media (max-width: 1280px) {
display: none;
}
@media (max-width: 1024px) {
display: block;
}
}
&--server-name {
width: 220px;
.server-name {
height: 32px;
line-height: 34px;
font-size: 16px;
font-weight: bold;
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
@media (max-width: 1280px) {
width: 180px;
}
@media (max-width: 1024px) {
width: 300px;
}
}
}
</style>

View File

@ -0,0 +1,131 @@
<template>
<transition-group
v-if="showTransition"
name="list"
tag="div"
class="server-list-container"
:class="{
'server-list--row': showListRow,
'server-list--card': showListCard,
'server-list--status': showListByServerStatus,
}"
>
<slot />
</transition-group>
<div
v-else
class="server-list-container"
:class="{
'server-list--row': showListRow,
'server-list--card': showListCard,
'server-list--status': showListByServerStatus,
}"
>
<slot />
</div>
</template>
<script setup>
/**
* 服务器列表
*/
defineProps({
showTransition: {
type: Boolean,
default: true,
},
showListRow: {
type: Boolean,
default: false,
},
showListCard: {
type: Boolean,
default: false,
},
showListByServerStatus: {
type: Boolean,
default: false,
},
});
</script>
<style lang="scss" scoped>
.server-list-container.server-list--card {
--list-padding: 20px;
--list-gap-size: 20px;
--list-item-num: 3;
--list-item-width: calc(
(
var(--list-container-width)
- (var(--list-padding) * 2)
- (
var(--list-gap-size)
* (var(--list-item-num) - 1)
)
)
/ var(--list-item-num)
);
position: relative;
display: flex;
flex-wrap: wrap;
gap: var(--list-gap-size);
padding: 0 var(--list-padding);
width: var(--list-container-width);
margin: auto;
// 1440px
@media screen and (max-width: 1440px) {
--list-gap-size: 10px;
}
@media screen and (max-width: 1024px) {
--list-item-num: 2;
}
@media screen and (max-width: 680px) {
--list-item-num: 1;
}
}
.server-list-container.server-list--row {
--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;
}
.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 {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(-30px);
}
.list-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-leave-active {
position: absolute;
}
</style>

View File

@ -1,16 +1,32 @@
<template>
<div class="server-option-box">
<div
class="server-option-box"
:class="{
'server-option-box--light-background': lightBackground,
'server-option-box--mobile-hide': !mobileShow,
}"
>
<div
v-for="item in options"
:key="item.key"
class="server-option-item"
:class="{
'has-icon': item.icon,
active: activeValue === item.value,
}"
:title="item?.title || false"
@click="toggleModelValue(item)"
>
<span class="option-label">{{ item.label }}</span>
<i
v-if="item.icon"
class="option-icon"
:class="item.icon"
:title="item.label"
/>
<span
v-else
class="option-label"
>{{ item.label }}</span>
</div>
</div>
</template>
@ -22,6 +38,7 @@
import {
computed,
} from 'vue';
import config from '@/config';
const props = defineProps({
modelValue: {
@ -32,7 +49,11 @@ const props = defineProps({
type: Array,
default: () => [],
},
accpetEmpty: {
acceptEmpty: {
type: Boolean,
default: true,
},
mobileShow: {
type: Boolean,
default: true,
},
@ -42,6 +63,8 @@ const emits = defineEmits([
'update:modelValue',
]);
const lightBackground = computed(() => config.nazhua.lightBackground);
const activeValue = computed({
get: () => props.modelValue,
set: (val) => {
@ -51,7 +74,7 @@ const activeValue = computed({
function toggleModelValue(item) {
if (activeValue.value === item.value) {
if (props.accpetEmpty) {
if (props.acceptEmpty) {
activeValue.value = '';
}
} else {
@ -67,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;
@ -75,18 +104,62 @@ function toggleModelValue(item) {
line-height: 1.2;
border-radius: 6px;
background: rgba(#000, 0.3);
transition: all 0.3s linear;
cursor: pointer;
&.has-icon {
padding: 0 10px;
}
@media screen and (max-width: 768px) {
height: 30px;
padding: 0 10px;
border-radius: 3px;
background-color: rgba(#000, 0.8);
cursor: default;
}
.option-icon {
line-height: 1;
font-size: 18px;
}
.option-label {
color: #fff;
font-weight: bold;
transition: all 0.3s linear;
}
@media screen and (min-width: 768px) {
&:hover {
.option-label {
color: var(--option-high-color);
}
}
}
&.active {
background: rgba(#ff7500, 0.75);
background: var(--option-high-color-active);
.option-label {
color: #fff;
}
}
}
@media screen and (min-width: 768px) {
&--light-background {
.server-option-item {
background: rgba(#000, 0.5);
&:hover {
background: rgba(#000, 0.8);
}
&.active {
background: var(--option-high-color-active);
}
}
}
}
}

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@ -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);
}
}
}
}

View File

@ -12,7 +12,7 @@
<template #default>
<div
class="chart-donut-label"
:title="`${(used).toFixed(1) * 1}%`"
:title="valPercent ? valPercent : `${(used).toFixed(1) * 1}%`"
>
<div class="server-status-val-text">
<span>{{ valText }}</span>
@ -73,6 +73,10 @@ defineProps({
type: String,
default: '',
},
valPercent: {
type: String,
default: '',
},
label: {
type: String,
default: '',
@ -103,24 +107,25 @@ defineProps({
flex-direction: column;
align-items: center;
justify-content: center;
transform: scale(var(--server-status-label-scale, 1));
cursor: pointer;
}
.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;

View File

@ -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">
@ -75,8 +78,12 @@ const progressStyle = computed(() => {
style.width = `${Math.min(props.used, 100)}%`;
const color = typeof props.colors === 'string' ? props.colors : props.colors?.used;
if (color) {
if (Array.isArray(color)) {
style.background = `linear-gradient(-35deg, ${color.join(',')})`;
} else {
style.backgroundColor = color;
}
}
return style;
});
</script>
@ -92,20 +99,20 @@ 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;
width: 100%;
height: var(--progress-bar-height);
background: rgba(255, 255, 255, 0.2);
border-radius: var(--progress-bar-height);
border-radius: calc(var(--progress-bar-height) / 2);
overflow: hidden;
}
@ -115,7 +122,7 @@ const progressStyle = computed(() => {
left: 0;
bottom: 0;
background-color: #08f;
border-radius: var(--progress-bar-height);
border-radius: calc(var(--progress-bar-height) / 2);
}
.progress-bar-label {

View File

@ -25,7 +25,8 @@ export default (params) => {
billingDataMod,
planDataMod,
} = props.info.PublicNote;
let months;
// 默认1个月
let months = 1;
// 套餐资费
let cycleLabel;
if (validate.isSet(billingDataMod?.cycle)) {

View File

@ -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 {
threshold,
mean,
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 {
median,
tolerancePercent,
min,
max,
};
@ -38,6 +80,13 @@ export function getThreshold(data, tolerance = 2) {
*/
const lineColorMap = {};
const lineColors = [];
const defaultColors = [
'#5470C6', '#91CC75', '#FAC858', '#EE6666',
'#73C0DE', '#3BA272', '#FC8452', '#9A60B4',
'#EA7CCC', '#C23531', '#2F4554', '#61A0A8',
'#D48265', '#91C7AE', '#749F83', '#CA8622',
'#BDA29A', '#6E7074', '#546570', '#C4CCD3',
];
/**
* 将十六进制颜色转换为 RGB 数组
@ -71,20 +120,24 @@ function rgbDistance(color1, color2) {
* 获取一个随机颜色
* @returns {string} 返回一个随机颜色的字符串
*/
function getColor() {
function getColor(count = 0, len = 0) {
// 如果尝试次数超过 3 次,返回固定颜色组里面的颜色
if (count > 3) {
return defaultColors[len % defaultColors.length];
}
const { color } = uniqolor.random({
saturation: [75, 90],
lightness: [65, 70],
differencePoint: 100,
});
if (lineColors.includes(color)) {
return getColor();
return getColor(count + 1, len);
}
if (lineColors.some((i) => rgbDistance(
hexToRgb(i),
hexToRgb(color),
) < 80)) {
return getColor();
) < 50)) {
return getColor(count + 1, len);
}
return color;
}
@ -99,7 +152,7 @@ export function getLineColor(name) {
if (lineColorMap[name]) {
return lineColorMap[name];
}
const color = getColor();
const color = getColor(0, lineColors.length);
lineColorMap[name] = color;
lineColors.push(color);
return color;

View File

@ -122,6 +122,56 @@ export default (params) => {
return result;
});
const inTransfer = computed(() => {
const inStats = hostUtils.calcBinary(props.info?.State?.NetInTransfer || 0);
const result = {
value: 0,
unit: '',
};
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) {
result.value = (inStats.m).toFixed(1) * 1;
result.unit = 'M';
} else {
result.value = (inStats.k).toFixed(1) * 1;
result.unit = 'K';
}
return result;
});
const outTransfer = computed(() => {
const outStats = hostUtils.calcBinary(props.info?.State?.NetOutTransfer || 0);
const result = {
value: 0,
unit: '',
};
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) {
result.value = (outStats.m).toFixed(1) * 1;
result.unit = 'M';
} else {
result.value = (outStats.k).toFixed(1) * 1;
result.unit = 'K';
}
return result;
});
/**
* 计算入向网速
*/
@ -183,6 +233,34 @@ 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 {
key,
label: '入网流量',
value: inTransfer.value?.value,
unit: inTransfer.value?.unit,
show: validate.isSet(inTransfer.value?.value),
};
case 'outTransfer':
return {
key,
label: '出网流量',
value: outTransfer.value?.value,
unit: outTransfer.value?.unit,
show: validate.isSet(outTransfer.value?.value),
};
case 'inSpeed':
return {
@ -201,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: '网速',
@ -222,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) * 1,
unit: '',
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:
}

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

View File

@ -1,9 +1,36 @@
import {
computed,
} from 'vue';
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,
@ -12,6 +39,18 @@ export default (params) => {
if (!props?.info) {
return {};
}
const lightBackground = computed(() => config.nazhua.lightBackground);
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]) {
return hostUtils.getCPUInfo(props.info.Host.CPU[0]);
@ -59,18 +98,23 @@ export default (params) => {
* 状态列表
*/
const serverStatusList = computed(() => statusListTpl.split(',').map((i) => {
const totalColor = lightBackground.value ? 'rgba(125, 125, 125, 0.5)' : 'rgba(255, 255, 255, 0.25)';
switch (i) {
case 'cpu':
{
const CoresVal = cpuInfo.value?.cores ? `${cpuInfo.value?.cores}C` : '-';
const usedColor = getColor('cpu', serverStatusColorMode.value);
const valPercent = `${(props.info.State?.CPU || 0).toFixed(1) * 1}%`;
const valText = valPercent;
return {
type: 'cpu',
used: (props.info.State?.CPU || 0).toFixed(1) * 1,
colors: {
used: '#0088ff',
total: 'rgba(255, 255, 255, 0.2)',
used: usedColor,
total: totalColor,
},
valText: `${(props.info.State?.CPU || 0).toFixed(1) * 1}%`,
valText,
valPercent,
label: 'CPU',
content: {
default: cpuInfo.value?.core || CoresVal,
@ -80,11 +124,11 @@ export default (params) => {
}
case 'mem':
{
let usedVal;
let valText;
if (useMemAndTotalMem.value.used.g >= 10 && useMemAndTotalMem.value.total.g >= 10) {
usedVal = `${(useMemAndTotalMem.value.used.g).toFixed(1) * 1}G`;
valText = `${(useMemAndTotalMem.value.used.g).toFixed(1) * 1}G`;
} else {
usedVal = `${Math.ceil(useMemAndTotalMem.value.used.m)}M`;
valText = `${Math.ceil(useMemAndTotalMem.value.used.m)}M`;
}
let contentVal;
if (useMemAndTotalMem.value.total.g > 4) {
@ -92,14 +136,16 @@ export default (params) => {
} else {
contentVal = `${Math.ceil(useMemAndTotalMem.value.total.m)}M`;
}
const usedColor = getColor('mem', serverStatusColorMode.value);
return {
type: 'mem',
used: useMemAndTotalMem.value.usePercent,
colors: {
used: '#0aa344',
total: 'rgba(255, 255, 255, 0.2)',
used: usedColor,
total: totalColor,
},
valText: usedVal,
valText,
valPercent: `${useMemAndTotalMem.value.usePercent.toFixed(1) * 1}%`,
label: '内存',
content: {
default: `运行内存${contentVal}`,
@ -112,11 +158,11 @@ export default (params) => {
if (!useSwapAndTotalSwap.value) {
return null;
}
let usedVal;
let valText;
if (useSwapAndTotalSwap.value.used.g >= 10 && useSwapAndTotalSwap.value.total.g >= 10) {
usedVal = `${(useSwapAndTotalSwap.value.used.g).toFixed(1) * 1}G`;
valText = `${(useSwapAndTotalSwap.value.used.g).toFixed(1) * 1}G`;
} else {
usedVal = `${Math.ceil(useSwapAndTotalSwap.value.used.m)}M`;
valText = `${Math.ceil(useSwapAndTotalSwap.value.used.m)}M`;
}
let contentVal;
if (useSwapAndTotalSwap.value.total.g > 4) {
@ -124,14 +170,16 @@ export default (params) => {
} else {
contentVal = `${Math.ceil(useSwapAndTotalSwap.value.total.m)}M`;
}
const usedColor = getColor('swap', serverStatusColorMode.value);
return {
type: 'swap',
used: useSwapAndTotalSwap.value.usePercent,
colors: {
used: '#ff8c00',
total: 'rgba(255, 255, 255, 0.2)',
used: usedColor,
total: totalColor,
},
valText: usedVal,
valText,
valPercent: `${useSwapAndTotalSwap.value.usePercent.toFixed(1) * 1}%`,
label: '交换',
content: {
default: `交换内存${contentVal}`,
@ -141,20 +189,28 @@ export default (params) => {
}
case 'disk':
{
let valText;
if (useDiskAndTotalDisk.value.used.t >= 1 && useDiskAndTotalDisk.value.total.t >= 1) {
valText = `${(useDiskAndTotalDisk.value.used.t).toFixed(1) * 1}T`;
} else {
valText = `${Math.ceil(useDiskAndTotalDisk.value.used.g)}G`;
}
let contentValue;
if (useDiskAndTotalDisk.value.total.t >= 1) {
contentValue = `${(useDiskAndTotalDisk.value.total.t).toFixed(1) * 1}T`;
} else {
contentValue = `${Math.ceil(useDiskAndTotalDisk.value.total.g)}G`;
}
const usedColor = getColor('disk', serverStatusColorMode.value);
return {
type: 'disk',
used: useDiskAndTotalDisk.value.usePercent,
colors: {
used: '#70f3ff',
total: 'rgba(255, 255, 255, 0.2)',
used: usedColor,
total: totalColor,
},
valText: `${(useDiskAndTotalDisk.value.used.g).toFixed(1) * 1}G`,
valText,
valPercent: `${useDiskAndTotalDisk.value.usePercent.toFixed(1) * 1}%`,
label: '磁盘',
content: {
default: `磁盘容量${contentValue}`,

View File

@ -6,11 +6,12 @@
'server--offline': info?.online !== 1,
}"
>
<template v-if="showWorldMap && worldMapPosition === 'top'">
<world-map
v-if="showWorldMap"
: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>
@ -54,6 +61,7 @@ import {
alias2code,
locationCode2Info,
} from '@/utils/world-map';
import pageTitle from '@/utils/page-title';
import WorldMap from '@/components/world-map/world-map.vue';
import ServerName from './components/server-detail/server-name.vue';
@ -118,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(
@ -129,8 +144,9 @@ function handleWorldMapWidth() {
);
}
watch(() => info.value, () => {
if (info.value) {
watch(() => info.value, (oldValue, newValue) => {
if (!oldValue && newValue && router.currentRoute.value.name === 'ServerDetail') {
pageTitle(newValue?.Name, '节点详情');
handleWorldMapWidth();
}
});
@ -145,6 +161,7 @@ watch(() => dataInit.value, () => {
onMounted(() => {
if (info.value) {
pageTitle(info.value?.Name, '节点详情');
handleWorldMapWidth();
}
window.addEventListener('resize', handleWorldMapWidth);

View File

@ -1,45 +1,97 @@
<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--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"
:options="onlineOptions"
/>
<server-option-box
v-if="config.nazhua.listServerItemTypeToggle"
v-model="listType"
:options="listTypeOptions"
:accept-empty="false"
:mobile-show="false"
/>
</div>
</div>
<transition-group
name="list"
tag="div"
class="server-list-container"
<!-- 列表模式 -->
<server-list-warp
v-if="showListRow"
:show-transition="showTransition"
:show-list-row="showListRow"
>
<server-item
v-for="item in filterServerList"
<server-row-item
v-for="item in filterServerList.list"
:key="item.ID"
:info="item"
/>
</transition-group>
</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"
:show-list-card="showListCard"
>
<server-card-item
v-for="item in filterServerList.list"
:key="item.ID"
: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>
@ -51,9 +103,14 @@
import {
ref,
provide,
computed,
onMounted,
onUnmounted,
onActivated,
onDeactivated,
nextTick,
watch,
} from 'vue';
import {
useStore,
@ -66,13 +123,80 @@ import {
count2size,
} from '@/utils/world-map';
import uuid from '@/utils/uuid';
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 ServerItem from './components/server-list/server-list-item.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();
const windowWidth = ref(window.innerWidth);
const listType = ref(config.nazhua.listServerItemType || 'card');
const showTransition = computed(() => {
//
if (config.nazhua.forceTransition) {
return true;
}
// ->
if (window.navigator.userAgent.includes('Android')) {
return false;
}
// 7
return store.state.serverList.length < 7;
});
const showListRow = computed(() => {
if (windowWidth.value > 1024) {
if (config.nazhua.listServerItemTypeToggle) {
return listType.value === 'row';
}
return config.nazhua.listServerItemType === 'row';
}
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 === 'card';
}
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({
@ -95,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) => ({
//
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) {
@ -120,7 +252,52 @@ const onlineOptions = computed(() => {
return [];
});
const filterServerList = computed(() => serverList.value.filter((i) => {
/**
* 筛选离线时离线数量变为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: '卡片模式',
value: 'card',
icon: 'ri-gallery-view-2',
}, {
key: 'row',
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 = {};
const list = serverList.value.filter((i) => {
const isFilterArr = [];
if (filterFormData.value.tag) {
const group = store.state.serverGroup.find((o) => o.name === filterFormData.value.tag);
@ -129,18 +306,34 @@ const filterServerList = computed(() => serverList.value.filter((i) => {
if (filterFormData.value.online) {
isFilterArr.push(i.online === (filterFormData.value.online * 1));
}
return isFilterArr.length ? isFilterArr.every((o) => o) : true;
}));
/**
* 解构服务器列表的位置数据
*/
const serverLocations = computed(() => {
const locationMap = {};
filterServerList.value.forEach((i) => {
if (i.online === -1) {
return;
const status = isFilterArr.length ? isFilterArr.every((o) => o) : true;
if (!status) {
return false;
}
//
if (i.PublicNote) {
const {
billingDataMod,
planDataMod,
customData,
} = i.PublicNote;
if (validate.isSet(billingDataMod?.amount)) {
fields.billing = true;
}
if (validate.isSet(billingDataMod?.endDate)) {
fields.remainingTime = true;
}
if (validate.isSet(planDataMod?.bandwidth)) {
fields.bandwidth = true;
}
if (validate.isSet(customData?.orderLink) && config.nazhua.hideListItemLink !== true) {
fields.orderLink = true;
}
}
//
if (i.online === 1) {
let aliasCode;
let locationCode;
if (i?.PublicNote?.customData?.location) {
@ -156,9 +349,25 @@ const serverLocations = computed(() => {
}
locationMap[code].push(i);
}
}
return true;
});
list.sort((a, b) => serverSortHandler(a, b, sortData.value.prop, sortData.value.order));
return {
fields,
list,
locationMap,
};
});
provide('filterServerList', filterServerList);
/**
* 解构服务器列表的位置数据
*/
const serverLocations = computed(() => {
const locations = [];
Object.entries(locationMap).forEach(([code, servers]) => {
Object.entries(filterServerList.value.locationMap).forEach(([code, servers]) => {
const {
x,
y,
@ -192,11 +401,22 @@ 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;
}
onMounted(() => {
@ -207,27 +427,32 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
const scrollPosition = ref(0);
onDeactivated(() => {
//
scrollPosition.value = document.documentElement.scrollTop || document.body.scrollTop;
});
onActivated(() => {
//
if (scrollPosition.value > 0) {
nextTick(() => {
window.scrollTo({
top: scrollPosition.value,
behavior: 'instant',
});
});
}
});
</script>
<style lang="scss" scoped>
.index-container {
width: 100%;
height: 100%;
--list-padding: 20px;
--list-gap-size: 20px;
--list-item-num: 3;
--list-item-width: calc(
(
var(--list-container-width)
- (var(--list-padding) * 2)
- (
var(--list-gap-size)
* (var(--list-item-num) - 1)
)
)
/ var(--list-item-num)
);
overflow: hidden;
.scroll-container {
display: flex;
@ -242,53 +467,45 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
}
.server-list-container {
position: relative;
display: flex;
flex-wrap: wrap;
gap: var(--list-gap-size);
padding: 0 var(--list-padding);
width: var(--list-container-width);
margin: auto;
.bottom-world-map {
margin-top: 30px;
}
&.list-is--server-status {
--list-container-width: 1300px;
// 1440px
@media screen and (max-width: 1440px) {
--list-gap-size: 10px;
--list-container-width: 1300px;
}
@media screen and (max-width: 1024px) {
--list-item-num: 2;
// 1280px
@media screen and (max-width: 1280px) {
--list-container-width: 1200px;
}
@media screen and (max-width: 680px) {
--list-item-num: 1;
}
}
.fitler-group {
.filter-group {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 10px 20px;
width: var(--list-container-width);
padding: 0 20px;
margin: auto;
}
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(-30px);
}
.list-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-leave-active {
position: absolute;
.left-box {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.right-box {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
}
</style>

View File

@ -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();

View File

@ -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);
});

3097
yarn.lock

File diff suppressed because it is too large Load Diff