Showing preview only (374K chars total). Download the full file or copy to clipboard to get everything.
Repository: hi2shark/nazhua
Branch: main
Commit: 53d6f70d0319
Files: 110
Total size: 329.2 KB
Directory structure:
gitextract_ju633mka/
├── .eslintignore
├── .eslintrc.cjs
├── .github/
│ └── workflows/
│ ├── docker-build.yml
│ ├── eslint.yml
│ └── release.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── doc/
│ ├── deploy.md
│ ├── public-note.md
│ └── update.md
├── docker-compose.yaml.template
├── fonts/
│ ├── SarasaTermSC/
│ │ └── font.css
│ └── readme.md
├── index.html
├── nginx-default.conf.template
├── package.json
├── public/
│ ├── config.js
│ └── style.css
├── readme.md
├── src/
│ ├── App.vue
│ ├── assets/
│ │ ├── fonts/
│ │ │ └── SarasaTermSC/
│ │ │ ├── cdn-font.css
│ │ │ └── font.css
│ │ └── scss/
│ │ ├── base.scss
│ │ ├── sarasa-term-sc.scss
│ │ └── variables.scss
│ ├── components/
│ │ ├── charts/
│ │ │ ├── donut.js
│ │ │ ├── donut.vue
│ │ │ ├── line.js
│ │ │ └── line.vue
│ │ ├── dot-dot-box.vue
│ │ ├── fireworks.vue
│ │ ├── lantern.vue
│ │ ├── popover.vue
│ │ ├── server-flag.vue
│ │ └── world-map/
│ │ ├── world-map-point.vue
│ │ └── world-map.vue
│ ├── config/
│ │ └── index.js
│ ├── data/
│ │ └── code-maps.js
│ ├── layout/
│ │ ├── box.vue
│ │ ├── components/
│ │ │ ├── dashboard-btn.vue
│ │ │ ├── footer.vue
│ │ │ ├── header.vue
│ │ │ ├── search-box.vue
│ │ │ ├── search-list-item.vue
│ │ │ ├── server-count.vue
│ │ │ └── server-stat.vue
│ │ └── main.vue
│ ├── load.js
│ ├── main.js
│ ├── router/
│ │ └── index.js
│ ├── store/
│ │ └── index.js
│ ├── use.js
│ ├── utils/
│ │ ├── custom-error.js
│ │ ├── date.js
│ │ ├── host.js
│ │ ├── load-nezha-v0-config.js
│ │ ├── load-nezha-v1-config.js
│ │ ├── object-mapping.js
│ │ ├── page-title.js
│ │ ├── request.js
│ │ ├── sleep.js
│ │ ├── subscribe.js
│ │ ├── transform-v1-2-v0.js
│ │ ├── tsdb.js
│ │ ├── uuid.js
│ │ ├── validate.js
│ │ ├── world-map.js
│ │ └── zIndexManager.js
│ ├── views/
│ │ ├── components/
│ │ │ ├── server/
│ │ │ │ ├── server-real-time.vue
│ │ │ │ ├── server-status-donut.vue
│ │ │ │ └── server-status-progress.vue
│ │ │ ├── server-detail/
│ │ │ │ ├── server-info-box.vue
│ │ │ │ ├── server-monitor.vue
│ │ │ │ ├── server-name.vue
│ │ │ │ └── server-status-box.vue
│ │ │ └── server-list/
│ │ │ ├── card/
│ │ │ │ ├── server-list-item-bill.vue
│ │ │ │ ├── server-list-item-status.vue
│ │ │ │ └── server-list-item.vue
│ │ │ ├── row/
│ │ │ │ ├── server-list-column.vue
│ │ │ │ ├── server-list-item-bill.vue
│ │ │ │ ├── server-list-item-real-time.vue
│ │ │ │ ├── server-list-item-status-progress.vue
│ │ │ │ ├── server-list-item-status.vue
│ │ │ │ └── server-list-item.vue
│ │ │ ├── server-list-warp.vue
│ │ │ ├── server-option-box.vue
│ │ │ ├── server-sort-box.vue
│ │ │ ├── server-sort-dropdown-menu.vue
│ │ │ └── server-status/
│ │ │ ├── main.vue
│ │ │ ├── server-info/
│ │ │ │ ├── conns.vue
│ │ │ │ ├── country.vue
│ │ │ │ ├── net-speed.vue
│ │ │ │ ├── status-icon.vue
│ │ │ │ ├── system-os.vue
│ │ │ │ └── transfer.vue
│ │ │ ├── server-status.js
│ │ │ └── table/
│ │ │ ├── td.vue
│ │ │ └── th.vue
│ │ ├── composable/
│ │ │ ├── server-bill-and-plan.js
│ │ │ ├── server-info.js
│ │ │ ├── server-monitor.js
│ │ │ ├── server-real-time.js
│ │ │ ├── server-sort.js
│ │ │ └── server-status.js
│ │ ├── detail.vue
│ │ └── home.vue
│ └── ws/
│ ├── index.js
│ └── service.js
└── vite.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintignore
================================================
build/*.js
public
dist
================================================
FILE: .eslintrc.cjs
================================================
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:vue/vue3-essential',
'@vue/airbnb',
],
globals: {
defineEmits: true,
defineExpose: true,
defineProps: true,
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
camelcase: 'off',
'vue/component-definition-name-casing': ['error', 'PascalCase'],
'vue/html-closing-bracket-newline': ['error', {
singleline: 'never',
multiline: 'always',
}],
'vue/no-v-html': 'off',
'vue/no-mutating-props': 'off',
'vue/max-attributes-per-line': ['error', {
singleline: {
max: 1,
},
multiline: {
max: 1,
},
}],
'vue/multi-word-component-names': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/valid-v-slot': 'off',
'vue/no-template-target-blank': 'off',
'vuejs-accessibility/anchor-has-content': 'off',
'vuejs-accessibility/alt-text': 'off',
'vuejs-accessibility/label-has-for': 'off',
'vuejs-accessibility/click-events-have-key-events': 'off',
'vuejs-accessibility/form-control-has-label': 'off',
'vuejs-accessibility/iframe-has-title': 'off',
'vuejs-accessibility/media-has-caption': 'off',
'accessor-pairs': 2,
'arrow-spacing': [2, {
before: true,
after: true,
}],
indent: [
2, 2,
{
SwitchCase: 1,
offsetTernaryExpressions: false,
},
],
'default-case-last': 'off',
'func-names': ['error', 'never'],
'no-console': 'off',
'no-debugger': 'off',
'no-param-reassign': 'off',
'no-underscore-dangle': 'off',
'no-unsafe-optional-chaining': 'off',
'max-classes-per-file': 'off',
'max-len': ['warn', 120],
'vue/max-len': ['warn', 120],
'object-property-newline': ['error', {
allowAllPropertiesOnSameLine: false,
}],
'one-var-declaration-per-line': ['error', 'always'],
'prefer-destructuring': ['error',
{
VariableDeclarator: {
array: false,
object: true,
},
AssignmentExpression: {
array: true,
object: false,
},
},
],
'import/no-cycle': 'off',
'import/no-unresolved': 'off',
'import/no-extraneous-dependencies': 'off',
'import/prefer-default-export': 'off',
'import/extensions': ['error', 'never', {
ignorePackages: true,
pattern: {
vue: 'always',
},
}],
},
};
================================================
FILE: .github/workflows/docker-build.yml
================================================
name: Build and Push Docker Image
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to use for the Docker image'
required: false
jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Determine version
id: determine_version
run: |
if [ -z "${{ github.event.inputs.version }}" ]; then
echo "VERSION=$(node -p 'require("./package.json").version')" >> $GITHUB_ENV
else
echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
fi
- name: Print version
run: echo "Version is $VERSION"
- name: 构建完整引用版本
run: npm run build
- name: 构建完整引用版本的Docker镜像
run: |
docker build -t ghcr.io/${{ github.repository }}:$VERSION .
- name: 构建CDN引用版本
env:
VITE_SARASA_TERM_SC_USE_CDN: '1'
VITE_USE_CDN: '1'
VITE_CDN_LIB_TYPE: 'loli'
run: npm run build
- name: 构建CDN引用版本的Docker镜像
run: |
docker build -t ghcr.io/${{ github.repository }}:$VERSION-cdn .
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push Docker image
run: |
docker push ghcr.io/${{ github.repository }}:$VERSION
docker tag ghcr.io/${{ github.repository }}:$VERSION ghcr.io/${{ github.repository }}:latest
docker push ghcr.io/${{ github.repository }}:latest
- name: Push CDN Docker image
run: |
docker push ghcr.io/${{ github.repository }}:$VERSION-cdn
docker tag ghcr.io/${{ github.repository }}:$VERSION-cdn ghcr.io/${{ github.repository }}:cdn
docker push ghcr.io/${{ github.repository }}:cdn
================================================
FILE: .github/workflows/eslint.yml
================================================
name: ESLint Lint for Pull Requests
on:
pull_request:
paths:
- '**/*.js'
- '**/*.ts'
- '**/*.vue'
jobs:
lint:
runs-on: ubuntu-latest
steps:
# 检出代码
- name: Checkout code
uses: actions/checkout@v3
# 设置 Node.js 环境
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
# 安装依赖
- name: Install dependencies
run: npm install
# 运行 ESLint
- name: Run ESLint
run: npm run lint
================================================
FILE: .github/workflows/release.yml
================================================
name: Build and Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
version:
description: 'Version to release'
required: false
jobs:
build-and-release:
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 20
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Get version from package.json
id: get_version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Determine version
id: determine_version
run: |
if [ "${{ github.event.inputs.version }}" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
else
echo "version=${{ steps.get_version.outputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Generate release notes
id: release_notes
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
- name: Install dependencies
run: npm install
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ steps.determine_version.outputs.version }}
release_name: Release v${{ steps.determine_version.outputs.version }}
draft: true
prerelease: false
- name: 构建自动版 - 完整引用版本
run: npm run build
- name: 打包v${{ steps.determine_version.outputs.version }}-all.zip
run: zip -r v${{ steps.determine_version.outputs.version }}-all.zip dist
- name: 构建自动版 - JSDeliver引用版本
env:
VITE_SARASA_TERM_SC_USE_CDN: '1'
VITE_USE_CDN: '1'
VITE_CDN_LIB_TYPE: 'jsdelivr'
run: npm run build
- name: 打包v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip
run: zip -r v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip dist
- name: 构建自动版 - loli(CDNJS)引用版本
env:
VITE_DISABLE_SARASA_TERM_SC: '1'
VITE_USE_CDN: '1'
VITE_CDN_LIB_TYPE: 'loli'
run: npm run build
- name: 打包v${{ steps.determine_version.outputs.version }}-cdn-loli.zip
run: zip -r v${{ steps.determine_version.outputs.version }}-cdn-loli.zip dist
- name: 构建哪吒v0子目录版本
env:
VITE_BASE_PATH: '/nazhua/'
VITE_NEZHA_VERSION: 'v0'
VITE_DISABLE_SARASA_TERM_SC: '1'
run: npm run build
- name: 打包v0-nazhua.zip
run: zip -r v0-nazhua.zip dist
- name: 构建哪吒v0版本
env:
VITE_NEZHA_VERSION: 'v0'
VITE_DISABLE_SARASA_TERM_SC: '1'
run: npm run build
- name: 打包v0-dist.zip
run: zip -r v0-dist.zip dist
- name: 构建哪吒v1版本
env:
VITE_NEZHA_VERSION: 'v1'
VITE_DISABLE_SARASA_TERM_SC: '1'
run: npm run build
- name: 打包dist.zip
run: zip -r dist.zip dist
- name: Upload v${{ steps.determine_version.outputs.version }}-all.zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./v${{ steps.determine_version.outputs.version }}-all.zip
asset_name: v${{ steps.determine_version.outputs.version }}-all.zip
asset_content_type: application/zip
- name: Upload v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip
asset_name: v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip
asset_content_type: application/zip
- name: Upload v${{ steps.determine_version.outputs.version }}-cdn-loli.zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./v${{ steps.determine_version.outputs.version }}-cdn-loli.zip
asset_name: v${{ steps.determine_version.outputs.version }}-cdn-loli.zip
asset_content_type: application/zip
- name: Upload v0-nazhua.zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./v0-nazhua.zip
asset_name: v0-nazhua.zip
asset_content_type: application/zip
- name: Upload v0-dist.zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./v0-dist.zip
asset_name: v0-dist.zip
asset_content_type: application/zip
- name: Upload dist.zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./dist.zip
asset_name: dist.zip
asset_content_type: application/zip
- name: Add release notes
run: |
# 更新发布说明
gh release edit v${{ steps.determine_version.outputs.version }} --notes-file release_notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
demo
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
================================================
FILE: Dockerfile
================================================
FROM nginx:1.31-alpine-slim
COPY ./dist /home/wwwroot/html
COPY ./nginx-default.conf.template /etc/nginx/templates/default.conf.template
ENV DOMAIN=_
# 暴露端口
EXPOSE 80
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 hi2hi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: doc/deploy.md
================================================
# 🚀 部署指南
## 部署概述
> 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;
}
```
================================================
FILE: doc/public-note.md
================================================
# 📝 公开备注配置指南
[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)已添加公开备注编辑器,方便大家配置公开备注
================================================
FILE: doc/update.md
================================================
# 📝 更新日志
> 此处仅记录功能性更新,Bug修复不在此记录
## 📦 v0.6.4 更新
- ✨ **新增**: 网络监控折线图拆分单一图表功能
- 🌍 **新增**: 公开备注中支持自定义国家/地区旗帜 (`flag` 字段)
- 🔄 **新增**: 支持地图在首页与详情页的上下位置切换
## 📦 v0.5.7 更新
- 🖼️ **新增**: 自定义favicon支持
## 📦 v0.5.4 更新
- 🔍 **新增**: 内置搜索功能,支持 `Ctrl+K` 快速打开搜索
## 📦 v0.5.3 更新
- 🛒 **新增**: 支持单独设置服务器购买按钮的文案和图标
### 使用方法
- `buyBtnText`: 设置购买按钮文案
- `buyBtnIcon`: 设置购买按钮图标,支持Remixicon图标
### 图标配置示例
1. 访问 [Remixicon官网](https://www.remixicon.com/)
2. 选择并复制图标名称
3. 在 `buyBtnIcon` 字段中填写,补齐 `ri-` 前缀

> 当前支持版本: Remixicon 4.6.0(cdn版本,受限于更新原因,支持到4.3.0)
================================================
FILE: docker-compose.yaml.template
================================================
services:
nazhua:
image: ghcr.io/hi2shark/nazhua:latest
container_name: nazhua
restart: unless-stopped
environment:
# - DOMAIN=_ # 监听的域名,默认为_(监听所有)
- NEZHA=http://nezha-dashboard/
# 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 # 自定义样式文件
expose:
- 80
# ports:
# - 80:80
================================================
FILE: fonts/SarasaTermSC/font.css
================================================
@font-face {
font-family: "Sarasa Term SC";
src: url("./SarasaTermSC-SemiBold.woff2") format("woff2"),
url("./SarasaTermSC-SemiBold.woff") format("woff");
font-display: swap;
}
================================================
FILE: fonts/readme.md
================================================
# Nazhua内置字体
## Sarasa Term SC
字体出处:[Sarasa-Gothic](https://github.com/be5invis/Sarasa-Gothic)
具体引用:`Sarasa Term SC SemiBold`
由TTF转换为WOFF2格式,以便在网页中使用。
使用方法:
```css
@font-face {
font-family: "Sarasa Term SC";
src: url("./fonts/SarasaTermSC/SarasaTermSC-SemiBold.woff2") format("woff2"),
url("./fonts/SarasaTermSC/SarasaTermSC-SemiBold.woff") format("woff");
font-display: swap;
}
.sarasa-term-sc {
font-family: "Sarasa Term SC";
}
```
================================================
FILE: index.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nazhua</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<script src="./config.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<link rel="stylesheet" href="./style.css" />
</body>
</html>
================================================
FILE: nginx-default.conf.template
================================================
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name ${DOMAIN};
client_max_body_size 1024m;
location /ws {
proxy_pass ${NEZHA}ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $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
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 ${NEZHA}api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /nezha/ {
proxy_pass ${NEZHA};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
root /home/wwwroot/html;
}
}
================================================
FILE: package.json
================================================
{
"name": "nazhua",
"version": "0.9.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"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:fix": "eslint . --fix"
},
"dependencies": {
"axios": "^1.13.2",
"dayjs": "^1.11.13",
"echarts": "^5.5.1",
"flag-icons": "^7.2.3",
"font-logos": "^1.3.0",
"remixicon": "^4.7.0",
"uniqolor": "^1.1.1",
"vue": "^3.5.12",
"vue-echarts": "^7.0.3",
"vue-router": "^4.4.5",
"vuex": "^4.1.0"
},
"devDependencies": {
"@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.2.4",
"@vue/eslint-config-airbnb": "^7.0.0",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
"eslint": "^8.57.1",
"eslint-plugin-vue": "^9.33.0",
"sass": "^1.81.0",
"vite": "^6.4.1",
"vite-plugin-babel": "^1.3.2",
"vite-plugin-eslint": "^1.8.1",
"vite-svg-loader": "^5.1.0"
},
"repository": {
"type": "git",
"url": "https://github.com/hi2shark/nazhua"
}
}
================================================
FILE: public/config.js
================================================
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: 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, // 隐藏筛选
// hideSort: 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地址
};
================================================
FILE: public/style.css
================================================
================================================
FILE: readme.md
================================================
# Nazhua
<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>
## 📢 使用须知
**使用前,请务必阅读本文档,对您的部署会有很大帮助**
- 基于哪吒监控(nezha.wiki)v0版本构建的前端主题,兼容v1版本数据结构
- 考虑到国内用户访问需求,默认使用cdnjs的loli.net作为CDN引用源
- 如需使用SarasaTermSC字体,请选择Docker镜像全量包进行部署
## 🚀 部署指南
**推荐使用Docker Compose + Cloudflare Tunnels部署Nazhua**
👉 [详细部署文档](./doc/deploy.md)
Nazhua提供了丰富的配置选项:
- 支持点阵地图显示/隐藏
- 首页风格切换等多种个性化设置
配置方式:
- **V1内置版本**:使用[配置生成器](https://hi2shark.github.io/nazhua-generator/)生成配置,填入控制台自定义代码
- **Docker部署**:手动配置`config.js`文件(包括v0版本)
## 🗺️ 节点位置配置
要在地图上显示节点位置,需在公开备注中指定`location`字段
👉 [公开备注配置文档](./doc/public-note.md)
## 📝 更新日志
👉 [功能更新记录](./doc/update.md)
## 🤝 赞助商
<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>
## 💻 开发者指南
### 环境配置
在`.env.development.local`中配置以下变量:
```bash
#### Sarasa Term SC字体设置
# VITE_DISABLE_SARASA_TERM_SC=1
# VITE_SARASA_TERM_SC_USE_CDN=1
#### CDN配置
# VITE_USE_CDN=1
# VITE_CDN_LIB_TYPE=jsdelivr # jsdelivr | cdnjs | loli
#### 哪吒版本控制
# VITE_NEZHA_VERSION=v1 # v0 | v1
#### 本地开发设置
# PROXY_WS_HOST= # 本地开发时,可以代理WS服务的地址,启用后,自动转发至 {PROXY_WS_HOST}/proxy?wsPath={WS_HOST}
# API_HOST= # 本地开发时,代理的API服务地址
# WS_HOST= # 本地开发时,代理的WS服务地址
##### 仅限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` |
================================================
FILE: src/App.vue
================================================
<template>
<layout-main>
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</layout-main>
</template>
<script setup>
import {
ref,
computed,
watch,
provide,
onMounted,
onUnmounted,
} from 'vue';
import { useStore } from 'vuex';
import { useRoute } from 'vue-router';
import config, {
init as initConfig,
} from '@/config';
import sleep from '@/utils/sleep';
import LayoutMain from './layout/main.vue';
import { WS_CONNECTION_STATUS } from './ws/service';
import activeWebsocketService, {
wsService,
restart,
msg,
} from './ws';
const store = useStore();
const route = useRoute();
const currentTime = ref(0);
provide('currentTime', currentTime);
/**
* 刷新当前时间
* 使用 requestAnimationFrame 持续更新时间,但只在秒级变化时更新值以减少不必要的响应式更新
*/
let lastUpdateTime = 0;
function refreshTime() {
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);
if (isWindows) {
document.body.classList.add('windows');
}
// 是否加载Sarasa Term SC字体
const loadSarasaTermSC = computed(() => config.nazhua.disableSarasaTermSC !== true);
watch(loadSarasaTermSC, (value) => {
if (value) {
document.body.classList.add('sarasa-term-sc');
} else {
document.body.classList.remove('sarasa-term-sc');
}
}, {
immediate: true,
});
/**
* websocket断连的自动重连
*/
let stopReconnect = false;
async function wsReconnect() {
if (stopReconnect) {
return;
}
stopReconnect = true;
await sleep(1000);
console.log('reconnect ws');
activeWebsocketService();
stopReconnect = false;
}
onMounted(async () => {
refreshTime();
// 如果没有配置哪吒版本,尝试载入 v1 版本配置
if (!config.init) {
await initConfig();
}
/**
* 初始化服务器信息
*/
await store.dispatch('initServerInfo', {
route,
});
/**
* 初始化WS重连维护
*/
msg.on('close', () => {
console.log('ws closed');
wsReconnect();
});
msg.on('error', () => {
console.log('ws error');
stopReconnect = true;
});
msg.on('connect', () => {
console.log('ws connected');
store.dispatch('watchWsMsg');
});
const handleFocus = () => {
// ws在离开焦点后出现断连,尝试重新连接
// 仅针对已关闭状态进行重连
if (wsService.connected === WS_CONNECTION_STATUS.CLOSED) {
restart();
}
};
window.addEventListener('focus', handleFocus);
/**
* 激活websocket服务
*/
activeWebsocketService();
onUnmounted(() => {
window.removeEventListener('focus', handleFocus);
});
});
window.addEventListener('unhandledrejection', (event) => {
console.error('未处理的rejection:', event.reason);
event.preventDefault();
});
</script>
================================================
FILE: src/assets/fonts/SarasaTermSC/cdn-font.css
================================================
@font-face {
font-family: "Sarasa Term SC";
src: url("https://cdn.jsdelivr.net/gh/hi2shark/nazhua@main/fonts/SarasaTermSC/SarasaTermSC-SemiBold.woff2") format("woff2"),
url("https://cdn.jsdelivr.net/gh/hi2shark/nazhua@main/fonts/SarasaTermSC/SarasaTermSC-SemiBold.woff") format("woff");
font-display: swap;
}
================================================
FILE: src/assets/fonts/SarasaTermSC/font.css
================================================
@font-face {
font-family: "Sarasa Term SC";
src: url("./SarasaTermSC-SemiBold.woff2") format("woff2"),
url("./SarasaTermSC-SemiBold.woff") format("woff");
font-display: swap;
}
================================================
FILE: src/assets/scss/base.scss
================================================
@use "./variables.scss";
body {
line-height: 1.8;
font-size: 14px;
font-family: 'Microsoft YaHei', '微软雅黑', 'PingFang SC', 'HanHei SC', 'Helvetica Neue', 'Helvetica', 'STHeitiSC-Light', 'Arial', sans-serif;
color: var(--global-text-color);
background: var(--global-background-color);
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
ul,
ul li {
list-style: none;
}
html,
body,
h1,
h2,
h3,
h4,
h5,
h6,
ul,
li,
ol,
blockquote,
pre,
p,
table,
tbody,
th,
td,
tr,
span {
margin: 0;
padding: 0;
border-radius: 0px;
}
img {
border: none;
outline: none;
}
input[type="text"],
input[type="text"]:focus,
input[type="text"]:active,
input[type="email"],
input[type="email"]:focus,
input[type="email"]:active,
input[type="number"],
input[type="number"]:focus,
input[type="number"]:active,
input[type="password"],
input[type="password"]:focus,
input[type="password"]:active,
textarea,
textarea:active,
textarea:focus,
button,
button:active,
button:focus,
button:invalid,
a:active,
a:visited,
a:link {
outline: 0;
outline-color: transparent;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
input[type="number"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
a:link,
a:visited {
text-decoration: none;
}
a:hover {
color: #08a;
}
a {
color: var(--global-link-color);
transition: color 150ms linear;
cursor: pointer;
}
// 默认盒模型为border-box
*,
*::before,
*::after {
box-sizing: border-box;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: normal;
}
.fl {
float: left;
}
.fr {
float: right;
}
.clear,
.clear::before,
.clear::after {
clear: both;
}
.clear::before,
.clear::after {
content: '';
display: table;
}
div:focus {
outline: none;
}
================================================
FILE: src/assets/scss/sarasa-term-sc.scss
================================================
body.sarasa-term-sc {
font-family:
"Sarasa Term SC",
'Microsoft YaHei',
'微软雅黑',
'PingFang SC',
'HanHei SC',
'Helvetica Neue',
'Helvetica',
'STHeitiSC-Light',
'Arial',
sans-serif;
}
================================================
FILE: src/assets/scss/variables.scss
================================================
// 原生CSS变量 -- 顶级作用域
:root {
--layout-header-height: 60px;
--layout-main-height: calc(100vh - var(--layout-header-height));
--list-container-width: 1300px;
--detail-container-width: 900px;
--global-background-color: #392f41;
--global-text-color: #ddd;
--global-link-color: #2ca9e1;
--layout-main-bg-color: rgba(20, 30, 40, 0.75);
--layout-bg-color: #252748;
--world-map-point-color: #fff143;
--duration-color: #89c3eb;
--transfer-color: #f9ed69;
--transfer-in-color: var(--transfer-color);
--transfer-out-color: #90f2ff;
--net-speed-color: #90f2ff;
--net-speed-in-color: #f5b199;
--net-speed-out-color: #89c3eb;
--conn-color: #90f2ff;
--conn-tcp-color: #89c3eb;
--conn-udp-color: #2ca9e1;
--load-color: #90f2ff;
--process-color: #f5b199;
--cpu-text-color: #89c3eb;
--mem-text-color: #2ca9e1;
--disk-text-color: #90f2ff;
--swap-text-color: #f5b199;
--list-item-price-color: #eee;
--list-item-buy-link-color: #ffc300;
--list-item-buy-link-color-hover: #ff9900;
--public-note-tag-color: #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) {
--list-container-width: 1120px;
}
// 针对1280px以下的屏幕
@media screen and (max-width: 1280px) {
--list-container-width: 1024px;
}
@media screen and (max-width: 1024px) {
--list-container-width: 800px;
--detail-container-width: 800px;
}
@media screen and (max-width: 800px) {
--list-container-width: 720px;
--detail-container-width: 720px;
}
@media screen and (max-width: 720px) {
--list-container-width: 100vw;
--detail-container-width: 100vw;
}
}
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);
}
================================================
FILE: src/components/charts/donut.js
================================================
import { use } from 'echarts/core';
import { SVGRenderer } from 'echarts/renderers';
import {
BarChart,
} from 'echarts/charts';
import {
PolarComponent,
} from 'echarts/components';
import config from '@/config';
use([
SVGRenderer,
BarChart,
PolarComponent,
]);
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, // 满分
// 隐藏刻度线
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
show: false,
},
splitLine: {
show: false,
},
},
radiusAxis: {
type: 'category',
// 隐藏刻度线
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
show: false,
},
splitLine: {
show: false,
},
},
polar: {
center: ['50%', '50%'],
radius: ['50%', '100%'],
},
series: [{
type: 'bar',
data: [{
value: used,
}],
itemStyle: {
color: typeof itemColors === 'string' ? itemColors : handleColor(itemColors?.used),
borderRadius: 5,
shadowColor: (() => {
if (config.nazhua.serverStatusLinear) {
return 'rgba(0, 0, 0, 0.5)';
}
if (config.nazhua.lightBackground) {
return 'rgba(0, 0, 0, 0.2)';
}
return undefined;
})(),
shadowBlur: isLinear ? 10 : undefined,
},
coordinateSystem: 'polar',
cursor: 'default',
roundCap: true,
barWidth: Math.ceil((size / 100) * 10),
barGap: '-100%', // 两环重叠
z: 10,
}, {
type: 'bar',
data: [{
value: total,
}],
itemStyle: {
color: handleColor(itemColors?.total) || 'rgba(255, 255, 255, 0.2)',
},
coordinateSystem: 'polar',
cursor: 'default',
barWidth: Math.ceil((size / 100) * 10),
barGap: '-100%', // 两环重叠
z: 5,
}],
};
};
================================================
FILE: src/components/charts/donut.vue
================================================
<template>
<div
v-if="option"
ref="chartBoxRef"
class="donut-box"
:class="{
'donut-box--content': showContent,
}"
>
<v-chart
ref="chartRef"
class="donut-box-v-chart"
:option="option"
/>
<div
v-if="showContent"
class="donunt-content"
>
<slot />
</div>
</div>
</template>
<script setup>
/**
* 环状图
*/
import {
ref,
computed,
onMounted,
onUnmounted,
} from 'vue';
import VChart from 'vue-echarts';
import donut from './donut';
const props = defineProps({
used: {
type: [Number, String],
default: 0,
},
total: {
type: [Number, String],
default: 100,
},
itemColors: {
type: [Object, String],
default: () => ({
used: '#409EFF',
total: '#E6A23C',
}),
},
showContent: {
type: Boolean,
default: true,
},
});
const chartBoxRef = ref();
const chartRef = ref();
const chartSize = ref(100);
const option = computed(() => {
if (props.used) {
return donut(
props.used,
props.total,
props.itemColors,
chartSize.value || 100,
);
}
return null;
});
function handleResize() {
const {
offsetWidth,
offsetHeight,
} = chartBoxRef.value;
const oldSize = chartSize.value;
chartSize.value = Math.floor(Math.min(offsetWidth, offsetHeight));
if (oldSize !== chartSize.value && chartRef?.value?.resize) {
chartRef.value.resize();
}
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style lang="scss" scoped>
.donut-box {
width: var(--donut-box-size, 100px);
height: var(--donut-box-size, 100px);
}
.donut-box--content {
position: relative;
.donunt-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
</style>
================================================
FILE: src/components/charts/line.js
================================================
import { use } from 'echarts/core';
import { SVGRenderer } from 'echarts/renderers';
import { LineChart } from 'echarts/charts';
import {
TooltipComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components';
import dayjs from 'dayjs';
import config from '@/config';
use([
SVGRenderer,
LineChart,
TooltipComponent,
GridComponent,
DataZoomComponent,
]);
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',
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
formatter: (params) => {
const time = dayjs(parseInt(params[0].axisValue, 10)).format('YYYY.MM.DD HH:mm');
let res = `<p style="font-weight: bold; color: #ff6;">${time}</p>`;
if (params.length < 10) {
params.forEach((i) => {
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)',
borderColor: mode === 'dark' ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',
textStyle: {
color: mode === 'dark' ? '#ddd' : '#222',
fontFamily: 'Sarasa Term SC',
fontSize: 14,
},
},
grid: {
top: 10,
left: 5,
right: 5,
bottom: 50,
containLabel: true,
},
dataZoom: [{
id: 'dataZoomX',
type: 'slider',
xAxisIndex: [0],
filterMode: 'filter',
}],
yAxis: {
type: 'value',
splitLine: {
lineStyle: {
color: mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.4)',
},
},
axisLabel: {
fontFamily,
color: mode === 'dark' ? '#ddd' : '#222',
fontSize: 12,
},
},
xAxis: {
type: 'time',
data: dateList,
axisLabel: {
hideOverlap: true,
nameTextStyle: {
fontSize: 12,
},
fontFamily,
color: mode === 'dark' ? '#eee' : '#222',
},
},
series: valueList.map((i) => ({
...i,
type: 'line',
smooth: true,
connectNulls,
legendHoverLink: false,
symbol: 'none',
})),
};
return option;
};
================================================
FILE: src/components/charts/line.vue
================================================
<template>
<div
v-if="option"
class="line-box"
:style="boxStyle"
>
<v-chart
ref="chartRef"
class="chart"
:option="option"
/>
</div>
</template>
<script setup>
/**
* 折线图
*/
import {
ref,
computed,
onMounted,
onUnmounted,
} from 'vue';
import VChart from 'vue-echarts';
import lineChart from './line';
const props = defineProps({
dateList: {
type: Array,
default: () => [],
},
valueList: {
type: Array,
default: () => [],
},
size: {
type: [Number, String],
default: null,
},
connectNulls: {
type: [Boolean, String],
default: true,
},
});
const chartRef = ref();
const option = computed(() => {
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?.();
}
onMounted(() => {
handleResize();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
</script>
<style lang="scss" scoped>
.line-box {
width: 100%;
height: var(--line-chart-size, 300px);
}
</style>
================================================
FILE: src/components/dot-dot-box.vue
================================================
<template>
<div
class="dot-dot-box"
:class="{
'dot-dot-box--hide': hideDotBG,
}"
:style="boxStyle"
>
<slot />
</div>
</template>
<script setup>
/**
* 点格子背景盒子
*/
import { computed } from 'vue';
import config from '@/config';
const props = defineProps({
borderRadius: {
type: [String, Number],
default: 12,
},
padding: {
type: [String, Number],
default: 20,
},
color: {
type: String,
default: '#eee',
},
});
const lightBackground = computed(() => config.nazhua.lightBackground);
const hideDotBG = computed(() => lightBackground.value || config.nazhua?.hideDotBG === true);
const boxStyle = computed(() => {
const style = {};
if (props.borderRadius) {
if (typeof props.borderRadius === 'number') {
style['--border-radius'] = `${props.borderRadius}px`;
} else {
style['--border-radius'] = `${props.borderRadius}`;
}
}
if (props.padding) {
if (typeof props.padding === 'number') {
style.padding = `${props.padding}px`;
} else {
style.padding = props.padding;
}
}
if (props.color) {
style.color = props.color;
}
return style;
});
</script>
<style lang="scss" scoped>
.dot-dot-box {
--border-radius: 12px;
color: #eee;
border-radius: var(--border-radius);
box-shadow: 2px 4px 6px rgba(#000, 0.4);
background-image: radial-gradient(transparent 1px, rgba(#000, 0.6) 1px);
background-size: 3px 3px;
backdrop-filter: saturate(50%) blur(3px);
&--hide {
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) {
background-color: rgba(#000, 0.8);
background-image: none;
backdrop-filter: none;
}
}
</style>
================================================
FILE: src/components/fireworks.vue
================================================
<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>
================================================
FILE: src/components/lantern.vue
================================================
<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>
================================================
FILE: src/components/popover.vue
================================================
<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>
================================================
FILE: src/components/server-flag.vue
================================================
<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>
================================================
FILE: src/components/world-map/world-map-point.vue
================================================
<template>
<div
ref="pointRef"
class="world-map-point"
:class="'world-map-point--' + (info?.type || 'default')"
:style="pointStyle"
:title="info?.label || ''"
@click="handleClick"
>
<div class="point-block" />
</div>
</template>
<script setup>
/**
* 世界地图点
*/
import {
ref,
computed,
} from 'vue';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const emits = defineEmits([
'point-tap',
]);
const pointRef = ref();
const pointStyle = computed(() => {
const style = {};
style['--map-point-left'] = `${props.info.left}px`;
style['--map-point-top'] = `${props.info.top}px`;
if (props.info?.size) {
style['--map-point-size'] = `${props.info.size}px`;
}
return style;
});
function handleClick() {
emits('point-tap', props.info);
}
</script>
<style lang="scss" scoped>
.world-map-point {
--map-point-size: 6px;
--map-point-scale: 1;
position: absolute;
left: var(--map-point-left);
top: var(--map-point-top);
width: 16px;
height: 16px;
transform: translate(-50%, -50%);
:hover {
z-index: 100;
}
.point-block {
position: absolute;
top: 50%;
left: 50%;
width: calc(var(--map-point-size) * var(--map-point-scale));
height: calc(var(--map-point-size) * var(--map-point-scale));
transform: translate(-50%, -50%);
background: var(--world-map-point-color);
border-radius: 50%;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: calc(var(--map-point-size) * var(--map-point-scale) + (8px * var(--map-point-scale)));
height: calc(var(--map-point-size) * var(--map-point-scale) + (8px * var(--map-point-scale)));
transform: translate(-50%, -50%);
border: calc(2px * var(--map-point-scale)) solid var(--world-map-point-color);
border-radius: 50%;
}
}
@media screen and (max-width: 720px) {
--map-point-scale: 0.5;
}
&--group {
.point-block {
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: calc(var(--map-point-size) * var(--map-point-scale) + (16px * var(--map-point-scale)));
height: calc(var(--map-point-size) * var(--map-point-scale) + (16px * var(--map-point-scale)));
transform: translate(-50%, -50%);
border: calc(2px * var(--map-point-scale)) solid var(--world-map-point-color);
border-radius: 50%;
opacity: 0.7;
transition: opacity 0.3s;
}
&:hover {
&::after {
opacity: 1;
}
}
}
}
}
</style>
================================================
FILE: src/components/world-map/world-map.vue
================================================
<template>
<div
class="world-map-group"
:class="{
'world-map-group--light-background': lightBackground,
}"
:style="mapStyle"
>
<div class="world-map-img" />
<transition-group
name="point"
tag="div"
class="world-map-point-container"
>
<world-map-point
v-for="pointItem in mapPoints"
:key="pointItem.key"
:info="pointItem"
@point-tap="handlePointTap"
/>
</transition-group>
<transition name="point">
<div
v-if="tipsShow"
class="world-map-tips"
:style="tipsContentStyle"
>
<span>{{ tipsContent }}</span>
</div>
</transition>
</div>
</template>
<script setup>
/**
* 世界地图盒子
*/
import {
ref,
computed,
watch,
} from 'vue';
import config from '@/config';
import validate from '@/utils/validate';
import WorldMapPoint from './world-map-point.vue';
import {
findIntersectingGroups,
} from '@/utils/world-map';
const props = defineProps({
width: {
type: [Number, String],
// default: 1280,
default: null,
},
height: {
type: [Number, String],
// default: 621,
default: null,
},
locations: {
type: Array,
default: () => [],
},
});
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,
};
}
if (!validate.isEmpty(props.width) && validate.isEmpty(props.height)) {
return {
width: adjustedWidth,
height: Math.ceil((621 / 1280) * adjustedWidth),
};
}
if (validate.isEmpty(props.width) && !validate.isEmpty(props.height)) {
return {
width: Math.ceil((1280 / 621) * adjustedHeight),
height: adjustedHeight,
};
}
if (adjustedWidth / adjustedHeight > 1280 / 621) {
return {
width: Math.ceil(adjustedHeight * (1280 / 621)),
height: adjustedHeight,
};
}
return {
width: adjustedWidth,
height: Math.ceil(adjustedWidth * (621 / 1280)),
};
});
const mapStyle = computed(() => {
const style = {};
style['--world-map-width'] = `${computedSize.value.width}px`;
style['--world-map-height'] = `${computedSize.value.height}px`;
return style;
});
const mapPoints = ref([]);
let computeMapPointsTimer = null;
function computeMapPoints() {
if (computeMapPointsTimer) {
clearTimeout(computeMapPointsTimer);
}
if (props.locations.length === 0) {
mapPoints.value = [];
return;
}
computeMapPointsTimer = setTimeout(() => {
const points = props.locations.map((i) => {
const item = {
key: i.key,
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,
type: 'single',
};
const halfSize = (item.size + 8) / 2;
item.topLeft = {
left: item.left - halfSize,
top: item.top - halfSize,
};
item.bottomRight = {
left: item.left + halfSize,
top: item.top + halfSize,
};
return item;
});
const groups = findIntersectingGroups(points);
Object.entries(groups).forEach(([key, group]) => {
const item = points.find((i) => i.key === key);
if (item.parent) {
return;
}
item.size = 4;
item.type = 'group';
item.children = group;
let label = item.label || '';
let servers = [...(item.servers || [])];
group.forEach((i) => {
if (!i.parent && !i.children) {
i.parent = item;
label += `\n${i.label}`;
servers = servers.concat((i.servers || []));
}
});
item.label = label;
item.servers = servers;
});
mapPoints.value = points.filter((i) => !i.parent);
}, 100);
}
watch(() => props.locations, () => {
computeMapPoints();
}, {
immediate: true,
});
watch(() => computedSize.value, () => {
computeMapPoints();
}, {
immediate: true,
deep: true,
});
/**
* 提示框
*/
const tipsShow = ref(false);
const tipsContent = ref('');
const activeTipsXY = ref({
x: 0,
y: 0,
});
const tipsContentStyle = computed(() => {
const style = {};
if (window.innerWidth > 500) {
style.top = `${activeTipsXY.value.y}px`;
style.left = `${activeTipsXY.value.x}px`;
style.transform = 'translate(-50%, 20px)';
} else {
style.bottom = '4px';
style.left = '50%';
style.transform = 'translate(-50%, 0)';
}
return style;
});
let handlePointTapTimer = null;
function handlePointTap(e) {
tipsContent.value = e.label;
activeTipsXY.value = {
x: e.left,
y: e.top - 10,
};
tipsShow.value = true;
if (handlePointTapTimer) {
clearTimeout(handlePointTapTimer);
}
handlePointTapTimer = setTimeout(() => {
tipsShow.value = false;
}, 5000);
}
</script>
<style lang="scss" scoped>
.world-map-group {
width: var(--world-map-width, 1280px);
height: var(--world-map-height, 621px);
position: relative;
&--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);
background: url(@/assets/images/world-map.svg) 50% 50% no-repeat;
background-size: 100%;
opacity: 0.75;
}
.world-map-tips {
position: absolute;
padding: 5px 10px;
border-radius: 5px;
line-height: 20px;
white-space: pre;
color: #eee;
background: rgba(#000, 0.8);
box-shadow: 1px 4px 8px rgba(#303841, 0.4);
z-index: 100;
// 向上的尖角
&::before {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
width: 0;
height: 0;
border: 5px solid transparent;
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;
}
}
}
}
.point-move,
.point-enter-active,
.point-leave-active {
transition: opacity 0.3s ease-in-out;
}
.point-enter-from,
.point-leave-to {
opacity: 0;
}
</style>
================================================
FILE: src/config/index.js
================================================
import {
reactive,
} from 'vue';
import {
loadProfile as loadNezhaV1Profile,
} from '@/utils/load-nezha-v1-config';
const defaultNezhaVersion = import.meta.env.VITE_NEZHA_VERSION;
const config = reactive({
init: false,
nazhua: {
title: '哪吒监控',
// 如果打包禁用 Sarasa Term SC 字体,默认为禁用该字体的配置
disableSarasaTermSC: import.meta.env.VITE_DISABLE_SARASA_TERM_SC === '1',
nezhaVersion: ['v0', 'v1'].includes(defaultNezhaVersion) ? defaultNezhaVersion : null,
apiMonitorPath: '/api/v1/monitor/{id}',
wsPath: '/ws',
nezhaPath: '/nezha/',
nezhaV0ConfigType: 'servers',
v1ApiMonitorPath: '/api/v1/server/{id}/service',
v1ApiMonitorPathFallback: '/api/v1/service/{id}',
v1WsPath: '/api/v1/ws/server',
v1GroupPath: '/api/v1/server-group',
v1ApiSettingPath: '/api/v1/setting',
v1ApiProfilePath: '/api/v1/profile',
// 解构载入自定义配置
...(window.$$nazhuaConfig || {}),
},
});
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;
export default config;
export const init = async () => {
await loadNezhaV1Profile(true).then((res) => {
config.nazhua.nezhaVersion = res ? 'v1' : 'v0';
});
config.init = true;
};
================================================
FILE: src/data/code-maps.js
================================================
const codeMaps = {
PEK: {
x: 1025,
y: 178,
name: '北京',
country: '中国',
},
PVG: {
x: 1057,
y: 225,
name: '上海',
country: '中国',
},
CKG: {
x: 1010,
y: 235,
name: '重庆',
country: '中国',
},
TFU: {
x: 1000,
y: 230,
name: '成都',
country: '中国',
},
HKG: {
x: 1039,
y: 263,
name: '香港',
country: '中国',
},
MFM: {
x: 1035,
y: 264,
name: '澳门',
country: '中国',
},
TPE: {
x: 1067,
y: 253,
name: '台北',
country: '中国',
},
OSA: {
x: 1109,
y: 207,
name: '大阪',
country: '日本',
},
TYO: {
x: 1124,
y: 199,
name: '东京',
country: '日本',
},
SEL: {
x: 1077,
y: 198,
name: '首尔',
country: '韩国',
},
SIN: {
x: 1000,
y: 354,
name: '新加坡',
country: '新加坡',
},
JHB: {
x: 997,
y: 350,
name: '新山',
country: '马来西亚',
},
KUL: {
x: 990,
y: 345,
name: '吉隆坡',
country: '马来西亚',
},
BKK: {
name: '曼谷',
country: '泰国',
x: 985,
y: 296,
},
HAN: {
x: 998,
y: 274,
name: '河内',
country: '越南',
},
SGN: {
x: 1015,
y: 314,
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,
name: '洛杉矶',
country: '美国',
},
LAS: {
x: 98,
y: 198,
name: '拉斯维加斯',
country: '美国',
},
SLC: {
x: 111,
y: 189,
name: '盐湖城',
country: '美国',
},
SJC: {
x: 87,
y: 193,
name: '圣何塞',
country: '美国',
},
SEA: {
x: 118,
y: 143,
name: '西雅图',
country: '美国',
},
MIA: {
x: 237,
y: 249,
name: '迈阿密',
country: '美国',
},
ORD: {
x: 233,
y: 175,
name: '芝加哥',
country: '美国',
},
NYC: {
x: 280,
y: 179,
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,
name: '悉尼',
country: '澳大利亚',
},
AMS: {
x: 595,
y: 125,
name: '阿姆斯特丹',
country: '荷兰',
},
LON: {
x: 571,
y: 127,
name: '伦敦',
country: '英国',
},
FRA: {
x: 603,
y: 137,
name: '法兰克福',
country: '德国',
},
BER: {
x: 620,
y: 130,
name: '柏林',
country: '德国',
},
LUX: {
x: 591,
y: 140,
name: '卢森堡',
country: '卢森堡',
},
CDG: {
x: 579,
y: 145,
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,
name: '莫斯科',
country: '俄罗斯',
},
OTP: {
x: 673,
y: 160,
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,
name: '伊斯坦布尔',
country: '土耳其',
},
};
export const aliasMapping = {
SGP: 'SIN',
ICN: 'SEL',
NRT: 'TYO',
HND: 'TYO',
KIX: 'OSA',
PAR: 'CDG',
MOW: 'SVO',
CHI: 'ORD',
SHA: 'PVG',
CAN: 'CKG',
CTU: 'TFU',
BJS: 'PEK',
HK: 'HKG',
MO: 'MFM',
TW: 'TPE',
ASH: 'IAD',
};
export const countryCodeMapping = {
CN: 'PEK',
JP: 'TYO',
SG: 'SIN',
KR: 'SEL',
MY: 'KUL',
VN: 'HAN',
IN: 'DEL',
TH: 'BKK',
AE: 'DXB',
TR: 'IST',
RO: 'OTP',
LU: 'LUX',
FR: 'CDG',
RU: 'SVO',
DE: 'FRA',
NL: 'AMS',
UK: 'LON',
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;
================================================
FILE: src/layout/box.vue
================================================
<template>
<router-view />
</template>
<script>
export default {
name: 'LayoutBox',
};
</script>
================================================
FILE: src/layout/components/dashboard-btn.vue
================================================
<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>
================================================
FILE: src/layout/components/footer.vue
================================================
<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
<a
ref="nofollow"
href="https://nezha.wiki"
:title="'当前为哪吒监控' + $config.nazhua.nezhaVersion"
target="_blank"
>哪吒监控</a>
</span>
<span class="text">
Theme By <a
ref="nofollow"
class="nazhua"
href="https://github.com/hi2shark/nazhua"
target="_blank"
>Nazhua</a>
{{ version }}
</span>
</div>
<div
ref="dynamicContentRef"
v-html="dynamicContent"
/>
</div>
</template>
<script setup>
/**
* Footer
*/
import {
ref,
computed,
watch,
onMounted,
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;
}
return '';
});
// 执行动态脚本的方法
const executeScripts = () => {
nextTick(() => {
if (!dynamicContentRef.value) return;
const scripts = dynamicContentRef.value.querySelectorAll('script');
scripts.forEach((script) => {
try {
// 生成脚本唯一标识,避免重复执行
const scriptIdentifier = script.src || script.textContent || '';
if (!scriptIdentifier || executedScripts.value.has(scriptIdentifier)) {
return;
}
const newScript = document.createElement('script');
newScript.type = script.type || 'text/javascript';
// 复制所有相关属性
if (script.async !== undefined) newScript.async = script.async;
if (script.defer !== undefined) newScript.defer = script.defer;
if (script.crossOrigin) newScript.crossOrigin = script.crossOrigin;
if (script.integrity) newScript.integrity = script.integrity;
if (script.noModule !== undefined) newScript.noModule = script.noModule;
if (script.referrerPolicy) newScript.referrerPolicy = script.referrerPolicy;
if (script.src) {
// 外部脚本:监听加载完成事件
newScript.src = script.src;
newScript.onload = () => {
executedScripts.value.add(scriptIdentifier);
};
newScript.onerror = (error) => {
console.error('Failed to load external script:', script.src, error);
};
document.body.appendChild(newScript);
} else {
// 内联脚本:直接执行
newScript.textContent = script.textContent;
document.body.appendChild(newScript);
executedScripts.value.add(scriptIdentifier);
// 内联脚本执行后可以安全移除
document.body.removeChild(newScript);
}
} catch (error) {
console.error('Error executing dynamic script:', error);
}
});
});
};
// 清理已执行脚本的记录(当内容变化时)
const cleanupScripts = () => {
executedScripts.value.clear();
};
watch(dynamicContent, (newVal, oldVal) => {
// 内容变化时,清理旧的执行记录
if (newVal !== oldVal) {
cleanupScripts();
}
if (newVal) {
// 确保 DOM 已更新
nextTick(() => {
executeScripts();
});
}
});
onMounted(() => {
if (dynamicContent.value) {
executeScripts();
}
});
</script>
<style lang="scss" scoped>
.layout-footer {
padding: 20px;
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;
gap: 1em;
}
.nazhua {
color: #fa0;
&:hover {
color: #fff;
}
}
a {
color: #fff;
&:hover {
color: #08f;
}
}
}
</style>
================================================
FILE: src/layout/components/header.vue
================================================
<template>
<div
class="layout-header"
:class="headerClass"
:style="headerStyle"
>
<div class="layer-header-container">
<div class="left-box">
<span
class="site-name"
@click="toHome"
>{{ title }}</span>
</div>
<div class="right-box">
<server-count
v-if="showServerCount"
/>
<server-stat
v-if="showServerStat"
/>
<dashboard-btn
v-if="showDashboardBtn"
/>
</div>
</div>
</div>
</template>
<script setup>
/**
* LayoutHeader
*/
import {
computed,
} from 'vue';
import {
useRoute,
useRouter,
} from 'vue-router';
import config from '@/config';
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') {
style['--layout-header-container-width'] = 'var(--detail-container-width)';
} else {
style['--layout-header-container-width'] = 'var(--list-container-width)';
}
return style;
});
const showServerCount = computed(() => config.nazhua.hideNavbarServerCount !== true);
const showServerStat = computed(() => config.nazhua.hideNavbarServerStat !== true);
const title = computed(() => config.nazhua.title);
const headerClass = computed(() => {
const classes = [];
if (route.name === 'ServerDetail') {
classes.push('layout-header--detail');
}
if (showServerStat.value) {
classes.push('layout-header--show-server-stat');
}
if (showServerCount.value) {
classes.push('layout-header--show-server-count');
}
if (lightBackground.value) {
classes.push('layout-header--light-background');
}
return classes;
});
function toHome() {
if (route.name !== 'Home') {
router.push({
name: 'Home',
});
}
}
const showDashboardBtn = computed(() => [
config.nazhua.nezhaVersion === 'v1',
config.nazhua.v1HideNezhaDashboardBtn !== true,
].every((item) => item));
</script>
<style lang="scss" scoped>
.layout-header {
position: sticky;
top: 0;
z-index: 100;
min-height: var(--layout-header-height);
background-position: 0% 0%;
background-image: radial-gradient(transparent 1px, rgba(#000, 0.8) 2px);
background-size: 3px 3px;
backdrop-filter: saturate(50%) blur(3px);
box-shadow: 0 2px 4px rgba(#000, 0.2);
&--show-server-stat {
@media screen and (max-width: 450px) {
padding-top: 10px;
}
}
&--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;
}
.layer-header-container {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0 20px;
width: var(--layout-header-container-width, 100%);
margin: auto;
padding: 10px 20px;
transition: width 0.3s;
}
.right-box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 20px;
color: #ddd;
}
}
</style>
================================================
FILE: src/layout/components/search-box.vue
================================================
<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>
================================================
FILE: src/layout/components/search-list-item.vue
================================================
<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>
================================================
FILE: src/layout/components/server-count.vue
================================================
<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>
================================================
FILE: src/layout/components/server-stat.vue
================================================
<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>
================================================
FILE: src/layout/main.vue
================================================
<template>
<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>
<script setup>
/**
* 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>
.layout-group {
position: relative;
width: 100%;
min-height: 100vh;
.layout-main {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--layout-main-bg-color);
}
.layout-bg {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
background: var(--layout-bg-color) url('~@/assets/images/bg.webp') no-repeat 50% 100%;
background-size: 100% auto;
}
}
</style>
================================================
FILE: src/load.js
================================================
// 是否禁用 Sarasa Term SC 字体
if (import.meta.env.VITE_DISABLE_SARASA_TERM_SC !== '1') {
if (import.meta.env.VITE_SARASA_TERM_SC_USE_CDN) {
import('./assets/fonts/SarasaTermSC/cdn-font.css');
} else {
import('./assets/fonts/SarasaTermSC/font.css');
}
import('./assets/scss/sarasa-term-sc.scss');
}
/**
* 使用 CDN 加载 CSS 文件
*/
function useCdnCss(item) {
const cdnType = import.meta.env.VITE_CDN_LIB_TYPE;
let cssUrl = item.jsdelivr;
if (['cdnjs', 'loli'].includes(cdnType)) {
cssUrl = item.cdnjs;
if (cdnType === 'loli') {
cssUrl = cssUrl.replace('https://cdnjs.cloudflare.com/', 'https://cdnjs.loli.net/');
}
}
const cdnStylesheet = document.createElement('link');
cdnStylesheet.rel = 'stylesheet';
cdnStylesheet.href = cssUrl;
document.head.appendChild(cdnStylesheet);
}
// 判断是否使用 CDN
if (import.meta.env.VITE_USE_CDN) {
Object.entries({
remixicon: {
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@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@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);
});
} else {
import('remixicon/fonts/remixicon.css');
import('flag-icons/css/flag-icons.min.css');
import('font-logos/assets/font-logos.css');
}
================================================
FILE: src/main.js
================================================
import { createApp } from 'vue';
import App from './App.vue';
import customUse from './use';
const app = createApp(App);
customUse(app);
app.mount('#app');
================================================
FILE: src/router/index.js
================================================
import {
createRouter,
createWebHistory,
createWebHashHistory,
} from 'vue-router';
import config from '@/config';
import pageTitle from '@/utils/page-title';
const constantRoutes = [{
name: 'Home',
path: '/',
component: () => import('@/views/home.vue'),
}, {
name: 'ServerDetail',
path: '/:serverId(\\d+)',
component: () => import('@/views/detail.vue'),
meta: {
title: '节点详情',
},
props: true,
}, {
path: '/:pathMatch(.*)*',
redirect: {
name: 'Home',
},
}];
const routerOptions = {
history: config.nazhua.routeMode === 'h5' ? createWebHistory() : createWebHashHistory(),
scrollBehavior: (to, from, savedPosition) => {
if (savedPosition) {
return savedPosition;
}
return {
top: 0,
behavior: 'smooth',
};
},
routes: constantRoutes,
};
const router = createRouter(routerOptions);
router.beforeResolve((to, from, next) => {
if (to?.meta?.title) {
pageTitle(to?.meta?.title);
} else if (to.name === 'Home') {
pageTitle(config.nazhua.title);
}
next();
});
export default router;
================================================
FILE: src/store/index.js
================================================
import {
createStore,
} from 'vuex';
import dayjs from 'dayjs';
import config from '@/config';
import loadNezhaV0Config, {
loadServerGroup as loadNezhaV0ServerGroup,
} from '@/utils/load-nezha-v0-config';
import {
loadServerGroup as loadNezhaV1ServerGroup,
loadSetting as loadNezhaV1Setting,
loadProfile as loadNezhaV1Profile,
} from '@/utils/load-nezha-v1-config';
import {
msg,
} from '@/ws';
const defaultState = () => ({
init: false,
serverTime: 0,
serverGroup: [],
serverList: [],
serverListColumnWidths: {},
serverCount: {
total: 0,
online: 0,
offline: 0,
},
profile: {},
setting: {},
});
function isOnline(LastActive, currentTime = Date.now()) {
const lastActiveTime = dayjs(LastActive)?.valueOf?.() || 0;
if (currentTime - lastActiveTime > 10 * 1000) {
return -1;
}
return 1;
}
function handleServerCount(servers) {
const counts = {
total: servers.length,
online: servers.filter((i) => i.online === 1).length,
offline: servers.filter((i) => i.online === -1).length,
};
return counts;
}
let firstSetServers = true;
const store = createStore({
state: defaultState(),
mutations: {
SET_SERVER_TIME(state, time) {
state.serverTime = time;
},
SET_SERVER_GROUP(state, serverGroup) {
state.serverGroup = serverGroup;
},
SET_SERVERS(state, servers) {
const newServers = [...servers];
newServers.sort((a, b) => b.DisplayIndex - a.DisplayIndex);
state.serverList = newServers;
state.serverCount = handleServerCount(newServers);
state.init = true;
},
UPDATE_SERVERS(state, servers) {
// 遍历新的servers 处理新的内容
const oldServersMap = {};
state.serverList.forEach((server) => {
oldServersMap[server.ID] = server;
});
let newServers = servers.map((server) => {
const oldItem = oldServersMap[server.ID];
const serverItem = {
...server,
};
if (oldItem?.PublicNote) {
serverItem.PublicNote = oldItem.PublicNote;
}
return serverItem;
});
newServers = newServers.filter((server) => server);
newServers.sort((a, b) => b.DisplayIndex - a.DisplayIndex);
state.serverList = newServers;
state.serverCount = handleServerCount(newServers);
state.init = true;
},
SET_PROFILE(state, profile) {
state.profile = profile;
},
SET_SETTING(state, setting) {
state.setting = setting;
},
SET_SERVER_LIST_COLUMN_WIDTHS(state, widths) {
state.serverListColumnWidths = widths;
},
},
actions: {
/**
* 加载服务器列表
*/
async initServerInfo({ commit }, params) {
firstSetServers = true;
// 如果是v1版本的话,加载v1版本的数据
if (config.nazhua.nezhaVersion === 'v1') {
const {
route,
} = params || {};
loadNezhaV1ServerGroup().then((res) => {
if (res) {
commit('SET_SERVER_GROUP', res);
}
});
loadNezhaV1Setting().then((res) => {
if (res) {
commit('SET_SETTING', res);
// 如果自定义配置没有设置title,使用站点名称
if (!window.$$nazhuaConfig.title) {
config.nazhua.title = res.config?.site_name || res.site_name;
if (route?.name === 'Home' || !route) {
document.title = config.nazhua.title;
}
}
}
});
loadNezhaV1Profile().then((res) => {
if (res) {
commit('SET_PROFILE', res);
}
});
return;
}
// 如果是v0版本的话,加载v0版本的数据
// 加载初始化的服务器列表,需要其中的公开备注字段
const serverResult = await loadNezhaV0Config();
if (!serverResult) {
console.error('load server config failed');
return;
}
const servers = serverResult.servers?.map?.((i) => {
const item = {
...i,
online: isOnline(i.LastActive, serverResult.now),
};
return item;
}) || [];
const res = loadNezhaV0ServerGroup(servers);
if (res) {
commit('SET_SERVER_GROUP', res);
}
firstSetServers = false;
commit('SET_SERVERS', servers);
},
/**
* 开始监听ws消息
*/
watchWsMsg({
commit,
}) {
msg.on('servers', (res) => {
if (res) {
if (res.now) {
commit('SET_SERVER_TIME', res.now);
}
const servers = res.servers?.map?.((i) => {
const item = {
...i,
online: isOnline(i.LastActive, res.now),
};
return item;
}) || [];
if (firstSetServers) {
firstSetServers = false;
commit('SET_SERVERS', servers);
// 在v0没抓页面配置的情况下,从服务器列表中分离出标签列表
if (config.nazhua.nezhaVersion !== 'v1') {
const group = loadNezhaV0ServerGroup(servers);
if (group) {
commit('SET_SERVER_GROUP', group);
}
}
} else {
commit('UPDATE_SERVERS', servers);
}
}
});
},
/**
* 设置服务器列表行宽度
*/
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);
},
},
});
export default store;
================================================
FILE: src/use.js
================================================
import './load';
import './assets/scss/base.scss';
import router from './router';
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;
};
================================================
FILE: src/utils/custom-error.js
================================================
/**
* 自定义错误
*/
class CustomError extends Error {
constructor(msg, code) {
super(msg);
this.code = code;
}
}
export default CustomError;
================================================
FILE: src/utils/date.js
================================================
import dayjs from 'dayjs';
/**
* 计算时长工具
* @param {Date|Number|String} startDate 开始时间
* @param {Date|Number|String} endDate 结束时间
* @param {Boolean} noSub 不带子单位
*
* @returns {String} 时长
* 1. 1小时以内,显示N分钟N秒
* 2. 1小时以上,显示N小时N分钟
* 3. 1天以上,显示N天
*/
export const duration = (startDate, endDate, noSub = false) => {
const startTime = dayjs(startDate).valueOf();
const endTime = dayjs(endDate).valueOf();
const diff = endTime - startTime;
if (diff < 0) {
return '刚刚启动';
}
const second = 1000;
const minute = second * 60;
const hour = minute * 60;
const day = hour * 24;
if (diff < minute) {
return `${Math.floor(diff / second)}秒`;
}
if (diff < hour) {
if (noSub) {
return `${Math.floor(diff / minute)}分钟`;
}
return `${Math.floor(diff / minute)}分钟${Math.floor((diff % minute) / second)}秒`;
}
if (diff < day) {
if (noSub) {
return `${Math.floor(diff / hour)}小时`;
}
return `${Math.floor(diff / hour)}小时${Math.floor((diff % hour) / minute)}分钟`;
}
return `${Math.floor(diff / day)}天`;
};
/**
* 计算时长,返回详细信息
* @param {Date|Number|String} startDate 开始时间
* @param {Date|Number|String} endDate 结束时间
*/
export const duration2 = (startDate, endDate) => {
const startTime = dayjs(startDate).valueOf();
const endTime = dayjs(endDate).valueOf();
const diff = endTime - startTime;
const second = 1000;
const minute = second * 60;
const hour = minute * 60;
const day = hour * 24;
const result = {
days: Math.floor(diff / day),
hours: Math.floor(diff / hour) % 24,
minutes: Math.floor(diff / minute) % 60,
seconds: Math.floor(diff / second) % 60,
$unit: {
day: '天',
hour: '小时',
minute: '分钟',
second: '秒',
},
};
return result;
};
/**
* 按周期月数计算下一个日期,必须大于传入的第三个参数(指定日期,为空则为当前日期)
*
* @param {Date|Number|String} startDate 起始日期
* @param {Number} months 周期月份数
* @param {Date|Number|String} specifiedDate 指定日期
*
* @returns {Number} 下一个日期的时间毫秒数
*/
export function getNextCycleTime(startDate, months, specifiedDate) {
const start = dayjs(startDate);
const checkDate = dayjs(specifiedDate);
if (!start.isValid() || months <= 0) {
throw new Error('参数无效:请检查起始日期、周期月份数和指定日期。');
}
let nextDate = start;
// 循环增加周期直到大于当前日期
let whileStatus = true;
while (whileStatus) {
nextDate = nextDate.add(months, 'month');
whileStatus = nextDate.valueOf() <= checkDate.valueOf();
}
return nextDate.valueOf(); // 返回时间毫秒数
}
================================================
FILE: src/utils/host.js
================================================
/**
* 主机匹配信息工具
*/
/**
* 匹配CPU信息
* @param {string} text CPU信息文本
* 示例文本:
* Intel(R) Xeon(R) Platinum 2 Virtual Core
* Intel Core Processor (Broadwell, IBRS) 1 Virtual Core
* Intel(R) Xeon(R) Gold 6133 CPU @ 2.50GHz 1 Virtual Core
* Intel(R) Xeon(R) CPU E5-2697 v3 @ 2.60GHz 1 Virtual Core
* Intel(R) Xeon(R) Platinum 1 Virtual Core
* AMD EPYC 7B13 64-Core Processor 1 Virtual Core
* AMD EPYC 7B13 64-Core Processor 1 Virtual Core
* AMD EPYC 9654 96-Core Processor 1 Virtual Core
* AMD Ryzen 9 7950X 16-Core Processor 1 Virtual Core
* AMD Ryzen 9 9900X 12-Core Processor 1 Virtual Core
*
* @returns {object} 匹配结果
* - {string} company CPU厂商
* - {string} model CPU型号
* - {string} modelNum CPU型号编号
* - {string} core CPU核心信息
* - {string} cores CPU核心数
*/
export function getCPUInfo(text = '') {
const cpuInfo = {
company: '',
model: '',
modelNum: '',
core: '',
cores: '',
};
const companyReg = /Intel|AMD|ARM|Qualcomm|Apple|Samsung|IBM|NVIDIA/;
// eslint-disable-next-line max-len, vue/max-len
const modelReg = /Xeon|Threadripper|Athlon|Pentium|Celeron|Opteron|Phenom|Turion|Sempron|FX|A-Series|R-Series|EPYC|Ryzen/;
const coresReg = /(\d+) (Virtual|Physics|Physical) Core/;
const companyMatch = text.match(companyReg);
const modelMatch = text.match(modelReg);
const coresMatch = text.match(coresReg);
if (companyMatch) {
[cpuInfo.company] = companyMatch;
}
if (modelMatch) {
[cpuInfo.model] = modelMatch;
}
if (text.includes('Ryzen')) {
// 匹配各种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[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')) {
// 匹配各种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')) {
const modelNumReg = /(E\d-\d{4}(?:\s?v\d)?)/;
const modelNumMatch = text.match(modelNumReg);
if (modelNumMatch) {
[, cpuInfo.modelNum] = modelNumMatch;
}
} 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)')) {
// 匹配如 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('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\)\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;
}
return cpuInfo;
}
/**
* 计算十进制存储大小
*
* @returns {object} 内存信息
* - {string} t TB值
* - {string} g GB值
* - {string} m MB值
* - {string} k KB值
*/
export function calcDecimal(memTotal) {
const k = memTotal / 1000;
const m = memTotal / 1000 ** 2;
const g = memTotal / 1000 ** 3;
const t = memTotal / 1000 ** 4;
return {
k,
m,
g,
t,
};
}
/**
* 计算字节大小
* @param {number} bytes 字节数
* @returns {object} 字节大小
* - {number} kb KB值
* - {number} mb MB值
* - {number} gb GB值
* - {number} tb TB值
*/
export function calcBinary(bytes) {
const k = bytes / 1024;
const m = k / 1024;
const g = m / 1024;
const t = g / 1024;
let p = null;
if (t > 1000) {
p = t / 1024;
}
return {
k,
m,
g,
t,
p,
};
}
/**
* 计算流量规格
*/
export function calcTransfer(bytes) {
const stats = calcBinary(bytes);
const result = {
value: '',
unit: '',
stats,
};
if (stats.t > 1) {
result.value = (stats.t).toFixed(2) * 1;
result.unit = 'T';
} else if (stats.g > 1) {
result.value = (stats.g).toFixed(2) * 1;
result.unit = 'G';
} else if (stats.m > 1) {
result.value = (stats.m).toFixed(1) * 1;
result.unit = 'M';
} else if (stats.p > 0) {
result.value = (stats.p).toFixed(1) * 1;
result.unit = 'P';
} else {
result.value = (stats.k).toFixed(1) * 1;
result.unit = 'K';
}
return result;
}
export function getPlatformLogoIconClassName(platform) {
const platformStr = (platform || '').toLowerCase();
if (platformStr.includes('windows') || platformStr.includes('microsoft')) {
return 'ri-microsoft-fill';
}
switch (platformStr) {
case 'darwin':
case 'macos':
return 'fl-apple';
default:
}
if (platform) {
return `fl-${platform}`;
}
return 'ri-server-line';
}
/**
* 获取系统发行版本
*/
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':
return 'Linux';
case 'darwin':
return 'MacOS';
case 'debian':
return 'Debian';
case 'ubuntu':
return 'Ubuntu';
case 'centos':
return 'CentOS';
case 'fedora':
return 'Fedora';
case 'redhat':
return 'RedHat';
case 'suse':
return 'SUSE';
case 'gentoo':
return 'Gentoo';
case 'arch':
return 'Arch';
case 'alpine':
return 'Alpine';
case 'raspbian':
return 'Raspbian';
case 'openwrt':
return 'OpenWRT';
case 'freebsd':
return 'FreeBSD';
case 'netbsd':
return 'NetBSD';
case 'openbsd':
return 'OpenBSD';
case 'dragonfly':
return 'DragonFly';
case 'solaris':
return 'Solaris';
case 'aix':
return 'AIX';
case 'hpux':
return 'HP-UX';
case 'irix':
return 'IRIX';
case 'osf':
return 'OSF';
case 'tru64':
return 'Tru64';
case 'unixware':
return 'UnixWare';
case 'sco':
return 'SCO';
default:
return platform;
}
}
================================================
FILE: src/utils/load-nezha-v0-config.js
================================================
import config from '@/config';
function getNezhaConfigUrl() {
const { nezhaPath } = config.nazhua;
if (nezhaPath.startsWith('http')) {
return nezhaPath;
}
const a = document.createElement('a');
if (nezhaPath === '/nezha/' && (import.meta.env.VITE_BASE_PATH && import.meta.env.VITE_BASE_PATH !== '/')) {
[a.href] = window.location.href.split(import.meta.env.VITE_BASE_PATH);
} else {
a.href = nezhaPath;
}
return a.href;
}
const configReg = (type) => new RegExp(`${type} = JSON.parse\\('(.*)'\\)`);
// 格式化数据,保证JSON.parse能够正常解析
const unescaped = (str) => {
let str2 = str.replace(/\\u([\d\w]{4})/gi, (match, grp) => String.fromCharCode(parseInt(grp, 16)));
str2 = str2.replace(/\\\\r/g, '');
str2 = str2.replace(/\\\\n/g, '');
str2 = str2.replace(/\\\\/g, '\\');
return str2;
};
export default async () => fetch(getNezhaConfigUrl()).then((res) => res.text()).then((res) => {
let resMatch = res?.match?.(configReg(config.nazhua.nezhaV0ConfigType));
// 尝试兼容不同的nezha前台主题
if (!resMatch) {
resMatch = res?.match?.(configReg(
config.nazhua.nezhaV1ConfigType === 'servers' ? 'initData' : 'servers',
));
}
const configStr = resMatch?.[1];
if (!configStr) {
return null;
}
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 = {
...i,
};
try {
item.PublicNote = JSON.parse(i.PublicNote);
} catch (error) {
console.warn('Failed to parse PublicNote for server:', i.ID || i.id, error);
item.PublicNote = {};
}
return item;
});
return remoteConfig;
}
return null;
}).catch((error) => {
console.error('Failed to load nezha config:', error);
return null;
});
/**
* 获取标签列表
*/
export const loadServerGroup = (services) => {
const tagMap = {};
services.forEach((i) => {
if (i.Tag) {
if (!tagMap[i.Tag]) {
tagMap[i.Tag] = [];
}
tagMap[i.Tag].push(i);
}
});
const tagList = [];
Object.entries(tagMap).forEach(([tag, serviceList]) => {
tagList.push({
name: tag,
count: serviceList.length,
servers: serviceList.map((i) => i.ID),
group: {
name: tag,
},
});
});
return tagList;
};
================================================
FILE: src/utils/load-nezha-v1-config.js
================================================
/**
* V1版数据加载
*/
import config from '@/config';
import request from '@/utils/request';
export const loadServerGroup = async () => request({
url: config.nazhua.v1GroupPath || config.nazhua.v1ApiGroupPath,
type: 'GET',
}).then((res) => {
if (res.status === 200 && res.data?.success) {
const list = res.data?.data || [];
return list.map((i) => {
const item = {
...i,
name: i?.group?.name,
count: i?.servers?.length,
};
return item;
});
}
return null;
}).catch((error) => {
console.error('Failed to load server group:', error);
return null;
});
/**
* 加载网站配置
*
* 暂时只使用site_name\custom_code
* 哪吒v1.4.9之后,上面的参数调整至data.config
*/
export const loadSetting = async () => request({
url: config.nazhua.v1ApiSettingPath,
type: 'GET',
}).then((res) => {
if (res.status === 200 && res.data?.success) {
return res.data?.data || {};
}
return null;
}).catch((error) => {
console.error('Failed to load setting:', error);
return null;
});
/**
* 加载个人信息
*/
export const loadProfile = async (check) => request({
url: config.nazhua.v1ApiProfilePath,
type: 'GET',
}).then((res) => {
if (check) {
return res.status === 200;
}
if (res.status === 200 && res.data?.success) {
return res.data?.data || {};
}
return null;
}).catch((error) => {
console.error('Failed to load profile:', error);
return null;
});
================================================
FILE: src/utils/object-mapping.js
================================================
/**
* 对象映射封装
*/
class Mapping {
/**
* 字符串映射对象
*
* @param {Record<string, any>} obj 查找的对象
* @param {string} key 查找的属性
*
* @return {any}
*/
static mapping(obj, key) {
// 检查 obj 是否为对象,如果不是,返回 undefined
if (!obj || typeof obj !== 'object') {
return undefined;
}
// 检查 key 是否为字符串,如果不是,返回 undefined
if (typeof key !== 'string') {
return undefined;
}
// 检查 key 是否包含非法字符,如果包含,返回 undefined
if (key.includes('..') || key.startsWith('.') || key.endsWith('.')) {
return undefined;
}
// 如果 key 包含 '.',使用 reduce 方法递归获取嵌套属性值
if (key.includes('.')) {
return key.split('.').reduce((val, k) => (val !== undefined ? Mapping.get(val, k) : undefined), obj);
}
// 如果 key 不包含 '.',直接获取属性值
return Mapping.get(obj, key);
}
/**
* 获取数据
* 支持处理数组指针
* @param {Record<string, any> | any[]} obj 属性对象
* @param {string} key 属性名称
* @return {any}
*/
static get(obj, key) {
if (!obj || typeof obj !== 'object' || !key) {
return undefined;
}
const indexMatch = key.match(/\[(\d+)\]/);
if (indexMatch) {
const [fullMatch, indexStr] = indexMatch;
const index = Number(indexStr);
const matchIndex = key.indexOf(fullMatch);
if (matchIndex === 0) {
if (Array.isArray(obj) && index < obj.length) {
const val = obj[index];
const restKey = key.slice(fullMatch.length);
return restKey ? Mapping.get(val, restKey) : val;
}
} else {
const pre = key.slice(0, matchIndex);
const list = obj[pre];
if (Array.isArray(list) && index < list.length) {
const val = list[index];
const restKey = key.slice(matchIndex + fullMatch.length);
return restKey ? Mapping.get(val, restKey) : val;
}
}
return undefined;
}
return obj[key];
}
/**
* 数据根据key的映射进行组装
*
* @param {KeyMap} keys 映射对象
* @param {Record<string, unknown>} data 数据对象
*
* @return {Record<string, unknown>}
*/
static each(keys, data) {
// 检查 keys 是否为对象,如果不是,返回 undefined
if (!keys || typeof keys !== 'object') {
return undefined;
}
// 检查 data 是否为对象,如果不是,返回 undefined
if (!data || typeof data !== 'object') {
return undefined;
}
return Object.entries(keys).reduce((acc, [key, value]) => {
if (typeof value === 'string') {
acc[key] = Mapping.mapping(data, value);
}
return acc;
}, {});
}
}
const { mapping } = Mapping;
export { Mapping, mapping };
export default mapping;
================================================
FILE: src/utils/page-title.js
================================================
import config from '@/config';
export default (...args) => {
const titles = [...new Set([...args, config.nazhua.title])].filter((i) => i);
document.title = titles.join(' - ');
};
================================================
FILE: src/utils/request.js
================================================
import axios from 'axios';
import uuid from '@/utils/uuid';
import CustomError from './custom-error';
const limit = 10;
const requestTagMap = {};
/**
* axios请求
* @param {object} options 请求参数
* @return {Promise}
*/
async function axiosRequest(options) {
return axios(options).then((res) => res).catch((err) => {
if (err.response) {
return err.response;
}
if (err.request) {
// 请求已经成功发起,但没有收到响应
return null;
}
throw new CustomError(err.message);
});
}
/**
* 网络请求
*/
class NetworkRequest {
constructor() {
this.tasks = [];
this.tasking = 0;
}
/**
* 是否为Form请求
*/
static FormRequest = (headers) => {
if (!headers) return false;
const keys = Object.keys(headers);
for (let i = 0, n = keys.length; i < n; i += 1) {
if (keys[i].toLowerCase() === 'content-type') {
return headers[keys[i]].includes('x-www-form-urlencoded');
}
}
return false;
};
/**
* 添加请求
*
* @param {string} url 请求的相对路径
* @param {string} type 请求的Method
* @param {object} headers Header请求参数
* @param {object} data 请求参数
* @param {boolean} defaultContentType 默认的请求方式
* @param {Boolean} priority 优先调用请求
*
* @return {Promise}
*/
push(
options = {},
controller = {},
priority = false,
) {
const {
url,
type,
headers,
data,
defaultContentType = true,
requestTag = undefined,
responseType,
} = options || {};
const {
abortController,
} = controller || {};
const tag = requestTag || uuid();
if (requestTagMap[tag]) {
return requestTagMap[tag];
}
return new Promise((resolve, reject) => {
const defaultHeaders = {};
if (defaultContentType === false) {
if (NetworkRequest.FormRequest(defaultHeaders)) {
defaultHeaders['content-type'] = 'application/json';
} else {
defaultHeaders['content-type'] = 'application/x-www-form-urlencoded';
}
}
const requestOptions = [
{
url,
method: type,
headers: {
...defaultHeaders,
...headers,
},
data,
signal: abortController?.signal ?? undefined,
responseType,
},
(res) => {
resolve(res);
},
(err) => {
reject(err);
},
tag,
];
if (priority) {
this.tasks.unshift(requestOptions);
} else {
this.tasks.push(requestOptions);
}
this.nextTask();
});
}
/**
* 下一个请求任务
*/
nextTask() {
if (this.tasking >= limit) {
setTimeout(() => {
this.nextTask();
}, 1000);
return;
}
if (this.tasks.length === 0) {
return;
}
const [options, success, fail, tag] = this.tasks.pop();
// 请求未执行已被中止
if (options?.signal?.aborted) {
this.overTask();
return;
}
requestTagMap[tag] = axiosRequest(options);
requestTagMap[tag].finally(() => {
this.overTask();
// 一秒内请求不重复
setTimeout(() => {
delete requestTagMap[tag];
}, 1000);
});
requestTagMap[tag].then(success).catch(fail);
this.tasking += 1;
}
/**
* 结束请求任务
*/
overTask() {
this.tasking -= 1;
this.nextTask();
}
}
const request = new NetworkRequest();
export {
NetworkRequest,
};
export default (...args) => request.push(...args);
================================================
FILE: src/utils/sleep.js
================================================
export default (timed = 1000) => new Promise((resolve) => {
setTimeout(() => resolve(), timed > 0 ? timed : 30);
});
================================================
FILE: src/utils/subscribe.js
================================================
/**
* 消息订阅器
*/
class MessageSubscribe {
constructor() {
this.subscribers = {};
}
/**
* 订阅消息
* @params {String} key 消息类型
* @params {Function} callback 回调函数
*/
on(key, callback) {
if (!this.subscribers[key]) {
this.subscribers[key] = [];
}
this.subscribers[key].push(callback);
if (import.meta.env.VITE_LIVE_SUBSCRIBE_DEBUG) {
console.log('subscribers on by key:', key);
console.log('subscribers on', this.subscribers);
}
}
/**
* 订阅一次消息
* @params {String} key 消息类型
* @params {Function} callback 回调函数
*/
once(key, callback) {
const onceCallback = (...args) => {
callback(...args);
this.off(key, onceCallback);
};
this.on(key, onceCallback);
}
/**
* 取消订阅
* @params {String} key 消息类型
* @params {Function} callback 回调函数
*/
off(key, callback) {
if (!this.subscribers[key]) {
return;
}
const index = this.subscribers[key].indexOf(callback);
if (index !== -1) {
this.subscribers[key].splice(index, 1);
}
if (import.meta.env.VITE_LIVE_SUBSCRIBE_DEBUG) {
console.log('subscribers off by key:', key);
console.log('subscribers off', this.subscribers);
}
}
/**
* 发布消息
* @params {String} key 消息类型
* @params {Object} data 消息数据
*/
emit(key, data) {
if (!this.subscribers[key]) {
return;
}
this.subscribers[key].forEach((callback) => {
callback(data);
});
if (import.meta.env.VITE_LIVE_SUBSCRIBE_DEBUG) {
console.log('subscribers emit by key:', key);
console.log('subscribers emit', key, this.subscribers[key]);
}
}
}
export default MessageSubscribe;
================================================
FILE: src/utils/transform-v1-2-v0.js
================================================
/**
* V1版数据加载
*/
import store from '@/store';
import validate from '@/utils/validate';
import { Mapping } from '@/utils/object-mapping';
/**
* 字段映射
*/
export const SERVER_FIELD_MAPS = {
ID: 'id',
CreatedAt: undefined,
UpdatedAt: undefined,
DeletedAt: undefined,
Name: 'name',
Tag: '_$function|queryGroup|id',
DisplayIndex: 'display_index',
HideForGuest: undefined,
EnableDDNS: undefined,
Host: '_$mapping|HOST_FIELD_MAPS',
State: '_$mapping|STATE_FIELD_MAPS',
LastActive: 'last_active',
};
export const HOST_FIELD_MAPS = {
Platform: 'host.platform',
PlatformVersion: 'host.platform_version',
CPU: 'host.cpu',
MemTotal: 'host.mem_total',
DiskTotal: 'host.disk_total',
SwapTotal: 'host.swap_total',
Arch: 'host.arch',
Virtualization: 'host.virtualization',
BootTime: 'host.boot_time',
CountryCode: 'country_code',
Version: 'host.version',
GPU: 'host.gpu',
};
export const STATE_FIELD_MAPS = {
CPU: 'state.cpu',
MemUsed: 'state.mem_used',
SwapUsed: 'state.swap_used',
DiskUsed: 'state.disk_used',
NetInTransfer: 'state.net_in_transfer',
NetOutTransfer: 'state.net_out_transfer',
NetInSpeed: 'state.net_in_speed',
NetOutSpeed: 'state.net_out_speed',
Uptime: 'state.uptime',
Load1: 'state.load_1',
Load5: 'state.load_5',
Load15: 'state.load_15',
TcpConnCount: 'state.tcp_conn_count',
UdpConnCount: 'state.udp_conn_count',
ProcessCount: 'state.process_count',
Temperatures: 'state.temperatures',
GPU: 'state.gpu',
};
/**
* 魔法方法
*/
const magics = {
HOST_FIELD_MAPS,
STATE_FIELD_MAPS,
queryGroup: (id) => {
const groupItem = store.state.serverGroup?.find?.((i) => {
if (i.servers) {
return i.servers.includes(id);
}
return false;
});
return groupItem?.name;
},
};
/**
* 处理V1版数据
* @param {Object} v1Data V1版数据
* @return {Object} V0版数据
*/
export default function (v1Data) {
const v0Data = {};
Object.keys(SERVER_FIELD_MAPS).forEach((key) => {
if (SERVER_FIELD_MAPS[key] === undefined) {
return;
}
if (SERVER_FIELD_MAPS[key].includes('_$')) {
const $magic = SERVER_FIELD_MAPS[key].split('|');
switch ($magic[0]) {
case '_$function':
if ($magic.length >= 3 && magics[$magic[1]]) {
v0Data[key] = magics[$magic[1]](
Mapping.mapping(v1Data, $magic[2]),
);
} else {
v0Data[key] = undefined;
}
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;
}
return;
}
v0Data[key] = Mapping.mapping(v1Data, SERVER_FIELD_MAPS[key]);
});
if (v1Data.public_note) {
try {
v0Data.PublicNote = JSON.parse(v1Data.public_note);
} catch (e) {
console.warn('Failed to parse public_note for server:', v1Data.id, e);
v0Data.PublicNote = null;
}
} else {
v0Data.PublicNote = null;
}
return v0Data;
}
================================================
FILE: src/utils/tsdb.js
================================================
/**
* v1 后端 TSDB 相关判断
* tsdb_enabled 为 true 时:用 period=1d 拉取监控数据,WS 不返回 tcp/udp,前端需隐藏连接数展示
*/
import config from '@/config';
/**
* 是否开启 TSDB(v1 且 setting 中 tsdb_enabled 为 true)
* @param {import('vuex').Store} store
* @returns {boolean}
*/
export function isTsdbEnabled(store) {
if (config.nazhua.nezhaVersion !== 'v1' || !store?.state?.setting) {
return false;
}
const { setting } = store.state;
return setting?.config?.tsdb_enabled === true || setting?.tsdb_enabled === true;
}
/**
* 是否有 tsdb_enabled 字段(存在即可,不要求为 true)
* @param {import('vuex').Store} store
* @returns {boolean}
*/
export function hasTsdb(store) {
if (config.nazhua.nezhaVersion !== 'v1' || !store?.state?.setting) {
return false;
}
const { setting } = store.state;
return 'tsdb_enabled' in (setting?.config ?? {}) || 'tsdb_enabled' in (setting ?? {});
}
================================================
FILE: src/utils/uuid.js
================================================
/* eslint-disable */
export default () => {
if (crypto?.randomUUID) {
return crypto.randomUUID();
}
// Public Domain/MIT
// Timestamp
let d = new Date().getTime();
// Time in microseconds since page-load or 0 if unsupported
let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0;
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
// random number between 0 and 16
let r = Math.random() * 16;
// Use timestamp until depleted
if (d > 0) {
r = (d + r) % 16 | 0;
d = Math.floor(d / 16);
} else {
// Use microseconds since page-load if supported
r = (d2 + r) % 16 | 0;
d2 = Math.floor(d2 / 16);
}
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
================================================
FILE: src/utils/validate.js
================================================
/**
* 校验方法
*/
const validate = {
/**
* 判断值是否已经设置类型数据
* null|undefined为false
*/
isSet(val) {
if (
val === null
|| val === undefined
) {
return false;
}
return true;
},
/**
* 判断是否为空值
* null|undefined|空字符串 绝对为空
* 空对象、空数组根据拓展选项来控制
*
* @param {Any} val 验证值
* @param {Object|Boolean} options 验证选项
* @param {Boolean} options.allEmpty 全部验证
* @param {Boolean} options.objectEmpty 对象验证
* @param {Boolean} options.arrayEmpty 数组验证
*
* @return {Boolean} 是否为空
*/
isEmpty(val, options = null) {
let allEmpty = false;
let objectEmpty = false;
let arrayEmpty = false;
if (options === true) {
allEmpty = true;
} else {
const emptyOptions = options || {};
allEmpty = emptyOptions.allEmpty;
objectEmpty = emptyOptions.objectEmpty;
arrayEmpty = emptyOptions.arrayEmpty;
}
if (
val === null
|| val === undefined
|| (
val.constructor.name === 'String'
&& val === ''
)
) {
return true;
}
if (
(allEmpty || objectEmpty)
&& val.constructor.name === 'Object'
&& Object.getOwnPropertyNames(val).length === 0
) {
return true;
}
if (
(allEmpty || arrayEmpty)
&& Array.isArray(val)
&& val.length === 0
) {
return true;
}
return false;
},
/**
* 是否为对象
*/
isObject(val) {
return typeof val === 'object' && val !== null && val.constructor.name === 'Object';
},
hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
},
};
export default validate;
================================================
FILE: src/utils/world-map.js
================================================
import config from '@/config';
import CODE_MAPS, {
countryCodeMapping,
aliasMapping,
} from '@/data/code-maps';
export const ALIAS_CODE = {
...aliasMapping,
...countryCodeMapping,
};
export const alias2code = (code) => ALIAS_CODE[code];
export const locationCode2Info = (code) => {
const maps = {
...CODE_MAPS,
...(config.nazhua.customCodeMap || {}),
};
let info = maps[code];
const aliasCode = aliasMapping[code];
if (!info && aliasCode) {
info = maps[aliasCode];
}
return info;
};
export const count2size = (count) => {
if (count < 3) {
return 4;
}
if (count < 5) {
return 6;
}
return 8;
};
export function findIntersectingGroups(coordinates) {
const groups = {};
coordinates.forEach((coordinate, index) => {
const intersects = [];
const n = -2;
coordinates.forEach((otherCoordinate, otherIndex) => {
if (index !== otherIndex) {
if (
coordinate.topLeft.top - otherCoordinate.bottomRight.top < n
&& coordinate.topLeft.left - otherCoordinate.bottomRight.left < n
&& coordinate.bottomRight.top - otherCoordinate.topLeft.top > -n
&& coordinate.bottomRight.left - otherCoordinate.topLeft.left > -n
) {
intersects.push(otherCoordinate);
}
}
});
if (intersects.length > 0) {
groups[coordinate.key] = intersects;
}
});
return groups;
}
================================================
FILE: src/utils/zIndexManager.js
================================================
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;
};
================================================
FILE: src/views/components/server/server-real-time.vue
================================================
<template>
<div class="server-real-time-group">
<div
v-for="item in serverRealTimeList"
:key="item.key"
class="server-real-time-item"
:class="`server-real-time--${item.key}`"
>
<div class="item-content">
<div
v-if="item.show && item.values"
class="item-content-sub-group"
>
<span
v-for="subItem in item.values"
:key="`${item.key}_${subItem.key}`"
class="item-content-sub-item"
:class="`item-content-sub-item--${item.key}-${subItem.key}`"
>
<span class="item-content-sub-label">
{{ subItem.label }}
</span>
<span class="item-content-sub-content">
<span class="item-value">{{ subItem.show ? subItem?.value : '-' }}</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
v-if="item.show"
class="item-unit item-text"
>{{ item?.unit }}</span>
</template>
</div>
<span
v-if="!item.values"
class="item-label"
>
{{ item.label }}
</span>
</div>
</div>
</template>
<script setup>
/**
* 服务器数据统计
*/
import {
inject,
} from 'vue';
import handleServerRealTime from '@/views/composable/server-real-time';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
serverRealTimeListTpls: {
type: String,
default: undefined,
},
});
const currentTime = inject('currentTime', {
value: Date.now(),
});
const {
serverRealTimeList,
} = handleServerRealTime({
props,
currentTime,
serverRealTimeListTpls: props.serverRealTimeListTpls,
});
</script>
<style lang="scss" scoped>
.server-real-time-group {
display: flex;
align-items: center;
.server-real-time-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: default;
.item-value {
line-height: 1em;
font-size: var(--real-time-value-font-size, 24px);
}
.item-content {
display: flex;
align-items: flex-end;
gap: 2px;
}
.item-text {
line-height: 1.3em;
font-size: var(--real-time-text-font-size, 12px);
color: #ddd;
}
.item-label {
line-height: 1.2em;
font-size: var(--real-time-label-font-size, 14px);
color: #ddd;
}
.item-content-sub-group {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
.item-content-sub-item {
flex: 1;
display: flex;
align-items: center;
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,
.item-text,
.item-label {
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);
}
}
.item-content-sub-item--speeds-out {
.item-value {
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);
}
}
}
}
.server-real-time--duration {
.item-value {
color: var(--duration-color);
}
}
.server-real-time--transfer {
.item-value {
color: var(--transfer-color);
}
}
.server-real-time--inSpeed,
.server-real-time--speed {
.item-value {
color: var(--net-speed-in-color);
}
}
.server-real-time--outSpeed {
.item-value {
color: var(--net-speed-out-color);
}
}
}
</style>
================================================
FILE: src/views/components/server/server-status-donut.vue
================================================
<template>
<div
class="server-status"
:class="'server-status--' + type"
>
<div class="server-status-donut">
<chart-donut
:size="size"
:used="Math.min(Math.max(used, 1), 100)"
:item-colors="colors"
>
<template #default>
<div
class="chart-donut-label"
:title="valPercent ? valPercent : `${(used).toFixed(1) * 1}%`"
>
<div class="server-status-val-text">
<span>{{ valText }}</span>
</div>
<div class="server-status-label">
{{ label }}
</div>
</div>
</template>
</chart-donut>
</div>
<div
v-if="content"
class="server-status-content"
>
<span
v-if="content?.default"
class="default-content"
>
{{ content?.default }}
</span>
<span
v-if="content?.mobile"
class="default-mobile"
>
{{ content?.mobile }}
</span>
</div>
</div>
</template>
<script setup>
/**
* 服务器状态单项
*/
import ChartDonut from '@/components/charts/donut.vue';
defineProps({
type: {
type: String,
default: '',
},
size: {
type: Number,
default: 100,
},
used: {
type: [Number, String],
default: 1,
},
colors: {
type: Object,
default: () => ({}),
},
valText: {
type: String,
default: '',
},
valPercent: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
content: {
type: [String, Object],
default: '',
},
});
</script>
<style lang="scss" scoped>
.server-status {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 5px;
.server-status-donut {
--donut-box-size: var(--server-status-size);
height: var(--server-status-size);
}
.chart-donut-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
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: var(--server-status-value-color);
}
.server-status-label {
line-height: 1.1em;
font-size: var(--server-status-label-font-size, 12px);
color: var(--server-status-label-color);
}
.server-status-content {
line-height: 1.2em;
font-size: var(--server-status-content-font-size, 14px);
color: var(--server-status-content-color);
.default-mobile {
display: none;
}
@media screen and (max-width: 768px) {
.default-content {
display: none;
}
.default-mobile {
display: block;
}
}
}
}
</style>
================================================
FILE: src/views/components/server/server-status-progress.vue
================================================
<template>
<div
class="server-status-progress"
:class="'server-status--' + type"
>
<div class="progress-bar-box">
<div
class="progress-bar-inner"
:style="progressStyle"
/>
<div
class="progress-bar-label"
:title="label + '使用' + used + '%'"
>
<span
v-if="label"
class="server-status-label"
>
{{ label }}:
</span>
<span class="server-status-val-text">
{{ valText }}
</span>
</div>
</div>
<div
v-if="content"
class="server-status-progress-content"
>
<span>{{ content?.default }}</span>
</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: '',
},
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-status-progress {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 5px;
@media screen and (max-width: 480px) {
flex: none;
width: var(--progress-bar-width, calc(50% - 5px));
}
// @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: calc(var(--progress-bar-height) / 2);
overflow: hidden;
}
.progress-bar-inner {
position: absolute;
top: 0;
left: 0;
bottom: 0;
background-color: #08f;
border-radius: calc(var(--progress-bar-height) / 2);
}
.progress-bar-label {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
line-height: var(--progress-bar-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;
}
.server-status-val-text {
color: #a1eafb;
}
.server-status-label {
color: #ddd;
}
.server-status-progress-content {
color: #eee;
@media screen and (max-width: 480px) {
line-height: 20px;
font-size: 12px;
}
}
}
</style>
================================================
FILE: src/views/components/server-detail/server-info-box.vue
================================================
<template>
<dot-dot-box class="server-info-box">
<div class="server-info-group server-info--cpu">
<div class="server-info-label">
CPU
</div>
<div class="server-info-content">
<template v-if="info?.Host?.CPU?.length === 1">
<span
class="cpu-info"
:title="info.Host.CPU[0]"
>
<span>{{ info.Host.CPU[0] }}</span>
</span>
</template>
<div
v-else
class="server-info-item-group"
>
<span
v-for="(cpuItem, cpuIndex) in info.Host.CPU"
:key="`${info.ID}_cpu_${cpuIndex}`"
class="server-info-item"
>
<span class="server-info-item-label">CPU.{{ cpuIndex + 1 }}</span>
<span class="server-info-item-value">{{ cpuItem }}</span>
</span>
</div>
</div>
</div>
<div
v-if="gpuList.length"
class="server-info-group server-info--gpu"
>
<div class="server-info-label">
GPU
</div>
<div class="server-info-content">
<template v-if="gpuList.length === 1">
<span
class="gpu-info"
:title="gpuList[0]"
>
<span>{{ gpuList[0] }}</span>
</span>
</template>
<div
v-else
class="server-info-item-group"
>
<span
v-for="(gpuItem, gpuIndex) in gpuList"
:key="`${info.ID}_gpu_${gpuIndex}`"
class="server-info-item"
>
<span class="server-info-item-label">GPU.{{ gpuIndex + 1 }}</span>
<span class="server-info-item-value">{{ gpuItem }}</span>
</span>
</div>
</div>
</div>
<div
v-if="temperatureData.list.length"
class="server-info-group server-info--temperature"
>
<div class="server-info-label">
温度
</div>
<div class="server-info-content">
<div class="server-info-item-group">
<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}`"
>
<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>
<div class="server-info-group server-info--system-os">
<div class="server-info-label">
系统
</div>
<div class="server-info-content">
<span class="server-info-item">
<span class="server-info-item-label">{{ systemOSLabel }}</span>
<span
v-if="info?.Host?.PlatformVersion"
class="server-info-item-value"
>
{{ info?.Host?.PlatformVersion }}
</span>
</span>
</div>
</div>
<div class="server-info-group server-info--load">
<div class="server-info-label">
占用
</div>
<div class="server-info-content">
<div class="server-info-item-group">
<span class="server-info-item process-count">
<span class="server-info-item-label">进程数</span>
<span class="server-info-item-value">{{ processCount }}</span>
</span>
<span class="server-info-item load">
<span class="server-info-item-label">负载</span>
<span class="server-info-item-value">
{{ sysLoadInfo }}
</span>
</span>
</div>
</div>
</div>
<div class="server-info-group server-info--transfer">
<div class="server-info-label">
流量
</div>
<div class="server-info-content">
<div class="server-info-item-group">
<span class="server-info-item transfer--in">
<span class="server-info-item-label">入网</span>
<span class="server-info-item-value">
<span class="text-value">{{ transfer?.in?.value }}</span>
<span class="text-unit">{{ transfer?.in?.unit }}</span>
</span>
</span>
<span class="server-info-item transfer--out">
<span class="server-info-item-label">出网</span>
<span class="server-info-item-value">
<span class="text-value">{{ transfer?.out?.value }}</span>
<span class="text-unit">{{ transfer?.out?.unit }}</span>
</span>
</span>
</div>
</div>
</div>
<div
v-if="!hideConns"
class="server-info-group server-info--conn"
>
<div class="server-info-label">
连接
</div>
<div class="server-info-content">
<div class="server-info-item-group">
<span class="server-info-item conn--tcp">
<span class="server-info-item-label">TCP</span>
<span class="server-info-item-value">{{ tcpConnCount }}</span>
</span>
<span class="server-info-item conn--tcp">
<span class="server-info-item-label">UDP</span>
<span class="server-info-item-value">{{ udpConnCount }}</span>
</span>
</div>
</div>
</div>
<div class="server-info-group server-info--boottime">
<div class="server-info-label">
启动
</div>
<div class="server-info-content">
<span class="server-info-item runtime--boottime">
<span class="server-info-item-value">{{ bootTime }}</span>
</span>
</div>
</div>
<div class="server-info-group server-info--lasttime">
<div class="server-info-label">
活跃
</div>
<div class="server-info-content">
<span class="server-info-item runtime--lasttime">
<span class="server-info-item-value">{{ lastActive }}</span>
</span>
</div>
</div>
<div
v-if="billPlanData.length"
class="server-info-group server-info--biil-plan"
>
<div class="server-info-label">
套餐
</div>
<div class="server-info-content">
<div class="server-info-item-group">
<span
v-for="item in billPlanData"
:key="item.label"
class="server-info-item"
>
<span
v-if="item.label"
class="server-info-item-label"
>{{ item.label }}</span>
<span class="server-info-item-value">{{ item.value }}</span>
</span>
</div>
</div>
</div>
<div
v-if="tagList?.length"
class="server-info-group server-info--tag-list"
>
<div class="server-info-label">
标签
</div>
<div class="server-info-content">
<div class="server-info-tag-list">
<span
v-for="(tag, index) in tagList"
:key="`${tag}_${index}`"
class="server-info-tag-item"
:class="{
'has-sarasa-term': $hasSarasaTerm && config.nazhua.disableSarasaTermSC !== true,
}"
>
{{ tag }}
</span>
</div>
</div>
</div>
<div
v-if="showBuyBtn"
class="server-info-group server-info--order-link"
>
<div class="server-info-content">
<div
class="buy-btn"
@click.stop="toBuy"
>
<span class="icon">
<span :class="buyBtnIcon" />
</span>
<span class="text">{{ buyBtnText }}</span>
</div>
</div>
</div>
</dot-dot-box>
</template>
<script setup>
/**
* 服务器信息盒子
*/
import {
computed,
} from 'vue';
import dayjs from 'dayjs';
import config from '@/config';
import * as hostUtils from '@/utils/host';
import handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const 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() {
const decodeUrl = decodeURIComponent(props.info?.PublicNote?.customData?.orderLink);
window.open(decodeUrl, '_blank');
}
/**
* GPU列表
*/
const gpuList = computed(() => {
const gpus = props.info?.Host?.GPU || [];
if (config.nazhua?.filterGPUKeywords?.length) {
// 过滤奇怪的GPU,可以考虑过滤掉 Virtual Display
const keywors = Array.isArray(config.nazhua.filterGPUKeywords)
? config.nazhua.filterGPUKeywords
: [config.nazhua.filterGPUKeywords];
return gpus.filter((i) => {
if (keywors.length) {
return !keywors.some((k) => i.toLowerCase().includes(k.toLowerCase()));
}
return true;
});
}
return gpus;
});
const sysLoadInfo = computed(() => {
if (props.info?.State?.Load1 !== undefined) {
return [
props.info.State?.Load1,
props.info.State?.Load5,
props.info.State?.Load15,
].filter((i) => i !== undefined).map((i) => (i).toFixed(2) * 1).join(',');
}
return '-';
});
const temperatureData = computed(() => {
const data = [];
if (props.info?.State?.Temperatures) {
const acpitz = [];
const coretemp_package_id = [];
const coretemp_core = [];
const nvme = [];
const k10temp = [];
const amdgpu = [];
const other = [];
// 温度数据分类处理
props.info.State.Temperatures.forEach((item) => {
const name = item.Name.toLowerCase();
const temp = item.Temperature;
if (name.startsWith('acpitz')) {
acpitz.push(temp);
return;
}
if (name.startsWith('coretemp_package_id_')) {
const coreIndex = parseInt(name.replace('coretemp_package_id_', ''), 10);
coretemp_package_id.push({
index: coreIndex,
value: temp,
});
return;
}
if (name.startsWith('coretemp_core_')) {
const coreIndex = parseInt(name.replace('coretemp_core_', ''), 10);
coretemp_core.push({
index: coreIndex,
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;
}
other.push({
label: item.Name,
value: temp,
type: 'other',
});
});
// 主板温度处理
if (acpitz.length) {
const acpitzMean = (acpitz.reduce((a, b) => a + b, 0) / acpitz.length).toFixed(1);
data.push({
label: '主板',
value: `${acpitzMean}℃`,
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) {
const cpuTemps = coretemp_package_id.map((i) => `${parseFloat(i.value).toFixed(1)}℃`);
temps.push(cpuTemps.join(', '));
details.push(...coretemp_package_id.map((i) => `CPU.${i.index + 1}: ${parseFloat(i.value).toFixed(1)}℃`));
}
// 处理核心温度
if (coretemp_core.length) {
const coreMean = (coretemp_core.reduce((a, b) => a + b.value, 0) / coretemp_core.length).toFixed(1);
temps.push(`${parseFloat(coreMean).toFixed(1)}℃`);
details.push(...coretemp_core.map((i) => `核心${i.index + 1}: ${parseFloat(i.value).toFixed(1)}℃`));
}
data.push({
label: 'CPU',
value: temps.join(' / '),
title: details.join('\n'),
type: 'cpu',
});
}
// AMD CPU温度处理
if (k10temp.length) {
const tctl = k10temp.find((i) => i.name.includes('tctl'));
if (tctl) {
data.push({
label: 'AMD CPU',
value: `${parseFloat(tctl.value).toFixed(1)}℃`,
title: k10temp.map((i) => `${i.name}: ${parseFloat(i.value).toFixed(1)}℃`).join('\n'),
type: 'cpu',
});
}
}
// AMD GPU温度处理
if (amdgpu.length) {
const edge = amdgpu.find((i) => i.name.includes('edge'));
if (edge) {
data.push({
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,
};
});
const {
billAndPlan,
} = handleServerBillAndPlan({
props,
});
const billPlanData = computed(() => ['billing', 'remainingTime', 'bandwidth', 'traffic'].map((i) => {
if (billAndPlan.value[i]) {
return {
label: billAndPlan.value[i].label,
value: billAndPlan.value[i].value,
};
}
return null;
}).filter((i) => i));
const tagList = computed(() => {
const list = [];
const {
networkRoute,
extra,
IPv4,
IPv6,
} = props?.info?.PublicNote?.planDataMod || {};
if (networkRoute) {
list.push(...networkRoute?.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;
});
const systemOSLabel = computed(() => {
if (props?.info?.Host?.Platform) {
return hostUtils.getSystemOSLabel(props.info.Host.Platform);
}
return '';
});
const bootTime = computed(() => {
if (props?.info?.Host?.BootTime) {
return dayjs(props.info.Host.BootTime * 1000).format('YYYY.MM.DD HH:mm:ss');
}
return '-';
});
const lastActive = computed(() => {
if (props?.info?.Host?.BootTime && props?.info?.LastActive) {
return dayjs(props.info.LastActive).format('YYYY.MM.DD HH:mm:ss');
}
return '-';
});
/**
* 计算流量
*/
const transfer = computed(() => {
const stats = {
in: 0,
out: 0,
total: 0,
};
if (props?.info?.State?.NetInTransfer) {
stats.total += props.info.State.NetInTransfer;
stats.in = props.info.State.NetInTransfer;
}
if (props?.info?.State?.NetOutTransfer) {
stats.total += props.info.State.NetOutTransfer;
stats.out = props.info.State.NetOutTransfer;
}
const result = {
in: hostUtils.calcTransfer(stats.in),
out: hostUtils.calcTransfer(stats.out),
total: hostUtils.calcTransfer(stats.total),
stats,
};
return result;
});
const hideConns = computed(() => {
const tcp = props.info?.State?.TcpConnCount;
const udp = props.info?.State?.UdpConnCount;
return (tcp == null) && (udp == null);
});
const tcpConnCount = computed(() => props.info?.State?.TcpConnCount);
const udpConnCount = computed(() => props.info?.State?.UdpConnCount);
const processCount = computed(() => props.info?.State?.ProcessCount);
</script>
<style lang="scss" scoped>
.server-info-box {
--server-info-item-size: 24px;
@media screen and (max-width: 480px) {
--server-info-item-size: 30px;
}
.server-info-group {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
font-size: 14px;
.server-info-label {
width: 2.4em;
text-align: center;
line-height: var(--server-info-item-size);
color: #ccc;
}
.server-info-content {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
line-height: 18px;
text-align: right;
cursor: default;
}
}
.server-info-item-group {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0 12px;
&.temperature--other {
// 移动端不显示
@media screen and (max-width: 768px) {
display: none;
}
}
}
.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 {
color: #00fff0;
}
.transfer--in {
.server-info-item-value {
color: #ddd;
}
.text-value {
color: var(--transfer-in-color);
}
}
.transfer--out {
.server-info-item-value {
color: #ddd;
}
.text-value {
color: var(--transfer-out-color);
}
}
.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;
}
.buy-btn {
display: flex;
align-items: center;
justify-content: center;
height: 36px;
padding: 0 10px;
gap: 5px;
line-height: 1;
font-weight: bold;
color: var(--list-item-buy-link-color);
border: 2px solid var(--list-item-buy-link-color);
border-radius: 8px;
transition: all 150ms ease;
cursor: pointer;
&:hover {
color: #111;
border-color: var(--list-item-buy-link-color);
background-color: var(--list-item-buy-link-color);
}
@media screen and (max-width: 768px) {
cursor: default;
}
.icon {
font-size: 18px;
font-weight: normal;
}
}
.server-info-tag-list {
display: flex;
gap: 6px;
.server-info-tag-item {
height: 18px;
padding: 0 5px 0 6px;
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>
================================================
FILE: src/views/components/server-detail/server-monitor.vue
================================================
<template>
<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">
<div class="left-box">
<span class="module-title">
网络监控
</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="是否自动刷新"
@click="switchRefresh"
>
<span class="label-text">刷新</span>
<div
class="switch-box"
:class="{
active: refreshData,
}"
>
<span class="switch-dot" />
</div>
</div>
<div
class="peak-shaving-group"
title="过滤太高或太低的数据"
@click="switchPeakShaving"
>
<span class="label-text">削峰</span>
<div
class="switch-box"
:class="{
active: peakShaving,
}"
>
<span class="switch-dot" />
</div>
</div>
<div class="last-update-time-group">
<span class="last-update-time-label">
最近
</span>
<div class="minutes">
<div
v-for="minuteItem in minutes"
:key="minuteItem.value"
class="minute-item"
:class="{
active: minuteItem.value === minute,
}"
@click="toggleMinute(minuteItem.value)"
>
<span>{{ minuteItem.label }}</span>
</div>
<div
class="active-arrow"
:style="minuteActiveArrowStyle"
/>
</div>
</div>
</div>
</div>
<template v-if="monitorChartType === 'single'">
<div
class="monitor-chart-group"
:class="'monitor-chart-len--' + monitorChartData.cateList.length"
>
<div
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,
}"
:style="{
'--cate-color': cateItem.color,
}"
>
<span class="cate-legend" />
<span
class="cate-name"
>
{{ cateItem.name }}
</span>
<span
v-if="cateItem.avg !== 0"
class="cate-avg-ms"
>
{{ cateItem.avg }}ms
</span>
<span
v-if="cateItem.over !== 0"
class="cate-over-rate"
>
{{ cateItem.over }}%
</span>
</div>
</template>
</popover>
</div>
<line-chart
:date-list="monitorChartData.dateList"
:value-list="[monitorChartData.valueList[index]]"
:size="240"
:connect-nulls="false"
/>
</div>
</div>
</template>
<template v-else>
<div class="monitor-cate-group">
<template
v-for="cateItem in monitorChartData.cateList"
:key="cateItem.id"
>
<popover :title="cateItem.title">
<template #trigger>
<div
class="monitor-cate-item"
:class="{
disabled: showCates[cateItem.id] === false,
}"
:style="{
'--cate-color': cateItem.color,
}"
@click="toggleShowCate(cateItem.id)"
@touchstart="handleTouchStart(cateItem.id)"
@touchend="handleTouchEnd(cateItem.id)"
@touchmove="handleTouchMove(cateItem.id)"
>
<span class="cate-legend" />
<span
class="cate-name"
>
{{ cateItem.name }}
</span>
<span
v-if="cateItem.avg !== 0"
class="cate-avg-ms"
>
{{ cateItem.avg }}ms
</span>
</div>
</template>
</popover>
</template>
</div>
<line-chart
:date-list="monitorChartData.dateList"
:value-list="monitorChartData.valueList"
:connect-nulls="false"
/>
</template>
</dot-dot-box>
</template>
<script setup>
/**
* 服务器监控
*/
import {
ref,
computed,
onMounted,
onUnmounted,
} from 'vue';
import { useStore } from 'vuex';
import config from '@/config';
import request from '@/utils/request';
import validate from '@/utils/validate';
import { isTsdbEnabled, hasTsdb } from '@/utils/tsdb';
import LineChart from '@/components/charts/line.vue';
import {
getThreshold,
getLineColor,
} from '@/views/composable/server-monitor';
const props = defineProps({
info: {
type: Object,
default: () => ({}),
},
});
const store = useStore();
const userLogin = computed(() => store.state.profile?.username);
const minute = ref(1440);
const baseMinutes = [{
label: '30分钟',
value: 30,
}, {
label: '1小时',
value: 60,
}, {
label: '3小时',
value: 180,
}, {
label: '6小时',
value: 360,
}, {
label: '12小时',
value: 720,
}, {
label: '24小时',
value: 1440,
}];
const minutes = computed(() => {
if (!userLogin.value || !hasTsdb(store)) {
return baseMinutes;
}
return [
...baseMinutes,
{
label: '7天',
value: 10080,
},
{
label: '30天',
value: 43200,
},
];
});
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 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.value.findIndex((i) => i.value === minute.value);
return {
left: `calc(${index} * var(--minute-item-width))`,
};
});
const monitorChartData = computed(() => {
/**
* 处理监控数据以生成分类的平均延迟随时间变化的列表。
*
* @returns {Object} 返回一个对象,包含:
* - cateList {Array}: 唯一监控名称的列表。
* - dateList {Array}: 排序后的唯一时间戳列表。
* - valueList {Array}: 包含以下内容的对象列表:
* - name {String}: 监控名称。
* - data {Array}: [时间戳, 平均延迟] 对的数组。
*/
const cateList = [];
const cateMap = {};
const dateSet = new Set();
let valueList = [];
monitorData.value.forEach((i) => {
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 cateDelayMap = new Map();
const cateAcceptTimeMap = new Map();
const cateCreateTime = new Set();
const isPeriodRange = minute.value === 10080 || minute.value === 43200;
// 实际数据的最早时间戳
let earliestTimestamp = nowServerTime.value;
created_at.forEach((time, index) => {
if (time < earliestTimestamp) {
earliestTimestamp = time;
}
const status = isPeriodRange || time >= acceptShowTime.value;
// 允许显示的数据,记录到cateAcceptTime
if (status) {
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]);
}
});
if (import.meta.env.VITE_MONITOR_DEBUG === '1') {
gitextract_ju633mka/ ├── .eslintignore ├── .eslintrc.cjs ├── .github/ │ └── workflows/ │ ├── docker-build.yml │ ├── eslint.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── doc/ │ ├── deploy.md │ ├── public-note.md │ └── update.md ├── docker-compose.yaml.template ├── fonts/ │ ├── SarasaTermSC/ │ │ └── font.css │ └── readme.md ├── index.html ├── nginx-default.conf.template ├── package.json ├── public/ │ ├── config.js │ └── style.css ├── readme.md ├── src/ │ ├── App.vue │ ├── assets/ │ │ ├── fonts/ │ │ │ └── SarasaTermSC/ │ │ │ ├── cdn-font.css │ │ │ └── font.css │ │ └── scss/ │ │ ├── base.scss │ │ ├── sarasa-term-sc.scss │ │ └── variables.scss │ ├── components/ │ │ ├── charts/ │ │ │ ├── donut.js │ │ │ ├── donut.vue │ │ │ ├── line.js │ │ │ └── line.vue │ │ ├── dot-dot-box.vue │ │ ├── fireworks.vue │ │ ├── lantern.vue │ │ ├── popover.vue │ │ ├── server-flag.vue │ │ └── world-map/ │ │ ├── world-map-point.vue │ │ └── world-map.vue │ ├── config/ │ │ └── index.js │ ├── data/ │ │ └── code-maps.js │ ├── layout/ │ │ ├── box.vue │ │ ├── components/ │ │ │ ├── dashboard-btn.vue │ │ │ ├── footer.vue │ │ │ ├── header.vue │ │ │ ├── search-box.vue │ │ │ ├── search-list-item.vue │ │ │ ├── server-count.vue │ │ │ └── server-stat.vue │ │ └── main.vue │ ├── load.js │ ├── main.js │ ├── router/ │ │ └── index.js │ ├── store/ │ │ └── index.js │ ├── use.js │ ├── utils/ │ │ ├── custom-error.js │ │ ├── date.js │ │ ├── host.js │ │ ├── load-nezha-v0-config.js │ │ ├── load-nezha-v1-config.js │ │ ├── object-mapping.js │ │ ├── page-title.js │ │ ├── request.js │ │ ├── sleep.js │ │ ├── subscribe.js │ │ ├── transform-v1-2-v0.js │ │ ├── tsdb.js │ │ ├── uuid.js │ │ ├── validate.js │ │ ├── world-map.js │ │ └── zIndexManager.js │ ├── views/ │ │ ├── components/ │ │ │ ├── server/ │ │ │ │ ├── server-real-time.vue │ │ │ │ ├── server-status-donut.vue │ │ │ │ └── server-status-progress.vue │ │ │ ├── server-detail/ │ │ │ │ ├── server-info-box.vue │ │ │ │ ├── server-monitor.vue │ │ │ │ ├── server-name.vue │ │ │ │ └── server-status-box.vue │ │ │ └── server-list/ │ │ │ ├── card/ │ │ │ │ ├── server-list-item-bill.vue │ │ │ │ ├── server-list-item-status.vue │ │ │ │ └── server-list-item.vue │ │ │ ├── row/ │ │ │ │ ├── server-list-column.vue │ │ │ │ ├── server-list-item-bill.vue │ │ │ │ ├── server-list-item-real-time.vue │ │ │ │ ├── server-list-item-status-progress.vue │ │ │ │ ├── server-list-item-status.vue │ │ │ │ └── server-list-item.vue │ │ │ ├── server-list-warp.vue │ │ │ ├── server-option-box.vue │ │ │ ├── server-sort-box.vue │ │ │ ├── server-sort-dropdown-menu.vue │ │ │ └── server-status/ │ │ │ ├── main.vue │ │ │ ├── server-info/ │ │ │ │ ├── conns.vue │ │ │ │ ├── country.vue │ │ │ │ ├── net-speed.vue │ │ │ │ ├── status-icon.vue │ │ │ │ ├── system-os.vue │ │ │ │ └── transfer.vue │ │ │ ├── server-status.js │ │ │ └── table/ │ │ │ ├── td.vue │ │ │ └── th.vue │ │ ├── composable/ │ │ │ ├── server-bill-and-plan.js │ │ │ ├── server-info.js │ │ │ ├── server-monitor.js │ │ │ ├── server-real-time.js │ │ │ ├── server-sort.js │ │ │ └── server-status.js │ │ ├── detail.vue │ │ └── home.vue │ └── ws/ │ ├── index.js │ └── service.js └── vite.config.js
SYMBOL INDEX (77 symbols across 23 files)
FILE: src/components/charts/donut.js
function handleColor (line 18) | function handleColor(color) {
FILE: src/config/index.js
function handle$$serverStatus (line 37) | function handle$$serverStatus() {
function setColorMode (line 45) | function setColorMode() {
function replaceFavicon (line 57) | function replaceFavicon() {
function mergeNazhuaConfig (line 70) | function mergeNazhuaConfig(customConfig) {
FILE: src/load.js
function useCdnCss (line 14) | function useCdnCss(item) {
FILE: src/store/index.js
function isOnline (line 34) | function isOnline(LastActive, currentTime = Date.now()) {
function handleServerCount (line 42) | function handleServerCount(servers) {
method SET_SERVER_TIME (line 55) | SET_SERVER_TIME(state, time) {
method SET_SERVER_GROUP (line 58) | SET_SERVER_GROUP(state, serverGroup) {
method SET_SERVERS (line 61) | SET_SERVERS(state, servers) {
method UPDATE_SERVERS (line 68) | UPDATE_SERVERS(state, servers) {
method SET_PROFILE (line 90) | SET_PROFILE(state, profile) {
method SET_SETTING (line 93) | SET_SETTING(state, setting) {
method SET_SERVER_LIST_COLUMN_WIDTHS (line 96) | SET_SERVER_LIST_COLUMN_WIDTHS(state, widths) {
method initServerInfo (line 104) | async initServerInfo({ commit }, params) {
method watchWsMsg (line 159) | watchWsMsg({
method setServerListColumnWidths (line 194) | setServerListColumnWidths({
method setServerListColumnWidth (line 204) | setServerListColumnWidth({
FILE: src/utils/custom-error.js
class CustomError (line 5) | class CustomError extends Error {
method constructor (line 6) | constructor(msg, code) {
FILE: src/utils/date.js
function getNextCycleTime (line 86) | function getNextCycleTime(startDate, months, specifiedDate) {
FILE: src/utils/host.js
function getCPUInfo (line 27) | function getCPUInfo(text = '') {
function calcDecimal (line 204) | function calcDecimal(memTotal) {
function calcBinary (line 226) | function calcBinary(bytes) {
function calcTransfer (line 247) | function calcTransfer(bytes) {
function getPlatformLogoIconClassName (line 273) | function getPlatformLogoIconClassName(platform) {
function getSystemOSLabel (line 293) | function getSystemOSLabel(platform, short = false) {
FILE: src/utils/load-nezha-v0-config.js
function getNezhaConfigUrl (line 3) | function getNezhaConfigUrl() {
FILE: src/utils/object-mapping.js
class Mapping (line 4) | class Mapping {
method mapping (line 13) | static mapping(obj, key) {
method get (line 41) | static get(obj, key) {
method each (line 78) | static each(keys, data) {
FILE: src/utils/request.js
function axiosRequest (line 15) | async function axiosRequest(options) {
class NetworkRequest (line 31) | class NetworkRequest {
method constructor (line 32) | constructor() {
method push (line 63) | push(
method nextTask (line 127) | nextTask() {
method overTask (line 158) | overTask() {
FILE: src/utils/subscribe.js
class MessageSubscribe (line 5) | class MessageSubscribe {
method constructor (line 6) | constructor() {
method on (line 15) | on(key, callback) {
method once (line 31) | once(key, callback) {
method off (line 44) | off(key, callback) {
method emit (line 63) | emit(key, data) {
FILE: src/utils/transform-v1-2-v0.js
constant SERVER_FIELD_MAPS (line 11) | const SERVER_FIELD_MAPS = {
constant HOST_FIELD_MAPS (line 25) | const HOST_FIELD_MAPS = {
constant STATE_FIELD_MAPS (line 39) | const STATE_FIELD_MAPS = {
FILE: src/utils/tsdb.js
function isTsdbEnabled (line 12) | function isTsdbEnabled(store) {
function hasTsdb (line 25) | function hasTsdb(store) {
FILE: src/utils/validate.js
method isSet (line 10) | isSet(val) {
method isEmpty (line 32) | isEmpty(val, options = null) {
method isObject (line 74) | isObject(val) {
method hasOwn (line 77) | hasOwn(obj, key) {
FILE: src/utils/world-map.js
constant ALIAS_CODE (line 7) | const ALIAS_CODE = {
function findIntersectingGroups (line 37) | function findIntersectingGroups(coordinates) {
FILE: src/utils/zIndexManager.js
constant BASE_Z_INDEX (line 1) | const BASE_Z_INDEX = 1000;
FILE: src/views/components/server-list/server-status/server-status.js
constant COLUMN_MAP (line 22) | const COLUMN_MAP = Object.freeze({
constant DEFAULT_COLUMNS (line 162) | const DEFAULT_COLUMNS = 'status,name,country,system,config,duration,spee...
constant RELD_TIME_DATA (line 167) | const RELD_TIME_DATA = [
FILE: src/views/composable/server-monitor.js
function getThreshold (line 15) | function getThreshold(data) {
function hexToRgb (line 96) | function hexToRgb(hex) {
function rgbDistance (line 113) | function rgbDistance(color1, color2) {
function getColor (line 123) | function getColor(count = 0, len = 0) {
function getLineColor (line 150) | function getLineColor(name) {
FILE: src/views/composable/server-sort.js
function serverSortHandler (line 75) | function serverSortHandler(a, b, sortby, order) {
FILE: src/views/composable/server-status.js
function getColor (line 8) | function getColor(type, mode) {
FILE: src/ws/index.js
function getWsApiPath (line 10) | function getWsApiPath() {
function restart (line 52) | function restart() {
FILE: src/ws/service.js
constant WS_CONNECTION_STATUS (line 2) | const WS_CONNECTION_STATUS = {
class WSService (line 9) | class WSService {
method constructor (line 10) | constructor(options) {
method isConnected (line 61) | get isConnected() {
method active (line 65) | active() {
method send (line 100) | send(data) {
method close (line 104) | close() {
FILE: vite.config.js
method manualChunks (line 96) | manualChunks(id) {
Condensed preview — 110 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (377K chars).
[
{
"path": ".eslintignore",
"chars": 23,
"preview": "build/*.js\npublic\ndist\n"
},
{
"path": ".eslintrc.cjs",
"chars": 2578,
"preview": "module.exports = {\n root: true,\n env: {\n browser: true,\n es2021: true,\n },\n extends: [\n 'eslint:recommended"
},
{
"path": ".github/workflows/docker-build.yml",
"chars": 1992,
"preview": "name: Build and Push Docker Image\n\non:\n push:\n tags:\n - 'v*.*.*'\n workflow_dispatch:\n inputs:\n version"
},
{
"path": ".github/workflows/eslint.yml",
"chars": 527,
"preview": "name: ESLint Lint for Pull Requests\n\non:\n pull_request:\n paths:\n - '**/*.js'\n - '**/*.ts'\n - '**/*.vu"
},
{
"path": ".github/workflows/release.yml",
"chars": 5968,
"preview": "name: Build and Release\n\non:\n push:\n tags:\n - 'v*.*.*'\n workflow_dispatch:\n inputs:\n version:\n "
},
{
"path": ".gitignore",
"chars": 258,
"preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndis"
},
{
"path": "Dockerfile",
"chars": 170,
"preview": "FROM nginx:1.31-alpine-slim\n\nCOPY ./dist /home/wwwroot/html\nCOPY ./nginx-default.conf.template /etc/nginx/templates/defa"
},
{
"path": "LICENSE",
"chars": 1061,
"preview": "MIT License\n\nCopyright (c) 2024 hi2hi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof t"
},
{
"path": "doc/deploy.md",
"chars": 5972,
"preview": "# 🚀 部署指南\n\n## 部署概述\n> Nazhua主题是纯前端项目,可部署在静态服务器上\n> \n> **跨域解决注重点**:\n> - **V0版本**:需解决 `/api/v1/monitor/${id}`、`/ws` 和 `/` 的跨域"
},
{
"path": "doc/public-note.md",
"chars": 2491,
"preview": "# 📝 公开备注配置指南\n\n[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/#/?tab=publicNote)已添加公开备注编辑器,方便大家配置公开备注\n\n## 🗺️ 点"
},
{
"path": "doc/update.md",
"chars": 587,
"preview": "# 📝 更新日志\n\n> 此处仅记录功能性更新,Bug修复不在此记录\n\n## 📦 v0.6.4 更新\n- ✨ **新增**: 网络监控折线图拆分单一图表功能\n- 🌍 **新增**: 公开备注中支持自定义国家/地区旗帜 (`flag` 字段)\n"
},
{
"path": "docker-compose.yaml.template",
"chars": 478,
"preview": "services:\n nazhua:\n image: ghcr.io/hi2shark/nazhua:latest\n container_name: nazhua\n restart: unless-stopped\n "
},
{
"path": "fonts/SarasaTermSC/font.css",
"chars": 187,
"preview": "@font-face {\n font-family: \"Sarasa Term SC\";\n src: url(\"./SarasaTermSC-SemiBold.woff2\") format(\"woff2\"),\n url(\"./Sa"
},
{
"path": "fonts/readme.md",
"chars": 454,
"preview": "# Nazhua内置字体\n\n## Sarasa Term SC\n字体出处:[Sarasa-Gothic](https://github.com/be5invis/Sarasa-Gothic) \n具体引用:`Sarasa Term SC S"
},
{
"path": "index.html",
"chars": 442,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-w"
},
{
"path": "nginx-default.conf.template",
"chars": 1406,
"preview": "map $http_upgrade $connection_upgrade {\n default upgrade;\n '' close;\n}\n\nserver {\n listen 80;\n server_name $"
},
{
"path": "package.json",
"chars": 1403,
"preview": "{\n \"name\": \"nazhua\",\n \"version\": \"0.9.1\",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite bui"
},
{
"path": "public/config.js",
"chars": 2506,
"preview": "window.$$nazhuaConfig = {\n // title: '哪吒监控', // 网站标题\n // footerSlogan: '不要年付!不要年付!不要年付!<span style=\"color: #f00;\">欢迎访问"
},
{
"path": "public/style.css",
"chars": 0,
"preview": ""
},
{
"path": "readme.md",
"chars": 1992,
"preview": "# Nazhua\n\n<div>\n <img src=\"./.github/images/nazhua-main.webp\" style=\"max-height: 500px;\" alt=\"Nazhua桌面版\"/>\n <img src=\""
},
{
"path": "src/App.vue",
"chars": 2854,
"preview": "<template>\n <layout-main>\n <router-view v-slot=\"{ Component }\">\n <keep-alive>\n <component :is=\"Component"
},
{
"path": "src/assets/fonts/SarasaTermSC/cdn-font.css",
"chars": 319,
"preview": "@font-face {\n font-family: \"Sarasa Term SC\";\n src: url(\"https://cdn.jsdelivr.net/gh/hi2shark/nazhua@main/fonts/SarasaT"
},
{
"path": "src/assets/fonts/SarasaTermSC/font.css",
"chars": 187,
"preview": "@font-face {\n font-family: \"Sarasa Term SC\";\n src: url(\"./SarasaTermSC-SemiBold.woff2\") format(\"woff2\"),\n url(\"./Sa"
},
{
"path": "src/assets/scss/base.scss",
"chars": 1925,
"preview": "@use \"./variables.scss\";\n\nbody {\n line-height: 1.8;\n font-size: 14px;\n font-family: 'Microsoft YaHei', '微软雅黑', 'PingF"
},
{
"path": "src/assets/scss/sarasa-term-sc.scss",
"chars": 223,
"preview": "body.sarasa-term-sc {\n font-family:\n \"Sarasa Term SC\",\n 'Microsoft YaHei',\n '微软雅黑',\n 'PingFang SC',\n 'Ha"
},
{
"path": "src/assets/scss/variables.scss",
"chars": 2612,
"preview": "// 原生CSS变量 -- 顶级作用域\n:root {\n --layout-header-height: 60px;\n --layout-main-height: calc(100vh - var(--layout-header-hei"
},
{
"path": "src/components/charts/donut.js",
"chars": 2466,
"preview": "import { use } from 'echarts/core';\nimport { SVGRenderer } from 'echarts/renderers';\nimport {\n BarChart,\n} from 'echart"
},
{
"path": "src/components/charts/donut.vue",
"chars": 1908,
"preview": "<template>\n <div\n v-if=\"option\"\n ref=\"chartBoxRef\"\n class=\"donut-box\"\n :class=\"{\n 'donut-box--content'"
},
{
"path": "src/components/charts/line.js",
"chars": 3051,
"preview": "import { use } from 'echarts/core';\nimport { SVGRenderer } from 'echarts/renderers';\nimport { LineChart } from 'echarts/"
},
{
"path": "src/components/charts/line.vue",
"chars": 1387,
"preview": "<template>\n <div\n v-if=\"option\"\n class=\"line-box\"\n :style=\"boxStyle\"\n >\n <v-chart\n ref=\"chartRef\"\n "
},
{
"path": "src/components/dot-dot-box.vue",
"chars": 1850,
"preview": "<template>\n <div\n class=\"dot-dot-box\"\n :class=\"{\n 'dot-dot-box--hide': hideDotBG,\n }\"\n :style=\"boxStyl"
},
{
"path": "src/components/fireworks.vue",
"chars": 3096,
"preview": "<template>\n <canvas\n ref=\"canvas\"\n class=\"fireworks-canvas\"\n />\n</template>\n\n<script setup>\nimport {\n ref,\n on"
},
{
"path": "src/components/lantern.vue",
"chars": 5052,
"preview": "<template>\n <div class=\"lantern-container\">\n <div class=\"lantern-group right-group\">\n <div class=\"deng-box\">\n "
},
{
"path": "src/components/popover.vue",
"chars": 6226,
"preview": "<template>\n <div\n ref=\"triggerRef\"\n class=\"popover-trigger\"\n @mouseenter=\"handleMouseEnter\"\n @mouseleave=\"h"
},
{
"path": "src/components/server-flag.vue",
"chars": 508,
"preview": "<template>\n <span\n class=\"server-flag\"\n >\n <span\n class=\"fi\"\n :class=\"'fi-' + lastFlag\"\n />\n </spa"
},
{
"path": "src/components/world-map/world-map-point.vue",
"chars": 2624,
"preview": "<template>\n <div\n ref=\"pointRef\"\n class=\"world-map-point\"\n :class=\"'world-map-point--' + (info?.type || 'defau"
},
{
"path": "src/components/world-map/world-map.vue",
"chars": 6889,
"preview": "<template>\n <div\n class=\"world-map-group\"\n :class=\"{\n 'world-map-group--light-background': lightBackground,\n"
},
{
"path": "src/config/index.js",
"chars": 2158,
"preview": "import {\n reactive,\n} from 'vue';\nimport {\n loadProfile as loadNezhaV1Profile,\n} from '@/utils/load-nezha-v1-config';\n"
},
{
"path": "src/data/code-maps.js",
"chars": 4625,
"preview": "const codeMaps = {\n PEK: {\n x: 1025,\n y: 178,\n name: '北京',\n country: '中国',\n },\n PVG: {\n x: 1057,\n y"
},
{
"path": "src/layout/box.vue",
"chars": 102,
"preview": "<template>\n <router-view />\n</template>\n\n<script>\nexport default {\n name: 'LayoutBox',\n};\n</script>\n"
},
{
"path": "src/layout/components/dashboard-btn.vue",
"chars": 1012,
"preview": "<template>\n <div\n class=\"nezha-user-info-group\"\n >\n <a\n :href=\"dashboardUrl\"\n class=\"dashboard-url\"\n "
},
{
"path": "src/layout/components/footer.vue",
"chars": 4151,
"preview": "<template>\n <div class=\"layout-footer\">\n <div\n v-if=\"footerSlogan\"\n class=\"footer-slogan\"\n >\n <div"
},
{
"path": "src/layout/components/header.vue",
"chars": 3387,
"preview": "<template>\n <div\n class=\"layout-header\"\n :class=\"headerClass\"\n :style=\"headerStyle\"\n >\n <div class=\"layer-"
},
{
"path": "src/layout/components/search-box.vue",
"chars": 6660,
"preview": "<template>\n <transition name=\"fadeIn\">\n <div\n v-if=\"show\"\n class=\"search-box-background\"\n @click=\"clo"
},
{
"path": "src/layout/components/search-list-item.vue",
"chars": 1841,
"preview": "<template>\n <div\n class=\"search-list-item\"\n @click=\"openDetail\"\n >\n <div class=\"server-name\">\n {{ info.N"
},
{
"path": "src/layout/components/server-count.vue",
"chars": 1442,
"preview": "<template>\n <div\n v-if=\"serverCount?.total\"\n class=\"server-count-group\"\n >\n <span class=\"server-count server-"
},
{
"path": "src/layout/components/server-stat.vue",
"chars": 6518,
"preview": "<template>\n <div\n v-if=\"serverStat\"\n class=\"server-stat-group\"\n >\n <div\n v-if=\"serverStat.transfer\"\n "
},
{
"path": "src/layout/main.vue",
"chars": 2396,
"preview": "<template>\n <div\n class=\"layout-group\"\n :style=\"layoutGroupStyle\"\n >\n <div\n class=\"layout-bg\"\n :sty"
},
{
"path": "src/load.js",
"chars": 1690,
"preview": "// 是否禁用 Sarasa Term SC 字体\nif (import.meta.env.VITE_DISABLE_SARASA_TERM_SC !== '1') {\n if (import.meta.env.VITE_SARASA_T"
},
{
"path": "src/main.js",
"chars": 157,
"preview": "import { createApp } from 'vue';\nimport App from './App.vue';\nimport customUse from './use';\n\nconst app = createApp(App)"
},
{
"path": "src/router/index.js",
"chars": 1073,
"preview": "import {\n createRouter,\n createWebHistory,\n createWebHashHistory,\n} from 'vue-router';\nimport config from '@/config';"
},
{
"path": "src/store/index.js",
"chars": 5789,
"preview": "import {\n createStore,\n} from 'vuex';\nimport dayjs from 'dayjs';\nimport config from '@/config';\nimport loadNezhaV0Confi"
},
{
"path": "src/use.js",
"chars": 630,
"preview": "import './load';\nimport './assets/scss/base.scss';\nimport router from './router';\nimport store from './store';\nimport co"
},
{
"path": "src/utils/custom-error.js",
"chars": 152,
"preview": "/**\n * 自定义错误\n */\n\nclass CustomError extends Error {\n constructor(msg, code) {\n super(msg);\n this.code = code;\n }"
},
{
"path": "src/utils/date.js",
"chars": 2479,
"preview": "import dayjs from 'dayjs';\n\n/**\n * 计算时长工具\n * @param {Date|Number|String} startDate 开始时间\n * @param {Date|Number|String} e"
},
{
"path": "src/utils/host.js",
"chars": 9421,
"preview": "/**\n * 主机匹配信息工具\n */\n\n/**\n * 匹配CPU信息\n * @param {string} text CPU信息文本\n * 示例文本:\n * Intel(R) Xeon(R) Platinum 2 Virtual Co"
},
{
"path": "src/utils/load-nezha-v0-config.js",
"chars": 2440,
"preview": "import config from '@/config';\n\nfunction getNezhaConfigUrl() {\n const { nezhaPath } = config.nazhua;\n if (nezhaPath.st"
},
{
"path": "src/utils/load-nezha-v1-config.js",
"chars": 1402,
"preview": "/**\n * V1版数据加载\n */\nimport config from '@/config';\nimport request from '@/utils/request';\n\nexport const loadServerGroup ="
},
{
"path": "src/utils/object-mapping.js",
"chars": 2581,
"preview": "/**\n * 对象映射封装\n */\nclass Mapping {\n /**\n * 字符串映射对象\n *\n * @param {Record<string, any>} obj 查找的对象\n * @param {strin"
},
{
"path": "src/utils/page-title.js",
"chars": 184,
"preview": "import config from '@/config';\n\nexport default (...args) => {\n const titles = [...new Set([...args, config.nazhua.title"
},
{
"path": "src/utils/request.js",
"chars": 3449,
"preview": "import axios from 'axios';\nimport uuid from '@/utils/uuid';\n\nimport CustomError from './custom-error';\n\nconst limit = 10"
},
{
"path": "src/utils/sleep.js",
"chars": 119,
"preview": "export default (timed = 1000) => new Promise((resolve) => {\n setTimeout(() => resolve(), timed > 0 ? timed : 30);\n});\n"
},
{
"path": "src/utils/subscribe.js",
"chars": 1683,
"preview": "/**\n * 消息订阅器\n */\n\nclass MessageSubscribe {\n constructor() {\n this.subscribers = {};\n }\n\n /**\n * 订阅消息\n * @param"
},
{
"path": "src/utils/transform-v1-2-v0.js",
"chars": 3382,
"preview": "/**\n * V1版数据加载\n */\nimport store from '@/store';\nimport validate from '@/utils/validate';\nimport { Mapping } from '@/util"
},
{
"path": "src/utils/tsdb.js",
"chars": 860,
"preview": "/**\n * v1 后端 TSDB 相关判断\n * tsdb_enabled 为 true 时:用 period=1d 拉取监控数据,WS 不返回 tcp/udp,前端需隐藏连接数展示\n */\nimport config from '@/c"
},
{
"path": "src/utils/uuid.js",
"chars": 802,
"preview": "/* eslint-disable */\nexport default () => {\n if (crypto?.randomUUID) {\n return crypto.randomUUID();\n }\n // Public "
},
{
"path": "src/utils/validate.js",
"chars": 1639,
"preview": "/**\n * 校验方法\n */\n\nconst validate = {\n /**\n * 判断值是否已经设置类型数据\n * null|undefined为false\n */\n isSet(val) {\n if (\n "
},
{
"path": "src/utils/world-map.js",
"chars": 1413,
"preview": "import config from '@/config';\nimport CODE_MAPS, {\n countryCodeMapping,\n aliasMapping,\n} from '@/data/code-maps';\n\nexp"
},
{
"path": "src/utils/zIndexManager.js",
"chars": 273,
"preview": "const BASE_Z_INDEX = 1000;\nlet zIndexCounter = BASE_Z_INDEX;\n\nexport const getNextZIndex = () => {\n zIndexCounter += 1;"
},
{
"path": "src/views/components/server/server-real-time.vue",
"chars": 4993,
"preview": "<template>\n <div class=\"server-real-time-group\">\n <div\n v-for=\"item in serverRealTimeList\"\n :key=\"item.key"
},
{
"path": "src/views/components/server/server-status-donut.vue",
"chars": 2808,
"preview": "<template>\n <div\n class=\"server-status\"\n :class=\"'server-status--' + type\"\n >\n <div class=\"server-status-donu"
},
{
"path": "src/views/components/server/server-status-progress.vue",
"chars": 2940,
"preview": "<template>\n <div\n class=\"server-status-progress\"\n :class=\"'server-status--' + type\"\n >\n <div class=\"progress-"
},
{
"path": "src/views/components/server-detail/server-info-box.vue",
"chars": 20259,
"preview": "<template>\n <dot-dot-box class=\"server-info-box\">\n <div class=\"server-info-group server-info--cpu\">\n <div class"
},
{
"path": "src/views/components/server-detail/server-monitor.vue",
"chars": 21349,
"preview": "<template>\n <dot-dot-box\n v-if=\"monitorData.length\"\n class=\"server-monitor-group\"\n :class=\"{\n 'chart-type"
},
{
"path": "src/views/components/server-detail/server-name.vue",
"chars": 4916,
"preview": "<template>\n <dot-dot-box\n class=\"server-head\"\n padding=\"16px\"\n >\n <div class=\"server-flag-box\">\n <server"
},
{
"path": "src/views/components/server-detail/server-status-box.vue",
"chars": 4638,
"preview": "<template>\n <dot-dot-box\n padding=\"15px\"\n class=\"server-status-and-real-time\"\n :class=\"{\n 'status-type--p"
},
{
"path": "src/views/components/server-list/card/server-list-item-bill.vue",
"chars": 6680,
"preview": "<template>\n <div\n v-if=\"show\"\n class=\"server-list-item-bill\"\n :class=\"{\n 'dot-dot-box--hide': $config.naz"
},
{
"path": "src/views/components/server-list/card/server-list-item-status.vue",
"chars": 3170,
"preview": "<template>\n <div\n class=\"server-list-item-status\"\n :class=\"classNames\"\n >\n <component\n :is=\"componentMap"
},
{
"path": "src/views/components/server-list/card/server-list-item.vue",
"chars": 5072,
"preview": "<template>\n <dot-dot-box\n border-radius=\"var(--list-item-border-radius)\"\n :padding=\"0\"\n class=\"server-list-ite"
},
{
"path": "src/views/components/server-list/row/server-list-column.vue",
"chars": 4337,
"preview": "<template>\n <div\n class=\"list-column\"\n :class=\"`list-column--${prop}`\"\n :style=\"columnStyle\"\n >\n <div\n "
},
{
"path": "src/views/components/server-list/row/server-list-item-bill.vue",
"chars": 1794,
"preview": "<template>\n <server-list-column\n v-if=\"extraFields?.remainingTime\"\n prop=\"remaining-time\"\n label=\"剩余\"\n :val"
},
{
"path": "src/views/components/server-list/row/server-list-item-real-time.vue",
"chars": 823,
"preview": "<template>\n <server-list-column\n v-for=\"item in serverRealTimeList\"\n :key=\"item.key\"\n :prop=\"item.key\"\n :la"
},
{
"path": "src/views/components/server-list/row/server-list-item-status-progress.vue",
"chars": 2775,
"preview": "<template>\n <div\n class=\"server-list-item-status-progress\"\n :class=\"'server-status--' + type\"\n :title=\"valPerc"
},
{
"path": "src/views/components/server-list/row/server-list-item-status.vue",
"chars": 1576,
"preview": "<template>\n <div\n v-for=\"item in serverStatusList\"\n :key=\"item.type\"\n class=\"list-column-item list-column-item"
},
{
"path": "src/views/components/server-list/row/server-list-item.vue",
"chars": 3612,
"preview": "<template>\n <dot-dot-box\n border-radius=\"var(--list-item-border-radius)\"\n padding=\"var(--list-item-padding)\"\n "
},
{
"path": "src/views/components/server-list/server-list-warp.vue",
"chars": 2481,
"preview": "<template>\n <transition-group\n v-if=\"showTransition\"\n name=\"list\"\n tag=\"div\"\n class=\"server-list-container\""
},
{
"path": "src/views/components/server-list/server-option-box.vue",
"chars": 3008,
"preview": "<template>\n <div\n class=\"server-option-box\"\n :class=\"{\n 'server-option-box--light-background': lightBackgrou"
},
{
"path": "src/views/components/server-list/server-sort-box.vue",
"chars": 7192,
"preview": "<template>\n <div\n class=\"server-sort-box\"\n :class=\"{\n 'server-sort-box--light-background': lightBackground,\n"
},
{
"path": "src/views/components/server-list/server-sort-dropdown-menu.vue",
"chars": 2577,
"preview": "<template>\n <div\n v-show=\"visible\"\n ref=\"dropdownRef\"\n class=\"server-sort-select-dropdown\"\n :class=\"{\n "
},
{
"path": "src/views/components/server-list/server-status/main.vue",
"chars": 3835,
"preview": "<template>\n <dot-dot-box\n v-if=\"tableData\"\n border-radius=\"6px\"\n class=\"server-status\"\n >\n <table class=\"s"
},
{
"path": "src/views/components/server-list/server-status/server-info/conns.vue",
"chars": 1408,
"preview": "<template>\n <div\n v-if=\"!hideConns\"\n class=\"conn-group\"\n >\n <div class=\"conn--tcp\">\n {{ tcpConnCount }}\n"
},
{
"path": "src/views/components/server-list/server-status/server-info/country.vue",
"chars": 538,
"preview": "<template>\n <div class=\"country-content\">\n <server-flag :info=\"info\" />\n <span class=\"country-label\">\n {{ co"
},
{
"path": "src/views/components/server-list/server-status/server-info/net-speed.vue",
"chars": 1246,
"preview": "<template>\n <div class=\"net-speed-group\">\n <div class=\"net-speed--in\">\n {{ inSpeed }}\n </div>\n <div class"
},
{
"path": "src/views/components/server-list/server-status/server-info/status-icon.vue",
"chars": 775,
"preview": "<template>\n <div class=\"status-icon-box\">\n <div\n class=\"status-icon\"\n :class=\"{\n online: info.onlin"
},
{
"path": "src/views/components/server-list/server-status/server-info/system-os.vue",
"chars": 772,
"preview": "<template>\n <div class=\"system-os-content\">\n <span class=\"system-icon\">\n <span :class=\"platformLogoIconClassNam"
},
{
"path": "src/views/components/server-list/server-status/server-info/transfer.vue",
"chars": 1254,
"preview": "<template>\n <div class=\"transfer-group\">\n <div class=\"transfer--in\">\n {{ transferIn }}\n </div>\n <div clas"
},
{
"path": "src/views/components/server-list/server-status/server-status.js",
"chars": 9735,
"preview": "/**\n * ServerStatus风格的列表列配置\n */\nimport {\n h,\n} from 'vue';\n\n// import * as hostUtils from '@/utils/host';\nimport handle"
},
{
"path": "src/views/components/server-list/server-status/table/td.vue",
"chars": 3877,
"preview": "<template>\n <td\n class=\"server-status-td server-status-body-td\"\n :class=\"columnClass\"\n :style=\"columnStyle\"\n "
},
{
"path": "src/views/components/server-list/server-status/table/th.vue",
"chars": 1173,
"preview": "<template>\n <th\n class=\"server-status-th\"\n :class=\"columnClass\"\n :style=\"columnStyle\"\n >\n {{ column.label "
},
{
"path": "src/views/composable/server-bill-and-plan.js",
"chars": 4563,
"preview": "import {\n computed,\n} from 'vue';\nimport dayjs from 'dayjs';\nimport config from '@/config';\nimport validate from '@/uti"
},
{
"path": "src/views/composable/server-info.js",
"chars": 1082,
"preview": "import {\n computed,\n} from 'vue';\nimport * as hostUtils from '@/utils/host';\n\nexport default (params) => {\n const {\n "
},
{
"path": "src/views/composable/server-monitor.js",
"chars": 3796,
"preview": "import uniqolor from 'uniqolor';\n\n/**\n * 计算数据的统计信息,使用截尾中位数作为基准值\n * 根据平均延迟的不同范围,使用不同的容差百分比进行削峰\n *\n * @param {number[]} da"
},
{
"path": "src/views/composable/server-real-time.js",
"chars": 13712,
"preview": "import {\n computed,\n} from 'vue';\nimport dayjs from 'dayjs';\nimport validate from '@/utils/validate';\nimport * as dateU"
},
{
"path": "src/views/composable/server-sort.js",
"chars": 2508,
"preview": "/**\n * 服务器排序选项\n */\nexport const serverSortOptions = () => [{\n label: '排序值',\n value: 'DisplayIndex',\n}, {\n label: '主机名"
},
{
"path": "src/views/composable/server-status.js",
"chars": 6997,
"preview": "import {\n computed,\n} from 'vue';\nimport config from '@/config';\nimport validate from '@/utils/validate';\nimport * as h"
},
{
"path": "src/views/detail.vue",
"chars": 4063,
"preview": "<template>\n <div\n v-if=\"info\"\n class=\"detail-container\"\n :class=\"{\n 'server--offline': info?.online !== 1"
},
{
"path": "src/views/home.vue",
"chars": 12006,
"preview": "<template>\n <div\n class=\"index-container\"\n :class=\"indexContainerClass\"\n >\n <div class=\"scroll-container\">\n "
},
{
"path": "src/ws/index.js",
"chars": 1756,
"preview": "import config from '@/config';\nimport MessageSubscribe from '@/utils/subscribe';\nimport v1TransformV0 from '@/utils/tran"
},
{
"path": "src/ws/service.js",
"chars": 2749,
"preview": "// WebSocket 连接状态常量\nexport const WS_CONNECTION_STATUS = {\n DISCONNECTED: 0, // 未连接\n CONNECTING: 1, // 连接中\n CONNECTED:"
},
{
"path": "vite.config.js",
"chars": 2653,
"preview": "import path from 'path';\nimport dotenv from 'dotenv';\nimport { defineConfig } from 'vite';\nimport vue from '@vitejs/plug"
}
]
About this extraction
This page contains the full source code of the hi2shark/nazhua GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 110 files (329.2 KB), approximately 100.3k tokens, and a symbol index with 77 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.