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: '不要年付!不要年付!不要年付!欢迎访问Nazhua探针', // 底部标语,支持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使用方法](../.github/images/remixicon-select.jpg) > 当前支持版本: 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 ================================================ Nazhua
================================================ 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: '不要年付!不要年付!不要年付!欢迎访问Nazhua探针', // 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
Nazhua桌面版 Nazhua移动版 Nazhua详情页
## 📢 使用须知 **使用前,请务必阅读本文档,对您的部署会有很大帮助** - 基于哪吒监控(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) ## 🤝 赞助商
VMISS
VMISS
## 💻 开发者指南 ### 环境配置 在`.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 ================================================ ================================================ 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 ================================================ ================================================ 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 = `

${time}

`; if (params.length < 10) { params.forEach((i) => { res += i.value[1] ? `${i.marker} ${i.seriesName}: ${i.value[1]}ms
` : ''; }); } else { res += ''; let trEnd = false; params.forEach((i, index) => { if (index % 2 === 0) { res += ''; } res += i.value[1] ? `` : ''; if (index % 2 === 1) { res += ''; trEnd = true; } }); if (!trEnd) { res += ''; } res += '
${i.marker} ${i.seriesName}: ${i.value[1]}ms
'; } 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 ================================================ ================================================ FILE: src/components/dot-dot-box.vue ================================================ ================================================ FILE: src/components/fireworks.vue ================================================ ================================================ FILE: src/components/lantern.vue ================================================ ================================================ FILE: src/components/popover.vue ================================================ ================================================ FILE: src/components/server-flag.vue ================================================ ================================================ FILE: src/components/world-map/world-map-point.vue ================================================ ================================================ FILE: src/components/world-map/world-map.vue ================================================ ================================================ 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 ================================================ ================================================ FILE: src/layout/components/dashboard-btn.vue ================================================ ================================================ FILE: src/layout/components/footer.vue ================================================ ================================================ FILE: src/layout/components/header.vue ================================================ ================================================ FILE: src/layout/components/search-box.vue ================================================ ================================================ FILE: src/layout/components/search-list-item.vue ================================================ ================================================ FILE: src/layout/components/server-count.vue ================================================ ================================================ FILE: src/layout/components/server-stat.vue ================================================ ================================================ FILE: src/layout/main.vue ================================================ ================================================ 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} 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 | 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} data 数据对象 * * @return {Record} */ 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 ================================================ ================================================ FILE: src/views/components/server/server-status-donut.vue ================================================ ================================================ FILE: src/views/components/server/server-status-progress.vue ================================================ ================================================ FILE: src/views/components/server-detail/server-info-box.vue ================================================ ================================================ FILE: src/views/components/server-detail/server-monitor.vue ================================================ ================================================ FILE: src/views/components/server-detail/server-name.vue ================================================ ================================================ FILE: src/views/components/server-detail/server-status-box.vue ================================================ ================================================ FILE: src/views/components/server-list/card/server-list-item-bill.vue ================================================ ================================================ FILE: src/views/components/server-list/card/server-list-item-status.vue ================================================ ================================================ FILE: src/views/components/server-list/card/server-list-item.vue ================================================ ================================================ FILE: src/views/components/server-list/row/server-list-column.vue ================================================ ================================================ FILE: src/views/components/server-list/row/server-list-item-bill.vue ================================================ ================================================ FILE: src/views/components/server-list/row/server-list-item-real-time.vue ================================================ ================================================ FILE: src/views/components/server-list/row/server-list-item-status-progress.vue ================================================ ================================================ FILE: src/views/components/server-list/row/server-list-item-status.vue ================================================ ================================================ FILE: src/views/components/server-list/row/server-list-item.vue ================================================ ================================================ FILE: src/views/components/server-list/server-list-warp.vue ================================================ ================================================ FILE: src/views/components/server-list/server-option-box.vue ================================================ ================================================ FILE: src/views/components/server-list/server-sort-box.vue ================================================ ================================================ FILE: src/views/components/server-list/server-sort-dropdown-menu.vue ================================================ ================================================ FILE: src/views/components/server-list/server-status/main.vue ================================================ ================================================ FILE: src/views/components/server-list/server-status/server-info/conns.vue ================================================ ================================================ FILE: src/views/components/server-list/server-status/server-info/country.vue ================================================ ================================================ FILE: src/views/components/server-list/server-status/server-info/net-speed.vue ================================================ ================================================ FILE: src/views/components/server-list/server-status/server-info/status-icon.vue ================================================ ================================================ FILE: src/views/components/server-list/server-status/server-info/system-os.vue ================================================ ================================================ FILE: src/views/components/server-list/server-status/server-info/transfer.vue ================================================ ================================================ FILE: src/views/components/server-list/server-status/server-status.js ================================================ /** * ServerStatus风格的列表列配置 */ import { h, } from 'vue'; // import * as hostUtils from '@/utils/host'; import handleServerStatus from '@/views/composable/server-status'; import handleServerInfo from '@/views/composable/server-info'; import handleServerRealTime from '@/views/composable/server-real-time'; import handleServerBillAndPlan from '@/views/composable/server-bill-and-plan'; import ServerStatusProgress from '@/views/components/server/server-status-progress.vue'; import StatusIcon from '@/views/components/server-list/server-status/server-info/status-icon.vue'; import SystemOS from '@/views/components/server-list/server-status/server-info/system-os.vue'; import Country from '@/views/components/server-list/server-status/server-info/country.vue'; import NetSpeed from '@/views/components/server-list/server-status/server-info/net-speed.vue'; import Transfer from '@/views/components/server-list/server-status/server-info/transfer.vue'; import Conns from '@/views/components/server-list/server-status/server-info/conns.vue'; const COLUMN_MAP = Object.freeze({ status: { label: '状态', width: 40, }, name: { label: '名称', minWidth: 100, align: 'left', }, config: { label: '规格', width: 80, align: 'left', }, system: { label: '系统', width: 90, align: 'left', }, country: { label: '地区', width: 60, align: 'left', }, duration: { label: '在线', width: 60, align: 'left', }, load: { label: '负载', width: 45, align: 'center', }, speeds: { label: '网速', width: 122, align: 'center', }, inSpeed: { label: '入网', width: 60, align: 'left', }, outSpeed: { label: '出网', width: 60, align: 'left', }, transfer: { label: '流量', width: 122, align: 'center', }, inTransfer: { label: '入网流量', width: 60, align: 'left', }, outTransfer: { label: '出网流量', width: 60, align: 'left', }, conns: { label: '连接', width: 72, align: 'center', }, tcp: { label: 'TCP', width: 40, align: 'left', }, udp: { label: 'UDP', width: 40, align: 'left', }, cpu: { label: 'CPU', width: 80, align: 'center', }, cpuText: { valProp: 'cpu', label: 'CPU', width: 40, align: 'center', }, mem: { label: '内存', width: 80, align: 'center', }, memText: { valProp: 'mem', label: '内存', width: 40, align: 'center', }, swap: { label: '交换', width: 80, align: 'center', }, swapText: { valProp: 'swap', label: '交换', width: 40, align: 'center', }, disk: { label: '硬盘', width: 80, align: 'center', }, diskText: { valProp: 'disk', label: '硬盘', width: 40, align: 'center', }, billing: { label: '价格', width: 100, align: 'right', }, remainingTime: { label: '剩余', width: 70, align: 'right', }, }); /** * 默认列配置 */ // eslint-disable-next-line max-len, vue/max-len const DEFAULT_COLUMNS = 'status,name,country,system,config,duration,speeds,transfer,load,cpu,mem,disk,billing,remainingTime'; /** * 需要实时更新的数据 */ const RELD_TIME_DATA = [ 'speeds', 'inSpeed', 'outSpeed', 'transfer', 'inTransfer', 'outTransfer', 'conns', 'tcp', 'udp', 'duration', 'load', ]; /** * 获取列配置 * @param {string} columnsTpls 列配置模板 * @returns {Object} 列配置 * @property {Array} columns 列配置 */ export const getColumnPropsConfig = (tpls = DEFAULT_COLUMNS) => { const tplList = tpls.split(','); const columnList = []; tplList.forEach((tpl) => { if (COLUMN_MAP[tpl]) { columnList.push({ prop: tpl, ...COLUMN_MAP[tpl], }); } }); return columnList; }; /** * 将服务器数据转换为表格数据 * @param {Object} server 服务器数据 * @returns {Object} 表格数据 */ export const handleServerItemData = (params) => { const { column, server, realTimeData, progressData, billAndPlan, } = params || {}; switch (column.prop) { case 'status': return { type: 'component', component: h(StatusIcon, { info: server }), originalData: params, }; case 'name': return { type: 'text', value: server.Name, originalData: params, }; case 'config': { const { cpuAndMemAndDisk } = handleServerInfo({ props: { info: server, }, originalData: params, }); return { type: 'text', value: cpuAndMemAndDisk, originalData: params, }; } case 'system': return { type: 'component', component: h(SystemOS, { info: server }), originalData: params, }; case 'country': return { type: 'component', component: h(Country, { info: server }), originalData: params, }; case 'speeds': return { type: 'component', component: h(NetSpeed, { realTimeData }), originalData: params, }; case 'transfer': return { type: 'component', component: h(Transfer, { realTimeData }), originalData: params, }; case 'conns': return { type: 'component', component: h(Conns, { realTimeData }), originalData: params, }; case 'cpu': case 'mem': case 'disk': case 'swap': { const progressItem = progressData[column.prop]; return { type: 'component', component: h(ServerStatusProgress, { type: column.prop, used: progressItem?.used || 0, colors: progressItem?.colors || {}, valText: progressItem?.valPercent || '', }), originalData: params, }; } case 'cpuText': case 'memText': case 'diskText': case 'swapText': { const progressItem = progressData[column.valProp]; return { prop: column.prop, type: 'text', value: parseFloat(progressItem?.used || 0).toFixed(1), unit: '%', text: progressItem?.valPercent || '', originalData: params, }; } case 'billing': { const item = billAndPlan?.value?.billing; const texts = []; if (item?.value) { texts.push(item.value || '-'); } if (item?.cycleLabel) { texts.push(item.cycleLabel); } return { prop: column.prop, type: 'text', text: texts.length ? texts.join('/') : '-', originalData: params, }; } case 'remainingTime': { const item = billAndPlan?.value?.remainingTime; return { prop: column.prop, type: 'text', text: item?.value || '-', originalData: params, }; } default: { if (RELD_TIME_DATA.includes(column.prop) && realTimeData[column.prop]) { const item = realTimeData[column.prop]; return { prop: column.prop, type: 'text', text: item?.text, value: item?.value, unit: item?.unit, originalData: params, }; } return { prop: column.prop, type: 'text', value: '-', originalData: params, }; } } }; /** * 将服务器数据转换为表格数据 * @param {Object} server 服务器数据 * @param {Array} columns 列配置 * @returns {Array} 表格数据 */ export const handleServerListColumn = (serverList, columnTpls = DEFAULT_COLUMNS) => { const columnProps = getColumnPropsConfig(columnTpls); const tpls = columnProps.map((column) => column.valProp || column.prop).join(','); const hasBilling = columnTpls.includes('billing'); const hasRemainingTime = columnTpls.includes('remainingTime'); let showBilling = false; let showRemainingTime = false; const list = serverList.map((server) => { // 负载\网速\流量\在线等 const realTimeResult = handleServerRealTime({ props: { info: server, }, serverRealTimeListTpls: tpls, }); const realTimeData = {}; realTimeResult?.serverRealTimeList?.value?.forEach?.((item) => { if (item.show) { const text = [item.value]; if (item.unit) { text.push(item.unit); } realTimeData[item.key] = { value: item.value, unit: item.unit, text: text.join(''), item, }; } else { realTimeData[item.key] = { text: '-', item, }; } }); // CPU\内存\硬盘\交换 进度条 const { serverStatusList, } = handleServerStatus({ props: { info: server, }, statusListTpl: tpls, statusListItemContent: false, }); const progressData = {}; serverStatusList.value?.forEach?.((item) => { progressData[item.type] = item; }); let billAndPlan = null; if (hasBilling || hasRemainingTime) { const result = handleServerBillAndPlan({ props: { info: server, }, }); billAndPlan = result.billAndPlan; if (billAndPlan?.value?.billing) { showBilling = true; } if (billAndPlan?.value?.remainingTime) { showRemainingTime = true; } } const columnData = []; columnProps.forEach((columnItem) => { columnData.push({ ...columnItem, data: handleServerItemData({ column: columnItem, server, realTimeData, progressData, billAndPlan, }), }); }); return { info: server, columnData, computedData: { realTimeData, progressData, billAndPlan, }, }; }); return { list, columnProps, showBilling, showRemainingTime, }; }; ================================================ FILE: src/views/components/server-list/server-status/table/td.vue ================================================ ================================================ FILE: src/views/components/server-list/server-status/table/th.vue ================================================ ================================================ FILE: src/views/composable/server-bill-and-plan.js ================================================ import { computed, } from 'vue'; import dayjs from 'dayjs'; import config from '@/config'; import validate from '@/utils/validate'; import * as dateUtils from '@/utils/date'; export default (params) => { const { props, } = params || {}; /** * 账单和计划 */ const billAndPlan = computed(() => { const obj = { billing: null, remainingTime: null, bandwidth: null, traffic: null, }; if (props?.info?.PublicNote) { const { billingDataMod, planDataMod, } = props.info.PublicNote; // 默认1个月 let months = 1; // 套餐资费 let cycleLabel; if (validate.isSet(billingDataMod?.cycle)) { switch (billingDataMod.cycle.toLowerCase()) { case '月': case 'm': case 'mo': case 'month': case 'monthly': cycleLabel = '月'; months = 1; break; case '年': case 'y': case 'yr': case 'year': case 'annual': cycleLabel = '年'; months = 12; break; case '季': case 'quarterly': cycleLabel = '季'; months = 3; break; case '半': case '半年': case 'h': case 'half': case 'semi-annually': cycleLabel = '半年'; months = 6; break; default: cycleLabel = billingDataMod.cycle; break; } } if (validate.isSet(billingDataMod?.amount)) { let isFree = false; let amountValue = billingDataMod.amount; let label; if (billingDataMod.amount.toString() === '-1') { amountValue = '按量'; label = cycleLabel ? `每${cycleLabel}` : ''; } else if (billingDataMod.amount.toString() === '0') { amountValue = config.nazhua.freeAmount || '免费'; isFree = true; } else { label = cycleLabel ? `${cycleLabel}付` : ''; } obj.billing = { label, value: amountValue, cycleLabel, months, isFree, }; } // 剩余时间 if (validate.isSet(billingDataMod?.endDate)) { const { endDate, autoRenewal, } = billingDataMod; const nowTime = new Date().getTime(); const endTime = dayjs(endDate).valueOf(); if (endDate.indexOf('0000-00-00') === 0) { obj.remainingTime = { label: '剩余', value: config.nazhua.infinityCycle || '长期有效', type: 'infinity', }; } else if (autoRenewal === '1') { // 自动续费时间计算,cycleType 为 1 时为月,为 12 时为年 // 判断endDate是否超过当前时间,超过则显示剩余时间 if (endTime > nowTime) { const diff = dayjs(endTime).diff(dayjs(), 'day') + 1; obj.remainingTime = { label: '剩余', value: `${diff}天`, value2: diff, type: 'autoRenewal-endTime', }; } else { // endDate如果早于当前时间,按照cycleType计算出超过当前时间的结束时间 const nextTime = dateUtils.getNextCycleTime(endTime, months, nowTime); const diff = dayjs(nextTime).diff(dayjs(), 'day') + 1; obj.remainingTime = { label: '剩余', value: `${diff}天`, value2: diff, type: 'autoRenewal-nextTime', }; } } else if (endTime > nowTime) { const diff = dayjs(endTime).diff(dayjs(), 'day') + 1; obj.remainingTime = { label: '剩余', value: `${diff}天`, value2: diff, type: 'endTime', }; } else { obj.remainingTime = { label: '剩余', value: '已过期', type: 'expired', }; } } // 带宽、流量 if (planDataMod) { if (planDataMod.bandwidth) { obj.bandwidth = { label: '带宽', value: planDataMod.bandwidth, }; } if (planDataMod.trafficVol) { let trafficTypeLabel = '双向'; if (planDataMod.trafficType === '1') { trafficTypeLabel = '单向出'; } else if (planDataMod.trafficType === '3') { trafficTypeLabel = '单向取最大'; } obj.traffic = { label: `${trafficTypeLabel}流量`, value: planDataMod.trafficVol, }; } } } return obj; }); return { billAndPlan, }; }; ================================================ FILE: src/views/composable/server-info.js ================================================ import { computed, } from 'vue'; import * as hostUtils from '@/utils/host'; export default (params) => { const { props, } = params || {}; const cpuAndMemAndDisk = computed(() => { let cpuInfo; let memInfo; let distInfo; if (props.info?.Host?.CPU?.[0]) { cpuInfo = hostUtils.getCPUInfo(props.info.Host.CPU[0]); } if (props.info?.Host?.MemTotal) { memInfo = hostUtils.calcBinary(props.info.Host.MemTotal); } if (props.info?.Host?.DiskTotal) { distInfo = hostUtils.calcBinary(props.info.Host.DiskTotal); } const text = []; if (cpuInfo) { text.push(`${cpuInfo.cores}C`); } if (memInfo) { if (memInfo.m > 900) { text.push(`${Math.round(memInfo.g)}G`); } else { text.push(`${(memInfo.g).toFixed(1) * 1}G`); } } if (distInfo) { if (distInfo.g > 900) { text.push(`${Math.round(distInfo.t)}T`); } else { text.push(`${Math.ceil(distInfo.g)}G`); } } return text.join(''); }); return { cpuAndMemAndDisk, }; }; ================================================ FILE: src/views/composable/server-monitor.js ================================================ import uniqolor from 'uniqolor'; /** * 计算数据的统计信息,使用截尾中位数作为基准值 * 根据平均延迟的不同范围,使用不同的容差百分比进行削峰 * * @param {number[]} data - 要计算的数据数组 * @returns {{median: number, tolerancePercent: number, min: number, max: number}} * 返回包含统计信息的对象 * @property {number} median - 截尾中位数(去掉极端值后的中位数) * @property {number} tolerancePercent - 根据中位数计算的容差百分比 * @property {number} min - 最小值 * @property {number} max - 最大值 */ export function getThreshold(data) { // 过滤掉null和0的数据,只对有效延迟值计算统计量 const filteredData = data.filter((value) => value !== 0 && value !== null); if (filteredData.length === 0) { return { median: 0, tolerancePercent: 0.2, min: 0, max: 0, }; } // 排序数据 const sortedData = [...filteredData].sort((a, b) => Math.ceil(a) - Math.ceil(b)); const len = sortedData.length; // 计算需要裁剪的数量(10%) const trimCount = Math.floor(len * 0.1); // 用于计算中位数的数据:如果10%的数量>=1,则去掉最大和最小的10% let dataForMedian; if (trimCount >= 1) { // 截尾:去掉最小的10%和最大的10% dataForMedian = sortedData.slice(trimCount, len - trimCount); } else { // 数据量太少,不裁剪 dataForMedian = sortedData; } // 计算截尾中位数 const medianLen = dataForMedian.length; const median = medianLen % 2 === 0 ? (dataForMedian[medianLen / 2 - 1] + dataForMedian[medianLen / 2]) / 2 : dataForMedian[Math.floor(medianLen / 2)]; // 根据中位数确定容差百分比,延迟越小容差越大 let tolerancePercent; if (median <= 10) { tolerancePercent = 0.50; // 50% } else if (median <= 30) { tolerancePercent = 0.35; // 35% } else if (median <= 50) { tolerancePercent = 0.25; // 25% } else if (median <= 100) { tolerancePercent = 0.20; // 20% } else { tolerancePercent = 0.15; // 15% } const min = sortedData[0]; const max = sortedData[len - 1]; // console.log(min, max, median, sortedData); return { median, tolerancePercent, min, max, }; } /** * - 处理相对固定折线的颜色 */ const lineColorMap = {}; const lineColors = []; const defaultColors = [ '#5470C6', '#91CC75', '#FAC858', '#EE6666', '#73C0DE', '#3BA272', '#FC8452', '#9A60B4', '#EA7CCC', '#C23531', '#2F4554', '#61A0A8', '#D48265', '#91C7AE', '#749F83', '#CA8622', '#BDA29A', '#6E7074', '#546570', '#C4CCD3', ]; /** * 将十六进制颜色转换为 RGB 数组 * @param {string} hex - 十六进制颜色字符串 * @returns {number[]} 返回包含 RGB 数组的对象 */ function hexToRgb(hex) { // 去掉可能的前缀 "#" hex = hex.replace(/^#/, ''); // 将字符串拆分为 r, g, b 三个部分 const bigint = parseInt(hex, 16); const r = Math.floor(bigint / (256 * 256)) % 256; const g = Math.floor(bigint / 256) % 256; const b = bigint % 256; return [r, g, b]; } /** * 计算两个 RGB 颜色之间的距离 * @param {number[]} color1 - 第一个颜色的 RGB 数组 * @param {number[]} color2 - 第二个颜色的 RGB 数组 * @returns {number} 返回两个颜色之间的距离 */ function rgbDistance(color1, color2) { const [r1, g1, b1] = color1; const [r2, g2, b2] = color2; return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2); } /** * 获取一个随机颜色 * @returns {string} 返回一个随机颜色的字符串 */ function getColor(count = 0, len = 0) { // 如果尝试次数超过 3 次,返回固定颜色组里面的颜色 if (count > 3) { return defaultColors[len % defaultColors.length]; } const { color } = uniqolor.random({ saturation: [75, 90], lightness: [65, 70], differencePoint: 100, }); if (lineColors.includes(color)) { return getColor(count + 1, len); } if (lineColors.some((i) => rgbDistance( hexToRgb(i), hexToRgb(color), ) < 50)) { return getColor(count + 1, len); } return color; } /** * 获取线的颜色 * @param {string} name - 线的名称 * @returns {string} 返回线的颜色 */ export function getLineColor(name) { // 如果已经有了对应的颜色,直接返回 if (lineColorMap[name]) { return lineColorMap[name]; } const color = getColor(0, lineColors.length); lineColorMap[name] = color; lineColors.push(color); return color; } ================================================ FILE: src/views/composable/server-real-time.js ================================================ import { computed, } from 'vue'; import dayjs from 'dayjs'; import validate from '@/utils/validate'; import * as dateUtils from '@/utils/date'; import * as hostUtils from '@/utils/host'; export default (params) => { const { props, currentTime, serverRealTimeListTpls = 'duration,transfer,inSpeed,outSpeed', } = params || {}; if (!props?.info) { return {}; } /** * 计算在线时长 */ const duration = computed(() => { if (props.info?.Host?.BootTime) { const lastActive = dayjs(props.info.LastActive)?.valueOf?.(); const data = dateUtils.duration2(props.info.Host.BootTime * 1000, lastActive || currentTime.value); if (data.days > 0) { return { value: data.days, unit: data.$unit.day, }; } if (data.hours > 0) { return { value: data.hours, unit: data.$unit.hour, }; } if (data.minutes > 0) { return { value: data.minutes, unit: data.$unit.minute, }; } return { value: data.seconds, unit: data.$unit.second, }; } return null; }); /** * 计算流量 */ const transfer = computed(() => { const stats = { in: null, out: null, total: null, }; let total = 0; if (props.info?.State?.NetInTransfer) { total += props.info.State.NetInTransfer; stats.in = hostUtils.calcBinary(props.info.State.NetInTransfer); } if (props.info?.State?.NetOutTransfer) { total += props.info.State.NetOutTransfer; stats.out = hostUtils.calcBinary(props.info.State.NetOutTransfer); } stats.total = hostUtils.calcBinary(total); const result = { value: 0, unit: '', statType: '', statTypeLabel: '', stats, }; let ruleStat; ruleStat = total; result.statType = 'Total'; result.statTypeLabel = '双向'; if (props.info?.PublicNote && validate.isSet(props.info.PublicNote?.planDataMod?.trafficType)) { const { trafficType = 2, } = props.info.PublicNote.planDataMod; switch (+trafficType) { case 1: ruleStat = props.info.State.NetOutTransfer; result.statType = 'Out'; result.statTypeLabel = '单向出'; break; case 3: if (props.info?.State?.NetOutTransfer >= props.info?.State?.NetInTransfer) { ruleStat = props.info.State.NetOutTransfer; result.statType = 'MaxOut'; result.statTypeLabel = '最大出'; } else if (props.info?.State?.NetOutTransfer < props.info?.State?.NetInTransfer) { ruleStat = props.info.State.NetInTransfer; result.statType = 'MaxIn'; result.statTypeLabel = '最大入'; } break; default: } } const ruleStats = hostUtils.calcBinary(ruleStat); if (ruleStats.t > 1) { result.value = (ruleStats.t).toFixed(2) * 1; result.unit = 'T'; } else if (ruleStats.g > 1) { result.value = (ruleStats.g).toFixed(2) * 1; result.unit = 'G'; } else if (ruleStats.m > 1) { result.value = (ruleStats.m).toFixed(1) * 1; result.unit = 'M'; } else { result.value = (ruleStats.k).toFixed(1) * 1; result.unit = 'K'; } return result; }); const inTransfer = computed(() => { const inStats = hostUtils.calcBinary(props.info?.State?.NetInTransfer || 0); const result = { value: 0, unit: '', }; if (inStats.p > 1) { result.value = (inStats.p).toFixed(1) * 1; result.unit = 'P'; } else if (inStats.t > 1) { result.value = (inStats.t).toFixed(1) * 1; result.unit = 'T'; } else if (inStats.g > 1) { result.value = (inStats.g).toFixed(1) * 1; result.unit = 'G'; } else if (inStats.m > 1) { result.value = (inStats.m).toFixed(1) * 1; result.unit = 'M'; } else { result.value = (inStats.k).toFixed(1) * 1; result.unit = 'K'; } return result; }); const outTransfer = computed(() => { const outStats = hostUtils.calcBinary(props.info?.State?.NetOutTransfer || 0); const result = { value: 0, unit: '', }; if (outStats.p > 1) { result.value = (outStats.p).toFixed(1) * 1; result.unit = 'P'; } else if (outStats.t > 1) { result.value = (outStats.t).toFixed(1) * 1; result.unit = 'T'; } else if (outStats.g > 1) { result.value = (outStats.g).toFixed(1) * 1; result.unit = 'G'; } else if (outStats.m > 1) { result.value = (outStats.m).toFixed(1) * 1; result.unit = 'M'; } else { result.value = (outStats.k).toFixed(1) * 1; result.unit = 'K'; } return result; }); /** * 计算入向网速 */ const netInSpeed = computed(() => { const inSpeed = hostUtils.calcBinary(props.info?.State?.NetInSpeed || 0); const result = { value: 0, unit: '', }; if (inSpeed.g > 1) { result.value = (inSpeed.g).toFixed(1) * 1; result.unit = 'G'; } else if (inSpeed.m > 1) { result.value = (inSpeed.m).toFixed(1) * 1; result.unit = 'M'; } else { result.value = (inSpeed.k).toFixed(1) * 1; result.unit = 'K'; } return result; }); /** * 计算出向网速 */ const netOutSpeed = computed(() => { const outSpeed = hostUtils.calcBinary(props.info?.State?.NetOutSpeed || 0); const result = { value: 0, unit: '', }; if (outSpeed.g > 1) { result.value = (outSpeed.g).toFixed(1) * 1; result.unit = 'G'; } else if (outSpeed.m > 1) { result.value = (outSpeed.m).toFixed(1) * 1; result.unit = 'M'; } else { result.value = (outSpeed.k).toFixed(1) * 1; result.unit = 'K'; } return result; }); const serverRealTimeList = computed(() => serverRealTimeListTpls.split(',').map((key) => { switch (key) { case 'duration': return { key, label: '在线', value: duration.value?.value, unit: duration.value?.unit, show: validate.isSet(duration.value?.value), }; case 'transfer': return { key, label: `${transfer.value.statTypeLabel}流量`, value: transfer.value?.value, unit: transfer.value?.unit, show: validate.isSet(transfer.value?.value), data: { in: { value: inTransfer.value?.value, unit: inTransfer.value?.unit, show: validate.isSet(inTransfer.value?.value), }, out: { value: outTransfer.value?.value, unit: outTransfer.value?.unit, show: validate.isSet(outTransfer.value?.value), }, }, }; case 'inTransfer': return { key, label: '入网流量', value: inTransfer.value?.value, unit: inTransfer.value?.unit, show: validate.isSet(inTransfer.value?.value), }; case 'outTransfer': return { key, label: '出网流量', value: outTransfer.value?.value, unit: outTransfer.value?.unit, show: validate.isSet(outTransfer.value?.value), }; case 'inSpeed': return { key, label: '入网', value: netInSpeed.value?.value, unit: netInSpeed.value?.unit, show: validate.isSet(netInSpeed.value?.value), }; case 'outSpeed': return { key, label: '出网', value: netOutSpeed.value?.value, unit: netOutSpeed.value?.unit, show: validate.isSet(netOutSpeed.value?.value), }; case 'speeds': return { key, label: '网速', value: [ `${netInSpeed.value?.value}${netInSpeed.value?.unit}`, `${netOutSpeed.value?.value}${netOutSpeed.value?.unit}`, ].join('|'), show: validate.isSet(netInSpeed.value?.value) && validate.isSet(netOutSpeed.value?.value), data: { in: { value: netInSpeed.value?.value, unit: netInSpeed.value?.unit, show: validate.isSet(netInSpeed.value?.value), }, out: { value: netOutSpeed.value?.value, unit: netOutSpeed.value?.unit, show: validate.isSet(netOutSpeed.value?.value), }, }, }; case 'load': return { key, label: '负载', value: (props.info.State?.Load1 || 0).toFixed(2), show: validate.isSet(props.info.State?.Load1), }; case 'loads': { const loads = []; loads.push((props.info.State?.Load1 || 0).toFixed(2)); loads.push((props.info.State?.Load5 || 0).toFixed(2)); loads.push((props.info.State?.Load15 || 0).toFixed(2)); return { key, label: '负载', value: loads.join(','), show: loads.some((load) => validate.isSet(load)), data: { load1: { value: (props.info.State?.Load1 || 0).toFixed(2), show: validate.isSet(props.info.State?.Load1), }, load5: { value: (props.info.State?.Load5 || 0).toFixed(2), show: validate.isSet(props.info.State?.Load5), }, load15: { value: (props.info.State?.Load15 || 0).toFixed(2), show: validate.isSet(props.info.State?.Load15), }, }, }; } case 'conns': return { key, label: '连接', value: `${props.info.State?.TcpConnCount || 0}|${props.info.State?.UdpConnCount || 0}`, show: validate.isSet(props.info.State?.TcpConnCount) || validate.isSet(props.info.State?.UdpConnCount), data: { tcp: { value: props.info.State?.TcpConnCount || 0, show: validate.isSet(props.info.State?.TcpConnCount), }, udp: { value: props.info.State?.UdpConnCount || 0, show: validate.isSet(props.info.State?.UdpConnCount), }, }, }; case 'tcp': return { key, label: 'TCP', value: props.info.State?.TcpConnCount || 0, show: validate.isSet(props.info.State?.TcpConnCount), }; case 'udp': return { key, label: 'UDP', value: props.info.State?.UdpConnCount || 0, show: validate.isSet(props.info.State?.UdpConnCount), }; // 入网和出网 case 'I-A-O': return { key, label: '网速', values: [ { key: 'in', label: '入网', value: netInSpeed.value?.value, unit: netInSpeed.value?.unit, show: validate.isSet(netInSpeed.value?.value), }, { key: 'out', label: '出网', value: netOutSpeed.value?.value, unit: netOutSpeed.value?.unit, show: validate.isSet(netOutSpeed.value?.value), }, ], show: validate.isSet(netInSpeed.value?.value) && validate.isSet(netOutSpeed.value?.value), }; // 负载和进程 case 'L-A-P': return { key, label: '负载', values: [ { key: 'load', label: '负载', value: (props.info.State?.Load1 || 0).toFixed(2), show: validate.isSet(props.info.State?.Load1), }, { key: 'process', label: '进程', value: props.info.State?.ProcessCount || 0, show: validate.isSet(props.info.State?.ProcessCount), }, ], show: validate.isSet(props.info.State?.Load1) || validate.isSet(props.info.State?.ProcessCount), }; // 连接 TCP和UDP case 'T-A-U': return { key, label: '连接', values: [ { key: 'tcp', label: 'TCP', value: (props.info.State?.TcpConnCount || 0).toString().padEnd(3, ' '), show: validate.isSet(props.info.State?.TcpConnCount), }, { key: 'udp', label: 'UDP', value: (props.info.State?.UdpConnCount || 0).toString().padEnd(3, ' '), show: validate.isSet(props.info.State?.UdpConnCount), }, ], show: validate.isSet(props.info.State?.TcpConnCount) || validate.isSet(props.info.State?.UdpConnCount), }; // 在线和流量 case 'D-A-T': return { key, label: '统计', values: [ { key: 'duration', label: '在线', value: duration.value?.value, unit: duration.value?.unit, show: validate.isSet(duration.value?.value), }, { key: 'transfer', label: '流量', title: `${transfer.value.statTypeLabel}流量`, value: transfer.value?.value, unit: transfer.value?.unit, show: validate.isSet(transfer.value?.value), }, ], show: validate.isSet(duration.value?.value) || validate.isSet(transfer.value?.value), }; default: } return null; }).filter((item) => item)); return { duration, transfer, netInSpeed, netOutSpeed, serverRealTimeList, }; }; ================================================ FILE: src/views/composable/server-sort.js ================================================ /** * 服务器排序选项 */ export const serverSortOptions = () => [{ label: '排序值', value: 'DisplayIndex', }, { label: '主机名称', value: 'Name', }, { label: '国家地区', value: 'Host.CountryCode', }, { label: '系统平台', value: 'Host.Platform', }, { label: '在线时长', value: 'Host.BootTime', }, { label: '入网速度', value: 'State.NetInSpeed', }, { label: '出网速度', value: 'State.NetOutSpeed', }, { label: '入网流量', value: 'State.NetInTransfer', }, { label: '出网流量', value: 'State.NetOutTransfer', }, { label: '合计流量', value: '$.TotalTransfer', }, { label: 'TCP连接', value: 'State.TcpConnCount', }, { label: 'UDP连接', value: 'State.UdpConnCount', }, { label: '总连接数', value: '$.TotalConnCount', }, { label: '1分钟负载', value: 'State.Load1', }, { label: 'CPU占用', value: 'State.CPU', }, { label: '核心数量', value: '$.CPU', }, { label: '内存占用', value: 'State.MemUsed', }, { label: '内存大小', value: 'Host.MemTotal', }, { label: '交换占用', value: 'State.SwapUsed', }, { label: '交换大小', value: 'Host.SwapTotal', }, { label: '硬盘占用', value: 'State.DiskUsed', }, { label: '硬盘大小', value: 'Host.DiskTotal', }]; /** * 服务器排序处理 */ export function serverSortHandler(a, b, sortby, order) { let aValue; let bValue; const hasDot = sortby.includes('.'); if (!hasDot) { aValue = a[sortby]; bValue = b[sortby]; } else { const [sortby1, sortby2] = sortby.split('.'); if (sortby1 !== '$') { switch (sortby2) { case 'BootTime': { const currentTime = Date.now(); aValue = currentTime - a.Host.BootTime * 1000; bValue = currentTime - b.Host.BootTime * 1000; break; } default: { aValue = a[sortby1][sortby2]; bValue = b[sortby1][sortby2]; break; } } } else { switch (sortby2) { case 'TotalTransfer': { aValue = a.State.NetInTransfer + a.State.NetOutTransfer; bValue = b.State.NetInTransfer + b.State.NetOutTransfer; break; } case 'TotalConnCount': { aValue = a.State.TcpConnCount + a.State.UdpConnCount; bValue = b.State.TcpConnCount + b.State.UdpConnCount; break; } case 'CPU': { aValue = a.Host.CPU.length; bValue = b.Host.CPU.length; break; } default: } } } if (order === 'desc') { return bValue - aValue; } return aValue - bValue; } ================================================ FILE: src/views/composable/server-status.js ================================================ import { computed, } from 'vue'; import config from '@/config'; import validate from '@/utils/validate'; import * as hostUtils from '@/utils/host'; function getColor(type, mode) { const colors = { cpu: { linear: ['#0088FF', '#72B7FF'], default: '#0088FF', simple: '#007B43', }, mem: { linear: ['#2B6939', '#0AA344'], default: '#0AA344', simple: '#007B43', }, swap: { linear: ['#FF8C00', '#F38100'], default: '#FF8C00', simple: '#007B43', }, disk: { linear: ['#00848F', '#70F3FF'], default: '#70F3FF', simple: '#007B43', }, }; return colors[type][mode]; } export default (params) => { const { props, statusListTpl = 'cpu,mem,disk', } = params || {}; if (!props?.info) { return {}; } const lightBackground = computed(() => config.nazhua.lightBackground); const serverStatusColorMode = computed(() => { if (config.nazhua.simpleColorMode) { return 'simple'; } if (config.nazhua.serverStatusLinear || lightBackground.value) { return 'linear'; } return 'default'; }); const cpuInfo = computed(() => { if (props.info?.Host?.CPU?.[0]) { return hostUtils.getCPUInfo(props.info.Host.CPU[0]); } return {}; }); const useMemAndTotalMem = computed(() => { const used = hostUtils.calcBinary(props.info?.State?.MemUsed || 0); const total = hostUtils.calcBinary(props.info?.Host?.MemTotal || 1); const usePercent = ((props.info?.State?.MemUsed / props.info?.Host?.MemTotal) * 100).toFixed(2) * 1 || 0; return { used, total, usePercent, }; }); const useSwapAndTotalSwap = computed(() => { if (!props.info?.Host?.SwapTotal || props.info?.Host?.SwapTotal === 0) { return null; } const used = hostUtils.calcBinary(props.info?.State?.SwapUsed || 0); const total = hostUtils.calcBinary(props.info?.Host?.SwapTotal || 1); const usePercent = ((props.info?.State?.SwapUsed / props.info?.Host?.SwapTotal) * 100).toFixed(2) * 1 || 0; return { used, total, usePercent, }; }); const useDiskAndTotalDisk = computed(() => { const used = hostUtils.calcBinary(props.info?.State?.DiskUsed || 0); const total = hostUtils.calcBinary(props.info?.Host?.DiskTotal || 1); const usePercent = ((props.info?.State?.DiskUsed / props.info?.Host?.DiskTotal) * 100).toFixed(2) * 1 || 0; return { used, total, usePercent, }; }); /** * 状态列表 */ const serverStatusList = computed(() => statusListTpl.split(',').map((i) => { const totalColor = lightBackground.value ? 'rgba(125, 125, 125, 0.5)' : 'rgba(255, 255, 255, 0.25)'; switch (i) { case 'cpu': { const CoresVal = cpuInfo.value?.cores ? `${cpuInfo.value?.cores}C` : '-'; const usedColor = getColor('cpu', serverStatusColorMode.value); const valPercent = `${(props.info.State?.CPU || 0).toFixed(1) * 1}%`; const valText = valPercent; return { type: 'cpu', used: (props.info.State?.CPU || 0).toFixed(1) * 1, colors: { used: usedColor, total: totalColor, }, valText, valPercent, label: 'CPU', content: { default: cpuInfo.value?.core || CoresVal, mobile: CoresVal, }, }; } case 'mem': { let valText; if (useMemAndTotalMem.value.used.g >= 10 && useMemAndTotalMem.value.total.g >= 10) { valText = `${(useMemAndTotalMem.value.used.g).toFixed(1) * 1}G`; } else { valText = `${Math.ceil(useMemAndTotalMem.value.used.m)}M`; } let contentVal; if (useMemAndTotalMem.value.total.g > 4) { contentVal = `${(useMemAndTotalMem.value.total.g).toFixed(1) * 1}G`; } else { contentVal = `${Math.ceil(useMemAndTotalMem.value.total.m)}M`; } const usedColor = getColor('mem', serverStatusColorMode.value); return { type: 'mem', used: useMemAndTotalMem.value.usePercent, colors: { used: usedColor, total: totalColor, }, valText, valPercent: `${useMemAndTotalMem.value.usePercent.toFixed(1) * 1}%`, label: '内存', content: { default: `运行内存${contentVal}`, mobile: `内存${contentVal}`, }, }; } case 'swap': { if (!useSwapAndTotalSwap.value) { return null; } let valText; if (useSwapAndTotalSwap.value.used.g >= 10 && useSwapAndTotalSwap.value.total.g >= 10) { valText = `${(useSwapAndTotalSwap.value.used.g).toFixed(1) * 1}G`; } else { valText = `${Math.ceil(useSwapAndTotalSwap.value.used.m)}M`; } let contentVal; if (useSwapAndTotalSwap.value.total.g > 4) { contentVal = `${(useSwapAndTotalSwap.value.total.g).toFixed(1) * 1}G`; } else { contentVal = `${Math.ceil(useSwapAndTotalSwap.value.total.m)}M`; } const usedColor = getColor('swap', serverStatusColorMode.value); return { type: 'swap', used: useSwapAndTotalSwap.value.usePercent, colors: { used: usedColor, total: totalColor, }, valText, valPercent: `${useSwapAndTotalSwap.value.usePercent.toFixed(1) * 1}%`, label: '交换', content: { default: `交换内存${contentVal}`, mobile: `交换${contentVal}`, }, }; } case 'disk': { let valText; if (useDiskAndTotalDisk.value.used.t >= 1 && useDiskAndTotalDisk.value.total.t >= 1) { valText = `${(useDiskAndTotalDisk.value.used.t).toFixed(1) * 1}T`; } else { valText = `${Math.ceil(useDiskAndTotalDisk.value.used.g)}G`; } let contentValue; if (useDiskAndTotalDisk.value.total.t >= 1) { contentValue = `${(useDiskAndTotalDisk.value.total.t).toFixed(1) * 1}T`; } else { contentValue = `${Math.ceil(useDiskAndTotalDisk.value.total.g)}G`; } const usedColor = getColor('disk', serverStatusColorMode.value); return { type: 'disk', used: useDiskAndTotalDisk.value.usePercent, colors: { used: usedColor, total: totalColor, }, valText, valPercent: `${useDiskAndTotalDisk.value.usePercent.toFixed(1) * 1}%`, label: '磁盘', content: { default: `磁盘容量${contentValue}`, mobile: `磁盘${contentValue}`, }, }; } default: } return null; }).filter((i) => validate.isSet(i))); return { cpuInfo, useMemAndTotalMem, useSwapAndTotalSwap, useDiskAndTotalDisk, serverStatusList, }; }; ================================================ FILE: src/views/detail.vue ================================================ ================================================ FILE: src/views/home.vue ================================================ ================================================ FILE: src/ws/index.js ================================================ import config from '@/config'; import MessageSubscribe from '@/utils/subscribe'; import v1TransformV0 from '@/utils/transform-v1-2-v0'; import WSService, { WS_CONNECTION_STATUS } from './service'; /** * 获取不同版本的WebSocket路径 */ function getWsApiPath() { let url = config?.nazhua?.wsPath; if (config?.nazhua?.nezhaVersion === 'v1') { url = config?.nazhua?.v1WsPath; } const a = document.createElement('a'); a.href = url; return a.href.replace(/^http/, 'ws'); } const msg = new MessageSubscribe(); const wsService = new WSService({ wsUrl: getWsApiPath(), onConnect: () => { msg.emit('connect'); }, onClose: () => { msg.emit('close'); }, onError: (error) => { msg.emit('error', error); }, onMessage: (data) => { // 消息体包含.now和.servers 粗暴的判定为服务器列表项信息 if (data?.now && data?.servers) { if (config.nazhua.nezhaVersion === 'v1') { msg.emit('servers', { now: data.now, servers: data?.servers?.map?.((server) => { const item = v1TransformV0(server); return item; }) || [], }); } else { msg.emit('servers', data); } } else { msg.emit('message', data); } }, }); function restart() { if (wsService.connected !== WS_CONNECTION_STATUS.DISCONNECTED) { wsService.close(); } wsService.active(); } export { wsService, msg, restart, }; export default (actived) => { if (wsService.connected === WS_CONNECTION_STATUS.CONNECTED) { if (actived) { actived(); } return; } msg.once('connect', () => { if (actived) { actived(); } }); // 如果已经连接中,则不再连接 if (wsService.connected === WS_CONNECTION_STATUS.CONNECTING) { return; } wsService.active(); }; ================================================ FILE: src/ws/service.js ================================================ // WebSocket 连接状态常量 export const WS_CONNECTION_STATUS = { DISCONNECTED: 0, // 未连接 CONNECTING: 1, // 连接中 CONNECTED: 2, // 已连接 CLOSED: -1, // 已关闭 }; class WSService { constructor(options) { const { wsUrl, onConnect, onClose, onError, onMessage, onMessageError, } = options || {}; this.debug = options?.debug || false; if (!wsUrl.startsWith('ws')) { throw new Error('WebSocket URL must start with ws:// or wss://'); } this.$wsUrl = wsUrl; this.$on = { close: onClose || (() => {}), error: onError || (() => {}), connect: onConnect || (() => {}), message: onMessage || (() => {}), messageError: onMessageError || (() => {}), }; // 单例模式:防止重复创建 WebSocket 连接 // 如果检测到已有实例,触发错误回调并返回,避免资源浪费 if (WSService.instance) { this.$on.error(new Error('WebSocket connection already exists')); return; } WSService.instance = this; this.connected = WS_CONNECTION_STATUS.DISCONNECTED; this.ws = undefined; this.evt = (event) => { if (this.debug) { console.log('Message from server ', event.data); } try { const data = JSON.parse(event.data); this.$on.message(data, event); } catch (error) { console.error('socket message error', error); if (this.debug) { console.log('message', event.data); } this.$on.messageError(error, event); } }; } get isConnected() { return this.connected === WS_CONNECTION_STATUS.CONNECTED; } active() { // 如果已经连接中或已连接,则不再连接 if (this.connected > WS_CONNECTION_STATUS.DISCONNECTED) { console.warn('WebSocket connection already exists or is connecting'); return; } // 标记为正在连接中 this.connected = WS_CONNECTION_STATUS.CONNECTING; // 创建 WebSocket 连接 this.ws = new WebSocket(this.$wsUrl); this.ws.addEventListener('open', (event) => { if (this.debug) { console.log('socket connected', event); } this.connected = WS_CONNECTION_STATUS.CONNECTED; this.$on.connect(event); }); this.ws.addEventListener('close', (event) => { if (this.debug) { console.log('socket closed', event); } this.connected = WS_CONNECTION_STATUS.CLOSED; WSService.instance = null; // 清除实例引用 this.$on.close(event); }); this.ws.addEventListener('message', this.evt); this.ws.addEventListener('error', (event) => { console.log('socket error', event); WSService.instance = null; // 清除实例引用 this.$on.error(event); }); } send(data) { this?.ws?.send?.(JSON.stringify(data)); } close() { this.ws?.close?.(); } } export default WSService; ================================================ FILE: vite.config.js ================================================ import path from 'path'; import dotenv from 'dotenv'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import babel from 'vite-plugin-babel'; import eslintPlugin from 'vite-plugin-eslint'; import svgLoader from 'vite-svg-loader'; import packageJson from './package'; let proxy; if (process.env.NODE_ENV === 'development') { dotenv.config({ path: '.env.development.local', }); proxy = { '/api': { target: process.env.API_HOST, changeOrigin: true, }, '/ws': { target: process.env.PROXY_WS_HOST || process.env.WS_HOST, changeOrigin: true, ws: true, rewrite: (e) => { if (process.env.PROXY_WS_HOST) { return `/proxy?wsPath=${process.env.WS_HOST}`; } return e; }, }, '/api/v1/ws/server': { target: process.env.PROXY_WS_HOST || process.env.WS_HOST, changeOrigin: true, ws: true, rewrite: (e) => { if (process.env.PROXY_WS_HOST) { return `/proxy?wsPath=${process.env.WS_HOST}`; } return e; }, }, }; if (process.env.VITE_BASE_PATH === '/' || !process.env.VITE_BASE_PATH) { proxy['/nezha/'] = { target: process.env.NEZHA_HOST, changeOrigin: true, rewrite: (e) => e.replace(/^\/nezha/, ''), }; } } // 读取版本号 process.env.VITE_APP_VERSION = process.env.VERSION || packageJson.version; // https://vite.dev/config/ export default defineConfig({ base: process.env.VITE_BASE_PATH || '/', server: { host: '0.0.0.0', port: 3000, hmr: { overlay: false, }, proxy, }, css: { preprocessorOptions: { scss: { api: 'modern-compiler', }, }, }, plugins: [ vue(), babel({ babelConfig: { babelrc: false, configFile: false, plugins: [ '@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-nullish-coalescing-operator', ], }, }), eslintPlugin({ include: ['src/**/*.js', 'src/**/*.vue', 'src/*.js', 'src/*.vue'], }), svgLoader(), ], build: { assetsInlineLimit: 8192, // 8KB 以下的资源会被内联 rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { return 'vendor'; } if (id.includes('.svg')) { return 'svg'; } return 'default'; }, }, }, }, resolve: { alias: (() => { const maps = { '@': path.resolve(__dirname, './src/'), '~@': path.resolve(__dirname, './src/'), }; return maps; })(), }, });