[
  {
    "path": ".eslintignore",
    "content": "build/*.js\npublic\ndist\n"
  },
  {
    "path": ".eslintrc.cjs",
    "content": "module.exports = {\n  root: true,\n  env: {\n    browser: true,\n    es2021: true,\n  },\n  extends: [\n    'eslint:recommended',\n    'plugin:vue/vue3-recommended',\n    'plugin:vue/vue3-essential',\n    '@vue/airbnb',\n  ],\n  globals: {\n    defineEmits: true,\n    defineExpose: true,\n    defineProps: true,\n  },\n  parserOptions: {\n    ecmaVersion: 'latest',\n    sourceType: 'module',\n  },\n  rules: {\n    camelcase: 'off',\n    'vue/component-definition-name-casing': ['error', 'PascalCase'],\n    'vue/html-closing-bracket-newline': ['error', {\n      singleline: 'never',\n      multiline: 'always',\n    }],\n    'vue/no-v-html': 'off',\n    'vue/no-mutating-props': 'off',\n    'vue/max-attributes-per-line': ['error', {\n      singleline: {\n        max: 1,\n      },\n      multiline: {\n        max: 1,\n      },\n    }],\n    'vue/multi-word-component-names': 'off',\n    'vue/singleline-html-element-content-newline': 'off',\n    'vue/valid-v-slot': 'off',\n    'vue/no-template-target-blank': 'off',\n    'vuejs-accessibility/anchor-has-content': 'off',\n    'vuejs-accessibility/alt-text': 'off',\n    'vuejs-accessibility/label-has-for': 'off',\n    'vuejs-accessibility/click-events-have-key-events': 'off',\n    'vuejs-accessibility/form-control-has-label': 'off',\n    'vuejs-accessibility/iframe-has-title': 'off',\n    'vuejs-accessibility/media-has-caption': 'off',\n    'accessor-pairs': 2,\n    'arrow-spacing': [2, {\n      before: true,\n      after: true,\n    }],\n    indent: [\n      2, 2,\n      {\n        SwitchCase: 1,\n        offsetTernaryExpressions: false,\n      },\n    ],\n    'default-case-last': 'off',\n    'func-names': ['error', 'never'],\n    'no-console': 'off',\n    'no-debugger': 'off',\n    'no-param-reassign': 'off',\n    'no-underscore-dangle': 'off',\n    'no-unsafe-optional-chaining': 'off',\n    'max-classes-per-file': 'off',\n    'max-len': ['warn', 120],\n    'vue/max-len': ['warn', 120],\n    'object-property-newline': ['error', {\n      allowAllPropertiesOnSameLine: false,\n    }],\n    'one-var-declaration-per-line': ['error', 'always'],\n    'prefer-destructuring': ['error',\n      {\n        VariableDeclarator: {\n          array: false,\n          object: true,\n        },\n        AssignmentExpression: {\n          array: true,\n          object: false,\n        },\n      },\n    ],\n    'import/no-cycle': 'off',\n    'import/no-unresolved': 'off',\n    'import/no-extraneous-dependencies': 'off',\n    'import/prefer-default-export': 'off',\n    'import/extensions': ['error', 'never', {\n      ignorePackages: true,\n      pattern: {\n        vue: 'always',\n      },\n    }],\n  },\n};\n"
  },
  {
    "path": ".github/workflows/docker-build.yml",
    "content": "name: Build and Push Docker Image\n\non:\n  push:\n    tags:\n      - 'v*.*.*'\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Version to use for the Docker image'\n        required: false\n\njobs:\n  build:\n    runs-on: ubuntu-24.04\n\n    steps:\n    - name: Checkout code\n      uses: actions/checkout@v3\n\n    - name: Setup Node.js\n      uses: actions/setup-node@v3\n      with:\n        node-version: '20'\n\n    - name: Install dependencies\n      run: npm install\n\n    - name: Determine version\n      id: determine_version\n      run: |\n        if [ -z \"${{ github.event.inputs.version }}\" ]; then\n          echo \"VERSION=$(node -p 'require(\"./package.json\").version')\" >> $GITHUB_ENV\n        else\n          echo \"VERSION=${{ github.event.inputs.version }}\" >> $GITHUB_ENV\n        fi\n\n    - name: Print version\n      run: echo \"Version is $VERSION\"\n\n    - name: 构建完整引用版本\n      run: npm run build\n\n    - name: 构建完整引用版本的Docker镜像\n      run: |\n        docker build -t ghcr.io/${{ github.repository }}:$VERSION .\n\n    - name: 构建CDN引用版本\n      env:\n        VITE_SARASA_TERM_SC_USE_CDN: '1'\n        VITE_USE_CDN: '1'\n        VITE_CDN_LIB_TYPE: 'loli'\n      run: npm run build\n\n    - name: 构建CDN引用版本的Docker镜像\n      run: |\n        docker build -t ghcr.io/${{ github.repository }}:$VERSION-cdn .\n\n    - name: Log in to GitHub Container Registry\n      run: echo \"${{ secrets.GITHUB_TOKEN }}\" | docker login ghcr.io -u ${{ github.actor }} --password-stdin\n\n    - name: Push Docker image\n      run: |\n        docker push ghcr.io/${{ github.repository }}:$VERSION\n        docker tag ghcr.io/${{ github.repository }}:$VERSION ghcr.io/${{ github.repository }}:latest\n        docker push ghcr.io/${{ github.repository }}:latest\n\n    - name: Push CDN Docker image\n      run: |\n        docker push ghcr.io/${{ github.repository }}:$VERSION-cdn\n        docker tag ghcr.io/${{ github.repository }}:$VERSION-cdn ghcr.io/${{ github.repository }}:cdn\n        docker push ghcr.io/${{ github.repository }}:cdn\n"
  },
  {
    "path": ".github/workflows/eslint.yml",
    "content": "name: ESLint Lint for Pull Requests\n\non:\n  pull_request:\n    paths:\n      - '**/*.js'\n      - '**/*.ts'\n      - '**/*.vue'\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n\n    steps:\n      # 检出代码\n      - name: Checkout code\n        uses: actions/checkout@v3\n\n      # 设置 Node.js 环境\n      - name: Set up Node.js\n        uses: actions/setup-node@v3\n        with:\n          node-version: '20'\n\n      # 安装依赖\n      - name: Install dependencies\n        run: npm install\n\n      # 运行 ESLint\n      - name: Run ESLint\n        run: npm run lint\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Build and Release\n\non:\n  push:\n    tags:\n      - 'v*.*.*'\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Version to release'\n        required: false\n\njobs:\n  build-and-release:\n    runs-on: ubuntu-24.04\n\n    steps:\n    - name: Checkout code\n      uses: actions/checkout@v3\n      with:\n        fetch-depth: 20\n\n    - name: Setup Node.js\n      uses: actions/setup-node@v3\n      with:\n        node-version: '20'\n\n    - name: Get version from package.json\n      id: get_version\n      run: echo \"version=$(node -p \"require('./package.json').version\")\" >> $GITHUB_OUTPUT\n\n    - name: Determine version\n      id: determine_version\n      run: |\n        if [ \"${{ github.event.inputs.version }}\" ]; then\n          echo \"version=${{ github.event.inputs.version }}\" >> $GITHUB_OUTPUT\n        else\n          echo \"version=${{ steps.get_version.outputs.version }}\" >> $GITHUB_OUTPUT\n        fi\n\n    - name: Generate release notes\n      id: release_notes\n      run: |\n        echo \"#### Changes\" > release_notes.md\n        git log -20 --pretty=format:\"- %s\" >> release_notes.md\n        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\n\n    - name: Install dependencies\n      run: npm install\n\n    - name: Create Release\n      id: create_release\n      uses: actions/create-release@v1\n      env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      with:\n        tag_name: v${{ steps.determine_version.outputs.version }}\n        release_name: Release v${{ steps.determine_version.outputs.version }}\n        draft: true\n        prerelease: false\n\n    - name: 构建自动版 - 完整引用版本\n      run: npm run build\n\n    - name: 打包v${{ steps.determine_version.outputs.version }}-all.zip\n      run: zip -r v${{ steps.determine_version.outputs.version }}-all.zip dist\n\n    - name: 构建自动版 - JSDeliver引用版本\n      env:\n        VITE_SARASA_TERM_SC_USE_CDN: '1'\n        VITE_USE_CDN: '1'\n        VITE_CDN_LIB_TYPE: 'jsdelivr'\n      run: npm run build\n\n    - name: 打包v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip\n      run: zip -r v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip dist\n\n    - name: 构建自动版 - loli(CDNJS)引用版本\n      env:\n        VITE_DISABLE_SARASA_TERM_SC: '1'\n        VITE_USE_CDN: '1'\n        VITE_CDN_LIB_TYPE: 'loli'\n      run: npm run build\n\n    - name: 打包v${{ steps.determine_version.outputs.version }}-cdn-loli.zip\n      run: zip -r v${{ steps.determine_version.outputs.version }}-cdn-loli.zip dist\n\n    - name: 构建哪吒v0子目录版本\n      env:\n        VITE_BASE_PATH: '/nazhua/'\n        VITE_NEZHA_VERSION: 'v0'\n        VITE_DISABLE_SARASA_TERM_SC: '1'\n      run: npm run build\n\n    - name: 打包v0-nazhua.zip\n      run: zip -r v0-nazhua.zip dist\n\n    - name: 构建哪吒v0版本\n      env:\n        VITE_NEZHA_VERSION: 'v0'\n        VITE_DISABLE_SARASA_TERM_SC: '1'\n      run: npm run build\n\n    - name: 打包v0-dist.zip\n      run: zip -r v0-dist.zip dist\n\n    - name: 构建哪吒v1版本\n      env:\n        VITE_NEZHA_VERSION: 'v1'\n        VITE_DISABLE_SARASA_TERM_SC: '1'\n      run: npm run build\n\n    - name: 打包dist.zip\n      run: zip -r dist.zip dist\n\n    - name: Upload v${{ steps.determine_version.outputs.version }}-all.zip\n      uses: actions/upload-release-asset@v1\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      with:\n        upload_url: ${{ steps.create_release.outputs.upload_url }}\n        asset_path: ./v${{ steps.determine_version.outputs.version }}-all.zip\n        asset_name: v${{ steps.determine_version.outputs.version }}-all.zip\n        asset_content_type: application/zip\n\n    - name: Upload v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip\n      uses: actions/upload-release-asset@v1\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      with:\n        upload_url: ${{ steps.create_release.outputs.upload_url }}\n        asset_path: ./v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip\n        asset_name: v${{ steps.determine_version.outputs.version }}-cdn-jsdelivr.zip\n        asset_content_type: application/zip\n\n    - name: Upload v${{ steps.determine_version.outputs.version }}-cdn-loli.zip\n      uses: actions/upload-release-asset@v1\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      with:\n        upload_url: ${{ steps.create_release.outputs.upload_url }}\n        asset_path: ./v${{ steps.determine_version.outputs.version }}-cdn-loli.zip\n        asset_name: v${{ steps.determine_version.outputs.version }}-cdn-loli.zip\n        asset_content_type: application/zip\n\n    - name: Upload v0-nazhua.zip\n      uses: actions/upload-release-asset@v1\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      with:\n        upload_url: ${{ steps.create_release.outputs.upload_url }}\n        asset_path: ./v0-nazhua.zip\n        asset_name: v0-nazhua.zip\n        asset_content_type: application/zip\n\n    - name: Upload v0-dist.zip\n      uses: actions/upload-release-asset@v1\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      with:\n        upload_url: ${{ steps.create_release.outputs.upload_url }}\n        asset_path: ./v0-dist.zip\n        asset_name: v0-dist.zip\n        asset_content_type: application/zip\n\n    - name: Upload dist.zip\n      uses: actions/upload-release-asset@v1\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      with:\n        upload_url: ${{ steps.create_release.outputs.upload_url }}\n        asset_path: ./dist.zip\n        asset_name: dist.zip\n        asset_content_type: application/zip\n\n    - name: Add release notes\n      run: |\n        # 更新发布说明\n        gh release edit v${{ steps.determine_version.outputs.version }} --notes-file release_notes.md\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\ndemo\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM nginx:1.31-alpine-slim\n\nCOPY ./dist /home/wwwroot/html\nCOPY ./nginx-default.conf.template /etc/nginx/templates/default.conf.template\n\nENV DOMAIN=_\n\n# 暴露端口\nEXPOSE 80\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 hi2hi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "doc/deploy.md",
    "content": "# 🚀 部署指南\n\n## 部署概述\n> Nazhua主题是纯前端项目，可部署在静态服务器上\n> \n> **跨域解决注重点**：\n> - **V0版本**：需解决 `/api/v1/monitor/${id}`、`/ws` 和 `/` 的跨域\n> - **V1版本**：需解决 `/api/xxx` 和 `/api/v1/ws/server` 的跨域\n> \n> 推荐使用 Nginx 或 Caddy 反向代理解决跨域问题\n\n## 🐳 Docker Compose + Cloudflare Tunnels 部署\n此方案便于后续更新，只需通过 `docker compose pull` 命令即可更新主题（镜像）。\n\n### 配置说明\n- **favicon.ico**：可通过挂载或配置文件指定（默认无）\n- **config.js**：需单独挂载，建议使用[配置生成器](https://hi2shark.github.io/nazhua-generator/)生成\n- **style.css**：用于自定义CSS样式，尽量保持选择器稳定\n\n### 部署示例\n```yaml\nservices:\n  nazhua:\n    image: ghcr.io/hi2shark/nazhua:latest\n    container_name: nazhua\n    ports:\n      - 80:80\n    # volumes:\n      # - ./favicon.ico:/home/wwwroot/html/favicon.ico:ro # 自定义favicon图标\n      # - ./config.js:/home/wwwroot/html/config.js:ro # 自定义配置文件\n      # - ./style.css:/home/wwwroot/html/style.css:ro # 自定义样式文件\n    environment:\n      - DOMAIN=_ # 监听的域名，默认为_（监听所有）\n      - NEZHA=http://nezha-dashboard.example.com/ # 可以被反向代理nezha主页地址\n    restart: unless-stopped\n```\n\n### 💡 小贴士\n- 推荐使用 docker-compose 部署 Nazhua 与 Nezha Dashboard，并通过 Cloudflare Tunnels 对外提供服务\n- 如需减少内置库体积，可使用 CDN 版本镜像：`ghcr.io/hi2shark/nazhua:cdn`\n- 隐藏原面板方案：使用 Zero Trust Tunnels 部署三个容器 (Tunnels、nezha-dashboard、nazhua)\n  - nazhua 通过 docker 内部地址访问 nezha-dashboard\n  - Tunnels 绑定 nazhua 到公开域名\n  - Tunnels 绑定 nezha-dashboard 到需要邮箱/IP验证的私密域名\n\n## 🌐 自定义Web服务部署\n\n### 安装步骤\n1. 在 [Releases页面](https://github.com/hi2shark/nazhua/releases) 下载最新版 `v{Nazhua版本号}-all.zip`\n2. 解压后将 `dist` 目录文件上传到Web服务目录\n\n### Nginx配置示例\n```nginx\nmap $http_upgrade $connection_upgrade {\n  default upgrade;\n  ''      close;\n}\n\nserver {\n  listen 80;\n  server_name nazhua.example.com;\n  client_max_body_size 1024m;\n\n  # 哪吒V0的WebSocket服务\n  location /ws {\n    proxy_pass ${NEZHA}ws;\n    proxy_http_version 1.1;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection $connection_upgrade;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n  }\n\n  # 哪吒V1的WebSocket服务\n  location /api/v1/ws/server {\n    proxy_pass ${NEZHA}api/v1/ws/server;\n    proxy_http_version 1.1;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection $connection_upgrade;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n  }\n\n  location /api {\n    proxy_pass http://nezha-dashboard.example.com/api;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header X-Forwarded-Proto $scheme;\n  }\n\n  location /nezha/ {\n    proxy_pass http://nezha-dashboard.example.com/;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header X-Forwarded-Proto $scheme;\n  }\n\n  location / {\n    try_files $uri $uri/ /index.html;\n    root /home/wwwroot/html;\n  }\n}\n```\n----  \n**Tips:** V0环境下若想与面板使用同域名，下载 `v0-nazhua.zip` 并将文件上传至面板目录下的 `nazhua` 文件夹\n\n----  \n\n## ⚙️ 配置文件\n\n### config.js 配置说明\n建议使用 [Nazhua 配置生成器](https://hi2shark.github.io/nazhua-generator/) 生成配置文件。\n\n```javascript\nwindow.$$nazhuaConfig = {\n  title: '哪吒监控', // 网站标题\n  footerSlogan: '不要年付！不要年付！不要年付！<span style=\"color: #f00;\">欢迎访问Nazhua探针</span>', // 底部标语，支持html渲染\n  freeAmount: '白嫖', // 免费服务的费用名称\n  infinityCycle: '长期有效', // 无限周期名称\n  buyBtnText: '购买', // 购买按钮文案\n  buyBtnIcon: '', // 购买按钮图标，取自remixicon\n  customBackgroundImage: '', // 自定义的背景图片地址\n  lightBackground: true, // 启用了浅色系背景图，会强制关闭点点背景\n  showFireworks: true, // 是否显示烟花，建议开启浅色系背景\n  showLantern: true, // 是否显示灯笼\n  enableInnerSearch: true, // 启用内部搜索\n  listServerItemTypeToggle: true, // 服务器列表项类型切换\n  listServerItemType: 'row', // 服务器列表项类型 card/row row列表模式移动端自动切换至card\n  listServerStatusType: 'progress', // 服务器状态类型--列表\n  listServerRealTimeShowLoad: true, // 列表显示服务器实时负载\n  detailServerStatusType: 'progress', // 服务器状态类型--详情页\n  simpleColorMode: true, // 服务器状态纯色显示\n  serverStatusLinear: true, // 服务器状态渐变线性显示 - 与pureColorMode互斥\n  disableSarasaTermSC: true, // 禁用Sarasa Term SC字体\n  hideWorldMap: false, // 隐藏地图\n  hideHomeWorldMap: false, // 隐藏首页地图\n  hideDetailWorldMap: false, // 隐藏详情地图\n  homeWorldMapPosition: 'top', // 首页地图位置 top/bottom\n  detailWorldMapPosition: 'top', // 详情页地图位置 top/bottom\n  hideNavbarServerCount: false, // 隐藏服务器数量\n  hideNavbarServerStat: false, // 隐藏服务器统计\n  hideListItemStatusDonut: false, // 隐藏列表项的饼图\n  hideListItemStat: false, // 隐藏列表项的统计信息\n  hideListItemBill: false, // 隐藏列表项的账单信息\n  hideListItemLink: true, // 隐藏列表项的购买链接\n  hideFilter: false, // 隐藏筛选\n  hideTag: false, // 隐藏标签\n  hideDotBG: true, // 隐藏框框里面的点点背景\n  monitorRefreshTime: 10, // 监控刷新时间间隔，单位s（秒）, 0为不刷新，为保证不频繁请求源站，最低生效值为10s\n  monitorChartType: 'multi', // 监控图表类型 single/multi\n  monitorChartTypeToggle: true, // 监控图表类型切换\n  filterGPUKeywords: ['Virtual Display'], // 如果GPU名称中包含这些关键字，则过滤掉\n  customCodeMap: {}, // 自定义的地图点信息\n  nezhaVersion: 'v1', // 哪吒版本 不填写则尝试自动识别\n  apiMonitorPath: '/api/v1/monitor/{id}',\n  wsPath: '/ws',\n  nezhaPath: '/nezha/',\n  nezhaV0ConfigType: 'servers', // 哪吒v0数据读取类型\n  v1ApiMonitorPath: '/api/v1/service/{id}',\n  v1WsPath: '/api/v1/ws/server',\n  v1ApiGroupPath: '/api/v1/server-group',\n  v1ApiSettingPath: '/api/v1/setting',\n  v1ApiProfilePath: '/api/v1/profile',\n  v1DashboardUrl: '/dashboard', // v1版本控制台地址\n  v1HideNezhaDashboardBtn: true, // v1版本导航栏控制台入口/登录按钮 在nezhaVersion为v1时有效\n  routeMode: 'h5', // 路由模式\n  customFavicon: '', // 自定义favicon, 填写完整的url地址\n};\n```\n\n### 🎨 自定义样式\n通过修改根目录下的 `style.css` 文件实现样式定制：\n\n```css\n:root {\n  /* 修改颜色 */\n  /* 地图上标记点的颜色 */\n  --world-map-point-color: #fff;\n  /* 列表项显示的价格颜色 */\n  --list-item-price-color: #ff6;\n  /* 购买链接的主要颜色 */\n  --list-item-buy-link-color: #f00;\n}\n\n/* 自定义背景图示例 */\n:root {\n  /* 图片太亮时，增加背景遮罩透明度 */\n  --layout-main-bg-color: rgba(0, 0, 0, 0.75);\n}\n.layout-group .layout-bg {\n  /* 添加!important强制背景图替换 */\n  background: url(./bg.jpg) no-repeat 50% 50% !important;\n  background-size: cover;\n}\n```\n"
  },
  {
    "path": "doc/public-note.md",
    "content": "# 📝 公开备注配置指南\n\n[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/#/?tab=publicNote)已添加公开备注编辑器，方便大家配置公开备注\n\n## 🗺️ 点阵地图节点显示\n\n### 地图说明\nNazhua采用的点阵地图是一个并非精准的变形地图，不能使用真实经纬度坐标进行换算定位，因此需要通过自定义坐标来指定位置。  \n\n### 配置方法\n使用[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/)获取内置的点阵地图坐标或者自定义坐标（可以在`config.js`中配置`customCodeMap`添加自定义地图点）  \n在节点的公开备注对象中设置位置代码：  \n```json\n{\n  \"customData\": {\n    \"location\": \"HKG\"  // 位置代码\n  }\n}\n```\n\n### 默认位置映射\n部分常见地区已有默认映射：\n- 中国大陆默认显示在北京（v0.4.6后添加）\n- 美国默认显示在洛杉矶\n\n## 🔧 customData 字段详解\n\n### 可用字段\n| 字段 | 用途 | 版本支持 |\n|------|------|---------|\n| `location` | 指定节点地理位置代码 | 全版本 |\n| `slogan` | 显示节点标语 | 全版本 |\n| `orderLink` | 购买链接地址 | 全版本 |\n| `flag` | 自定义国家/地区旗帜 | v0.6.4+ |\n| `buyBtnText` | 购买按钮文案 | v0.5.3+ |\n| `buyBtnIcon` | 购买按钮图标 | v0.5.3+ |\n\n### 示例配置\n```json\n{\n  \"customData\": {\n    \"location\": \"HKG\",\n    \"slogan\": \"这是一个香港节点\",\n    \"orderLink\": \"https://buy.example.com\",\n    \"buyBtnText\": \"官网\",\n    \"buyBtnIcon\": \"ri-gift-2-line\",\n    \"flag\": \"cn\"\n  }\n}\n```\n\n### 💡 链接编码提示\n由于配置数据无法正常解析符号`&`，请使用URL编码：\n- 在线工具：[https://www.bejson.com/enc/urlencode/](https://www.bejson.com/enc/urlencode/)\n- 浏览器控制台：执行`encodeURIComponent('链接内容')`获取编码后内容\n\n## 📊 原版公开备注支持\n在哪吒的主题ServerStatus迭代中，nap0o增加了一个公开备注的功能，可以给节点添加额外的展示信息  \n具体字段定义参考 [https://github.com/nezhahq/nezha/pull/425](https://github.com/nezhahq/nezha/pull/425)  \nNazhua支持原版ServerStatus主题的公开备注字段，支持的字段如下：\n\n### 账单信息 (billingDataMod)\n```json\n{\n  \"billingDataMod\": {\n    \"startDate\": \"2024-10-01T00:00:00+08:00\",\n    \"endDate\": \"2024-11-01T00:00:00+08:00\",\n    \"autoRenewal\": \"1\",\n    \"cycle\": \"月\",\n    \"amount\": \"$3.99\"\n  }\n}\n```\n\n### 配置信息 (planDataMod)\n```json\n{\n  \"planDataMod\": {\n    \"bandwidth\": \"30Mbps\",\n    \"trafficVol\": \"1TB/月\",\n    \"trafficType\": \"1\",\n    \"IPv4\": \"1\",\n    \"IPv6\": \"1\",\n    \"networkRoute\": \"CN2,GIA\",\n    \"extra\": \"传家宝,AS9929\"\n  }\n}\n```\n\n## 🔍 完整公开备注示例\n\n```json\n{\n  \"billingDataMod\": {\n    \"startDate\": \"2024-10-01\",\n    \"endDate\": \"2024-11-01\",\n    \"autoRenewal\": \"1\",\n    \"cycle\": \"月\",\n    \"amount\": \"$3.99\"\n  },\n  \"planDataMod\": {\n    \"bandwidth\": \"30Mbps\",\n    \"trafficVol\": \"1TB/月\",\n    \"trafficType\": \"1\",\n    \"IPv4\": \"1\",\n    \"IPv6\": \"1\",\n    \"networkRoute\": \"CN2,GIA\",\n    \"extra\": \"传家宝,AS9929\"\n  },\n  \"customData\": {\n    \"location\": \"HKG\",\n    \"slogan\": \"这是一个香港节点\",\n    \"orderLink\": \"https://buy.example.com\",\n    \"buyBtnText\": \"官网\",\n    \"buyBtnIcon\": \"ri-gift-2-line\",\n    \"flag\": \"cn\"\n  }\n}\n```\n[Nazhua配置生成器](https://hi2shark.github.io/nazhua-generator/#/?tab=publicNote)已添加公开备注编辑器，方便大家配置公开备注\n"
  },
  {
    "path": "doc/update.md",
    "content": "# 📝 更新日志\n\n> 此处仅记录功能性更新，Bug修复不在此记录\n\n## 📦 v0.6.4 更新\n- ✨ **新增**: 网络监控折线图拆分单一图表功能\n- 🌍 **新增**: 公开备注中支持自定义国家/地区旗帜 (`flag` 字段)\n- 🔄 **新增**: 支持地图在首页与详情页的上下位置切换\n\n## 📦 v0.5.7 更新\n- 🖼️ **新增**: 自定义favicon支持\n\n## 📦 v0.5.4 更新\n- 🔍 **新增**: 内置搜索功能，支持 `Ctrl+K` 快速打开搜索\n\n## 📦 v0.5.3 更新\n- 🛒 **新增**: 支持单独设置服务器购买按钮的文案和图标\n\n### 使用方法\n- `buyBtnText`: 设置购买按钮文案\n- `buyBtnIcon`: 设置购买按钮图标，支持Remixicon图标\n\n### 图标配置示例\n1. 访问 [Remixicon官网](https://www.remixicon.com/)\n2. 选择并复制图标名称\n3. 在 `buyBtnIcon` 字段中填写，补齐 `ri-` 前缀\n\n![remixicon使用方法](../.github/images/remixicon-select.jpg)\n\n> 当前支持版本: Remixicon 4.6.0（cdn版本，受限于更新原因，支持到4.3.0）\n"
  },
  {
    "path": "docker-compose.yaml.template",
    "content": "services:\n  nazhua:\n    image: ghcr.io/hi2shark/nazhua:latest\n    container_name: nazhua\n    restart: unless-stopped\n    environment:\n      # - DOMAIN=_ # 监听的域名，默认为_（监听所有）\n      - NEZHA=http://nezha-dashboard/\n    # volumes:\n      # - ./favicon.ico:/home/wwwroot/html/favicon.ico:ro # 自定义favicon图标\n      # - ./config.js:/home/wwwroot/html/config.js:ro # 自定义配置文件\n      # - ./style.css:/home/wwwroot/html/style.css:ro # 自定义样式文件\n    expose:\n      - 80\n    # ports:\n    #   - 80:80\n"
  },
  {
    "path": "fonts/SarasaTermSC/font.css",
    "content": "@font-face {\n  font-family: \"Sarasa Term SC\";\n  src: url(\"./SarasaTermSC-SemiBold.woff2\") format(\"woff2\"),\n    url(\"./SarasaTermSC-SemiBold.woff\") format(\"woff\");\n  font-display: swap;\n}\n"
  },
  {
    "path": "fonts/readme.md",
    "content": "# Nazhua内置字体\n\n## Sarasa Term SC\n字体出处：[Sarasa-Gothic](https://github.com/be5invis/Sarasa-Gothic)  \n具体引用：`Sarasa Term SC SemiBold`  \n由TTF转换为WOFF2格式，以便在网页中使用。  \n使用方法：\n```css\n@font-face {\n  font-family: \"Sarasa Term SC\";\n  src: url(\"./fonts/SarasaTermSC/SarasaTermSC-SemiBold.woff2\") format(\"woff2\"),\n    url(\"./fonts/SarasaTermSC/SarasaTermSC-SemiBold.woff\") format(\"woff\");\n  font-display: swap;\n}\n\n.sarasa-term-sc {\n  font-family: \"Sarasa Term SC\";\n}\n```\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Nazhua</title>\n    <link rel=\"icon\" type=\"image/x-icon\" href=\"/favicon.ico\">\n    <script src=\"./config.js\"></script>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n    <link rel=\"stylesheet\" href=\"./style.css\" />\n  </body>\n</html>\n"
  },
  {
    "path": "nginx-default.conf.template",
    "content": "map $http_upgrade $connection_upgrade {\n    default upgrade;\n    ''      close;\n}\n\nserver {\n  listen 80;\n  server_name ${DOMAIN};\n  client_max_body_size 1024m;\n\n  location /ws {\n    proxy_pass ${NEZHA}ws;\n    proxy_http_version 1.1;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection $connection_upgrade;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n  }\n\n  # 兼容哪吒V1\n  location /api/v1/ws/server {\n    proxy_pass ${NEZHA}api/v1/ws/server;\n    proxy_http_version 1.1;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection $connection_upgrade;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n  }\n\n  location /api {\n    proxy_pass ${NEZHA}api;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header X-Forwarded-Proto $scheme;\n  }\n\n  location /nezha/ {\n    proxy_pass ${NEZHA};\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header X-Forwarded-Proto $scheme;\n  }\n\n  location / {\n    try_files $uri $uri/ /index.html;\n    root /home/wwwroot/html;\n  }\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"nazhua\",\n  \"version\": \"0.9.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"build:cdn\": \"cross-env VITE_SARASA_TERM_SC_USE_CDN=1 VITE_USE_CDN=1 vite build\",\n    \"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\",\n    \"preview\": \"vite preview\",\n    \"lint\": \"eslint .\",\n    \"lint:fix\": \"eslint . --fix\"\n  },\n  \"dependencies\": {\n    \"axios\": \"^1.13.2\",\n    \"dayjs\": \"^1.11.13\",\n    \"echarts\": \"^5.5.1\",\n    \"flag-icons\": \"^7.2.3\",\n    \"font-logos\": \"^1.3.0\",\n    \"remixicon\": \"^4.7.0\",\n    \"uniqolor\": \"^1.1.1\",\n    \"vue\": \"^3.5.12\",\n    \"vue-echarts\": \"^7.0.3\",\n    \"vue-router\": \"^4.4.5\",\n    \"vuex\": \"^4.1.0\"\n  },\n  \"devDependencies\": {\n    \"@babel/core\": \"^7.28.5\",\n    \"@babel/eslint-parser\": \"^7.24.8\",\n    \"@babel/plugin-proposal-nullish-coalescing-operator\": \"^7.16.7\",\n    \"@babel/plugin-proposal-optional-chaining\": \"^7.21.0\",\n    \"@vitejs/plugin-vue\": \"^5.2.4\",\n    \"@vue/eslint-config-airbnb\": \"^7.0.0\",\n    \"cross-env\": \"^7.0.3\",\n    \"dotenv\": \"^16.4.5\",\n    \"eslint\": \"^8.57.1\",\n    \"eslint-plugin-vue\": \"^9.33.0\",\n    \"sass\": \"^1.81.0\",\n    \"vite\": \"^6.4.1\",\n    \"vite-plugin-babel\": \"^1.3.2\",\n    \"vite-plugin-eslint\": \"^1.8.1\",\n    \"vite-svg-loader\": \"^5.1.0\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/hi2shark/nazhua\"\n  }\n}\n"
  },
  {
    "path": "public/config.js",
    "content": "window.$$nazhuaConfig = {\n  // title: '哪吒监控', // 网站标题\n  // footerSlogan: '不要年付！不要年付！不要年付！<span style=\"color: #f00;\">欢迎访问Nazhua探针</span>',\n  // freeAmount: '白嫖', // 免费服务的费用名称\n  // infinityCycle: '长期有效', // 无限周期名称\n  // buyBtnText: '购买', // 购买按钮文案\n  // buyBtnIcon: '', // 购买按钮图标，取自remixicon\n  // customBackgroundImage: '', // 自定义的背景图片地址\n  // lightBackground: true, // 启用了浅色系背景图，会强制关闭点点背景\n  // showFireworks: true, // 是否显示烟花，建议开启浅色系背景\n  // showLantern: true, // 是否显示灯笼\n  enableInnerSearch: true, // 启用内部搜索\n  // listServerItemTypeToggle: true, // 服务器列表项类型切换\n  listServerItemType: 'card', // 服务器列表项类型 card/row/server-status row列表模式移动端自动切换至card\n  // serverStatusColumnsTpl: null, // 服务器状态列配置模板\n  // listServerStatusType: 'progress', // 服务器状态类型--列表\n  // listServerRealTimeShowLoad: true, // 列表显示服务器实时负载\n  // detailServerStatusType: 'progress', // 服务器状态类型--详情页\n  // simpleColorMode: true, // 服务器状态纯色显示\n  serverStatusLinear: true, // 服务器状态渐变线性显示 - 与pureColorMode互斥\n  // disableSarasaTermSC: true, // 禁用Sarasa Term SC字体\n  // hideWorldMap: false, // 隐藏地图\n  // hideHomeWorldMap: false, // 隐藏首页地图\n  // hideDetailWorldMap: false, // 隐藏详情地图\n  // homeWorldMapPosition: 'top', // 首页地图位置 top/bottom\n  // detailWorldMapPosition: 'top', // 详情页地图位置 top/bottom\n  // hideNavbarServerCount: false, // 隐藏服务器数量\n  // hideNavbarServerStat: false, // 隐藏服务器统计\n  // hideListItemStatusDonut: false, // 隐藏列表项的饼图\n  // hideListItemStat: false, // 隐藏列表项的统计信息\n  // hideListItemBill: false, // 隐藏列表项的账单信息\n  hideListItemLink: true, // 隐藏列表项的购买链接\n  // hideFilter: false, // 隐藏筛选\n  // hideSort: false, // 隐藏排序\n  // hideTag: false, // 隐藏标签\n  // hideDotBG: true, // 隐藏框框里面的点点背景\n  // monitorRefreshTime: 10, // 监控刷新时间间隔，单位s（秒）, 0为不刷新，为保证不频繁请求源站，最低生效值为10s\n  monitorChartType: 'multi', // 监控图表类型 single/multi\n  monitorChartTypeToggle: true, // 监控图表类型切换\n  // filterGPUKeywords: ['Virtual Display'], // 如果GPU名称中包含这些关键字，则过滤掉\n  // customCodeMap: {}, // 自定义的地图点信息\n  // nezhaVersion: 'v1', // 哪吒版本\n  // apiMonitorPath: '/api/v1/monitor/{id}',\n  // wsPath: '/ws',\n  // nezhaPath: '/nezha/',\n  // nezhaV0ConfigType: 'servers', // 哪吒v0数据读取类型\n  // v1ApiMonitorPath: '/api/v1/service/{id}',\n  // v1WsPath: '/api/v1/ws/server',\n  // v1ApiGroupPath: '/api/v1/server-group',\n  // v1ApiSettingPath: '/api/v1/setting',\n  // v1ApiProfilePath: '/api/v1/profile',\n  // v1DashboardUrl: '/dashboard', // v1版本控制台地址\n  // v1HideNezhaDashboardBtn: true, // v1版本导航栏控制台入口/登录按钮 在nezhaVersion为v1时有效\n  // routeMode: 'h5', // 路由模式\n  // customFavicon: '', // 自定义favicon, 填写完整的url地址\n};\n"
  },
  {
    "path": "public/style.css",
    "content": ""
  },
  {
    "path": "readme.md",
    "content": "# Nazhua\n\n<div>\n  <img src=\"./.github/images/nazhua-main.webp\" style=\"max-height: 500px;\" alt=\"Nazhua桌面版\"/>\n  <img src=\"./.github/images/nazhua-mobile.webp\" style=\"max-height: 500px;\" alt=\"Nazhua移动版\"/>\n  <img src=\"./.github/images/nazhua-detail-mobile.webp\" style=\"max-height: 500px;\" alt=\"Nazhua详情页\"/>\n</div>\n\n## 📢 使用须知\n\n**使用前，请务必阅读本文档，对您的部署会有很大帮助**\n\n- 基于哪吒监控(nezha.wiki)v0版本构建的前端主题，兼容v1版本数据结构\n- 考虑到国内用户访问需求，默认使用cdnjs的loli.net作为CDN引用源\n- 如需使用SarasaTermSC字体，请选择Docker镜像全量包进行部署\n\n## 🚀 部署指南\n\n**推荐使用Docker Compose + Cloudflare Tunnels部署Nazhua**\n\n👉 [详细部署文档](./doc/deploy.md)\n\nNazhua提供了丰富的配置选项：\n- 支持点阵地图显示/隐藏\n- 首页风格切换等多种个性化设置\n\n配置方式：\n- **V1内置版本**：使用[配置生成器](https://hi2shark.github.io/nazhua-generator/)生成配置，填入控制台自定义代码\n- **Docker部署**：手动配置`config.js`文件（包括v0版本）\n\n## 🗺️ 节点位置配置\n\n要在地图上显示节点位置，需在公开备注中指定`location`字段\n\n👉 [公开备注配置文档](./doc/public-note.md)\n\n## 📝 更新日志\n\n👉 [功能更新记录](./doc/update.md)\n\n## 🤝 赞助商\n\n<table>\n  <tr>\n    <td align=\"center\">\n      <a href=\"https://www.vmiss.com\" target=\"_blank\" title=\"VMISS，加拿大企业，打造全球优质优化线路。提供香港、日本、韩国、美国、英国的云服务器\">\n        <img src=\"./.github/images/vmiss-logo.jpg\" width=\"200px;\" alt=\"VMISS\"/>\n      </a>\n      <br />\n      <strong>VMISS</strong>\n    </td>\n  </tr>\n</table>\n\n## 💻 开发者指南\n\n### 环境配置\n\n在`.env.development.local`中配置以下变量：\n\n```bash\n#### Sarasa Term SC字体设置\n# VITE_DISABLE_SARASA_TERM_SC=1\n# VITE_SARASA_TERM_SC_USE_CDN=1\n\n#### CDN配置\n# VITE_USE_CDN=1\n# VITE_CDN_LIB_TYPE=jsdelivr # jsdelivr | cdnjs | loli\n\n#### 哪吒版本控制\n# VITE_NEZHA_VERSION=v1 # v0 | v1\n\n#### 本地开发设置\n# PROXY_WS_HOST= # 本地开发时，可以代理WS服务的地址，启用后，自动转发至 {PROXY_WS_HOST}/proxy?wsPath={WS_HOST}\n# API_HOST= # 本地开发时，代理的API服务地址\n# WS_HOST= # 本地开发时，代理的WS服务地址\n##### 仅限v0版本\n# NEZHA_HOST= # 本地开发时，代理的哪吒主页地址\n```\n\n### 数据来源参考\n\n| 数据类型 | V0版本 | V1版本 |\n|---------|--------|--------|\n| 全量配置 | 公开备注(PublicNote)：通过正则匹配节点列表，默认访问`/nezha/` | - |\n| 实时数据 | WS接口：`/ws` | WS接口：`/api/v1/ws/server` |\n| 监控数据 | API接口：`/api/v1/monitor/${id}` | API接口：`/api/v1/service/${id}` |\n| 分组数据 | 服务器节点列表的`Tag`字段匹配 | API接口：`/api/v1/server-group` |\n"
  },
  {
    "path": "src/App.vue",
    "content": "<template>\n  <layout-main>\n    <router-view v-slot=\"{ Component }\">\n      <keep-alive>\n        <component :is=\"Component\" />\n      </keep-alive>\n    </router-view>\n  </layout-main>\n</template>\n\n<script setup>\nimport {\n  ref,\n  computed,\n  watch,\n  provide,\n  onMounted,\n  onUnmounted,\n} from 'vue';\nimport { useStore } from 'vuex';\nimport { useRoute } from 'vue-router';\nimport config, {\n  init as initConfig,\n} from '@/config';\nimport sleep from '@/utils/sleep';\nimport LayoutMain from './layout/main.vue';\n\nimport { WS_CONNECTION_STATUS } from './ws/service';\nimport activeWebsocketService, {\n  wsService,\n  restart,\n  msg,\n} from './ws';\n\nconst store = useStore();\nconst route = useRoute();\n\nconst currentTime = ref(0);\n\nprovide('currentTime', currentTime);\n\n/**\n * 刷新当前时间\n * 使用 requestAnimationFrame 持续更新时间，但只在秒级变化时更新值以减少不必要的响应式更新\n */\nlet lastUpdateTime = 0;\nfunction refreshTime() {\n  const now = Date.now();\n  // 只在秒级变化时更新，减少响应式更新频率\n  if (Math.floor(now / 1000) !== Math.floor(lastUpdateTime / 1000)) {\n    currentTime.value = now;\n    lastUpdateTime = now;\n  }\n  window.requestAnimationFrame(refreshTime);\n}\nrefreshTime();\n\n// 是否为Windows系统\nconst isWindows = /windows|win32/i.test(navigator.userAgent);\nif (isWindows) {\n  document.body.classList.add('windows');\n}\n// 是否加载Sarasa Term SC字体\nconst loadSarasaTermSC = computed(() => config.nazhua.disableSarasaTermSC !== true);\nwatch(loadSarasaTermSC, (value) => {\n  if (value) {\n    document.body.classList.add('sarasa-term-sc');\n  } else {\n    document.body.classList.remove('sarasa-term-sc');\n  }\n}, {\n  immediate: true,\n});\n\n/**\n * websocket断连的自动重连\n */\nlet stopReconnect = false;\nasync function wsReconnect() {\n  if (stopReconnect) {\n    return;\n  }\n  stopReconnect = true;\n  await sleep(1000);\n  console.log('reconnect ws');\n  activeWebsocketService();\n  stopReconnect = false;\n}\n\nonMounted(async () => {\n  refreshTime();\n\n  // 如果没有配置哪吒版本，尝试载入 v1 版本配置\n  if (!config.init) {\n    await initConfig();\n  }\n\n  /**\n   * 初始化服务器信息\n   */\n  await store.dispatch('initServerInfo', {\n    route,\n  });\n\n  /**\n   * 初始化WS重连维护\n   */\n  msg.on('close', () => {\n    console.log('ws closed');\n    wsReconnect();\n  });\n  msg.on('error', () => {\n    console.log('ws error');\n    stopReconnect = true;\n  });\n  msg.on('connect', () => {\n    console.log('ws connected');\n    store.dispatch('watchWsMsg');\n  });\n  const handleFocus = () => {\n    // ws在离开焦点后出现断连，尝试重新连接\n    // 仅针对已关闭状态进行重连\n    if (wsService.connected === WS_CONNECTION_STATUS.CLOSED) {\n      restart();\n    }\n  };\n  window.addEventListener('focus', handleFocus);\n  /**\n   * 激活websocket服务\n   */\n  activeWebsocketService();\n\n  onUnmounted(() => {\n    window.removeEventListener('focus', handleFocus);\n  });\n});\n\nwindow.addEventListener('unhandledrejection', (event) => {\n  console.error('未处理的rejection:', event.reason);\n  event.preventDefault();\n});\n</script>\n"
  },
  {
    "path": "src/assets/fonts/SarasaTermSC/cdn-font.css",
    "content": "@font-face {\n  font-family: \"Sarasa Term SC\";\n  src: url(\"https://cdn.jsdelivr.net/gh/hi2shark/nazhua@main/fonts/SarasaTermSC/SarasaTermSC-SemiBold.woff2\") format(\"woff2\"),\n    url(\"https://cdn.jsdelivr.net/gh/hi2shark/nazhua@main/fonts/SarasaTermSC/SarasaTermSC-SemiBold.woff\") format(\"woff\");\n  font-display: swap;\n}\n"
  },
  {
    "path": "src/assets/fonts/SarasaTermSC/font.css",
    "content": "@font-face {\n  font-family: \"Sarasa Term SC\";\n  src: url(\"./SarasaTermSC-SemiBold.woff2\") format(\"woff2\"),\n    url(\"./SarasaTermSC-SemiBold.woff\") format(\"woff\");\n  font-display: swap;\n}\n"
  },
  {
    "path": "src/assets/scss/base.scss",
    "content": "@use \"./variables.scss\";\n\nbody {\n  line-height: 1.8;\n  font-size: 14px;\n  font-family: 'Microsoft YaHei', '微软雅黑', 'PingFang SC', 'HanHei SC', 'Helvetica Neue', 'Helvetica', 'STHeitiSC-Light', 'Arial', sans-serif;\n  color: var(--global-text-color);\n  background: var(--global-background-color);\n  -webkit-text-size-adjust: none;\n  text-size-adjust: none;\n}\n\nul,\nul li {\n  list-style: none;\n}\n\nhtml,\nbody,\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\nul,\nli,\nol,\nblockquote,\npre,\np,\ntable,\ntbody,\nth,\ntd,\ntr,\nspan {\n  margin: 0;\n  padding: 0;\n  border-radius: 0px;\n}\n\nimg {\n  border: none;\n  outline: none;\n}\n\ninput[type=\"text\"],\ninput[type=\"text\"]:focus,\ninput[type=\"text\"]:active,\ninput[type=\"email\"],\ninput[type=\"email\"]:focus,\ninput[type=\"email\"]:active,\ninput[type=\"number\"],\ninput[type=\"number\"]:focus,\ninput[type=\"number\"]:active,\ninput[type=\"password\"],\ninput[type=\"password\"]:focus,\ninput[type=\"password\"]:active,\ntextarea,\ntextarea:active,\ntextarea:focus,\nbutton,\nbutton:active,\nbutton:focus,\nbutton:invalid,\na:active,\na:visited,\na:link {\n  outline: 0;\n  outline-color: transparent;\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  appearance: none;\n}\n\ninput[type=\"number\"] {\n  -webkit-appearance: textfield;\n  -moz-appearance: textfield;\n  appearance: textfield;\n}\n\ninput[type=\"number\"]::-webkit-outer-spin-button,\ninput[type=\"number\"]::-webkit-inner-spin-button {\n  -webkit-appearance: none;\n  appearance: none;\n  margin: 0;\n}\n\na:link,\na:visited {\n  text-decoration: none;\n}\n\na:hover {\n  color: #08a;\n}\n\na {\n  color: var(--global-link-color);\n  transition: color 150ms linear;\n  cursor: pointer;\n}\n\n// 默认盒模型为border-box\n*,\n*::before,\n*::after {\n  box-sizing: border-box;\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n  font-weight: normal;\n}\n\n.fl {\n  float: left;\n}\n\n.fr {\n  float: right;\n}\n\n.clear,\n.clear::before,\n.clear::after {\n  clear: both;\n}\n\n.clear::before,\n.clear::after {\n  content: '';\n  display: table;\n}\n\ndiv:focus {\n  outline: none;\n}\n"
  },
  {
    "path": "src/assets/scss/sarasa-term-sc.scss",
    "content": "body.sarasa-term-sc {\n  font-family:\n    \"Sarasa Term SC\",\n    'Microsoft YaHei',\n    '微软雅黑',\n    'PingFang SC',\n    'HanHei SC',\n    'Helvetica Neue',\n    'Helvetica',\n    'STHeitiSC-Light',\n    'Arial',\n    sans-serif;\n}\n"
  },
  {
    "path": "src/assets/scss/variables.scss",
    "content": "// 原生CSS变量 -- 顶级作用域\n:root {\n  --layout-header-height: 60px;\n  --layout-main-height: calc(100vh - var(--layout-header-height));\n\n  --list-container-width: 1300px;\n\n  --detail-container-width: 900px;\n\n  --global-background-color: #392f41;\n  --global-text-color: #ddd;\n  --global-link-color: #2ca9e1;\n\n  --layout-main-bg-color: rgba(20, 30, 40, 0.75);\n  --layout-bg-color: #252748;\n  \n  --world-map-point-color: #fff143;\n\n  --duration-color: #89c3eb;\n  --transfer-color: #f9ed69;\n  --transfer-in-color: var(--transfer-color);\n  --transfer-out-color: #90f2ff;\n  --net-speed-color: #90f2ff;\n  --net-speed-in-color: #f5b199;\n  --net-speed-out-color: #89c3eb;\n  --conn-color: #90f2ff;\n  --conn-tcp-color: #89c3eb;\n  --conn-udp-color: #2ca9e1;\n  --load-color: #90f2ff;\n  --process-color: #f5b199;\n  --cpu-text-color: #89c3eb;\n  --mem-text-color: #2ca9e1;\n  --disk-text-color: #90f2ff;\n  --swap-text-color: #f5b199;\n\n  --list-item-price-color: #eee;\n  --list-item-buy-link-color: #ffc300;\n  --list-item-buy-link-color-hover: #ff9900;\n  --public-note-tag-color: #ccc;\n  --public-note-tag-bg: linear-gradient(125deg, #676ef7, #41459c);\n\n  --option-high-color: #ff7500;\n  --option-high-color-active: rgba(255, 177, 0, 0.75);\n\n  --server-status-value-color: #a1eafb;\n  --server-status-label-color: #ddd;\n  --server-status-content-color: #eee;\n\n  // 针对1440px以下的屏幕\n  @media screen and (max-width: 1440px) {\n    --list-container-width: 1120px;\n  }\n\n  // 针对1280px以下的屏幕\n  @media screen and (max-width: 1280px) {\n    --list-container-width: 1024px;\n  }\n\n  @media screen and (max-width: 1024px) {\n    --list-container-width: 800px;\n    --detail-container-width: 800px;\n  }\n\n  @media screen and (max-width: 800px) {\n    --list-container-width: 720px;\n    --detail-container-width: 720px;\n  }\n\n  @media screen and (max-width: 720px) {\n    --list-container-width: 100vw;\n    --detail-container-width: 100vw;\n  }\n}\n\nbody.simple-color-mode {\n\n  --world-map-point-color: #cbf1f5;\n  \n  --simple-color: #ccc;\n  --duration-color: var(--simple-color);\n  --transfer-color: var(--simple-color);\n  --transfer-in-color: var(--transfer-color);\n  --transfer-out-color: var(--simple-color);\n  --net-speed-in-color: var(--simple-color);\n  --net-speed-out-color: var(--simple-color);\n\n  --list-item-price-color: #eee;\n  --list-item-buy-link-color: var(--simple-color);\n  --list-item-buy-link-color-hover: draken(#cbf1f5, 10%);\n  --public-note-tag-color: #eee;\n  --public-note-tag-bg: transparent;\n\n  --option-high-color: rgb(93, 122, 126);\n  --option-high-color-active: rgba(93, 122, 126, 0.75);\n\n  --server-status-value-color: var(--simple-color);\n}\n"
  },
  {
    "path": "src/components/charts/donut.js",
    "content": "import { use } from 'echarts/core';\nimport { SVGRenderer } from 'echarts/renderers';\nimport {\n  BarChart,\n} from 'echarts/charts';\nimport {\n  PolarComponent,\n} from 'echarts/components';\n\nimport config from '@/config';\n\nuse([\n  SVGRenderer,\n  BarChart,\n  PolarComponent,\n]);\n\nfunction handleColor(color) {\n  if (Array.isArray(color)) {\n    return {\n      type: 'linear',\n      x: 1,\n      y: 1,\n      x2: 0,\n      y2: 0,\n      colorStops: [{\n        offset: 0,\n        color: color[0], // 0% 处的颜色\n      }, {\n        offset: 1,\n        color: color[1], // 100% 处的颜色\n      }],\n    };\n  }\n  return color;\n}\n\nexport default (used, total, itemColors, size = 100) => {\n  const isLinear = (\n    (config.nazhua.serverStatusLinear || config.nazhua.lightBackground)\n    && !config.nazhua.simpleColorMode\n  );\n  return {\n    angleAxis: {\n      max: total, // 满分\n      // 隐藏刻度线\n      axisLine: {\n        show: false,\n      },\n      axisTick: {\n        show: false,\n      },\n      axisLabel: {\n        show: false,\n      },\n      splitLine: {\n        show: false,\n      },\n    },\n    radiusAxis: {\n      type: 'category',\n      // 隐藏刻度线\n      axisLine: {\n        show: false,\n      },\n      axisTick: {\n        show: false,\n      },\n      axisLabel: {\n        show: false,\n      },\n      splitLine: {\n        show: false,\n      },\n    },\n    polar: {\n      center: ['50%', '50%'],\n      radius: ['50%', '100%'],\n    },\n    series: [{\n      type: 'bar',\n      data: [{\n        value: used,\n      }],\n      itemStyle: {\n        color: typeof itemColors === 'string' ? itemColors : handleColor(itemColors?.used),\n        borderRadius: 5,\n        shadowColor: (() => {\n          if (config.nazhua.serverStatusLinear) {\n            return 'rgba(0, 0, 0, 0.5)';\n          }\n          if (config.nazhua.lightBackground) {\n            return 'rgba(0, 0, 0, 0.2)';\n          }\n          return undefined;\n        })(),\n        shadowBlur: isLinear ? 10 : undefined,\n      },\n      coordinateSystem: 'polar',\n      cursor: 'default',\n      roundCap: true,\n      barWidth: Math.ceil((size / 100) * 10),\n      barGap: '-100%', // 两环重叠\n      z: 10,\n    }, {\n      type: 'bar',\n      data: [{\n        value: total,\n      }],\n      itemStyle: {\n        color: handleColor(itemColors?.total) || 'rgba(255, 255, 255, 0.2)',\n      },\n      coordinateSystem: 'polar',\n      cursor: 'default',\n      barWidth: Math.ceil((size / 100) * 10),\n      barGap: '-100%', // 两环重叠\n      z: 5,\n    }],\n  };\n};\n"
  },
  {
    "path": "src/components/charts/donut.vue",
    "content": "<template>\n  <div\n    v-if=\"option\"\n    ref=\"chartBoxRef\"\n    class=\"donut-box\"\n    :class=\"{\n      'donut-box--content': showContent,\n    }\"\n  >\n    <v-chart\n      ref=\"chartRef\"\n      class=\"donut-box-v-chart\"\n      :option=\"option\"\n    />\n    <div\n      v-if=\"showContent\"\n      class=\"donunt-content\"\n    >\n      <slot />\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 环状图\n */\n\nimport {\n  ref,\n  computed,\n  onMounted,\n  onUnmounted,\n} from 'vue';\nimport VChart from 'vue-echarts';\nimport donut from './donut';\n\nconst props = defineProps({\n  used: {\n    type: [Number, String],\n    default: 0,\n  },\n  total: {\n    type: [Number, String],\n    default: 100,\n  },\n  itemColors: {\n    type: [Object, String],\n    default: () => ({\n      used: '#409EFF',\n      total: '#E6A23C',\n    }),\n  },\n  showContent: {\n    type: Boolean,\n    default: true,\n  },\n});\n\nconst chartBoxRef = ref();\nconst chartRef = ref();\nconst chartSize = ref(100);\nconst option = computed(() => {\n  if (props.used) {\n    return donut(\n      props.used,\n      props.total,\n      props.itemColors,\n      chartSize.value || 100,\n    );\n  }\n  return null;\n});\n\nfunction handleResize() {\n  const {\n    offsetWidth,\n    offsetHeight,\n  } = chartBoxRef.value;\n  const oldSize = chartSize.value;\n  chartSize.value = Math.floor(Math.min(offsetWidth, offsetHeight));\n  if (oldSize !== chartSize.value && chartRef?.value?.resize) {\n    chartRef.value.resize();\n  }\n}\n\nonMounted(() => {\n  handleResize();\n  window.addEventListener('resize', handleResize);\n});\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize);\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.donut-box {\n  width: var(--donut-box-size, 100px);\n  height: var(--donut-box-size, 100px);\n}\n\n.donut-box--content {\n  position: relative;\n  .donunt-content {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    transform: translate(-50%, -50%);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/charts/line.js",
    "content": "import { use } from 'echarts/core';\nimport { SVGRenderer } from 'echarts/renderers';\nimport { LineChart } from 'echarts/charts';\nimport {\n  TooltipComponent,\n  GridComponent,\n  DataZoomComponent,\n} from 'echarts/components';\nimport dayjs from 'dayjs';\n\nimport config from '@/config';\n\nuse([\n  SVGRenderer,\n  LineChart,\n  TooltipComponent,\n  GridComponent,\n  DataZoomComponent,\n]);\n\nexport default (options) => {\n  const {\n    dateList,\n    valueList,\n    mode = 'dark',\n    connectNulls = true,\n  } = options || {};\n  const fontFamily = config.nazhua.disableSarasaTermSC === true ? undefined : 'Sarasa Term SC';\n  const option = {\n    darkMode: mode === 'dark',\n    tooltip: {\n      trigger: 'axis',\n      axisPointer: {\n        type: 'shadow',\n      },\n      formatter: (params) => {\n        const time = dayjs(parseInt(params[0].axisValue, 10)).format('YYYY.MM.DD HH:mm');\n        let res = `<p style=\"font-weight: bold; color: #ff6;\">${time}</p>`;\n        if (params.length < 10) {\n          params.forEach((i) => {\n            res += i.value[1] ? `${i.marker} ${i.seriesName}: ${i.value[1]}ms<br>` : '';\n          });\n        } else {\n          res += '<table>';\n          let trEnd = false;\n          params.forEach((i, index) => {\n            if (index % 2 === 0) {\n              res += '<tr>';\n            }\n            res += i.value[1]\n              ? `<td style=\"padding: 0 4px;\">${i.marker} ${i.seriesName}: ${i.value[1]}ms</td>`\n              : '<td style=\"padding: 0 4px;\"></td>';\n            if (index % 2 === 1) {\n              res += '</tr>';\n              trEnd = true;\n            }\n          });\n          if (!trEnd) {\n            res += '</tr>';\n          }\n          res += '</table>';\n        }\n        return res;\n      },\n      backgroundColor: mode === 'dark' ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',\n      borderColor: mode === 'dark' ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.7)',\n      textStyle: {\n        color: mode === 'dark' ? '#ddd' : '#222',\n        fontFamily: 'Sarasa Term SC',\n        fontSize: 14,\n      },\n    },\n    grid: {\n      top: 10,\n      left: 5,\n      right: 5,\n      bottom: 50,\n      containLabel: true,\n    },\n    dataZoom: [{\n      id: 'dataZoomX',\n      type: 'slider',\n      xAxisIndex: [0],\n      filterMode: 'filter',\n    }],\n    yAxis: {\n      type: 'value',\n      splitLine: {\n        lineStyle: {\n          color: mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.4)',\n        },\n      },\n      axisLabel: {\n        fontFamily,\n        color: mode === 'dark' ? '#ddd' : '#222',\n        fontSize: 12,\n      },\n    },\n    xAxis: {\n      type: 'time',\n      data: dateList,\n      axisLabel: {\n        hideOverlap: true,\n        nameTextStyle: {\n          fontSize: 12,\n        },\n        fontFamily,\n        color: mode === 'dark' ? '#eee' : '#222',\n      },\n    },\n    series: valueList.map((i) => ({\n      ...i,\n      type: 'line',\n      smooth: true,\n      connectNulls,\n      legendHoverLink: false,\n      symbol: 'none',\n    })),\n  };\n  return option;\n};\n"
  },
  {
    "path": "src/components/charts/line.vue",
    "content": "<template>\n  <div\n    v-if=\"option\"\n    class=\"line-box\"\n    :style=\"boxStyle\"\n  >\n    <v-chart\n      ref=\"chartRef\"\n      class=\"chart\"\n      :option=\"option\"\n    />\n  </div>\n</template>\n\n<script setup>\n/**\n * 折线图\n */\nimport {\n  ref,\n  computed,\n  onMounted,\n  onUnmounted,\n} from 'vue';\nimport VChart from 'vue-echarts';\nimport lineChart from './line';\n\nconst props = defineProps({\n  dateList: {\n    type: Array,\n    default: () => [],\n  },\n  valueList: {\n    type: Array,\n    default: () => [],\n  },\n  size: {\n    type: [Number, String],\n    default: null,\n  },\n  connectNulls: {\n    type: [Boolean, String],\n    default: true,\n  },\n});\n\nconst chartRef = ref();\nconst option = computed(() => {\n  if (props.dateList && props.valueList) {\n    return lineChart({\n      dateList: props.dateList,\n      valueList: props.valueList,\n      connectNulls: props.connectNulls,\n    });\n  }\n  return null;\n});\nconst boxStyle = computed(() => {\n  const style = {};\n  if (props.size > 0) {\n    style.height = `${props.size}px`;\n  }\n  return style;\n});\n\nfunction handleResize() {\n  chartRef.value?.resize?.();\n}\n\nonMounted(() => {\n  handleResize();\n  window.addEventListener('resize', handleResize);\n});\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize);\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.line-box {\n  width: 100%;\n  height: var(--line-chart-size, 300px);\n}\n</style>\n"
  },
  {
    "path": "src/components/dot-dot-box.vue",
    "content": "<template>\n  <div\n    class=\"dot-dot-box\"\n    :class=\"{\n      'dot-dot-box--hide': hideDotBG,\n    }\"\n    :style=\"boxStyle\"\n  >\n    <slot />\n  </div>\n</template>\n\n<script setup>\n/**\n * 点格子背景盒子\n */\n\nimport { computed } from 'vue';\nimport config from '@/config';\n\nconst props = defineProps({\n  borderRadius: {\n    type: [String, Number],\n    default: 12,\n  },\n  padding: {\n    type: [String, Number],\n    default: 20,\n  },\n  color: {\n    type: String,\n    default: '#eee',\n  },\n});\n\nconst lightBackground = computed(() => config.nazhua.lightBackground);\n\nconst hideDotBG = computed(() => lightBackground.value || config.nazhua?.hideDotBG === true);\n\nconst boxStyle = computed(() => {\n  const style = {};\n  if (props.borderRadius) {\n    if (typeof props.borderRadius === 'number') {\n      style['--border-radius'] = `${props.borderRadius}px`;\n    } else {\n      style['--border-radius'] = `${props.borderRadius}`;\n    }\n  }\n  if (props.padding) {\n    if (typeof props.padding === 'number') {\n      style.padding = `${props.padding}px`;\n    } else {\n      style.padding = props.padding;\n    }\n  }\n  if (props.color) {\n    style.color = props.color;\n  }\n  return style;\n});\n\n</script>\n\n<style lang=\"scss\" scoped>\n.dot-dot-box {\n  --border-radius: 12px;\n  color: #eee;\n  border-radius: var(--border-radius);\n  box-shadow: 2px 4px 6px rgba(#000, 0.4);\n\n  background-image: radial-gradient(transparent 1px, rgba(#000, 0.6) 1px);\n  background-size: 3px 3px;\n  backdrop-filter: saturate(50%) blur(3px);\n\n  &--hide {\n    background-color: rgba(#000, 0.5);\n    background-image: none;\n    backdrop-filter: none;\n    transition: all 0.3s linear;\n\n    &:hover {\n      background-color: rgba(#000, 0.8);\n    }\n  }\n\n  @media screen and (max-width: 768px) {\n    background-color: rgba(#000, 0.8);\n    background-image: none;\n    backdrop-filter: none;\n  }\n}\n\n</style>\n"
  },
  {
    "path": "src/components/fireworks.vue",
    "content": "<template>\n  <canvas\n    ref=\"canvas\"\n    class=\"fireworks-canvas\"\n  />\n</template>\n\n<script setup>\nimport {\n  ref,\n  onMounted,\n  onUnmounted,\n} from 'vue';\n\nconst canvas = ref(null);\nlet ctx = null;\nlet particles = [];\nlet rockets = [];\nlet animationFrameId = null;\n\nclass Particle {\n  constructor(x, y, color) {\n    this.x = x;\n    this.y = y;\n    this.color = color;\n    this.velocity = {\n      x: (Math.random() - 0.5) * 8,\n      y: (Math.random() - 0.5) * 12 - 8,\n    };\n    this.alpha = 1;\n    this.decay = 0.02;\n  }\n\n  draw() {\n    ctx.beginPath();\n    ctx.arc(this.x, this.y, 2, 0, Math.PI * 2);\n    ctx.fillStyle = `rgba(${this.color}, ${this.alpha})`;\n    ctx.fill();\n  }\n\n  update() {\n    this.velocity.y += 0.1;\n    this.x += this.velocity.x;\n    this.y += this.velocity.y;\n    this.alpha -= this.decay;\n  }\n}\n\nfunction createFirework(x, y) {\n  const colors = [\n    '255, 0, 0',\n    '0, 255, 0',\n    '0, 0, 255',\n    '255, 255, 0',\n    '255, 0, 255',\n    '0, 255, 255',\n  ];\n  const color = colors[Math.floor(Math.random() * colors.length)];\n  for (let i = 0; i < 80; i += 1) {\n    particles.push(new Particle(x, y, color));\n  }\n}\n\nclass Rocket {\n  constructor() {\n    this.x = Math.random() * canvas.value.width;\n    this.y = canvas.value.height;\n    this.targetY = canvas.value.height * 0.5;\n    this.speed = 15;\n    this.trail = [];\n    this.maxTrailLength = 5;\n  }\n\n  draw() {\n    // 绘制火箭尾迹\n    ctx.beginPath();\n    this.trail.forEach((pos, index) => {\n      ctx.fillStyle = `rgba(255, 200, 0, ${index / this.trail.length})`;\n      ctx.fillRect(pos.x, pos.y, 2, 2);\n    });\n\n    // 绘制火箭本体\n    ctx.fillStyle = 'rgba(255, 220, 0, 1)';\n    ctx.fillRect(this.x, this.y, 3, 3);\n  }\n\n  update() {\n    this.trail.push({\n      x: this.x,\n      y: this.y,\n    });\n    if (this.trail.length > this.maxTrailLength) {\n      this.trail.shift();\n    }\n    this.y -= this.speed;\n    if (this.y <= this.targetY) {\n      createFirework(this.x, this.y);\n      return false;\n    }\n    return true;\n  }\n}\n\nfunction animate() {\n  ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);\n\n  // 更新和绘制火箭\n  rockets = rockets.filter((rocket) => {\n    rocket.draw();\n    return rocket.update();\n  });\n\n  // 更新和绘制粒子\n  particles = particles.filter((particle) => particle.alpha > 0);\n  particles.forEach((particle) => {\n    particle.draw();\n    particle.update();\n  });\n\n  // 发射新的火箭\n  if (Math.random() < 0.03 && rockets.length < 3) {\n    rockets.push(new Rocket());\n  }\n\n  animationFrameId = requestAnimationFrame(animate);\n}\n\nfunction resizeCanvas() {\n  if (canvas.value) {\n    canvas.value.width = window.innerWidth;\n    canvas.value.height = window.innerHeight;\n  }\n}\n\nonMounted(() => {\n  ctx = canvas.value.getContext('2d');\n  resizeCanvas();\n  window.addEventListener('resize', resizeCanvas);\n  animate();\n});\n\nonUnmounted(() => {\n  window.removeEventListener('resize', resizeCanvas);\n  if (animationFrameId) {\n    cancelAnimationFrame(animationFrameId);\n  }\n});\n</script>\n\n<style scoped>\n.fireworks-canvas {\n  position: fixed;\n  top: 0;\n  left: 0;\n  z-index: 8;\n  pointer-events: none;\n}\n</style>\n"
  },
  {
    "path": "src/components/lantern.vue",
    "content": "<template>\n  <div class=\"lantern-container\">\n    <div class=\"lantern-group right-group\">\n      <div class=\"deng-box\">\n        <div class=\"deng\">\n          <div class=\"xian\" />\n          <div class=\"deng-a\">\n            <div class=\"deng-b\">\n              <div class=\"deng-t\">快</div>\n            </div>\n          </div>\n          <div class=\"shui shui-a\">\n            <div class=\"shui-c\" />\n            <div class=\"shui-b\" />\n          </div>\n        </div>\n      </div>\n      <div class=\"deng-box deng-box--2\">\n        <div class=\"deng\">\n          <div class=\"xian\" />\n          <div class=\"deng-a\">\n            <div class=\"deng-b\">\n              <div class=\"deng-t\">乐</div>\n            </div>\n          </div>\n          <div class=\"shui shui-a\">\n            <div class=\"shui-c\" />\n            <div class=\"shui-b\" />\n          </div>\n        </div>\n      </div>\n    </div>\n    <div class=\"lantern-group left-group\">\n      <div class=\"deng-box\">\n        <div class=\"deng\">\n          <div class=\"xian\" />\n          <div class=\"deng-a\">\n            <div class=\"deng-b\">\n              <div class=\"deng-t\">新</div>\n            </div>\n          </div>\n          <div class=\"shui shui-a\">\n            <div class=\"shui-c\" />\n            <div class=\"shui-b\" />\n          </div>\n        </div>\n      </div>\n      <div class=\"deng-box deng-box--2\">\n        <div class=\"deng\">\n          <div class=\"xian\" />\n          <div class=\"deng-a\">\n            <div class=\"deng-b\">\n              <div class=\"deng-t\">年</div>\n            </div>\n          </div>\n          <div class=\"shui shui-a\">\n            <div class=\"shui-c\" />\n            <div class=\"shui-b\" />\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\n// 灯笼组件\n// 由AI生成\n</script>\n\n<style lang=\"scss\" scoped>\n.lantern-container {\n  position: fixed;\n  top: calc(var(--layout-header-height) + 5px);\n  width: 100%;\n  z-index: 50;\n  pointer-events: none;\n}\n\n.lantern-group {\n  position: fixed;\n  top: 70px;\n  animation: swing 3s infinite ease-in-out;\n  transform-origin: 50% -10px;\n\n  &.left-group {\n    left: 40px;\n    animation-delay: -1.5s;\n\n    .deng-box:nth-child(2) {\n      margin-top: -12px;\n\n      .deng {\n        animation: swing-extra 2s infinite ease-in-out;\n        animation-delay: -0.5s;\n      }\n    }\n  }\n\n  &.right-group {\n    right: 30px;\n    animation-delay: -0.5s;\n\n    .deng-box:nth-child(2) {\n\n      .deng {\n        animation: swing-extra 2s infinite ease-in-out;\n        animation-delay: -1s;\n      }\n    }\n  }\n\n  .deng {\n    animation: none;\n  }\n\n  .deng-box {\n    position: relative;\n    top: -40px;\n\n    &:first-child {\n      z-index: 2;\n      .deng {\n        margin-bottom: 23px;\n      }\n    }\n\n    &:nth-child(2) {\n      z-index: 1;\n    }\n  }\n}\n\n.deng {\n  position: relative;\n  width: 120px;\n  height: 90px;\n  margin: 50px;\n  background: rgba(216, 0, 15, 0.8);\n  border-radius: 50% 50%;\n  transform-origin: 50% -100px;\n  animation: swing 3s infinite ease-in-out;\n  box-shadow: -5px 5px 50px 4px rgba(250, 108, 0, 1);\n}\n\n.deng-a {\n  width: 100px;\n  height: 90px;\n  background: rgba(216, 0, 15, 0.1);\n  margin: 12px 8px 8px 10px;\n  border-radius: 50% 50%;\n  border: 2px solid #dc8f03;\n}\n\n.deng-b {\n  width: 45px;\n  height: 90px;\n  background: rgba(216, 0, 15, 0.1);\n  margin: -4px 8px 8px 26px;\n  border-radius: 50% 50%;\n  border: 2px solid #dc8f03;\n}\n\n.xian {\n  position: absolute;\n  top: -20px;\n  left: 60px;\n  width: 2px;\n  height: 20px;\n  background: #dc8f03;\n}\n\n.shui-a {\n  position: relative;\n  width: 5px;\n  height: 20px;\n  margin: -5px 0 0 59px;\n  transform-origin: 50% -45px;\n  background: #ffa500;\n  border-radius: 0 0 5px 5px;\n}\n\n.shui-b {\n  position: absolute;\n  top: 14px;\n  left: -2px;\n  width: 10px;\n  height: 10px;\n  background: #dc8f03;\n  border-radius: 50%;\n}\n\n.shui-c {\n  position: absolute;\n  top: 18px;\n  left: -2px;\n  width: 10px;\n  height: 35px;\n  background: #ffa500;\n  border-radius: 0 0 0 5px;\n}\n\n.deng:before {\n  position: absolute;\n  top: -7px;\n  left: 29px;\n  height: 12px;\n  width: 60px;\n  content: \" \";\n  display: block;\n  z-index: 999;\n  border-radius: 5px 5px 0 0;\n  border: solid 1px #dc8f03;\n  background: linear-gradient(to right, #dc8f03, #ffa500, #dc8f03, #ffa500, #dc8f03);\n}\n\n.deng:after {\n  position: absolute;\n  bottom: -7px;\n  left: 10px;\n  height: 12px;\n  width: 60px;\n  content: \" \";\n  display: block;\n  margin-left: 20px;\n  border-radius: 0 0 5px 5px;\n  border: solid 1px #dc8f03;\n  background: linear-gradient(to right, #dc8f03, #ffa500, #dc8f03, #ffa500, #dc8f03);\n}\n\n.deng-t {\n  font-family: 华文行楷, Arial, Lucida Grande, Tahoma, sans-serif;\n  font-size: 3.2rem;\n  color: #ffd000;\n  line-height: 85px;\n  text-align: center;\n  margin-left: -5px;\n}\n\n@keyframes swing {\n  0% { transform: rotate(-6deg) }\n  50% { transform: rotate(6deg) }\n  100% { transform: rotate(-6deg) }\n}\n\n@keyframes swing-extra {\n  0% { transform: rotate(-3deg) }\n  50% { transform: rotate(3deg) }\n  100% { transform: rotate(-3deg) }\n}\n\n@media screen and (max-width: 1024px) {\n  .lantern-container {\n    display: none;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/popover.vue",
    "content": "<template>\n  <div\n    ref=\"triggerRef\"\n    class=\"popover-trigger\"\n    @mouseenter=\"handleMouseEnter\"\n    @mouseleave=\"handleMouseLeave\"\n    @focusin=\"handleFocusIn\"\n    @focusout=\"handleFocusOut\"\n    @click=\"handleTriggerClick\"\n  >\n    <slot name=\"trigger\" />\n  </div>\n\n  <Teleport to=\"body\">\n    <div\n      v-show=\"isShow\"\n      ref=\"popoverRef\"\n      class=\"popover\"\n      :style=\"[popoverStyle, { zIndex: currentZIndex }]\"\n    >\n      <template v-if=\"$slots.title || title\">\n        <div class=\"popover-body\">\n          {{ title }}\n        </div>\n      </template>\n      <template v-else>\n        <div class=\"popover-body\">\n          <slot name=\"default\" />\n        </div>\n      </template>\n    </div>\n  </Teleport>\n</template>\n\n<script setup>\n/**\n  组件名称：Popover\n\n  组件说明：\n  该组件在移动端与 PC 端提供不同的交互模式，通过 \"hover\" 或 \"click\" 来触发显示或隐藏提示浮层。\n  若设置 unique 属性，则在显示新浮层的同时会隐藏其他已显示的浮层。\n\n  使用示例：\n  <Popover title=\"示例标题\" trigger=\"click\">\n    <template #trigger>\n      <button>点击触发</button>\n    </template>\n    这是 Popover 的内容\n  </Popover>\n\n  Props:\n    - visible (Boolean，默认 false)\n      Popover 的可见状态，可供外部进行手动控制。\n    - title (String，默认 '')\n      Popover 的标题文本，如不传则展示默认内容插槽。\n    - trigger (String，默认 'hover')\n      触发模式，可选值为 \"hover\" 或 \"click\"。\n    - unique (Boolean，默认 true)\n      如果为 true，则在显示当前 Popover 时会自动隐藏其他已显示的 Popover。\n\n  方法说明：\n    - handleMouseEnter()\n      当鼠标移入触发元素时，若 trigger 为 hover，会显示 Popover。\n    - handleMouseLeave()\n      当鼠标移出触发元素时，若 trigger 为 hover，会隐藏 Popover。\n    - handleTriggerClick(e)\n      当在移动端或 trigger 为 click 时，点击触发元素会切换 Popover 显示状态，并在移动端下自动延时隐藏。\n    - handleFocusIn()\n      当触发元素获得焦点时，若触发方式为 hover，会显示 Popover。\n    - handleFocusOut()\n      当触发元素失去焦点时，若触发方式为 hover，会隐藏 Popover。\n\n  注意事项：\n    - 在移动端会根据窗口宽度做适配，通过 document 监听点击事件和窗口大小变化来控制显示与关闭。\n    - 当 visible 通过外部控制时，非移动端能手动实现 Popover 的显隐。\n */\nimport {\n  ref,\n  computed,\n  onMounted,\n  onUnmounted,\n  watch,\n} from 'vue';\nimport { getNextZIndex } from '../utils/zIndexManager';\n\nconst props = defineProps({\n  visible: {\n    type: Boolean,\n    default: false,\n  },\n  title: {\n    type: String,\n    default: '',\n  },\n  trigger: {\n    type: String,\n    default: 'hover',\n    validator: (value) => ['hover', 'click'].includes(value),\n  },\n  unique: {\n    type: Boolean,\n    default: true,\n  },\n});\n\n// 移除全局 Symbol 相关代码\n// 添加静态 z-index 计数器\n// const baseZIndex = 1000;\n// let zIndexCounter = baseZIndex;\n\nconst popoverRef = ref(null);\nconst position = ref({\n  x: 0,\n  y: 0,\n});\nconst isMobile = ref(window.innerWidth < 600);\nconst isShow = ref(false);\nconst triggerRef = ref(null);\nconst currentZIndex = ref(1000);\n\n// 移除 getCurrentPopover 和 setCurrentPopover 函数\n\n// 更新移动端位置\nconst updateMobilePosition = () => {\n  if (!triggerRef.value) return;\n  const rect = triggerRef.value.getBoundingClientRect();\n  position.value = {\n    x: rect.left + rect.width / 2,\n    y: rect.top + rect.height,\n  };\n};\n\n// 修改显示逻辑\nconst updateShow = (value) => {\n  if (value) {\n    currentZIndex.value = getNextZIndex();\n  }\n  isShow.value = value;\n};\n\nconst handleMouseEnter = () => {\n  if (!isMobile.value && props.trigger === 'hover') {\n    updateShow(true);\n  }\n};\n\nconst handleMouseLeave = () => {\n  if (!isMobile.value && props.trigger === 'hover') {\n    updateShow(false);\n  }\n};\n\nlet autoCloseTimer;\nconst handleTriggerClick = (e) => {\n  if (props.trigger === 'click' || isMobile.value) {\n    e.stopPropagation();\n    updateShow(!isShow.value);\n    if (isShow.value && isMobile.value) {\n      if (autoCloseTimer) {\n        clearTimeout(autoCloseTimer);\n      }\n      autoCloseTimer = setTimeout(() => {\n        isShow.value = false;\n      }, 5 * 1000);\n      updateMobilePosition();\n    }\n  }\n};\n\nconst handleFocusIn = () => {\n  if (!isMobile.value && props.trigger === 'hover') {\n    isShow.value = true;\n  }\n};\n\nconst handleFocusOut = () => {\n  if (!isMobile.value && props.trigger === 'hover') {\n    isShow.value = false;\n  }\n};\n\n// 修改点击事件处理\nconst handleDocumentClick = (e) => {\n  if (isShow.value && !triggerRef.value?.contains(e.target) && !popoverRef.value?.contains(e.target)) {\n    isShow.value = false;\n  }\n};\n\nconst updatePosition = (e) => {\n  if (isMobile.value || !isShow.value) return;\n  position.value = {\n    x: e.clientX,\n    y: e.clientY,\n  };\n};\n\nconst popoverStyle = computed(() => {\n  if (isMobile.value) {\n    return {\n      position: 'fixed',\n      bottom: '10vh',\n      left: '50%',\n      transform: 'translateX(-50%)',\n    };\n  }\n\n  const { x, y } = position.value;\n  const rect = popoverRef.value?.getBoundingClientRect();\n  const offset = 15; // 修改为20px偏移量\n\n  let left = x + offset;\n  let top = y + offset;\n\n  if (rect) {\n    // 防止超出右边界\n    if (left + rect.width > window.innerWidth) {\n      left = x - rect.width - offset;\n    }\n    // 防止超出下边界\n    if (top + rect.height > window.innerHeight) {\n      top = y - rect.height - offset;\n    }\n  }\n\n  return {\n    position: 'fixed',\n    left: `${left}px`,\n    top: `${top}px`,\n  };\n});\n\nconst handleResize = () => {\n  isMobile.value = window.innerWidth < 600;\n};\n\n// 监听visible属性变化\nwatch(() => props.visible, (newVal) => {\n  if (!isMobile.value) {\n    updateShow(newVal);\n  }\n});\n\nonMounted(() => {\n  if (isMobile.value || props.trigger === 'click') {\n    document.addEventListener('click', handleDocumentClick);\n  }\n  if (!isMobile.value) {\n    document.addEventListener('mousemove', updatePosition);\n  }\n  window.addEventListener('resize', handleResize);\n});\n\nonUnmounted(() => {\n  if (isMobile.value || props.trigger === 'click') {\n    document.removeEventListener('click', handleDocumentClick);\n  }\n  if (!isMobile.value) {\n    document.removeEventListener('mousemove', updatePosition);\n  }\n  window.removeEventListener('resize', handleResize);\n  // 移除全局 Popover 相关的清理代码\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.popover-trigger {\n  display: inline-block;\n  cursor: pointer;\n}\n\n.popover {\n  background: rgba(#000, 0.8);\n  padding: 10px;\n  border-radius: 8px;\n  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);\n  // 移除固定的 z-index\n  max-width: 300px;\n\n  @media screen and (max-width: 600px) {\n    max-width: 90%;\n    text-align: center;\n    box-shadow: 0 4px 12px rgba(251, 255, 217, 0.15);\n  }\n\n  .popover-body {\n    line-height: 1.4;\n    font-size: 14px;\n    // 允许换行\n    white-space: pre-wrap;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/server-flag.vue",
    "content": "<template>\n  <span\n    class=\"server-flag\"\n  >\n    <span\n      class=\"fi\"\n      :class=\"'fi-' + lastFlag\"\n    />\n  </span>\n</template>\n\n<script setup>\nimport { computed } from 'vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst lastFlag = computed(() => {\n  let flag = props.info?.Host?.CountryCode || 'un';\n  if (props.info?.PublicNote?.customData?.flag) {\n    flag = props.info.PublicNote.customData.flag;\n  }\n  return flag.toLowerCase();\n});\n</script>\n"
  },
  {
    "path": "src/components/world-map/world-map-point.vue",
    "content": "<template>\n  <div\n    ref=\"pointRef\"\n    class=\"world-map-point\"\n    :class=\"'world-map-point--' + (info?.type || 'default')\"\n    :style=\"pointStyle\"\n    :title=\"info?.label || ''\"\n    @click=\"handleClick\"\n  >\n    <div class=\"point-block\" />\n  </div>\n</template>\n\n<script setup>\n/**\n * 世界地图点\n */\n\nimport {\n  ref,\n  computed,\n} from 'vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst emits = defineEmits([\n  'point-tap',\n]);\n\nconst pointRef = ref();\n\nconst pointStyle = computed(() => {\n  const style = {};\n  style['--map-point-left'] = `${props.info.left}px`;\n  style['--map-point-top'] = `${props.info.top}px`;\n  if (props.info?.size) {\n    style['--map-point-size'] = `${props.info.size}px`;\n  }\n  return style;\n});\n\nfunction handleClick() {\n  emits('point-tap', props.info);\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.world-map-point {\n  --map-point-size: 6px;\n  --map-point-scale: 1;\n  position: absolute;\n  left: var(--map-point-left);\n  top: var(--map-point-top);\n  width: 16px;\n  height: 16px;\n  transform: translate(-50%, -50%);\n  :hover {\n    z-index: 100;\n  }\n\n  .point-block {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    width: calc(var(--map-point-size) * var(--map-point-scale));\n    height: calc(var(--map-point-size) * var(--map-point-scale));\n    transform: translate(-50%, -50%);\n    background: var(--world-map-point-color);\n    border-radius: 50%;\n\n    &::before {\n      content: '';\n      position: absolute;\n      top: 50%;\n      left: 50%;\n      width: calc(var(--map-point-size) * var(--map-point-scale) + (8px * var(--map-point-scale)));\n      height: calc(var(--map-point-size) * var(--map-point-scale) + (8px * var(--map-point-scale)));\n      transform: translate(-50%, -50%);\n      border: calc(2px * var(--map-point-scale)) solid var(--world-map-point-color);\n      border-radius: 50%;\n    }\n  }\n\n  @media screen and (max-width: 720px) {\n    --map-point-scale: 0.5;\n  }\n\n  &--group {\n    .point-block {\n      &::after {\n        content: '';\n        position: absolute;\n        top: 50%;\n        left: 50%;\n        width: calc(var(--map-point-size) * var(--map-point-scale) + (16px * var(--map-point-scale)));\n        height: calc(var(--map-point-size) * var(--map-point-scale) + (16px * var(--map-point-scale)));\n        transform: translate(-50%, -50%);\n        border: calc(2px * var(--map-point-scale)) solid var(--world-map-point-color);\n        border-radius: 50%;\n        opacity: 0.7;\n        transition: opacity 0.3s;\n      }\n\n      &:hover {\n        &::after {\n          opacity: 1;\n        }\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/components/world-map/world-map.vue",
    "content": "<template>\n  <div\n    class=\"world-map-group\"\n    :class=\"{\n      'world-map-group--light-background': lightBackground,\n    }\"\n    :style=\"mapStyle\"\n  >\n    <div class=\"world-map-img\" />\n    <transition-group\n      name=\"point\"\n      tag=\"div\"\n      class=\"world-map-point-container\"\n    >\n      <world-map-point\n        v-for=\"pointItem in mapPoints\"\n        :key=\"pointItem.key\"\n        :info=\"pointItem\"\n        @point-tap=\"handlePointTap\"\n      />\n    </transition-group>\n\n    <transition name=\"point\">\n      <div\n        v-if=\"tipsShow\"\n        class=\"world-map-tips\"\n        :style=\"tipsContentStyle\"\n      >\n        <span>{{ tipsContent }}</span>\n      </div>\n    </transition>\n  </div>\n</template>\n\n<script setup>\n/**\n * 世界地图盒子\n */\n\nimport {\n  ref,\n  computed,\n  watch,\n} from 'vue';\nimport config from '@/config';\nimport validate from '@/utils/validate';\n\nimport WorldMapPoint from './world-map-point.vue';\nimport {\n  findIntersectingGroups,\n} from '@/utils/world-map';\n\nconst props = defineProps({\n  width: {\n    type: [Number, String],\n    // default: 1280,\n    default: null,\n  },\n  height: {\n    type: [Number, String],\n    // default: 621,\n    default: null,\n  },\n  locations: {\n    type: Array,\n    default: () => [],\n  },\n});\n\nconst lightBackground = computed(() => config.nazhua.lightBackground);\nconst boxPadding = computed(() => (lightBackground.value ? 20 : 0));\n\n// 计算地图大小 保持1280:621的比例 保证地图不变形\nconst computedSize = computed(() => {\n  // 考虑内边距，从总宽高中减去padding\n  const adjustedWidth = Number(props.width) - (boxPadding.value * 2);\n  const adjustedHeight = Number(props.height) - (boxPadding.value * 2);\n\n  if (!validate.isEmpty(props.width) && !validate.isEmpty(props.height)) {\n    return {\n      width: 1280,\n      height: 621,\n    };\n  }\n\n  if (!validate.isEmpty(props.width) && validate.isEmpty(props.height)) {\n    return {\n      width: adjustedWidth,\n      height: Math.ceil((621 / 1280) * adjustedWidth),\n    };\n  }\n\n  if (validate.isEmpty(props.width) && !validate.isEmpty(props.height)) {\n    return {\n      width: Math.ceil((1280 / 621) * adjustedHeight),\n      height: adjustedHeight,\n    };\n  }\n\n  if (adjustedWidth / adjustedHeight > 1280 / 621) {\n    return {\n      width: Math.ceil(adjustedHeight * (1280 / 621)),\n      height: adjustedHeight,\n    };\n  }\n\n  return {\n    width: adjustedWidth,\n    height: Math.ceil(adjustedWidth * (621 / 1280)),\n  };\n});\n\nconst mapStyle = computed(() => {\n  const style = {};\n  style['--world-map-width'] = `${computedSize.value.width}px`;\n  style['--world-map-height'] = `${computedSize.value.height}px`;\n  return style;\n});\n\nconst mapPoints = ref([]);\nlet computeMapPointsTimer = null;\nfunction computeMapPoints() {\n  if (computeMapPointsTimer) {\n    clearTimeout(computeMapPointsTimer);\n  }\n  if (props.locations.length === 0) {\n    mapPoints.value = [];\n    return;\n  }\n  computeMapPointsTimer = setTimeout(() => {\n    const points = props.locations.map((i) => {\n      const item = {\n        key: i.key,\n        left: (computedSize.value.width / 1280) * i.x + boxPadding.value,\n        top: (computedSize.value.height / 621) * i.y + boxPadding.value,\n        size: i.size || 4,\n        label: i.label,\n        servers: i.servers,\n        type: 'single',\n      };\n      const halfSize = (item.size + 8) / 2;\n      item.topLeft = {\n        left: item.left - halfSize,\n        top: item.top - halfSize,\n      };\n      item.bottomRight = {\n        left: item.left + halfSize,\n        top: item.top + halfSize,\n      };\n      return item;\n    });\n    const groups = findIntersectingGroups(points);\n    Object.entries(groups).forEach(([key, group]) => {\n      const item = points.find((i) => i.key === key);\n      if (item.parent) {\n        return;\n      }\n      item.size = 4;\n      item.type = 'group';\n      item.children = group;\n      let label = item.label || '';\n      let servers = [...(item.servers || [])];\n      group.forEach((i) => {\n        if (!i.parent && !i.children) {\n          i.parent = item;\n          label += `\\n${i.label}`;\n          servers = servers.concat((i.servers || []));\n        }\n      });\n      item.label = label;\n      item.servers = servers;\n    });\n    mapPoints.value = points.filter((i) => !i.parent);\n  }, 100);\n}\n\nwatch(() => props.locations, () => {\n  computeMapPoints();\n}, {\n  immediate: true,\n});\n\nwatch(() => computedSize.value, () => {\n  computeMapPoints();\n}, {\n  immediate: true,\n  deep: true,\n});\n\n/**\n * 提示框\n */\nconst tipsShow = ref(false);\nconst tipsContent = ref('');\nconst activeTipsXY = ref({\n  x: 0,\n  y: 0,\n});\nconst tipsContentStyle = computed(() => {\n  const style = {};\n  if (window.innerWidth > 500) {\n    style.top = `${activeTipsXY.value.y}px`;\n    style.left = `${activeTipsXY.value.x}px`;\n    style.transform = 'translate(-50%, 20px)';\n  } else {\n    style.bottom = '4px';\n    style.left = '50%';\n    style.transform = 'translate(-50%, 0)';\n  }\n  return style;\n});\nlet handlePointTapTimer = null;\nfunction handlePointTap(e) {\n  tipsContent.value = e.label;\n  activeTipsXY.value = {\n    x: e.left,\n    y: e.top - 10,\n  };\n  tipsShow.value = true;\n  if (handlePointTapTimer) {\n    clearTimeout(handlePointTapTimer);\n  }\n  handlePointTapTimer = setTimeout(() => {\n    tipsShow.value = false;\n  }, 5000);\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.world-map-group {\n  width: var(--world-map-width, 1280px);\n  height: var(--world-map-height, 621px);\n  position: relative;\n\n  &--light-background {\n    padding: 20px;\n    background: rgba(#000, 0.6);\n    border-radius: 12px;\n    box-sizing: content-box;\n    transition: background-color 0.3s linear;\n\n    .world-map-img {\n      opacity: 1;\n    }\n\n    &:hover {\n      background: rgba(#000, 0.9);\n    }\n\n    @media screen and (max-width: 768px) {\n      background: rgba(#000, 0.8);\n\n      &:hover {\n        background: rgba(#000, 0.8);\n      }\n    }\n  }\n\n  .world-map-img {\n    width: var(--world-map-width, 1280px);\n    height: var(--world-map-height, 621px);\n    background: url(@/assets/images/world-map.svg) 50% 50% no-repeat;\n    background-size: 100%;\n    opacity: 0.75;\n  }\n\n  .world-map-tips {\n    position: absolute;\n    padding: 5px 10px;\n    border-radius: 5px;\n    line-height: 20px;\n    white-space: pre;\n    color: #eee;\n    background: rgba(#000, 0.8);\n    box-shadow: 1px 4px 8px rgba(#303841, 0.4);\n    z-index: 100;\n\n    // 向上的尖角\n    &::before {\n      content: '';\n      position: absolute;\n      bottom: 100%;\n      left: 50%;\n      width: 0;\n      height: 0;\n      border: 5px solid transparent;\n      border-bottom-color: rgba(#000, 0.8);\n      transform: translateX(-50%);\n    }\n\n    @media screen and (max-width: 500px) {\n      line-height: 16px;\n      font-size: 12px;\n\n      &::before {\n        display: none;\n      }\n    }\n  }\n}\n\n.point-move,\n.point-enter-active,\n.point-leave-active {\n  transition: opacity 0.3s ease-in-out;\n}\n.point-enter-from,\n.point-leave-to {\n  opacity: 0;\n}\n</style>\n"
  },
  {
    "path": "src/config/index.js",
    "content": "import {\n  reactive,\n} from 'vue';\nimport {\n  loadProfile as loadNezhaV1Profile,\n} from '@/utils/load-nezha-v1-config';\n\nconst defaultNezhaVersion = import.meta.env.VITE_NEZHA_VERSION;\n\nconst config = reactive({\n  init: false,\n  nazhua: {\n    title: '哪吒监控',\n    // 如果打包禁用 Sarasa Term SC 字体，默认为禁用该字体的配置\n    disableSarasaTermSC: import.meta.env.VITE_DISABLE_SARASA_TERM_SC === '1',\n\n    nezhaVersion: ['v0', 'v1'].includes(defaultNezhaVersion) ? defaultNezhaVersion : null,\n    apiMonitorPath: '/api/v1/monitor/{id}',\n    wsPath: '/ws',\n    nezhaPath: '/nezha/',\n    nezhaV0ConfigType: 'servers',\n    v1ApiMonitorPath: '/api/v1/server/{id}/service',\n    v1ApiMonitorPathFallback: '/api/v1/service/{id}',\n    v1WsPath: '/api/v1/ws/server',\n    v1GroupPath: '/api/v1/server-group',\n    v1ApiSettingPath: '/api/v1/setting',\n    v1ApiProfilePath: '/api/v1/profile',\n    // 解构载入自定义配置\n    ...(window.$$nazhuaConfig || {}),\n  },\n});\n\nif (config.nazhua.nezhaVersion) {\n  config.init = true;\n}\n\nfunction handle$$serverStatus() {\n  if (window.$$serverStatus) {\n    config.nazhua.listServerItemType = 'server-status';\n    config.nazhua.homeWorldMapPosition = 'bottom';\n  }\n}\nhandle$$serverStatus();\n\nfunction setColorMode() {\n  if (config.nazhua.simpleColorMode) {\n    document.body.classList.add('simple-color-mode');\n  } else {\n    document.body.classList.remove('simple-color-mode');\n  }\n}\nsetColorMode();\n\n/**\n * 替换网站图标\n */\nfunction replaceFavicon() {\n  if (config.nazhua.customFavicon) {\n    const link = document.querySelector(\"link[rel*='icon']\");\n    link.type = 'image/x-icon';\n    link.rel = 'shortcut icon';\n    link.href = config.nazhua.customFavicon;\n  }\n}\nreplaceFavicon();\n\n/**\n * 合并自定义配置\n */\nexport function mergeNazhuaConfig(customConfig) {\n  Object.keys(customConfig).forEach((key) => {\n    config.nazhua[key] = customConfig[key];\n  });\n  replaceFavicon();\n  setColorMode();\n  handle$$serverStatus();\n}\n// 暴露合并配置方法\nwindow.$mergeNazhuaConfig = mergeNazhuaConfig;\n\nexport default config;\n\nexport const init = async () => {\n  await loadNezhaV1Profile(true).then((res) => {\n    config.nazhua.nezhaVersion = res ? 'v1' : 'v0';\n  });\n  config.init = true;\n};\n"
  },
  {
    "path": "src/data/code-maps.js",
    "content": "const codeMaps = {\n  PEK: {\n    x: 1025,\n    y: 178,\n    name: '北京',\n    country: '中国',\n  },\n  PVG: {\n    x: 1057,\n    y: 225,\n    name: '上海',\n    country: '中国',\n  },\n  CKG: {\n    x: 1010,\n    y: 235,\n    name: '重庆',\n    country: '中国',\n  },\n  TFU: {\n    x: 1000,\n    y: 230,\n    name: '成都',\n    country: '中国',\n  },\n  HKG: {\n    x: 1039,\n    y: 263,\n    name: '香港',\n    country: '中国',\n  },\n  MFM: {\n    x: 1035,\n    y: 264,\n    name: '澳门',\n    country: '中国',\n  },\n  TPE: {\n    x: 1067,\n    y: 253,\n    name: '台北',\n    country: '中国',\n  },\n  OSA: {\n    x: 1109,\n    y: 207,\n    name: '大阪',\n    country: '日本',\n  },\n  TYO: {\n    x: 1124,\n    y: 199,\n    name: '东京',\n    country: '日本',\n  },\n  SEL: {\n    x: 1077,\n    y: 198,\n    name: '首尔',\n    country: '韩国',\n  },\n  SIN: {\n    x: 1000,\n    y: 354,\n    name: '新加坡',\n    country: '新加坡',\n  },\n  JHB: {\n    x: 997,\n    y: 350,\n    name: '新山',\n    country: '马来西亚',\n  },\n  KUL: {\n    x: 990,\n    y: 345,\n    name: '吉隆坡',\n    country: '马来西亚',\n  },\n  BKK: {\n    name: '曼谷',\n    country: '泰国',\n    x: 985,\n    y: 296,\n  },\n  HAN: {\n    x: 998,\n    y: 274,\n    name: '河内',\n    country: '越南',\n  },\n  SGN: {\n    x: 1015,\n    y: 314,\n    name: '胡志明市',\n    country: '越南',\n  },\n  BOM: {\n    name: '孟买',\n    country: '印度',\n    x: 874,\n    y: 284,\n  },\n  DEL: {\n    name: '新德里',\n    country: '印度',\n    x: 886,\n    y: 246,\n  },\n  DXB: {\n    name: '迪拜',\n    country: '阿联酋',\n    x: 794.5,\n    y: 252,\n  },\n  LAX: {\n    x: 95,\n    y: 207,\n    name: '洛杉矶',\n    country: '美国',\n  },\n  LAS: {\n    x: 98,\n    y: 198,\n    name: '拉斯维加斯',\n    country: '美国',\n  },\n  SLC: {\n    x: 111,\n    y: 189,\n    name: '盐湖城',\n    country: '美国',\n  },\n  SJC: {\n    x: 87,\n    y: 193,\n    name: '圣何塞',\n    country: '美国',\n  },\n  SEA: {\n    x: 118,\n    y: 143,\n    name: '西雅图',\n    country: '美国',\n  },\n  MIA: {\n    x: 237,\n    y: 249,\n    name: '迈阿密',\n    country: '美国',\n  },\n  ORD: {\n    x: 233,\n    y: 175,\n    name: '芝加哥',\n    country: '美国',\n  },\n  NYC: {\n    x: 280,\n    y: 179,\n    name: '纽约',\n    country: '美国',\n  },\n  IAD: {\n    name: '阿什本',\n    country: 'US',\n    x: 265,\n    y: 186,\n  },\n  DFW: {\n    x: 172,\n    y: 211,\n    name: '达拉斯',\n    country: '美国',\n  },\n  ATL: {\n    x: 225,\n    y: 205,\n    name: '亚特兰大',\n    country: '美国',\n  },\n  HNL: {\n    x: 28,\n    y: 270,\n    name: '檀香山',\n    country: '美国',\n  },\n  YYZ: {\n    x: 267,\n    y: 161,\n    name: '多伦多',\n    country: '加拿大',\n  },\n  MEX: {\n    x: 158,\n    y: 280,\n    name: '墨西哥城',\n    country: '墨西哥',\n  },\n  SCQ: {\n    x: 289,\n    y: 513,\n    name: '圣地亚哥',\n    country: '智利',\n  },\n  GRU: {\n    x: 370,\n    y: 473,\n    name: '圣保罗',\n    country: '巴西',\n  },\n  SYD: {\n    x: 1167,\n    y: 519,\n    name: '悉尼',\n    country: '澳大利亚',\n  },\n  AMS: {\n    x: 595,\n    y: 125,\n    name: '阿姆斯特丹',\n    country: '荷兰',\n  },\n  LON: {\n    x: 571,\n    y: 127,\n    name: '伦敦',\n    country: '英国',\n  },\n  FRA: {\n    x: 603,\n    y: 137,\n    name: '法兰克福',\n    country: '德国',\n  },\n  BER: {\n    x: 620,\n    y: 130,\n    name: '柏林',\n    country: '德国',\n  },\n  LUX: {\n    x: 591,\n    y: 140,\n    name: '卢森堡',\n    country: '卢森堡',\n  },\n  CDG: {\n    x: 579,\n    y: 145,\n    name: '巴黎',\n    country: '法国',\n  },\n  WAW: {\n    name: '华沙',\n    country: '波兰',\n    x: 649,\n    y: 123,\n  },\n  MAD: {\n    name: '马德里',\n    country: '西班牙',\n    x: 554,\n    y: 180,\n  },\n  MXP: {\n    name: '米兰',\n    country: '意大利',\n    x: 604,\n    y: 153,\n  },\n  SVO: {\n    x: 704,\n    y: 115,\n    name: '莫斯科',\n    country: '俄罗斯',\n  },\n  OTP: {\n    x: 673,\n    y: 160,\n    name: '布加勒斯特',\n    country: '罗马尼亚',\n  },\n  SOF: {\n    name: '索菲亚',\n    country: '保加利亚',\n    x: 662.5,\n    y: 167,\n  },\n  VNO: {\n    name: '维尔纽斯',\n    country: '立陶宛',\n    x: 657.5,\n    y: 110.5,\n  },\n  OSL: {\n    name: '奥斯陆',\n    country: '挪威',\n    x: 615.5,\n    y: 93,\n  },\n  RBA: {\n    name: '拉巴特',\n    country: '摩洛哥',\n    x: 545,\n    y: 212,\n  },\n  IST: {\n    x: 676,\n    y: 176,\n    name: '伊斯坦布尔',\n    country: '土耳其',\n  },\n};\n\nexport const aliasMapping = {\n  SGP: 'SIN',\n  ICN: 'SEL',\n  NRT: 'TYO',\n  HND: 'TYO',\n  KIX: 'OSA',\n  PAR: 'CDG',\n  MOW: 'SVO',\n  CHI: 'ORD',\n  SHA: 'PVG',\n  CAN: 'CKG',\n  CTU: 'TFU',\n  BJS: 'PEK',\n  HK: 'HKG',\n  MO: 'MFM',\n  TW: 'TPE',\n  ASH: 'IAD',\n};\n\nexport const countryCodeMapping = {\n  CN: 'PEK',\n  JP: 'TYO',\n  SG: 'SIN',\n  KR: 'SEL',\n  MY: 'KUL',\n  VN: 'HAN',\n  IN: 'DEL',\n  TH: 'BKK',\n  AE: 'DXB',\n  TR: 'IST',\n  RO: 'OTP',\n  LU: 'LUX',\n  FR: 'CDG',\n  RU: 'SVO',\n  DE: 'FRA',\n  NL: 'AMS',\n  UK: 'LON',\n  GB: 'LON',\n  AU: 'SYD',\n  US: 'LAX',\n  CA: 'YYZ',\n  MX: 'MEX',\n  CL: 'SCQ',\n  BR: 'GRU',\n  IT: 'MXP',\n  ES: 'MAD',\n  PL: 'WAW',\n  BG: 'SOF',\n  LT: 'VNO',\n  NO: 'OSL',\n  MA: 'RBA',\n};\n\nexport default codeMaps;\n"
  },
  {
    "path": "src/layout/box.vue",
    "content": "<template>\n  <router-view />\n</template>\n\n<script>\nexport default {\n  name: 'LayoutBox',\n};\n</script>\n"
  },
  {
    "path": "src/layout/components/dashboard-btn.vue",
    "content": "<template>\n  <div\n    class=\"nezha-user-info-group\"\n  >\n    <a\n      :href=\"dashboardUrl\"\n      class=\"dashboard-url\"\n      :title=\"userLogin ? '访问管理后台' : '登录管理后台'\"\n      target=\"_blank\"\n    >\n      <span\n        :class=\"{\n          'ri-dashboard-3-line': userLogin,\n          'ri-user-line': !userLogin,\n        }\"\n      />\n      <span>{{ userLogin ? '管理后台' : '登录' }}</span>\n    </a>\n  </div>\n</template>\n\n<script setup>\n/**\n * 控制台入口\n */\nimport {\n  computed,\n} from 'vue';\nimport {\n  useStore,\n} from 'vuex';\n\nimport config from '@/config';\n\nconst store = useStore();\n\nconst userLogin = computed(() => store.state.profile?.username);\nconst dashboardUrl = computed(() => config.nazhua.v1DashboardUrl || '/dashboard');\n</script>\n\n<style lang=\"scss\" scoped>\n.nezha-user-info-group {\n  display: flex;\n  align-items: center;\n  gap: 0 20px;\n\n  .dashboard-url {\n    display: flex;\n    align-items: center;\n    gap: 0 5px;\n    color: #ddd;\n    cursor: pointer;\n\n    &:hover {\n      color: #ff9a00;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layout/components/footer.vue",
    "content": "<template>\n  <div class=\"layout-footer\">\n    <div\n      v-if=\"footerSlogan\"\n      class=\"footer-slogan\"\n    >\n      <div v-html=\"footerSlogan\" />\n    </div>\n    <div class=\"copyright-text\">\n      <span class=\"text\">\n        Powered by\n        <a\n          ref=\"nofollow\"\n          href=\"https://nezha.wiki\"\n          :title=\"'当前为哪吒监控' + $config.nazhua.nezhaVersion\"\n          target=\"_blank\"\n        >哪吒监控</a>\n      </span>\n      <span class=\"text\">\n        Theme By <a\n          ref=\"nofollow\"\n          class=\"nazhua\"\n          href=\"https://github.com/hi2shark/nazhua\"\n          target=\"_blank\"\n        >Nazhua</a>\n        {{ version }}\n      </span>\n    </div>\n    <div\n      ref=\"dynamicContentRef\"\n      v-html=\"dynamicContent\"\n    />\n  </div>\n</template>\n\n<script setup>\n/**\n * Footer\n */\n\nimport {\n  ref,\n  computed,\n  watch,\n  onMounted,\n  nextTick,\n} from 'vue';\nimport { useStore } from 'vuex';\nimport config from '@/config';\n\nconst version = import.meta.env.VITE_APP_VERSION;\nconst store = useStore();\n\nconst footerSlogan = computed(() => decodeURIComponent(config.nazhua?.footerSlogan || ''));\n\nconst dynamicContentRef = ref();\nconst executedScripts = ref(new Set()); // 记录已执行的脚本，避免重复执行\n\nconst dynamicContent = computed(() => {\n  if (store.state.setting?.config?.custom_code) {\n    return store.state.setting.config.custom_code;\n  }\n  if (store.state.setting?.custom_code) {\n    return store.state.setting.custom_code;\n  }\n  return '';\n});\n\n// 执行动态脚本的方法\nconst executeScripts = () => {\n  nextTick(() => {\n    if (!dynamicContentRef.value) return;\n\n    const scripts = dynamicContentRef.value.querySelectorAll('script');\n\n    scripts.forEach((script) => {\n      try {\n        // 生成脚本唯一标识，避免重复执行\n        const scriptIdentifier = script.src || script.textContent || '';\n        if (!scriptIdentifier || executedScripts.value.has(scriptIdentifier)) {\n          return;\n        }\n\n        const newScript = document.createElement('script');\n        newScript.type = script.type || 'text/javascript';\n\n        // 复制所有相关属性\n        if (script.async !== undefined) newScript.async = script.async;\n        if (script.defer !== undefined) newScript.defer = script.defer;\n        if (script.crossOrigin) newScript.crossOrigin = script.crossOrigin;\n        if (script.integrity) newScript.integrity = script.integrity;\n        if (script.noModule !== undefined) newScript.noModule = script.noModule;\n        if (script.referrerPolicy) newScript.referrerPolicy = script.referrerPolicy;\n\n        if (script.src) {\n          // 外部脚本：监听加载完成事件\n          newScript.src = script.src;\n          newScript.onload = () => {\n            executedScripts.value.add(scriptIdentifier);\n          };\n          newScript.onerror = (error) => {\n            console.error('Failed to load external script:', script.src, error);\n          };\n          document.body.appendChild(newScript);\n        } else {\n          // 内联脚本：直接执行\n          newScript.textContent = script.textContent;\n          document.body.appendChild(newScript);\n          executedScripts.value.add(scriptIdentifier);\n          // 内联脚本执行后可以安全移除\n          document.body.removeChild(newScript);\n        }\n      } catch (error) {\n        console.error('Error executing dynamic script:', error);\n      }\n    });\n  });\n};\n\n// 清理已执行脚本的记录（当内容变化时）\nconst cleanupScripts = () => {\n  executedScripts.value.clear();\n};\n\nwatch(dynamicContent, (newVal, oldVal) => {\n  // 内容变化时，清理旧的执行记录\n  if (newVal !== oldVal) {\n    cleanupScripts();\n  }\n\n  if (newVal) {\n    // 确保 DOM 已更新\n    nextTick(() => {\n      executeScripts();\n    });\n  }\n});\n\nonMounted(() => {\n  if (dynamicContent.value) {\n    executeScripts();\n  }\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.layout-footer {\n  padding: 20px;\n  font-size: 12px;\n  color: #ccc;\n\n  .footer-slogan {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    margin-bottom: 5px;\n\n    font-size: 14px;\n    color: #fff;\n  }\n\n  .copyright-text {\n    display: flex;\n    justify-content: center;\n    gap: 1em;\n  }\n\n  .nazhua {\n    color: #fa0;\n    &:hover {\n      color: #fff;\n    }\n  }\n\n  a {\n    color: #fff;\n    &:hover {\n      color: #08f;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layout/components/header.vue",
    "content": "<template>\n  <div\n    class=\"layout-header\"\n    :class=\"headerClass\"\n    :style=\"headerStyle\"\n  >\n    <div class=\"layer-header-container\">\n      <div class=\"left-box\">\n        <span\n          class=\"site-name\"\n          @click=\"toHome\"\n        >{{ title }}</span>\n      </div>\n      <div class=\"right-box\">\n        <server-count\n          v-if=\"showServerCount\"\n        />\n        <server-stat\n          v-if=\"showServerStat\"\n        />\n        <dashboard-btn\n          v-if=\"showDashboardBtn\"\n        />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * LayoutHeader\n */\nimport {\n  computed,\n} from 'vue';\nimport {\n  useRoute,\n  useRouter,\n} from 'vue-router';\n\nimport config from '@/config';\n\nimport ServerCount from './server-count.vue';\nimport ServerStat from './server-stat.vue';\nimport DashboardBtn from './dashboard-btn.vue';\n\nconst route = useRoute();\nconst router = useRouter();\n\nconst lightBackground = computed(() => config.nazhua.lightBackground);\n\nconst headerStyle = computed(() => {\n  const style = {};\n  if (route.name === 'ServerDetail') {\n    style['--layout-header-container-width'] = 'var(--detail-container-width)';\n  } else {\n    style['--layout-header-container-width'] = 'var(--list-container-width)';\n  }\n  return style;\n});\n\nconst showServerCount = computed(() => config.nazhua.hideNavbarServerCount !== true);\n\nconst showServerStat = computed(() => config.nazhua.hideNavbarServerStat !== true);\n\nconst title = computed(() => config.nazhua.title);\n\nconst headerClass = computed(() => {\n  const classes = [];\n  if (route.name === 'ServerDetail') {\n    classes.push('layout-header--detail');\n  }\n  if (showServerStat.value) {\n    classes.push('layout-header--show-server-stat');\n  }\n  if (showServerCount.value) {\n    classes.push('layout-header--show-server-count');\n  }\n  if (lightBackground.value) {\n    classes.push('layout-header--light-background');\n  }\n  return classes;\n});\n\nfunction toHome() {\n  if (route.name !== 'Home') {\n    router.push({\n      name: 'Home',\n    });\n  }\n}\n\nconst showDashboardBtn = computed(() => [\n  config.nazhua.nezhaVersion === 'v1',\n  config.nazhua.v1HideNezhaDashboardBtn !== true,\n].every((item) => item));\n</script>\n\n<style lang=\"scss\" scoped>\n.layout-header {\n  position: sticky;\n  top: 0;\n  z-index: 100;\n  min-height: var(--layout-header-height);\n  background-position: 0% 0%;\n  background-image: radial-gradient(transparent 1px, rgba(#000, 0.8) 2px);\n  background-size: 3px 3px;\n  backdrop-filter: saturate(50%) blur(3px);\n  box-shadow: 0 2px 4px rgba(#000, 0.2);\n\n  &--show-server-stat {\n    @media screen and (max-width: 450px) {\n      padding-top: 10px;\n    }\n  }\n\n  &--light-background {\n    background-color: rgba(#000, 0.7);\n    background-image: none;\n    backdrop-filter: none;\n  }\n\n  .site-name {\n    line-height: calc(var(--layout-header-height) - 20px);\n    font-size: 24px;\n    font-weight: bold;\n    color: #fff;\n    text-shadow: 2px 2px 4px rgba(#000, 0.5);\n    cursor: pointer;\n  }\n\n  .layer-header-container {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    justify-content: space-between;\n    gap: 0 20px;\n    width: var(--layout-header-container-width, 100%);\n    margin: auto;\n    padding: 10px 20px;\n    transition: width 0.3s;\n  }\n\n  .right-box {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    gap: 0 20px;\n    color: #ddd;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layout/components/search-box.vue",
    "content": "<template>\n  <transition name=\"fadeIn\">\n    <div\n      v-if=\"show\"\n      class=\"search-box-background\"\n      @click=\"closeSearchBox\"\n    />\n  </transition>\n  <transition name=\"fadeIn\">\n    <div\n      v-if=\"show\"\n      class=\"search-box-group\"\n    >\n      <div class=\"search-box\">\n        <input\n          ref=\"searchInputRef\"\n          v-model.trim=\"searchWord\"\n          type=\"text\"\n          placeholder=\"可搜索服务器名称、标签、系统、国别代码\"\n          class=\"search-box-input\"\n          @input=\"onSearchInput\"\n          @keydown.enter=\"onSearchInput\"\n          @blur=\"onSearchInput\"\n        />\n        <span\n          v-if=\"searchWord\"\n          class=\"clear-btn\"\n          @click=\"clearSearchWord\"\n        >\n          <i class=\"clear-icon ri-close-fill\" />\n        </span>\n      </div>\n      <div class=\"result-server-list-container\">\n        <div class=\"search-list\">\n          <search-list-item\n            v-for=\"item in searchResult\"\n            :key=\"item.ID\"\n            :info=\"item\"\n            @open-detail=\"openDetail\"\n          />\n        </div>\n      </div>\n    </div>\n  </transition>\n\n  <div\n    class=\"search-active-btn\"\n    @click=\"activeSearchBox\"\n  >\n    <span class=\"icon\">\n      <i class=\"ri-search-eye-line\" />\n    </span>\n  </div>\n</template>\n\n<script setup>\n/**\n * 搜索盒子\n */\n\nimport {\n  computed,\n  ref,\n  onMounted,\n  onUnmounted,\n} from 'vue';\nimport {\n  useStore,\n} from 'vuex';\nimport {\n  useRouter,\n} from 'vue-router';\n\nimport SearchListItem from './search-list-item.vue';\n\nconst router = useRouter();\nconst store = useStore();\nconst serverList = computed(() => store.state.serverList);\n\nconst show = ref(false);\nconst searchWord = ref('');\nconst searchResult = ref([]);\nconst searchInputRef = ref(null);\n\nlet handleSearchTimer = null;\nfunction handleSearch() {\n  if (handleSearchTimer) {\n    clearTimeout(handleSearchTimer);\n  }\n  if (!searchWord.value) {\n    searchResult.value = [...serverList.value];\n    return;\n  }\n  handleSearchTimer = setTimeout(() => {\n    handleSearchTimer = null;\n    searchResult.value = serverList.value.filter((item) => {\n      {\n        const matched = item.Name.toLowerCase().includes(searchWord.value.toLowerCase());\n        if (matched) {\n          return true;\n        }\n      }\n      if (item?.PublicNote?.planDataMod) {\n        const {\n          networkRoute = '',\n          extra = '',\n        } = item.PublicNote.planDataMod;\n        return [\n          networkRoute.toLowerCase().includes(searchWord.value.toLowerCase()),\n          extra.toLowerCase().includes(searchWord.value.toLowerCase()),\n          (item.Host.Platform || '').toLowerCase().includes(searchWord.value.toLowerCase()),\n          (item.Host.CountryCode || '').toLowerCase().includes(searchWord.value.toLowerCase()),\n        ].some((match) => match);\n      }\n      return false;\n    });\n  }, 200);\n}\n\nfunction onSearchInput() {\n  handleSearch();\n}\n\nfunction clearSearchWord() {\n  searchWord.value = '';\n  searchResult.value = [...serverList.value];\n}\n\nfunction activeSearchBox() {\n  searchWord.value = '';\n  searchResult.value = [...serverList.value];\n  show.value = true;\n  // 锁定页面滚动\n  document.body.style.overflow = 'hidden';\n\n  // 聚焦到搜索框\n  setTimeout(() => {\n    searchInputRef.value.focus();\n  }, 30);\n}\n\nfunction closeSearchBox() {\n  show.value = false;\n  document.body.style.overflow = '';\n}\n\nfunction openDetail(info) {\n  router.push({\n    name: 'ServerDetail',\n    params: {\n      serverId: info.ID,\n    },\n  });\n  closeSearchBox();\n}\n\nfunction handleKeyDown(event) {\n  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {\n    event.stopPropagation();\n    event.preventDefault();\n    if (show.value) {\n      closeSearchBox();\n    } else {\n      activeSearchBox();\n    }\n  }\n}\n\nfunction handleEscKey(event) {\n  if (!show.value) {\n    return;\n  }\n  if (event.key === 'Escape') {\n    closeSearchBox();\n    event.stopPropagation();\n    event.preventDefault();\n  }\n}\n\nonMounted(() => {\n  // 监听按下快捷键 Ctrl+K 打开搜索框\n  window.addEventListener('keydown', handleKeyDown);\n  // 监听按下 Esc 关闭搜索框\n  window.addEventListener('keydown', handleEscKey);\n});\n\nonUnmounted(() => {\n  window.removeEventListener('keydown', handleKeyDown);\n  window.removeEventListener('keydown', handleEscKey);\n  if (handleSearchTimer) {\n    clearTimeout(handleSearchTimer);\n    handleSearchTimer = null;\n  }\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.search-box-background {\n  position: fixed;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  background: rgba(0, 0, 0, 0.15);\n  z-index: 1000;\n}\n\n.search-box-group {\n  position: fixed;\n  left: 50%;\n  top: 150px;\n  z-index: 1010;\n  transform: translate(-50%, 0);\n  width: 600px;\n  padding: 30px;\n  border-radius: 12px;\n  background-color: rgba(#000, 0.9);\n\n  @media screen and (max-width: 640px) {\n    width: auto;\n    top: 100px;\n    left: 20px;\n    right: 20px;\n    padding: 20px;\n    transform: translate(0, 0);\n  }\n\n  .search-box {\n    position: relative;\n    width: 100%;\n    padding-right: 40px;\n    border-radius: 20px;\n    background: #eee;\n\n    .search-box-input {\n      width: 100%;\n      height: 40px;\n      padding: 0 15px;\n      color: #234;\n      font-size: 14px;\n      background: transparent;\n      border: none;\n      outline: none;\n      transition: 0.3s;\n    }\n\n    .clear-btn {\n      position: absolute;\n      top: 0;\n      right: 0;\n      width: 40px;\n      height: 40px;\n      line-height: 40px;\n      text-align: center;\n      cursor: pointer;\n      transition: 0.3s;\n\n      .clear-icon {\n        font-size: 20px;\n        color: #666;\n      }\n\n      &:hover {\n        color: #333;\n      }\n    }\n  }\n\n  .search-list {\n    margin-top: 10px;\n    height: 300px;\n    overflow-x: hidden;\n    overflow-y: auto;\n    @media screen and (max-width: 640px) {\n      height: 50vh;\n    }\n\n    &::-webkit-scrollbar {\n      width: 8px;\n    }\n\n    &::-webkit-scrollbar-track {\n      background: rgba(255, 255, 255, 0.1);\n      border-radius: 4px;\n    }\n\n    &::-webkit-scrollbar-thumb {\n      background: rgba(255, 255, 255, 0.3);\n      border-radius: 4px;\n\n      &:hover {\n        background: rgba(255, 255, 255, 0.5);\n      }\n    }\n  }\n}\n\n.search-active-btn {\n  position: fixed;\n  right: 20px;\n  bottom: 20px;\n  z-index: 10;\n  width: 48px;\n  height: 48px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 50%;\n  background: rgba(#000, 0.7);\n  cursor: pointer;\n  transition: 0.3s;\n\n  .icon {\n    line-height: 1;\n    font-size: 24px;\n    color: #eee;\n  }\n\n  &:hover {\n    background: rgba(#000, 0.9);\n  }\n}\n\n.fadeIn-enter-active,\n.fadeIn-leave-active {\n  transition: opacity 0.3s ease-in-out;\n}\n.fadeIn-enter-from,\n.fadeIn-leave-to {\n  opacity: 0;\n}\n</style>\n"
  },
  {
    "path": "src/layout/components/search-list-item.vue",
    "content": "<template>\n  <div\n    class=\"search-list-item\"\n    @click=\"openDetail\"\n  >\n    <div class=\"server-name\">\n      {{ info.Name }}\n    </div>\n    <div class=\"server-tag-list\">\n      <span\n        v-for=\"(tagItem, index) in tagList\"\n        :key=\"`${tagItem}_${index}`\"\n        class=\"tag-item\"\n        :class=\"{\n          'has-sarasa-term': $hasSarasaTerm && config.nazhua.disableSarasaTermSC !== true,\n        }\"\n      >\n        {{ tagItem }}\n      </span>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 搜索后的单条展示\n */\n\nimport {\n  computed,\n} from 'vue';\n\nimport config from '@/config';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    required: true,\n  },\n});\n\nconst emits = defineEmits([\n  'open-detail',\n]);\n\nconst tagList = computed(() => {\n  const list = [];\n  const {\n    networkRoute,\n    extra,\n  } = props?.info?.PublicNote?.planDataMod || {};\n  if (networkRoute) {\n    list.push(...networkRoute.split(','));\n  }\n  if (extra) {\n    list.push(...extra.split(','));\n  }\n  // 列表最多显示3个标签\n  return list.slice(0, 3);\n});\n\nfunction openDetail() {\n  emits('open-detail', props.info);\n}\n\n</script>\n\n<style lang=\"scss\" scoped>\n.search-list-item {\n  cursor: pointer;\n  display: flex;\n  flex-wrap: wrap;\n  padding: 8px 10px;\n  border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n  .server-name {\n    flex: 1;\n    line-height: 30px;\n    font-size: 16px;\n    font-weight: bold;\n  }\n\n  .server-tag-list {\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    height: 30px;\n\n    .tag-item {\n      height: 18px;\n      padding: 0 4px;\n      line-height: 18px;\n      font-size: 12px;\n      color: var(--public-note-tag-color);\n      background: var(--public-note-tag-bg);\n      text-shadow: 1px 1px 2px rgba(#000, 0.2);\n      border-radius: 4px;\n\n      &.has-sarasa-term {\n        line-height: 20px;\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layout/components/server-count.vue",
    "content": "<template>\n  <div\n    v-if=\"serverCount?.total\"\n    class=\"server-count-group\"\n  >\n    <span class=\"server-count server-count--total\">\n      <span class=\"text\">共</span>\n      <span class=\"value\">{{ serverCount.total }}</span>\n      <span class=\"text\">台服务器</span>\n    </span>\n    <template v-if=\"serverCount.online !== serverCount.total\">\n      <span\n        class=\"server-count server-count--online\"\n      >\n        <span class=\"text\">在线</span>\n        <span class=\"value\">{{ serverCount.online }}</span>\n      </span>\n      <span\n        class=\"server-count server-count--offline\"\n      >\n        <span class=\"text\">离线</span>\n        <span class=\"value\">{{ serverCount.offline }}</span>\n      </span>\n    </template>\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器数量\n */\nimport {\n  computed,\n} from 'vue';\nimport {\n  useStore,\n} from 'vuex';\n\nconst store = useStore();\n\nconst serverCount = computed(() => store.state.serverCount);\n</script>\n\n<style lang=\"scss\" scoped>\n.server-count-group {\n  display: flex;\n  gap: 10px;\n\n  .server-count {\n    display: flex;\n    align-items: center;\n    gap: 3px;\n    color: #ddd;\n    line-height: 30px;\n\n    .value {\n      font-weight: bold;\n    }\n\n    &.server-count--total {\n      .value {\n        color: #70f3ff;\n      }\n    }\n\n    &.server-count--online {\n      .value {\n        color: #0f0;\n      }\n    }\n\n    &.server-count--offline {\n      .value {\n        color: #f00;\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layout/components/server-stat.vue",
    "content": "<template>\n  <div\n    v-if=\"serverStat\"\n    class=\"server-stat-group\"\n  >\n    <div\n      v-if=\"serverStat.transfer\"\n      class=\"server-stat server-stat--transfer\"\n    >\n      <span class=\"server-stat-label\">\n        <span class=\"text\">流量</span>\n      </span>\n      <div class=\"server-stat-content\">\n        <span class=\"server-stat-item server-stat-item--in\">\n          <span class=\"ri-download-line\" />\n          <span class=\"text-value\">\n            {{ serverStat.transfer.inData.value }}\n          </span>\n          <span class=\"text-unit\">\n            {{ serverStat.transfer.inData.unit }}\n          </span>\n        </span>\n        <span class=\"server-stat-item server-stat-item--out\">\n          <span class=\"ri-upload-line\" />\n          <span class=\"text-value\">\n            {{ serverStat.transfer.outData.value }}\n          </span>\n          <span class=\"text-unit\">\n            {{ serverStat.transfer.outData.unit }}\n          </span>\n        </span>\n      </div>\n    </div>\n    <div\n      v-if=\"serverStat.netSpeed\"\n      class=\"server-stat server-stat--net-speed\"\n    >\n      <span class=\"server-stat-label\">\n        <span class=\"text\">网速</span>\n      </span>\n      <div class=\"server-stat-content\">\n        <span class=\"server-stat-item server-stat-item--in\">\n          <span class=\"ri-arrow-down-line\" />\n          <span class=\"text-value\">\n            {{ serverStat.netSpeed.inData.value }}\n          </span>\n          <span class=\"text-unit\">\n            {{ serverStat.netSpeed.inData.unit }}\n          </span>\n        </span>\n        <span class=\"server-stat-item server-stat-item--out\">\n          <span class=\"ri-arrow-up-line\" />\n          <span class=\"text-value\">\n            {{ serverStat.netSpeed.outData.value }}\n          </span>\n          <span class=\"text-unit\">\n            {{ serverStat.netSpeed.outData.unit }}\n          </span>\n        </span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器统计\n */\nimport {\n  computed,\n} from 'vue';\nimport {\n  useStore,\n} from 'vuex';\nimport * as hostUtils from '@/utils/host';\n\nconst store = useStore();\n\nconst serverStat = computed(() => {\n  const transfer = {\n    in: 0,\n    inData: {\n      value: 0,\n      unit: '',\n    },\n    out: 0,\n    outData: {\n      value: 0,\n      unit: '',\n    },\n  };\n  const netSpeed = {\n    in: 0,\n    inData: {\n      value: 0,\n      unit: '',\n    },\n    out: 0,\n    outData: {\n      value: 0,\n      unit: '',\n    },\n  };\n  if (store.state.serverList.length) {\n    store.state.serverList.forEach((server) => {\n      if (server.online === 1 && server.State) {\n        if (typeof server.State.NetInTransfer === 'number') {\n          transfer.in += server.State.NetInTransfer;\n        }\n        if (typeof server.State.NetOutTransfer === 'number') {\n          transfer.out += server.State.NetOutTransfer;\n        }\n        if (typeof server.State.NetInSpeed === 'number') {\n          netSpeed.in += server.State.NetInSpeed;\n        }\n        if (typeof server.State.NetOutSpeed === 'number') {\n          netSpeed.out += server.State.NetOutSpeed;\n        }\n      }\n    });\n  }\n  const calcInTransfer = hostUtils.calcBinary(transfer.in);\n  if (calcInTransfer.t > 1) {\n    transfer.inData.value = (calcInTransfer.t).toFixed(1) * 1;\n    transfer.inData.unit = 'T';\n  } else if (calcInTransfer.g > 1) {\n    transfer.inData.value = (calcInTransfer.g).toFixed(1) * 1;\n    transfer.inData.unit = 'G';\n  } else if (calcInTransfer.m > 1) {\n    transfer.inData.value = (calcInTransfer.m).toFixed(1) * 1;\n    transfer.inData.unit = 'M';\n  } else {\n    transfer.inData.value = calcInTransfer.value;\n    transfer.inData.unit = 'K';\n  }\n  const calcOutTransfer = hostUtils.calcBinary(transfer.out);\n  if (calcOutTransfer.t > 1) {\n    transfer.outData.value = (calcOutTransfer.t).toFixed(1) * 1;\n    transfer.outData.unit = 'T';\n  } else if (calcOutTransfer.g > 1) {\n    transfer.outData.value = (calcOutTransfer.g).toFixed(1) * 1;\n    transfer.outData.unit = 'G';\n  } else if (calcOutTransfer.m > 1) {\n    transfer.outData.value = (calcOutTransfer.m).toFixed(1) * 1;\n    transfer.outData.unit = 'M';\n  } else {\n    transfer.outData.value = calcOutTransfer.value;\n    transfer.outData.unit = 'K';\n  }\n  const calcNetInSpeed = hostUtils.calcBinary(netSpeed.in);\n  if (calcNetInSpeed.t > 1) {\n    netSpeed.inData.value = (calcNetInSpeed.t).toFixed(1) * 1;\n    netSpeed.inData.unit = 'T';\n  } else if (calcNetInSpeed.g > 1) {\n    netSpeed.inData.value = (calcNetInSpeed.g).toFixed(1) * 1;\n    netSpeed.inData.unit = 'G';\n  } else if (calcNetInSpeed.m > 1) {\n    netSpeed.inData.value = (calcNetInSpeed.m).toFixed(1) * 1;\n    netSpeed.inData.unit = 'M';\n  } else {\n    netSpeed.inData.value = (calcNetInSpeed.k).toFixed(1) * 1;\n    netSpeed.inData.unit = 'K';\n  }\n  const calcNetOutSpeed = hostUtils.calcBinary(netSpeed.out);\n  if (calcNetOutSpeed.t > 1) {\n    netSpeed.outData.value = (calcNetOutSpeed.t).toFixed(1) * 1;\n    netSpeed.outData.unit = 'T';\n  } else if (calcNetOutSpeed.g > 1) {\n    netSpeed.outData.value = (calcNetOutSpeed.g).toFixed(1) * 1;\n    netSpeed.outData.unit = 'G';\n  } else if (calcNetOutSpeed.m > 1) {\n    netSpeed.outData.value = (calcNetOutSpeed.m).toFixed(1) * 1;\n    netSpeed.outData.unit = 'M';\n  } else {\n    netSpeed.outData.value = (calcNetOutSpeed.k).toFixed(1) * 1;\n    netSpeed.outData.unit = 'K';\n  }\n  return {\n    transfer,\n    netSpeed,\n  };\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-stat-group {\n  min-width: 160px;\n\n  @media screen and (max-width: 450px) {\n    position: absolute;\n    top: 0;\n    right: 0;\n    left: 0;\n    height: 28px;\n    padding: 0 20px;\n    display: flex;\n    align-items: center;\n    flex-direction: row-reverse;\n    gap: 10px;\n\n    .server-stat-label {\n      display: none;\n    }\n\n    .server-stat-content {\n      gap: 10px;\n    }\n  }\n\n  .server-stat {\n    display: flex;\n    gap: 8px;\n    line-height: 16px;\n    font-size: 12px;\n\n    .server-stat-content {\n      flex: 1;\n      display: flex;\n    }\n\n    .server-stat-item {\n      flex: 1;\n    }\n  }\n\n  .server-stat--transfer {\n    .server-stat-item--in {\n      .text-value {\n        color: var(--transfer-in-color);\n      }\n    }\n\n    .server-stat-item--out {\n      .text-value {\n        color: var(--transfer-out-color);\n      }\n    }\n  }\n\n  .server-stat--net-speed {\n    .server-stat-item--in {\n      .text-value {\n        color: var(--net-speed-in-color);\n      }\n    }\n\n    .server-stat-item--out {\n      .text-value {\n        color: var(--net-speed-out-color);\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/layout/main.vue",
    "content": "<template>\n  <div\n    class=\"layout-group\"\n    :style=\"layoutGroupStyle\"\n  >\n    <div\n      class=\"layout-bg\"\n      :style=\"layoutBGStyle\"\n    />\n    <div class=\"layout-main\">\n      <layout-header />\n      <slot />\n      <layout-footer />\n\n      <search-box\n        v-if=\"enableInnerSearch\"\n      />\n    </div>\n    <template v-if=\"showFireworks\">\n      <fireworks />\n    </template>\n    <template v-if=\"config.nazhua.showLantern\">\n      <lantern />\n    </template>\n  </div>\n</template>\n\n<script setup>\n/**\n * LayoutMain\n */\nimport {\n  ref,\n  computed,\n  onUnmounted,\n} from 'vue';\nimport config from '@/config';\nimport Fireworks from '@/components/fireworks.vue';\nimport Lantern from '@/components/lantern.vue';\nimport LayoutHeader from './components/header.vue';\nimport LayoutFooter from './components/footer.vue';\nimport SearchBox from './components/search-box.vue';\n\nconst windowWidth = ref(window.innerWidth);\n\nconst layoutGroupStyle = computed(() => {\n  const style = {};\n  if (config.nazhua.lightBackground) {\n    style['--layout-main-bg-color'] = 'rgba(20, 30, 40, 0.2)';\n  }\n  return style;\n});\n\nconst layoutBGStyle = computed(() => {\n  const style = {};\n  if (config.nazhua.customBackgroundImage) {\n    style.background = `url(${config.nazhua.customBackgroundImage}) 50% 50%`;\n    style.backgroundSize = 'cover';\n  }\n  return style;\n});\n\nconst showFireworks = computed(() => {\n  if (windowWidth.value < 800) {\n    return false;\n  }\n  return config.nazhua.showFireworks;\n});\n\nconst enableInnerSearch = computed(() => {\n  if (typeof config.nazhua.enableInnerSearch === 'undefined') {\n    return true;\n  }\n  return config.nazhua.enableInnerSearch;\n});\n\nconst handleResize = () => {\n  windowWidth.value = window.innerWidth;\n};\n\nwindow.addEventListener('resize', handleResize);\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize);\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.layout-group {\n  position: relative;\n  width: 100%;\n  min-height: 100vh;\n\n  .layout-main {\n    position: relative;\n    z-index: 10;\n    display: flex;\n    flex-direction: column;\n    min-height: 100vh;\n    background: var(--layout-main-bg-color);\n  }\n\n  .layout-bg {\n    position: fixed;\n    top: 0;\n    right: 0;\n    bottom: 0;\n    left: 0;\n    z-index: 1;\n    background: var(--layout-bg-color) url('~@/assets/images/bg.webp') no-repeat 50% 100%;\n    background-size: 100% auto;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/load.js",
    "content": "// 是否禁用 Sarasa Term SC 字体\nif (import.meta.env.VITE_DISABLE_SARASA_TERM_SC !== '1') {\n  if (import.meta.env.VITE_SARASA_TERM_SC_USE_CDN) {\n    import('./assets/fonts/SarasaTermSC/cdn-font.css');\n  } else {\n    import('./assets/fonts/SarasaTermSC/font.css');\n  }\n  import('./assets/scss/sarasa-term-sc.scss');\n}\n\n/**\n * 使用 CDN 加载 CSS 文件\n */\nfunction useCdnCss(item) {\n  const cdnType = import.meta.env.VITE_CDN_LIB_TYPE;\n  let cssUrl = item.jsdelivr;\n  if (['cdnjs', 'loli'].includes(cdnType)) {\n    cssUrl = item.cdnjs;\n    if (cdnType === 'loli') {\n      cssUrl = cssUrl.replace('https://cdnjs.cloudflare.com/', 'https://cdnjs.loli.net/');\n    }\n  }\n  const cdnStylesheet = document.createElement('link');\n  cdnStylesheet.rel = 'stylesheet';\n  cdnStylesheet.href = cssUrl;\n  document.head.appendChild(cdnStylesheet);\n}\n\n// 判断是否使用 CDN\nif (import.meta.env.VITE_USE_CDN) {\n  Object.entries({\n    remixicon: {\n      jsdelivr: 'https://cdn.jsdelivr.net/npm/remixicon@4.7.0/fonts/remixicon.css',\n      cdnjs: 'https://cdnjs.cloudflare.com/ajax/libs/remixicon/4.2.0/remixicon.css',\n    },\n    flagIcons: {\n      jsdelivr: 'https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css',\n      cdnjs: 'https://cdnjs.cloudflare.com/ajax/libs/flag-icons/7.2.3/css/flag-icons.min.css',\n    },\n    fontLogos: {\n      jsdelivr: 'https://cdn.jsdelivr.net/npm/font-logos@1.3.0/assets/font-logos.css',\n      cdnjs: 'https://cdnjs.cloudflare.com/ajax/libs/font-logos/1.2.0/font-logos.css',\n    },\n  }).forEach(([, item]) => {\n    useCdnCss(item);\n  });\n} else {\n  import('remixicon/fonts/remixicon.css');\n  import('flag-icons/css/flag-icons.min.css');\n  import('font-logos/assets/font-logos.css');\n}\n"
  },
  {
    "path": "src/main.js",
    "content": "import { createApp } from 'vue';\nimport App from './App.vue';\nimport customUse from './use';\n\nconst app = createApp(App);\ncustomUse(app);\napp.mount('#app');\n"
  },
  {
    "path": "src/router/index.js",
    "content": "import {\n  createRouter,\n  createWebHistory,\n  createWebHashHistory,\n} from 'vue-router';\nimport config from '@/config';\nimport pageTitle from '@/utils/page-title';\n\nconst constantRoutes = [{\n  name: 'Home',\n  path: '/',\n  component: () => import('@/views/home.vue'),\n}, {\n  name: 'ServerDetail',\n  path: '/:serverId(\\\\d+)',\n  component: () => import('@/views/detail.vue'),\n  meta: {\n    title: '节点详情',\n  },\n  props: true,\n}, {\n  path: '/:pathMatch(.*)*',\n  redirect: {\n    name: 'Home',\n  },\n}];\n\nconst routerOptions = {\n  history: config.nazhua.routeMode === 'h5' ? createWebHistory() : createWebHashHistory(),\n  scrollBehavior: (to, from, savedPosition) => {\n    if (savedPosition) {\n      return savedPosition;\n    }\n    return {\n      top: 0,\n      behavior: 'smooth',\n    };\n  },\n  routes: constantRoutes,\n};\nconst router = createRouter(routerOptions);\n\nrouter.beforeResolve((to, from, next) => {\n  if (to?.meta?.title) {\n    pageTitle(to?.meta?.title);\n  } else if (to.name === 'Home') {\n    pageTitle(config.nazhua.title);\n  }\n  next();\n});\n\nexport default router;\n"
  },
  {
    "path": "src/store/index.js",
    "content": "import {\n  createStore,\n} from 'vuex';\nimport dayjs from 'dayjs';\nimport config from '@/config';\nimport loadNezhaV0Config, {\n  loadServerGroup as loadNezhaV0ServerGroup,\n} from '@/utils/load-nezha-v0-config';\nimport {\n  loadServerGroup as loadNezhaV1ServerGroup,\n  loadSetting as loadNezhaV1Setting,\n  loadProfile as loadNezhaV1Profile,\n} from '@/utils/load-nezha-v1-config';\n\nimport {\n  msg,\n} from '@/ws';\n\nconst defaultState = () => ({\n  init: false,\n  serverTime: 0,\n  serverGroup: [],\n  serverList: [],\n  serverListColumnWidths: {},\n  serverCount: {\n    total: 0,\n    online: 0,\n    offline: 0,\n  },\n  profile: {},\n  setting: {},\n});\n\nfunction isOnline(LastActive, currentTime = Date.now()) {\n  const lastActiveTime = dayjs(LastActive)?.valueOf?.() || 0;\n  if (currentTime - lastActiveTime > 10 * 1000) {\n    return -1;\n  }\n  return 1;\n}\n\nfunction handleServerCount(servers) {\n  const counts = {\n    total: servers.length,\n    online: servers.filter((i) => i.online === 1).length,\n    offline: servers.filter((i) => i.online === -1).length,\n  };\n  return counts;\n}\n\nlet firstSetServers = true;\nconst store = createStore({\n  state: defaultState(),\n  mutations: {\n    SET_SERVER_TIME(state, time) {\n      state.serverTime = time;\n    },\n    SET_SERVER_GROUP(state, serverGroup) {\n      state.serverGroup = serverGroup;\n    },\n    SET_SERVERS(state, servers) {\n      const newServers = [...servers];\n      newServers.sort((a, b) => b.DisplayIndex - a.DisplayIndex);\n      state.serverList = newServers;\n      state.serverCount = handleServerCount(newServers);\n      state.init = true;\n    },\n    UPDATE_SERVERS(state, servers) {\n      // 遍历新的servers 处理新的内容\n      const oldServersMap = {};\n      state.serverList.forEach((server) => {\n        oldServersMap[server.ID] = server;\n      });\n      let newServers = servers.map((server) => {\n        const oldItem = oldServersMap[server.ID];\n        const serverItem = {\n          ...server,\n        };\n        if (oldItem?.PublicNote) {\n          serverItem.PublicNote = oldItem.PublicNote;\n        }\n        return serverItem;\n      });\n      newServers = newServers.filter((server) => server);\n      newServers.sort((a, b) => b.DisplayIndex - a.DisplayIndex);\n      state.serverList = newServers;\n      state.serverCount = handleServerCount(newServers);\n      state.init = true;\n    },\n    SET_PROFILE(state, profile) {\n      state.profile = profile;\n    },\n    SET_SETTING(state, setting) {\n      state.setting = setting;\n    },\n    SET_SERVER_LIST_COLUMN_WIDTHS(state, widths) {\n      state.serverListColumnWidths = widths;\n    },\n  },\n  actions: {\n    /**\n     * 加载服务器列表\n     */\n    async initServerInfo({ commit }, params) {\n      firstSetServers = true;\n      // 如果是v1版本的话，加载v1版本的数据\n      if (config.nazhua.nezhaVersion === 'v1') {\n        const {\n          route,\n        } = params || {};\n        loadNezhaV1ServerGroup().then((res) => {\n          if (res) {\n            commit('SET_SERVER_GROUP', res);\n          }\n        });\n        loadNezhaV1Setting().then((res) => {\n          if (res) {\n            commit('SET_SETTING', res);\n            // 如果自定义配置没有设置title，使用站点名称\n            if (!window.$$nazhuaConfig.title) {\n              config.nazhua.title = res.config?.site_name || res.site_name;\n              if (route?.name === 'Home' || !route) {\n                document.title = config.nazhua.title;\n              }\n            }\n          }\n        });\n        loadNezhaV1Profile().then((res) => {\n          if (res) {\n            commit('SET_PROFILE', res);\n          }\n        });\n        return;\n      }\n      // 如果是v0版本的话，加载v0版本的数据\n      // 加载初始化的服务器列表，需要其中的公开备注字段\n      const serverResult = await loadNezhaV0Config();\n      if (!serverResult) {\n        console.error('load server config failed');\n        return;\n      }\n      const servers = serverResult.servers?.map?.((i) => {\n        const item = {\n          ...i,\n          online: isOnline(i.LastActive, serverResult.now),\n        };\n        return item;\n      }) || [];\n      const res = loadNezhaV0ServerGroup(servers);\n      if (res) {\n        commit('SET_SERVER_GROUP', res);\n      }\n      firstSetServers = false;\n      commit('SET_SERVERS', servers);\n    },\n    /**\n     * 开始监听ws消息\n     */\n    watchWsMsg({\n      commit,\n    }) {\n      msg.on('servers', (res) => {\n        if (res) {\n          if (res.now) {\n            commit('SET_SERVER_TIME', res.now);\n          }\n          const servers = res.servers?.map?.((i) => {\n            const item = {\n              ...i,\n              online: isOnline(i.LastActive, res.now),\n            };\n            return item;\n          }) || [];\n          if (firstSetServers) {\n            firstSetServers = false;\n            commit('SET_SERVERS', servers);\n\n            // 在v0没抓页面配置的情况下，从服务器列表中分离出标签列表\n            if (config.nazhua.nezhaVersion !== 'v1') {\n              const group = loadNezhaV0ServerGroup(servers);\n              if (group) {\n                commit('SET_SERVER_GROUP', group);\n              }\n            }\n          } else {\n            commit('UPDATE_SERVERS', servers);\n          }\n        }\n      });\n    },\n    /**\n     * 设置服务器列表行宽度\n     */\n    setServerListColumnWidths({\n      commit,\n      state,\n    }, data) {\n      const newWidths = {\n        ...state.serverListColumnWidths,\n        ...data,\n      };\n      commit('SET_SERVER_LIST_COLUMN_WIDTHS', newWidths);\n    },\n    setServerListColumnWidth({\n      commit,\n      state,\n    }, data) {\n      const newWidths = {\n        ...state.serverListColumnWidths,\n      };\n      if (newWidths[data.prop]) {\n        newWidths[data.prop] = Math.max(newWidths[data.prop], data.width);\n      } else {\n        newWidths[data.prop] = data.width;\n      }\n      commit('SET_SERVER_LIST_COLUMN_WIDTHS', newWidths);\n    },\n  },\n});\n\nexport default store;\n"
  },
  {
    "path": "src/use.js",
    "content": "import './load';\nimport './assets/scss/base.scss';\nimport router from './router';\nimport store from './store';\nimport config from './config';\n\nimport DotDotBox from './components/dot-dot-box.vue';\nimport Popover from './components/popover.vue';\nimport ServerFlag from './components/server-flag.vue';\n\nexport default (app) => {\n  app.use(router);\n  app.use(store);\n  app.component('DotDotBox', DotDotBox);\n  app.component('Popover', Popover);\n  app.component('ServerFlag', ServerFlag);\n\n  app.config.globalProperties.$hasSarasaTerm = !import.meta.env.VITE_DISABLE_SARASA_TERM_SC;\n  app.config.globalProperties.$config = config;\n};\n"
  },
  {
    "path": "src/utils/custom-error.js",
    "content": "/**\n * 自定义错误\n */\n\nclass CustomError extends Error {\n  constructor(msg, code) {\n    super(msg);\n    this.code = code;\n  }\n}\n\nexport default CustomError;\n"
  },
  {
    "path": "src/utils/date.js",
    "content": "import dayjs from 'dayjs';\n\n/**\n * 计算时长工具\n * @param {Date|Number|String} startDate 开始时间\n * @param {Date|Number|String} endDate 结束时间\n * @param {Boolean} noSub 不带子单位\n *\n * @returns {String} 时长\n *  1. 1小时以内，显示N分钟N秒\n *  2. 1小时以上，显示N小时N分钟\n *  3. 1天以上，显示N天\n */\nexport const duration = (startDate, endDate, noSub = false) => {\n  const startTime = dayjs(startDate).valueOf();\n  const endTime = dayjs(endDate).valueOf();\n  const diff = endTime - startTime;\n\n  if (diff < 0) {\n    return '刚刚启动';\n  }\n\n  const second = 1000;\n  const minute = second * 60;\n  const hour = minute * 60;\n  const day = hour * 24;\n\n  if (diff < minute) {\n    return `${Math.floor(diff / second)}秒`;\n  }\n  if (diff < hour) {\n    if (noSub) {\n      return `${Math.floor(diff / minute)}分钟`;\n    }\n    return `${Math.floor(diff / minute)}分钟${Math.floor((diff % minute) / second)}秒`;\n  }\n  if (diff < day) {\n    if (noSub) {\n      return `${Math.floor(diff / hour)}小时`;\n    }\n    return `${Math.floor(diff / hour)}小时${Math.floor((diff % hour) / minute)}分钟`;\n  }\n  return `${Math.floor(diff / day)}天`;\n};\n\n/**\n * 计算时长，返回详细信息\n * @param {Date|Number|String} startDate 开始时间\n * @param {Date|Number|String} endDate 结束时间\n */\nexport const duration2 = (startDate, endDate) => {\n  const startTime = dayjs(startDate).valueOf();\n  const endTime = dayjs(endDate).valueOf();\n  const diff = endTime - startTime;\n\n  const second = 1000;\n  const minute = second * 60;\n  const hour = minute * 60;\n  const day = hour * 24;\n\n  const result = {\n    days: Math.floor(diff / day),\n    hours: Math.floor(diff / hour) % 24,\n    minutes: Math.floor(diff / minute) % 60,\n    seconds: Math.floor(diff / second) % 60,\n    $unit: {\n      day: '天',\n      hour: '小时',\n      minute: '分钟',\n      second: '秒',\n    },\n  };\n\n  return result;\n};\n\n/**\n * 按周期月数计算下一个日期，必须大于传入的第三个参数（指定日期，为空则为当前日期）\n *\n * @param {Date|Number|String} startDate 起始日期\n * @param {Number} months 周期月份数\n * @param {Date|Number|String} specifiedDate 指定日期\n *\n * @returns {Number} 下一个日期的时间毫秒数\n */\nexport function getNextCycleTime(startDate, months, specifiedDate) {\n  const start = dayjs(startDate);\n  const checkDate = dayjs(specifiedDate);\n\n  if (!start.isValid() || months <= 0) {\n    throw new Error('参数无效：请检查起始日期、周期月份数和指定日期。');\n  }\n\n  let nextDate = start;\n\n  // 循环增加周期直到大于当前日期\n  let whileStatus = true;\n  while (whileStatus) {\n    nextDate = nextDate.add(months, 'month');\n    whileStatus = nextDate.valueOf() <= checkDate.valueOf();\n  }\n\n  return nextDate.valueOf(); // 返回时间毫秒数\n}\n"
  },
  {
    "path": "src/utils/host.js",
    "content": "/**\n * 主机匹配信息工具\n */\n\n/**\n * 匹配CPU信息\n * @param {string} text CPU信息文本\n *  示例文本：\n *  Intel(R) Xeon(R) Platinum 2 Virtual Core\n *  Intel Core Processor (Broadwell, IBRS) 1 Virtual Core\n *  Intel(R) Xeon(R) Gold 6133 CPU @ 2.50GHz 1 Virtual Core\n *  Intel(R) Xeon(R) CPU E5-2697 v3 @ 2.60GHz 1 Virtual Core\n *  Intel(R) Xeon(R) Platinum 1 Virtual Core\n *  AMD EPYC 7B13 64-Core Processor 1 Virtual Core\n *  AMD EPYC 7B13 64-Core Processor 1 Virtual Core\n *  AMD EPYC 9654 96-Core Processor 1 Virtual Core\n *  AMD Ryzen 9 7950X 16-Core Processor 1 Virtual Core\n *  AMD Ryzen 9 9900X 12-Core Processor 1 Virtual Core\n *\n * @returns {object} 匹配结果\n *  - {string} company CPU厂商\n *  - {string} model CPU型号\n *  - {string} modelNum CPU型号编号\n *  - {string} core CPU核心信息\n *  - {string} cores CPU核心数\n */\nexport function getCPUInfo(text = '') {\n  const cpuInfo = {\n    company: '',\n    model: '',\n    modelNum: '',\n    core: '',\n    cores: '',\n  };\n  const companyReg = /Intel|AMD|ARM|Qualcomm|Apple|Samsung|IBM|NVIDIA/;\n  // eslint-disable-next-line max-len, vue/max-len\n  const modelReg = /Xeon|Threadripper|Athlon|Pentium|Celeron|Opteron|Phenom|Turion|Sempron|FX|A-Series|R-Series|EPYC|Ryzen/;\n  const coresReg = /(\\d+) (Virtual|Physics|Physical) Core/;\n  const companyMatch = text.match(companyReg);\n  const modelMatch = text.match(modelReg);\n  const coresMatch = text.match(coresReg);\n  if (companyMatch) {\n    [cpuInfo.company] = companyMatch;\n  }\n  if (modelMatch) {\n    [cpuInfo.model] = modelMatch;\n  }\n  if (text.includes('Ryzen')) {\n    // 匹配各种Ryzen型号：\n    // - 标准型号: 5900X, 5950X, 7900X, 7950X, 9900X, 9950X\n    // - 普通型号: 3600, 5600, 7600\n    // - G系列APU: 5700G, 3400G\n    // - XT系列: 3600XT, 5600XT\n    // - 移动版: 4800U, 5800H, 6800HS\n    const modelNumReg = /Ryzen\\s*(?:\\d|(?:TR))\\s*(?:\\d{4}(?:[A-Z]{1,2})?)/;\n    const modelNumMatch = text.match(modelNumReg);\n    if (modelNumMatch) {\n      cpuInfo.modelNum = modelNumMatch[0].replace(/Ryzen\\s*(?:\\d|(?:TR))\\s*/, '');\n    } else {\n      // 备用正则表达式，尝试匹配其他可能的格式\n      const altModelNumReg = /Ryzen.*?(\\d{3,4}(?:[A-Z]{0,2}))/;\n      const altModelNumMatch = text.match(altModelNumReg);\n      if (altModelNumMatch) {\n        [, cpuInfo.modelNum] = altModelNumMatch;\n      }\n    }\n  }\n  if (text.includes('EPYC')) {\n    // 匹配各种EPYC型号：\n    // - 第一代: 7001系列 (7351, 7551, 7601)\n    // - 第二代: 7002系列 (7252, 7542, 7742)\n    // - 第三代: 7003系列 (7313, 7543, 7763)\n    // - 第四代: 9004系列 (9124, 9354, 9654)\n    // - 特殊系列: 7Fxx, 7Hxx, 7Bxx (7F72, 7H12, 7B13)\n    const modelNumReg = /EPYC\\s+(\\d[A-Z0-9]{2,4})/i;\n    const modelNumMatch = text.match(modelNumReg);\n    if (modelNumMatch) {\n      [, cpuInfo.modelNum] = modelNumMatch;\n    } else {\n      // 备用匹配，处理可能的其他格式\n      const altModelNumReg = /EPYC.*?(\\d{4,5}[A-Z]?)/i;\n      const altModelNumMatch = text.match(altModelNumReg);\n      if (altModelNumMatch) {\n        [, cpuInfo.modelNum] = altModelNumMatch;\n      }\n    }\n  }\n  // 匹配特定的CPU型号编号\n  if (text.includes('Xeon')) {\n    // 匹配所有Xeon处理器系列\n    // - E系列: E3, E5, E7等\n    // - 金属系列: Platinum, Gold, Silver, Bronze\n    // - 数字系列: W-1290, D-1653N等\n    // - 扩展名系列: L, X, M, D等(如X7560, L5640)\n    if (text.includes(' E')) {\n      const modelNumReg = /(E\\d-\\d{4}(?:\\s?v\\d)?)/;\n      const modelNumMatch = text.match(modelNumReg);\n      if (modelNumMatch) {\n        [, cpuInfo.modelNum] = modelNumMatch;\n      }\n    } else if (text.includes('Platinum')) {\n      const modelNumReg = /(?:Platinum\\s+)(\\d{4}(?:\\w)?)/;\n      const modelNumMatch = text.match(modelNumReg);\n      if (modelNumMatch) {\n        [, cpuInfo.modelNum] = modelNumMatch;\n      }\n    } else if (text.includes('Gold')) {\n      const modelNumReg = /(?:Gold\\s+)(\\d{4}(?:\\w)?)/;\n      const modelNumMatch = text.match(modelNumReg);\n      if (modelNumMatch) {\n        [, cpuInfo.modelNum] = modelNumMatch;\n      }\n    } else if (text.includes('Silver')) {\n      const modelNumReg = /(?:Silver\\s+)(\\d{4}(?:\\w)?)/;\n      const modelNumMatch = text.match(modelNumReg);\n      if (modelNumMatch) {\n        [, cpuInfo.modelNum] = modelNumMatch;\n      }\n    } else if (text.includes('Bronze')) {\n      const modelNumReg = /(?:Bronze\\s+)(\\d{4}(?:\\w)?)/;\n      const modelNumMatch = text.match(modelNumReg);\n      if (modelNumMatch) {\n        [, cpuInfo.modelNum] = modelNumMatch;\n      }\n    } else {\n      // 通用Xeon型号匹配\n      const genericXeonReg = /Xeon(?:\\(R\\))?\\s+(?:\\w+-)?((?:W|D)?-?\\d{4,5}(?:\\w)?)/;\n      const genericMatch = text.match(genericXeonReg);\n      if (genericMatch) {\n        [, cpuInfo.modelNum] = genericMatch;\n      }\n    }\n  }\n\n  if (text.includes('Core')) {\n    if (text.includes('Core(TM)')) {\n      // 匹配如 Core(TM) i7-10700K 等格式\n      const modelNumReg = /Core\\(TM\\)\\s+(\\w\\d+-\\w+)/;\n      const modelNumMatch = text.match(modelNumReg);\n      if (modelNumMatch) {\n        [, cpuInfo.modelNum] = modelNumMatch;\n      }\n    } else {\n      // 匹配如 Core i9-12900K, Core i5-13600K 等格式\n      const coreReg = /Core\\s+(i[3579]-\\d{4,5}(?:\\w+)?)/i;\n      const coreMatch = text.match(coreReg);\n      if (coreMatch) {\n        [, cpuInfo.modelNum] = coreMatch;\n      }\n    }\n  }\n\n  if (text.includes('Celeron')) {\n    const modelNumReg = /Celeron(?:\\(R\\))?\\s+(\\w+\\d+(?:\\w+)?)/;\n    const modelNumMatch = text.match(modelNumReg);\n    if (modelNumMatch) {\n      [, cpuInfo.modelNum] = modelNumMatch;\n    }\n  }\n\n  if (text.includes('Pentium')) {\n    const modelNumReg = /Pentium(?:\\(R\\))?\\s+(\\w+\\d+(?:\\w+)?)/;\n    const modelNumMatch = text.match(modelNumReg);\n    if (modelNumMatch) {\n      [, cpuInfo.modelNum] = modelNumMatch;\n    }\n  }\n\n  if (text.includes('Intel(R) N')) {\n    const modelNumReg = /Intel\\(R\\)\\s+(N\\d+(?:\\w+)?)/;\n    const modelNumMatch = text.match(modelNumReg);\n    if (modelNumMatch) {\n      [, cpuInfo.modelNum] = modelNumMatch;\n    }\n  }\n\n  // 匹配Apple M系列芯片\n  if (text.includes('Apple') && text.match(/M\\d/)) {\n    // 匹配各种Apple Silicon M系列芯片：\n    // - 基本型号: M1, M2, M3等\n    // - 变种型号: M1 Pro, M2 Max, M3 Ultra等\n    const appleChipReg = /Apple\\s+(?:Silicon\\s+)?M(\\d+(?:\\s+(?:Pro|Max|Ultra|Extreme))?)/i;\n    const appleChipMatch = text.match(appleChipReg);\n    if (appleChipMatch) {\n      [, cpuInfo.modelNum] = appleChipMatch;\n    }\n  }\n\n  if (coresMatch) {\n    [cpuInfo.core, cpuInfo.cores] = coresMatch;\n  }\n  return cpuInfo;\n}\n\n/**\n * 计算十进制存储大小\n *\n * @returns {object} 内存信息\n *  - {string} t TB值\n *  - {string} g GB值\n *  - {string} m MB值\n *  - {string} k KB值\n */\nexport function calcDecimal(memTotal) {\n  const k = memTotal / 1000;\n  const m = memTotal / 1000 ** 2;\n  const g = memTotal / 1000 ** 3;\n  const t = memTotal / 1000 ** 4;\n  return {\n    k,\n    m,\n    g,\n    t,\n  };\n}\n\n/**\n * 计算字节大小\n * @param {number} bytes 字节数\n * @returns {object} 字节大小\n *  - {number} kb KB值\n *  - {number} mb MB值\n *  - {number} gb GB值\n *  - {number} tb TB值\n */\nexport function calcBinary(bytes) {\n  const k = bytes / 1024;\n  const m = k / 1024;\n  const g = m / 1024;\n  const t = g / 1024;\n  let p = null;\n  if (t > 1000) {\n    p = t / 1024;\n  }\n  return {\n    k,\n    m,\n    g,\n    t,\n    p,\n  };\n}\n\n/**\n * 计算流量规格\n */\nexport function calcTransfer(bytes) {\n  const stats = calcBinary(bytes);\n  const result = {\n    value: '',\n    unit: '',\n    stats,\n  };\n  if (stats.t > 1) {\n    result.value = (stats.t).toFixed(2) * 1;\n    result.unit = 'T';\n  } else if (stats.g > 1) {\n    result.value = (stats.g).toFixed(2) * 1;\n    result.unit = 'G';\n  } else if (stats.m > 1) {\n    result.value = (stats.m).toFixed(1) * 1;\n    result.unit = 'M';\n  } else if (stats.p > 0) {\n    result.value = (stats.p).toFixed(1) * 1;\n    result.unit = 'P';\n  } else {\n    result.value = (stats.k).toFixed(1) * 1;\n    result.unit = 'K';\n  }\n  return result;\n}\n\nexport function getPlatformLogoIconClassName(platform) {\n  const platformStr = (platform || '').toLowerCase();\n  if (platformStr.includes('windows') || platformStr.includes('microsoft')) {\n    return 'ri-microsoft-fill';\n  }\n  switch (platformStr) {\n    case 'darwin':\n    case 'macos':\n      return 'fl-apple';\n    default:\n  }\n  if (platform) {\n    return `fl-${platform}`;\n  }\n  return 'ri-server-line';\n}\n\n/**\n * 获取系统发行版本\n */\nexport function getSystemOSLabel(platform, short = false) {\n  const platformStr = (platform || '').toLowerCase();\n  // 匹配一些超长系统发行版本\n  if (short && platformStr.includes('windows')) {\n    return 'Windows';\n  }\n  switch (platformStr) {\n    case 'windows':\n      return 'Windows';\n    case 'linux':\n      return 'Linux';\n    case 'darwin':\n      return 'MacOS';\n    case 'debian':\n      return 'Debian';\n    case 'ubuntu':\n      return 'Ubuntu';\n    case 'centos':\n      return 'CentOS';\n    case 'fedora':\n      return 'Fedora';\n    case 'redhat':\n      return 'RedHat';\n    case 'suse':\n      return 'SUSE';\n    case 'gentoo':\n      return 'Gentoo';\n    case 'arch':\n      return 'Arch';\n    case 'alpine':\n      return 'Alpine';\n    case 'raspbian':\n      return 'Raspbian';\n    case 'openwrt':\n      return 'OpenWRT';\n    case 'freebsd':\n      return 'FreeBSD';\n    case 'netbsd':\n      return 'NetBSD';\n    case 'openbsd':\n      return 'OpenBSD';\n    case 'dragonfly':\n      return 'DragonFly';\n    case 'solaris':\n      return 'Solaris';\n    case 'aix':\n      return 'AIX';\n    case 'hpux':\n      return 'HP-UX';\n    case 'irix':\n      return 'IRIX';\n    case 'osf':\n      return 'OSF';\n    case 'tru64':\n      return 'Tru64';\n    case 'unixware':\n      return 'UnixWare';\n    case 'sco':\n      return 'SCO';\n    default:\n      return platform;\n  }\n}\n"
  },
  {
    "path": "src/utils/load-nezha-v0-config.js",
    "content": "import config from '@/config';\n\nfunction getNezhaConfigUrl() {\n  const { nezhaPath } = config.nazhua;\n  if (nezhaPath.startsWith('http')) {\n    return nezhaPath;\n  }\n  const a = document.createElement('a');\n  if (nezhaPath === '/nezha/' && (import.meta.env.VITE_BASE_PATH && import.meta.env.VITE_BASE_PATH !== '/')) {\n    [a.href] = window.location.href.split(import.meta.env.VITE_BASE_PATH);\n  } else {\n    a.href = nezhaPath;\n  }\n  return a.href;\n}\n\nconst configReg = (type) => new RegExp(`${type} = JSON.parse\\\\('(.*)'\\\\)`);\n// 格式化数据，保证JSON.parse能够正常解析\nconst unescaped = (str) => {\n  let str2 = str.replace(/\\\\u([\\d\\w]{4})/gi, (match, grp) => String.fromCharCode(parseInt(grp, 16)));\n  str2 = str2.replace(/\\\\\\\\r/g, '');\n  str2 = str2.replace(/\\\\\\\\n/g, '');\n  str2 = str2.replace(/\\\\\\\\/g, '\\\\');\n  return str2;\n};\nexport default async () => fetch(getNezhaConfigUrl()).then((res) => res.text()).then((res) => {\n  let resMatch = res?.match?.(configReg(config.nazhua.nezhaV0ConfigType));\n  // 尝试兼容不同的nezha前台主题\n  if (!resMatch) {\n    resMatch = res?.match?.(configReg(\n      config.nazhua.nezhaV1ConfigType === 'servers' ? 'initData' : 'servers',\n    ));\n  }\n  const configStr = resMatch?.[1];\n  if (!configStr) {\n    return null;\n  }\n  let remoteConfig;\n  try {\n    remoteConfig = JSON.parse(unescaped(configStr));\n  } catch (error) {\n    console.error('Failed to parse nezha config:', error);\n    return null;\n  }\n  if (remoteConfig?.servers) {\n    remoteConfig.servers = remoteConfig.servers.map((i) => {\n      const item = {\n        ...i,\n      };\n      try {\n        item.PublicNote = JSON.parse(i.PublicNote);\n      } catch (error) {\n        console.warn('Failed to parse PublicNote for server:', i.ID || i.id, error);\n        item.PublicNote = {};\n      }\n      return item;\n    });\n    return remoteConfig;\n  }\n  return null;\n}).catch((error) => {\n  console.error('Failed to load nezha config:', error);\n  return null;\n});\n\n/**\n * 获取标签列表\n */\nexport const loadServerGroup = (services) => {\n  const tagMap = {};\n  services.forEach((i) => {\n    if (i.Tag) {\n      if (!tagMap[i.Tag]) {\n        tagMap[i.Tag] = [];\n      }\n      tagMap[i.Tag].push(i);\n    }\n  });\n  const tagList = [];\n  Object.entries(tagMap).forEach(([tag, serviceList]) => {\n    tagList.push({\n      name: tag,\n      count: serviceList.length,\n      servers: serviceList.map((i) => i.ID),\n      group: {\n        name: tag,\n      },\n    });\n  });\n  return tagList;\n};\n"
  },
  {
    "path": "src/utils/load-nezha-v1-config.js",
    "content": "/**\n * V1版数据加载\n */\nimport config from '@/config';\nimport request from '@/utils/request';\n\nexport const loadServerGroup = async () => request({\n  url: config.nazhua.v1GroupPath || config.nazhua.v1ApiGroupPath,\n  type: 'GET',\n}).then((res) => {\n  if (res.status === 200 && res.data?.success) {\n    const list = res.data?.data || [];\n    return list.map((i) => {\n      const item = {\n        ...i,\n        name: i?.group?.name,\n        count: i?.servers?.length,\n      };\n      return item;\n    });\n  }\n  return null;\n}).catch((error) => {\n  console.error('Failed to load server group:', error);\n  return null;\n});\n\n/**\n * 加载网站配置\n *\n * 暂时只使用site_name\\custom_code\n * 哪吒v1.4.9之后，上面的参数调整至data.config\n */\nexport const loadSetting = async () => request({\n  url: config.nazhua.v1ApiSettingPath,\n  type: 'GET',\n}).then((res) => {\n  if (res.status === 200 && res.data?.success) {\n    return res.data?.data || {};\n  }\n  return null;\n}).catch((error) => {\n  console.error('Failed to load setting:', error);\n  return null;\n});\n\n/**\n * 加载个人信息\n */\nexport const loadProfile = async (check) => request({\n  url: config.nazhua.v1ApiProfilePath,\n  type: 'GET',\n}).then((res) => {\n  if (check) {\n    return res.status === 200;\n  }\n  if (res.status === 200 && res.data?.success) {\n    return res.data?.data || {};\n  }\n  return null;\n}).catch((error) => {\n  console.error('Failed to load profile:', error);\n  return null;\n});\n"
  },
  {
    "path": "src/utils/object-mapping.js",
    "content": "/**\n * 对象映射封装\n */\nclass Mapping {\n  /**\n   * 字符串映射对象\n   *\n   * @param {Record<string, any>} obj 查找的对象\n   * @param {string} key 查找的属性\n   *\n   * @return {any}\n   */\n  static mapping(obj, key) {\n    // 检查 obj 是否为对象，如果不是，返回 undefined\n    if (!obj || typeof obj !== 'object') {\n      return undefined;\n    }\n    // 检查 key 是否为字符串，如果不是，返回 undefined\n    if (typeof key !== 'string') {\n      return undefined;\n    }\n    // 检查 key 是否包含非法字符，如果包含，返回 undefined\n    if (key.includes('..') || key.startsWith('.') || key.endsWith('.')) {\n      return undefined;\n    }\n    // 如果 key 包含 '.'，使用 reduce 方法递归获取嵌套属性值\n    if (key.includes('.')) {\n      return key.split('.').reduce((val, k) => (val !== undefined ? Mapping.get(val, k) : undefined), obj);\n    }\n    // 如果 key 不包含 '.'，直接获取属性值\n    return Mapping.get(obj, key);\n  }\n\n  /**\n   * 获取数据\n   * 支持处理数组指针\n   * @param {Record<string, any> | any[]} obj 属性对象\n   * @param {string} key 属性名称\n   * @return {any}\n   */\n  static get(obj, key) {\n    if (!obj || typeof obj !== 'object' || !key) {\n      return undefined;\n    }\n    const indexMatch = key.match(/\\[(\\d+)\\]/);\n    if (indexMatch) {\n      const [fullMatch, indexStr] = indexMatch;\n      const index = Number(indexStr);\n      const matchIndex = key.indexOf(fullMatch);\n      if (matchIndex === 0) {\n        if (Array.isArray(obj) && index < obj.length) {\n          const val = obj[index];\n          const restKey = key.slice(fullMatch.length);\n          return restKey ? Mapping.get(val, restKey) : val;\n        }\n      } else {\n        const pre = key.slice(0, matchIndex);\n        const list = obj[pre];\n        if (Array.isArray(list) && index < list.length) {\n          const val = list[index];\n          const restKey = key.slice(matchIndex + fullMatch.length);\n          return restKey ? Mapping.get(val, restKey) : val;\n        }\n      }\n      return undefined;\n    }\n    return obj[key];\n  }\n\n  /**\n   * 数据根据key的映射进行组装\n   *\n   * @param {KeyMap} keys 映射对象\n   * @param {Record<string, unknown>} data 数据对象\n   *\n   * @return {Record<string, unknown>}\n   */\n  static each(keys, data) {\n    // 检查 keys 是否为对象，如果不是，返回 undefined\n    if (!keys || typeof keys !== 'object') {\n      return undefined;\n    }\n    // 检查 data 是否为对象，如果不是，返回 undefined\n    if (!data || typeof data !== 'object') {\n      return undefined;\n    }\n    return Object.entries(keys).reduce((acc, [key, value]) => {\n      if (typeof value === 'string') {\n        acc[key] = Mapping.mapping(data, value);\n      }\n      return acc;\n    }, {});\n  }\n}\nconst { mapping } = Mapping;\n\nexport { Mapping, mapping };\n\nexport default mapping;\n"
  },
  {
    "path": "src/utils/page-title.js",
    "content": "import config from '@/config';\n\nexport default (...args) => {\n  const titles = [...new Set([...args, config.nazhua.title])].filter((i) => i);\n  document.title = titles.join(' - ');\n};\n"
  },
  {
    "path": "src/utils/request.js",
    "content": "import axios from 'axios';\nimport uuid from '@/utils/uuid';\n\nimport CustomError from './custom-error';\n\nconst limit = 10;\n\nconst requestTagMap = {};\n\n/**\n * axios请求\n * @param {object} options 请求参数\n * @return {Promise}\n */\nasync function axiosRequest(options) {\n  return axios(options).then((res) => res).catch((err) => {\n    if (err.response) {\n      return err.response;\n    }\n    if (err.request) {\n      // 请求已经成功发起，但没有收到响应\n      return null;\n    }\n    throw new CustomError(err.message);\n  });\n}\n\n/**\n * 网络请求\n */\nclass NetworkRequest {\n  constructor() {\n    this.tasks = [];\n    this.tasking = 0;\n  }\n\n  /**\n   * 是否为Form请求\n   */\n  static FormRequest = (headers) => {\n    if (!headers) return false;\n    const keys = Object.keys(headers);\n    for (let i = 0, n = keys.length; i < n; i += 1) {\n      if (keys[i].toLowerCase() === 'content-type') {\n        return headers[keys[i]].includes('x-www-form-urlencoded');\n      }\n    }\n    return false;\n  };\n\n  /**\n   * 添加请求\n   *\n   * @param {string} url 请求的相对路径\n   * @param {string} type 请求的Method\n   * @param {object} headers Header请求参数\n   * @param {object} data 请求参数\n   * @param {boolean} defaultContentType 默认的请求方式\n   * @param {Boolean} priority 优先调用请求\n   *\n   * @return {Promise}\n   */\n  push(\n    options = {},\n    controller = {},\n    priority = false,\n  ) {\n    const {\n      url,\n      type,\n      headers,\n      data,\n      defaultContentType = true,\n      requestTag = undefined,\n      responseType,\n    } = options || {};\n    const {\n      abortController,\n    } = controller || {};\n    const tag = requestTag || uuid();\n\n    if (requestTagMap[tag]) {\n      return requestTagMap[tag];\n    }\n\n    return new Promise((resolve, reject) => {\n      const defaultHeaders = {};\n      if (defaultContentType === false) {\n        if (NetworkRequest.FormRequest(defaultHeaders)) {\n          defaultHeaders['content-type'] = 'application/json';\n        } else {\n          defaultHeaders['content-type'] = 'application/x-www-form-urlencoded';\n        }\n      }\n      const requestOptions = [\n        {\n          url,\n          method: type,\n          headers: {\n            ...defaultHeaders,\n            ...headers,\n          },\n          data,\n          signal: abortController?.signal ?? undefined,\n          responseType,\n        },\n        (res) => {\n          resolve(res);\n        },\n        (err) => {\n          reject(err);\n        },\n        tag,\n      ];\n      if (priority) {\n        this.tasks.unshift(requestOptions);\n      } else {\n        this.tasks.push(requestOptions);\n      }\n      this.nextTask();\n    });\n  }\n\n  /**\n   * 下一个请求任务\n   */\n  nextTask() {\n    if (this.tasking >= limit) {\n      setTimeout(() => {\n        this.nextTask();\n      }, 1000);\n      return;\n    }\n    if (this.tasks.length === 0) {\n      return;\n    }\n    const [options, success, fail, tag] = this.tasks.pop();\n    // 请求未执行已被中止\n    if (options?.signal?.aborted) {\n      this.overTask();\n      return;\n    }\n    requestTagMap[tag] = axiosRequest(options);\n    requestTagMap[tag].finally(() => {\n      this.overTask();\n      // 一秒内请求不重复\n      setTimeout(() => {\n        delete requestTagMap[tag];\n      }, 1000);\n    });\n    requestTagMap[tag].then(success).catch(fail);\n    this.tasking += 1;\n  }\n\n  /**\n   * 结束请求任务\n   */\n  overTask() {\n    this.tasking -= 1;\n    this.nextTask();\n  }\n}\n\nconst request = new NetworkRequest();\n\nexport {\n  NetworkRequest,\n};\n\nexport default (...args) => request.push(...args);\n"
  },
  {
    "path": "src/utils/sleep.js",
    "content": "export default (timed = 1000) => new Promise((resolve) => {\n  setTimeout(() => resolve(), timed > 0 ? timed : 30);\n});\n"
  },
  {
    "path": "src/utils/subscribe.js",
    "content": "/**\n * 消息订阅器\n */\n\nclass MessageSubscribe {\n  constructor() {\n    this.subscribers = {};\n  }\n\n  /**\n   * 订阅消息\n   * @params {String} key 消息类型\n   * @params {Function} callback 回调函数\n   */\n  on(key, callback) {\n    if (!this.subscribers[key]) {\n      this.subscribers[key] = [];\n    }\n    this.subscribers[key].push(callback);\n    if (import.meta.env.VITE_LIVE_SUBSCRIBE_DEBUG) {\n      console.log('subscribers on by key:', key);\n      console.log('subscribers on', this.subscribers);\n    }\n  }\n\n  /**\n   * 订阅一次消息\n   * @params {String} key 消息类型\n   * @params {Function} callback 回调函数\n   */\n  once(key, callback) {\n    const onceCallback = (...args) => {\n      callback(...args);\n      this.off(key, onceCallback);\n    };\n    this.on(key, onceCallback);\n  }\n\n  /**\n   * 取消订阅\n   * @params {String} key 消息类型\n   * @params {Function} callback 回调函数\n   */\n  off(key, callback) {\n    if (!this.subscribers[key]) {\n      return;\n    }\n    const index = this.subscribers[key].indexOf(callback);\n    if (index !== -1) {\n      this.subscribers[key].splice(index, 1);\n    }\n    if (import.meta.env.VITE_LIVE_SUBSCRIBE_DEBUG) {\n      console.log('subscribers off by key:', key);\n      console.log('subscribers off', this.subscribers);\n    }\n  }\n\n  /**\n   * 发布消息\n   * @params {String} key 消息类型\n   * @params {Object} data 消息数据\n   */\n  emit(key, data) {\n    if (!this.subscribers[key]) {\n      return;\n    }\n    this.subscribers[key].forEach((callback) => {\n      callback(data);\n    });\n    if (import.meta.env.VITE_LIVE_SUBSCRIBE_DEBUG) {\n      console.log('subscribers emit by key:', key);\n      console.log('subscribers emit', key, this.subscribers[key]);\n    }\n  }\n}\n\nexport default MessageSubscribe;\n"
  },
  {
    "path": "src/utils/transform-v1-2-v0.js",
    "content": "/**\n * V1版数据加载\n */\nimport store from '@/store';\nimport validate from '@/utils/validate';\nimport { Mapping } from '@/utils/object-mapping';\n\n/**\n * 字段映射\n */\nexport const SERVER_FIELD_MAPS = {\n  ID: 'id',\n  CreatedAt: undefined,\n  UpdatedAt: undefined,\n  DeletedAt: undefined,\n  Name: 'name',\n  Tag: '_$function|queryGroup|id',\n  DisplayIndex: 'display_index',\n  HideForGuest: undefined,\n  EnableDDNS: undefined,\n  Host: '_$mapping|HOST_FIELD_MAPS',\n  State: '_$mapping|STATE_FIELD_MAPS',\n  LastActive: 'last_active',\n};\nexport const HOST_FIELD_MAPS = {\n  Platform: 'host.platform',\n  PlatformVersion: 'host.platform_version',\n  CPU: 'host.cpu',\n  MemTotal: 'host.mem_total',\n  DiskTotal: 'host.disk_total',\n  SwapTotal: 'host.swap_total',\n  Arch: 'host.arch',\n  Virtualization: 'host.virtualization',\n  BootTime: 'host.boot_time',\n  CountryCode: 'country_code',\n  Version: 'host.version',\n  GPU: 'host.gpu',\n};\nexport const STATE_FIELD_MAPS = {\n  CPU: 'state.cpu',\n  MemUsed: 'state.mem_used',\n  SwapUsed: 'state.swap_used',\n  DiskUsed: 'state.disk_used',\n  NetInTransfer: 'state.net_in_transfer',\n  NetOutTransfer: 'state.net_out_transfer',\n  NetInSpeed: 'state.net_in_speed',\n  NetOutSpeed: 'state.net_out_speed',\n  Uptime: 'state.uptime',\n  Load1: 'state.load_1',\n  Load5: 'state.load_5',\n  Load15: 'state.load_15',\n  TcpConnCount: 'state.tcp_conn_count',\n  UdpConnCount: 'state.udp_conn_count',\n  ProcessCount: 'state.process_count',\n  Temperatures: 'state.temperatures',\n  GPU: 'state.gpu',\n};\n\n/**\n * 魔法方法\n */\nconst magics = {\n  HOST_FIELD_MAPS,\n  STATE_FIELD_MAPS,\n  queryGroup: (id) => {\n    const groupItem = store.state.serverGroup?.find?.((i) => {\n      if (i.servers) {\n        return i.servers.includes(id);\n      }\n      return false;\n    });\n    return groupItem?.name;\n  },\n};\n\n/**\n * 处理V1版数据\n * @param {Object} v1Data V1版数据\n * @return {Object} V0版数据\n */\nexport default function (v1Data) {\n  const v0Data = {};\n  Object.keys(SERVER_FIELD_MAPS).forEach((key) => {\n    if (SERVER_FIELD_MAPS[key] === undefined) {\n      return;\n    }\n    if (SERVER_FIELD_MAPS[key].includes('_$')) {\n      const $magic = SERVER_FIELD_MAPS[key].split('|');\n      switch ($magic[0]) {\n        case '_$function':\n          if ($magic.length >= 3 && magics[$magic[1]]) {\n            v0Data[key] = magics[$magic[1]](\n              Mapping.mapping(v1Data, $magic[2]),\n            );\n          } else {\n            v0Data[key] = undefined;\n          }\n          break;\n        case '_$mapping':\n          v0Data[key] = Mapping.each(magics[$magic[1]], v1Data);\n          if (key === 'State') {\n            // 修复Load1、Load5、Load15字段为空时的问题\n            [\n              'Load1', 'Load5', 'Load15',\n              'NetInTransfer', 'NetOutTransfer',\n              'NetInSpeed', 'NetOutSpeed',\n            ].forEach((k) => {\n              if (!validate.isSet(v0Data[key][k])) {\n                v0Data[key][k] = 0;\n              }\n            });\n          }\n          break;\n        default:\n          break;\n      }\n      return;\n    }\n    v0Data[key] = Mapping.mapping(v1Data, SERVER_FIELD_MAPS[key]);\n  });\n  if (v1Data.public_note) {\n    try {\n      v0Data.PublicNote = JSON.parse(v1Data.public_note);\n    } catch (e) {\n      console.warn('Failed to parse public_note for server:', v1Data.id, e);\n      v0Data.PublicNote = null;\n    }\n  } else {\n    v0Data.PublicNote = null;\n  }\n  return v0Data;\n}\n"
  },
  {
    "path": "src/utils/tsdb.js",
    "content": "/**\n * v1 后端 TSDB 相关判断\n * tsdb_enabled 为 true 时：用 period=1d 拉取监控数据，WS 不返回 tcp/udp，前端需隐藏连接数展示\n */\nimport config from '@/config';\n\n/**\n * 是否开启 TSDB（v1 且 setting 中 tsdb_enabled 为 true）\n * @param {import('vuex').Store} store\n * @returns {boolean}\n */\nexport function isTsdbEnabled(store) {\n  if (config.nazhua.nezhaVersion !== 'v1' || !store?.state?.setting) {\n    return false;\n  }\n  const { setting } = store.state;\n  return setting?.config?.tsdb_enabled === true || setting?.tsdb_enabled === true;\n}\n\n/**\n * 是否有 tsdb_enabled 字段（存在即可，不要求为 true）\n * @param {import('vuex').Store} store\n * @returns {boolean}\n */\nexport function hasTsdb(store) {\n  if (config.nazhua.nezhaVersion !== 'v1' || !store?.state?.setting) {\n    return false;\n  }\n  const { setting } = store.state;\n  return 'tsdb_enabled' in (setting?.config ?? {}) || 'tsdb_enabled' in (setting ?? {});\n}\n"
  },
  {
    "path": "src/utils/uuid.js",
    "content": "/* eslint-disable */\nexport default () => {\n  if (crypto?.randomUUID) {\n    return crypto.randomUUID();\n  }\n  // Public Domain/MIT\n  // Timestamp\n  let d = new Date().getTime();\n  // Time in microseconds since page-load or 0 if unsupported\n  let d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0;\n  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n    // random number between 0 and 16\n    let r = Math.random() * 16;\n    // Use timestamp until depleted\n    if (d > 0) {\n      r = (d + r) % 16 | 0;\n      d = Math.floor(d / 16);\n    } else {\n      // Use microseconds since page-load if supported\n      r = (d2 + r) % 16 | 0;\n      d2 = Math.floor(d2 / 16);\n    }\n    return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n  });\n}\n"
  },
  {
    "path": "src/utils/validate.js",
    "content": "/**\n * 校验方法\n */\n\nconst validate = {\n  /**\n   * 判断值是否已经设置类型数据\n   * null|undefined为false\n   */\n  isSet(val) {\n    if (\n      val === null\n      || val === undefined\n    ) {\n      return false;\n    }\n    return true;\n  },\n  /**\n   * 判断是否为空值\n   * null|undefined|空字符串 绝对为空\n   * 空对象、空数组根据拓展选项来控制\n   *\n   * @param {Any} val 验证值\n   * @param {Object|Boolean} options 验证选项\n   * @param {Boolean} options.allEmpty 全部验证\n   * @param {Boolean} options.objectEmpty 对象验证\n   * @param {Boolean} options.arrayEmpty 数组验证\n   *\n   * @return {Boolean} 是否为空\n   */\n  isEmpty(val, options = null) {\n    let allEmpty = false;\n    let objectEmpty = false;\n    let arrayEmpty = false;\n    if (options === true) {\n      allEmpty = true;\n    } else {\n      const emptyOptions = options || {};\n      allEmpty = emptyOptions.allEmpty;\n      objectEmpty = emptyOptions.objectEmpty;\n      arrayEmpty = emptyOptions.arrayEmpty;\n    }\n\n    if (\n      val === null\n      || val === undefined\n      || (\n        val.constructor.name === 'String'\n        && val === ''\n      )\n    ) {\n      return true;\n    }\n    if (\n      (allEmpty || objectEmpty)\n      && val.constructor.name === 'Object'\n      && Object.getOwnPropertyNames(val).length === 0\n    ) {\n      return true;\n    }\n    if (\n      (allEmpty || arrayEmpty)\n      && Array.isArray(val)\n      && val.length === 0\n    ) {\n      return true;\n    }\n    return false;\n  },\n  /**\n   * 是否为对象\n   */\n  isObject(val) {\n    return typeof val === 'object' && val !== null && val.constructor.name === 'Object';\n  },\n  hasOwn(obj, key) {\n    return Object.prototype.hasOwnProperty.call(obj, key);\n  },\n};\n\nexport default validate;\n"
  },
  {
    "path": "src/utils/world-map.js",
    "content": "import config from '@/config';\nimport CODE_MAPS, {\n  countryCodeMapping,\n  aliasMapping,\n} from '@/data/code-maps';\n\nexport const ALIAS_CODE = {\n  ...aliasMapping,\n  ...countryCodeMapping,\n};\n\nexport const alias2code = (code) => ALIAS_CODE[code];\n\nexport const locationCode2Info = (code) => {\n  const maps = {\n    ...CODE_MAPS,\n    ...(config.nazhua.customCodeMap || {}),\n  };\n  let info = maps[code];\n  const aliasCode = aliasMapping[code];\n  if (!info && aliasCode) {\n    info = maps[aliasCode];\n  }\n  return info;\n};\n\nexport const count2size = (count) => {\n  if (count < 3) {\n    return 4;\n  }\n  if (count < 5) {\n    return 6;\n  }\n  return 8;\n};\n\nexport function findIntersectingGroups(coordinates) {\n  const groups = {};\n\n  coordinates.forEach((coordinate, index) => {\n    const intersects = [];\n    const n = -2;\n    coordinates.forEach((otherCoordinate, otherIndex) => {\n      if (index !== otherIndex) {\n        if (\n          coordinate.topLeft.top - otherCoordinate.bottomRight.top < n\n          && coordinate.topLeft.left - otherCoordinate.bottomRight.left < n\n          && coordinate.bottomRight.top - otherCoordinate.topLeft.top > -n\n          && coordinate.bottomRight.left - otherCoordinate.topLeft.left > -n\n        ) {\n          intersects.push(otherCoordinate);\n        }\n      }\n    });\n    if (intersects.length > 0) {\n      groups[coordinate.key] = intersects;\n    }\n  });\n\n  return groups;\n}\n"
  },
  {
    "path": "src/utils/zIndexManager.js",
    "content": "const BASE_Z_INDEX = 1000;\nlet zIndexCounter = BASE_Z_INDEX;\n\nexport const getNextZIndex = () => {\n  zIndexCounter += 1;\n  return zIndexCounter;\n};\n\nexport const getCurrentZIndex = () => zIndexCounter;\n\nexport const resetZIndex = () => {\n  zIndexCounter = BASE_Z_INDEX;\n};\n"
  },
  {
    "path": "src/views/components/server/server-real-time.vue",
    "content": "<template>\n  <div class=\"server-real-time-group\">\n    <div\n      v-for=\"item in serverRealTimeList\"\n      :key=\"item.key\"\n      class=\"server-real-time-item\"\n      :class=\"`server-real-time--${item.key}`\"\n    >\n      <div class=\"item-content\">\n        <div\n          v-if=\"item.show && item.values\"\n          class=\"item-content-sub-group\"\n        >\n          <span\n            v-for=\"subItem in item.values\"\n            :key=\"`${item.key}_${subItem.key}`\"\n            class=\"item-content-sub-item\"\n            :class=\"`item-content-sub-item--${item.key}-${subItem.key}`\"\n          >\n            <span class=\"item-content-sub-label\">\n              {{ subItem.label }}\n            </span>\n            <span class=\"item-content-sub-content\">\n              <span class=\"item-value\">{{ subItem.show ? subItem?.value : '-' }}</span>\n              <span\n                v-if=\"subItem.show\"\n                class=\"item-unit item-text\"\n              >{{ subItem?.unit }}</span>\n            </span>\n          </span>\n        </div>\n        <template v-else>\n          <span class=\"item-value\">{{ item.show ? item?.value : '-' }}</span>\n          <span\n            v-if=\"item.show\"\n            class=\"item-unit item-text\"\n          >{{ item?.unit }}</span>\n        </template>\n      </div>\n      <span\n        v-if=\"!item.values\"\n        class=\"item-label\"\n      >\n        {{ item.label }}\n      </span>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器数据统计\n */\nimport {\n  inject,\n} from 'vue';\nimport handleServerRealTime from '@/views/composable/server-real-time';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n  serverRealTimeListTpls: {\n    type: String,\n    default: undefined,\n  },\n});\n\nconst currentTime = inject('currentTime', {\n  value: Date.now(),\n});\n\nconst {\n  serverRealTimeList,\n} = handleServerRealTime({\n  props,\n  currentTime,\n  serverRealTimeListTpls: props.serverRealTimeListTpls,\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-real-time-group {\n  display: flex;\n  align-items: center;\n\n  .server-real-time-item {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    cursor: default;\n\n    .item-value {\n      line-height: 1em;\n      font-size: var(--real-time-value-font-size, 24px);\n    }\n\n    .item-content {\n      display: flex;\n      align-items: flex-end;\n      gap: 2px;\n    }\n\n    .item-text {\n      line-height: 1.3em;\n      font-size: var(--real-time-text-font-size, 12px);\n      color: #ddd;\n    }\n\n    .item-label {\n      line-height: 1.2em;\n      font-size: var(--real-time-label-font-size, 14px);\n      color: #ddd;\n    }\n\n    .item-content-sub-group {\n      flex: 1;\n      display: flex;\n      flex-direction: column;\n      justify-content: space-between;\n\n      .item-content-sub-item {\n        flex: 1;\n        display: flex;\n        align-items: center;\n        gap: 0.2em;\n      }\n\n      --real-time-label-line-height: calc(var(--real-time-label-font-size, 14px) * 1.8);\n\n      .item-content-sub-label {\n        height: var(--real-time-label-line-height);\n        line-height: var(--real-time-label-line-height);\n        white-space: nowrap;\n      }\n\n      .item-content-sub-content {\n        display: flex;\n        align-items: center;\n        white-space: nowrap;\n      }\n\n      .item-value,\n      .item-text,\n      .item-label {\n        height: var(--real-time-label-line-height);\n        line-height: var(--real-time-label-line-height);\n        font-size: var(--real-time-label-font-size, 14px);\n      }\n\n      .item-content-sub-item--L-A-P-load {\n        .item-value {\n          color: var(--load-color);\n        }\n      }\n      .item-content-sub-item--L-A-P-process {\n        .item-value {\n          color: var(--process-color);\n        }\n      }\n\n      .item-content-sub-item--D-A-T-duration {\n        .item-value {\n          color: var(--duration-color);\n        }\n      }\n      .item-content-sub-item--D-A-T-transfer {\n        .item-value {\n          color: var(--transfer-color);\n        }\n      }\n\n      .item-content-sub-item--speeds-in {\n        .item-value {\n          color: var(--net-speed-in-color);\n        }\n      }\n      .item-content-sub-item--speeds-out {\n        .item-value {\n          color: var(--net-speed-out-color);\n        }\n      }\n\n      .item-content-sub-item--conn-tcp {\n        .item-value {\n          color: var(--conn-tcp-color);\n        }\n      }\n      .item-content-sub-item--conn-udp {\n        .item-value {\n          color: var(--conn-udp-color);\n        }\n      }\n    }\n  }\n\n  .server-real-time--duration {\n    .item-value {\n      color: var(--duration-color);\n    }\n  }\n  .server-real-time--transfer {\n    .item-value {\n      color: var(--transfer-color);\n    }\n  }\n  .server-real-time--inSpeed,\n  .server-real-time--speed {\n    .item-value {\n      color: var(--net-speed-in-color);\n    }\n  }\n  .server-real-time--outSpeed {\n    .item-value {\n      color: var(--net-speed-out-color);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server/server-status-donut.vue",
    "content": "<template>\n  <div\n    class=\"server-status\"\n    :class=\"'server-status--' + type\"\n  >\n    <div class=\"server-status-donut\">\n      <chart-donut\n        :size=\"size\"\n        :used=\"Math.min(Math.max(used, 1), 100)\"\n        :item-colors=\"colors\"\n      >\n        <template #default>\n          <div\n            class=\"chart-donut-label\"\n            :title=\"valPercent ? valPercent : `${(used).toFixed(1) * 1}%`\"\n          >\n            <div class=\"server-status-val-text\">\n              <span>{{ valText }}</span>\n            </div>\n            <div class=\"server-status-label\">\n              {{ label }}\n            </div>\n          </div>\n        </template>\n      </chart-donut>\n    </div>\n\n    <div\n      v-if=\"content\"\n      class=\"server-status-content\"\n    >\n      <span\n        v-if=\"content?.default\"\n        class=\"default-content\"\n      >\n        {{ content?.default }}\n      </span>\n      <span\n        v-if=\"content?.mobile\"\n        class=\"default-mobile\"\n      >\n        {{ content?.mobile }}\n      </span>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器状态单项\n */\n\nimport ChartDonut from '@/components/charts/donut.vue';\n\ndefineProps({\n  type: {\n    type: String,\n    default: '',\n  },\n  size: {\n    type: Number,\n    default: 100,\n  },\n  used: {\n    type: [Number, String],\n    default: 1,\n  },\n  colors: {\n    type: Object,\n    default: () => ({}),\n  },\n  valText: {\n    type: String,\n    default: '',\n  },\n  valPercent: {\n    type: String,\n    default: '',\n  },\n  label: {\n    type: String,\n    default: '',\n  },\n  content: {\n    type: [String, Object],\n    default: '',\n  },\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-status {\n  flex: 1;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-direction: column;\n  gap: 5px;\n\n  .server-status-donut {\n    --donut-box-size: var(--server-status-size);\n    height: var(--server-status-size);\n  }\n\n  .chart-donut-label {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    transform: scale(var(--server-status-label-scale, 1));\n    cursor: pointer;\n  }\n\n  .server-status-val-text {\n    line-height: 1.2em;\n    font-size: var(--server-status-val-text-font-size, 14px);\n    color: var(--server-status-value-color);\n  }\n  .server-status-label {\n    line-height: 1.1em;\n    font-size: var(--server-status-label-font-size, 12px);\n    color: var(--server-status-label-color);\n  }\n\n  .server-status-content {\n    line-height: 1.2em;\n    font-size: var(--server-status-content-font-size, 14px);\n    color: var(--server-status-content-color);\n\n    .default-mobile {\n      display: none;\n    }\n\n    @media screen and (max-width: 768px) {\n      .default-content {\n        display: none;\n      }\n      .default-mobile {\n        display: block;\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server/server-status-progress.vue",
    "content": "<template>\n  <div\n    class=\"server-status-progress\"\n    :class=\"'server-status--' + type\"\n  >\n    <div class=\"progress-bar-box\">\n      <div\n        class=\"progress-bar-inner\"\n        :style=\"progressStyle\"\n      />\n      <div\n        class=\"progress-bar-label\"\n        :title=\"label + '使用' + used + '%'\"\n      >\n        <span\n          v-if=\"label\"\n          class=\"server-status-label\"\n        >\n          {{ label }}:\n        </span>\n        <span class=\"server-status-val-text\">\n          {{ valText }}\n        </span>\n      </div>\n    </div>\n\n    <div\n      v-if=\"content\"\n      class=\"server-status-progress-content\"\n    >\n      <span>{{ content?.default }}</span>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器状态进度调单项\n */\n\nimport {\n  computed,\n} from 'vue';\n\nconst props = defineProps({\n  type: {\n    type: String,\n    default: '',\n  },\n  size: {\n    type: Number,\n    default: 100,\n  },\n  used: {\n    type: [Number, String],\n    default: 1,\n  },\n  colors: {\n    type: Object,\n    default: () => ({}),\n  },\n  valText: {\n    type: String,\n    default: '',\n  },\n  label: {\n    type: String,\n    default: '',\n  },\n  content: {\n    type: [String, Object],\n    default: '',\n  },\n});\n\nconst progressStyle = computed(() => {\n  const style = {};\n  style.width = `${Math.min(props.used, 100)}%`;\n  const color = typeof props.colors === 'string' ? props.colors : props.colors?.used;\n  if (color) {\n    if (Array.isArray(color)) {\n      style.background = `linear-gradient(-35deg, ${color.join(',')})`;\n    } else {\n      style.backgroundColor = color;\n    }\n  }\n  return style;\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-status-progress {\n  flex: 1;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-direction: column;\n  gap: 5px;\n\n  @media screen and (max-width: 480px) {\n    flex: none;\n    width: var(--progress-bar-width, calc(50% - 5px));\n  }\n\n  // @media screen and (max-width: 350px) {\n  //   flex: none;\n  //   width: 100%;\n  // }\n\n  .progress-bar-box {\n    position: relative;\n    width: 100%;\n    height: var(--progress-bar-height);\n    background: rgba(255, 255, 255, 0.2);\n    border-radius: calc(var(--progress-bar-height) / 2);\n    overflow: hidden;\n  }\n\n  .progress-bar-inner {\n    position: absolute;\n    top: 0;\n    left: 0;\n    bottom: 0;\n    background-color: #08f;\n    border-radius: calc(var(--progress-bar-height) / 2);\n  }\n\n  .progress-bar-label {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    line-height: var(--progress-bar-height);\n    font-size: 12px;\n    text-align: center;\n    text-shadow: 1px 1px 2px rgba(#000, 0.8), 0 0 1px rgba(#fff, 0.5);\n    cursor: default;\n  }\n\n  .server-status-val-text {\n    color: #a1eafb;\n  }\n  .server-status-label {\n    color: #ddd;\n  }\n\n  .server-status-progress-content {\n    color: #eee;\n    @media screen and (max-width: 480px) {\n      line-height: 20px;\n      font-size: 12px;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-detail/server-info-box.vue",
    "content": "<template>\n  <dot-dot-box class=\"server-info-box\">\n    <div class=\"server-info-group server-info--cpu\">\n      <div class=\"server-info-label\">\n        CPU\n      </div>\n      <div class=\"server-info-content\">\n        <template v-if=\"info?.Host?.CPU?.length === 1\">\n          <span\n            class=\"cpu-info\"\n            :title=\"info.Host.CPU[0]\"\n          >\n            <span>{{ info.Host.CPU[0] }}</span>\n          </span>\n        </template>\n        <div\n          v-else\n          class=\"server-info-item-group\"\n        >\n          <span\n            v-for=\"(cpuItem, cpuIndex) in info.Host.CPU\"\n            :key=\"`${info.ID}_cpu_${cpuIndex}`\"\n            class=\"server-info-item\"\n          >\n            <span class=\"server-info-item-label\">CPU.{{ cpuIndex + 1 }}</span>\n            <span class=\"server-info-item-value\">{{ cpuItem }}</span>\n          </span>\n        </div>\n      </div>\n    </div>\n    <div\n      v-if=\"gpuList.length\"\n      class=\"server-info-group server-info--gpu\"\n    >\n      <div class=\"server-info-label\">\n        GPU\n      </div>\n      <div class=\"server-info-content\">\n        <template v-if=\"gpuList.length === 1\">\n          <span\n            class=\"gpu-info\"\n            :title=\"gpuList[0]\"\n          >\n            <span>{{ gpuList[0] }}</span>\n          </span>\n        </template>\n        <div\n          v-else\n          class=\"server-info-item-group\"\n        >\n          <span\n            v-for=\"(gpuItem, gpuIndex) in gpuList\"\n            :key=\"`${info.ID}_gpu_${gpuIndex}`\"\n            class=\"server-info-item\"\n          >\n            <span class=\"server-info-item-label\">GPU.{{ gpuIndex + 1 }}</span>\n            <span class=\"server-info-item-value\">{{ gpuItem }}</span>\n          </span>\n        </div>\n      </div>\n    </div>\n    <div\n      v-if=\"temperatureData.list.length\"\n      class=\"server-info-group server-info--temperature\"\n    >\n      <div class=\"server-info-label\">\n        温度\n      </div>\n      <div class=\"server-info-content\">\n        <div class=\"server-info-item-group\">\n          <template\n            v-for=\"(ttItem, ttIndex) in temperatureData.list\"\n            :key=\"`${info.ID}_temperature_${ttIndex}`\"\n          >\n            <popover :title=\"ttItem?.title || (`${ttItem.label}: ${ttItem.value}`)\">\n              <template #trigger>\n                <span\n                  class=\"server-info-item\"\n                  :class=\"`temperature--${ttItem.type}`\"\n                >\n                  <span class=\"server-info-item-icon\">\n                    <i\n                      v-if=\"ttItem.type === 'cpu' || ttItem.label.toLowerCase().includes('cpu')\"\n                      class=\"ri-cpu-line\"\n                    />\n                    <i\n                      v-else-if=\"ttItem.type === 'gpu' || ttItem.label.toLowerCase().includes('gpu')\"\n                      class=\"ri-gamepad-line\"\n                    />\n                    <i\n                      v-else-if=\"ttItem.type === 'nvme' || ttItem.label.toLowerCase().includes('nvme')\"\n                      class=\"ri-hard-drive-3-line\"\n                    />\n                    <i\n                      v-else-if=\"ttItem.type === 'motherboard'\"\n                      class=\"ri-instance-line\"\n                    />\n                    <i\n                      v-else\n                      class=\"ri-temp-hot-line\"\n                    />\n                  </span>\n                  <span class=\"server-info-item-value\">\n                    {{ ttItem.value }}\n                  </span>\n                </span>\n              </template>\n            </popover>\n          </template>\n        </div>\n      </div>\n    </div>\n    <div class=\"server-info-group server-info--system-os\">\n      <div class=\"server-info-label\">\n        系统\n      </div>\n      <div class=\"server-info-content\">\n        <span class=\"server-info-item\">\n          <span class=\"server-info-item-label\">{{ systemOSLabel }}</span>\n          <span\n            v-if=\"info?.Host?.PlatformVersion\"\n            class=\"server-info-item-value\"\n          >\n            {{ info?.Host?.PlatformVersion }}\n          </span>\n        </span>\n      </div>\n    </div>\n    <div class=\"server-info-group server-info--load\">\n      <div class=\"server-info-label\">\n        占用\n      </div>\n      <div class=\"server-info-content\">\n        <div class=\"server-info-item-group\">\n          <span class=\"server-info-item process-count\">\n            <span class=\"server-info-item-label\">进程数</span>\n            <span class=\"server-info-item-value\">{{ processCount }}</span>\n          </span>\n          <span class=\"server-info-item load\">\n            <span class=\"server-info-item-label\">负载</span>\n            <span class=\"server-info-item-value\">\n              {{ sysLoadInfo }}\n            </span>\n          </span>\n        </div>\n      </div>\n    </div>\n    <div class=\"server-info-group server-info--transfer\">\n      <div class=\"server-info-label\">\n        流量\n      </div>\n      <div class=\"server-info-content\">\n        <div class=\"server-info-item-group\">\n          <span class=\"server-info-item transfer--in\">\n            <span class=\"server-info-item-label\">入网</span>\n            <span class=\"server-info-item-value\">\n              <span class=\"text-value\">{{ transfer?.in?.value }}</span>\n              <span class=\"text-unit\">{{ transfer?.in?.unit }}</span>\n            </span>\n          </span>\n          <span class=\"server-info-item transfer--out\">\n            <span class=\"server-info-item-label\">出网</span>\n            <span class=\"server-info-item-value\">\n              <span class=\"text-value\">{{ transfer?.out?.value }}</span>\n              <span class=\"text-unit\">{{ transfer?.out?.unit }}</span>\n            </span>\n          </span>\n        </div>\n      </div>\n    </div>\n    <div\n      v-if=\"!hideConns\"\n      class=\"server-info-group server-info--conn\"\n    >\n      <div class=\"server-info-label\">\n        连接\n      </div>\n      <div class=\"server-info-content\">\n        <div class=\"server-info-item-group\">\n          <span class=\"server-info-item conn--tcp\">\n            <span class=\"server-info-item-label\">TCP</span>\n            <span class=\"server-info-item-value\">{{ tcpConnCount }}</span>\n          </span>\n          <span class=\"server-info-item conn--tcp\">\n            <span class=\"server-info-item-label\">UDP</span>\n            <span class=\"server-info-item-value\">{{ udpConnCount }}</span>\n          </span>\n        </div>\n      </div>\n    </div>\n    <div class=\"server-info-group server-info--boottime\">\n      <div class=\"server-info-label\">\n        启动\n      </div>\n      <div class=\"server-info-content\">\n        <span class=\"server-info-item runtime--boottime\">\n          <span class=\"server-info-item-value\">{{ bootTime }}</span>\n        </span>\n      </div>\n    </div>\n    <div class=\"server-info-group server-info--lasttime\">\n      <div class=\"server-info-label\">\n        活跃\n      </div>\n      <div class=\"server-info-content\">\n        <span class=\"server-info-item runtime--lasttime\">\n          <span class=\"server-info-item-value\">{{ lastActive }}</span>\n        </span>\n      </div>\n    </div>\n    <div\n      v-if=\"billPlanData.length\"\n      class=\"server-info-group server-info--biil-plan\"\n    >\n      <div class=\"server-info-label\">\n        套餐\n      </div>\n      <div class=\"server-info-content\">\n        <div class=\"server-info-item-group\">\n          <span\n            v-for=\"item in billPlanData\"\n            :key=\"item.label\"\n            class=\"server-info-item\"\n          >\n            <span\n              v-if=\"item.label\"\n              class=\"server-info-item-label\"\n            >{{ item.label }}</span>\n            <span class=\"server-info-item-value\">{{ item.value }}</span>\n          </span>\n        </div>\n      </div>\n    </div>\n    <div\n      v-if=\"tagList?.length\"\n      class=\"server-info-group server-info--tag-list\"\n    >\n      <div class=\"server-info-label\">\n        标签\n      </div>\n      <div class=\"server-info-content\">\n        <div class=\"server-info-tag-list\">\n          <span\n            v-for=\"(tag, index) in tagList\"\n            :key=\"`${tag}_${index}`\"\n            class=\"server-info-tag-item\"\n            :class=\"{\n              'has-sarasa-term': $hasSarasaTerm && config.nazhua.disableSarasaTermSC !== true,\n            }\"\n          >\n            {{ tag }}\n          </span>\n        </div>\n      </div>\n    </div>\n    <div\n      v-if=\"showBuyBtn\"\n      class=\"server-info-group server-info--order-link\"\n    >\n      <div class=\"server-info-content\">\n        <div\n          class=\"buy-btn\"\n          @click.stop=\"toBuy\"\n        >\n          <span class=\"icon\">\n            <span :class=\"buyBtnIcon\" />\n          </span>\n          <span class=\"text\">{{ buyBtnText }}</span>\n        </div>\n      </div>\n    </div>\n  </dot-dot-box>\n</template>\n\n<script setup>\n/**\n * 服务器信息盒子\n */\nimport {\n  computed,\n} from 'vue';\nimport dayjs from 'dayjs';\nimport config from '@/config';\nimport * as hostUtils from '@/utils/host';\n\nimport handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst buyBtnIcon = computed(() => {\n  if (props.info?.PublicNote?.customData?.buyBtnIcon) {\n    return props.info?.PublicNote?.customData?.buyBtnIcon;\n  }\n  return config.nazhua.buyBtnIcon || 'ri-shopping-bag-3-line';\n});\nconst buyBtnText = computed(() => {\n  if (props.info?.PublicNote?.customData?.buyBtnText) {\n    return props.info?.PublicNote?.customData?.buyBtnText;\n  }\n  return config.nazhua.buyBtnText || '购买';\n});\nconst showBuyBtn = computed(() => !!props.info?.PublicNote?.customData?.orderLink);\n\nfunction toBuy() {\n  const decodeUrl = decodeURIComponent(props.info?.PublicNote?.customData?.orderLink);\n  window.open(decodeUrl, '_blank');\n}\n\n/**\n * GPU列表\n */\nconst gpuList = computed(() => {\n  const gpus = props.info?.Host?.GPU || [];\n  if (config.nazhua?.filterGPUKeywords?.length) {\n    // 过滤奇怪的GPU，可以考虑过滤掉 Virtual Display\n    const keywors = Array.isArray(config.nazhua.filterGPUKeywords)\n      ? config.nazhua.filterGPUKeywords\n      : [config.nazhua.filterGPUKeywords];\n    return gpus.filter((i) => {\n      if (keywors.length) {\n        return !keywors.some((k) => i.toLowerCase().includes(k.toLowerCase()));\n      }\n      return true;\n    });\n  }\n  return gpus;\n});\n\nconst sysLoadInfo = computed(() => {\n  if (props.info?.State?.Load1 !== undefined) {\n    return [\n      props.info.State?.Load1,\n      props.info.State?.Load5,\n      props.info.State?.Load15,\n    ].filter((i) => i !== undefined).map((i) => (i).toFixed(2) * 1).join(',');\n  }\n  return '-';\n});\n\nconst temperatureData = computed(() => {\n  const data = [];\n  if (props.info?.State?.Temperatures) {\n    const acpitz = [];\n    const coretemp_package_id = [];\n    const coretemp_core = [];\n    const nvme = [];\n    const k10temp = [];\n    const amdgpu = [];\n    const other = [];\n\n    // 温度数据分类处理\n    props.info.State.Temperatures.forEach((item) => {\n      const name = item.Name.toLowerCase();\n      const temp = item.Temperature;\n\n      if (name.startsWith('acpitz')) {\n        acpitz.push(temp);\n        return;\n      }\n      if (name.startsWith('coretemp_package_id_')) {\n        const coreIndex = parseInt(name.replace('coretemp_package_id_', ''), 10);\n        coretemp_package_id.push({\n          index: coreIndex,\n          value: temp,\n        });\n        return;\n      }\n      if (name.startsWith('coretemp_core_')) {\n        const coreIndex = parseInt(name.replace('coretemp_core_', ''), 10);\n        coretemp_core.push({\n          index: coreIndex,\n          value: temp,\n        });\n        return;\n      }\n      if (name.includes('nvme')) {\n        nvme.push({\n          name: item.Name,\n          value: temp,\n        });\n        return;\n      }\n      if (name.includes('k10temp')) {\n        k10temp.push({\n          name: item.Name,\n          value: temp,\n        });\n        return;\n      }\n      if (name.includes('amdgpu')) {\n        amdgpu.push({\n          name: item.Name,\n          value: temp,\n        });\n        return;\n      }\n      if (name.includes('motherboard') || name.includes('mainboard') || name.includes('board')) {\n        other.push({\n          label: '主板',\n          value: temp,\n          type: 'motherboard',\n        });\n        return;\n      }\n      other.push({\n        label: item.Name,\n        value: temp,\n        type: 'other',\n      });\n    });\n\n    // 主板温度处理\n    if (acpitz.length) {\n      const acpitzMean = (acpitz.reduce((a, b) => a + b, 0) / acpitz.length).toFixed(1);\n      data.push({\n        label: '主板',\n        value: `${acpitzMean}℃`,\n        title: acpitz.map((i, index) => `传感器${index + 1}: ${parseFloat(i).toFixed(1)}℃`).join('\\n'),\n        type: 'motherboard',\n      });\n    }\n\n    // CPU温度处理\n    if (coretemp_package_id.length || coretemp_core.length) {\n      const temps = [];\n      const details = [];\n\n      // 处理 CPU 温度\n      if (coretemp_package_id.length) {\n        const cpuTemps = coretemp_package_id.map((i) => `${parseFloat(i.value).toFixed(1)}℃`);\n        temps.push(cpuTemps.join(', '));\n        details.push(...coretemp_package_id.map((i) => `CPU.${i.index + 1}: ${parseFloat(i.value).toFixed(1)}℃`));\n      }\n\n      // 处理核心温度\n      if (coretemp_core.length) {\n        const coreMean = (coretemp_core.reduce((a, b) => a + b.value, 0) / coretemp_core.length).toFixed(1);\n        temps.push(`${parseFloat(coreMean).toFixed(1)}℃`);\n        details.push(...coretemp_core.map((i) => `核心${i.index + 1}: ${parseFloat(i.value).toFixed(1)}℃`));\n      }\n\n      data.push({\n        label: 'CPU',\n        value: temps.join(' / '),\n        title: details.join('\\n'),\n        type: 'cpu',\n      });\n    }\n\n    // AMD CPU温度处理\n    if (k10temp.length) {\n      const tctl = k10temp.find((i) => i.name.includes('tctl'));\n      if (tctl) {\n        data.push({\n          label: 'AMD CPU',\n          value: `${parseFloat(tctl.value).toFixed(1)}℃`,\n          title: k10temp.map((i) => `${i.name}: ${parseFloat(i.value).toFixed(1)}℃`).join('\\n'),\n          type: 'cpu',\n        });\n      }\n    }\n\n    // AMD GPU温度处理\n    if (amdgpu.length) {\n      const edge = amdgpu.find((i) => i.name.includes('edge'));\n      if (edge) {\n        data.push({\n          label: 'AMD GPU',\n          value: `${parseFloat(edge.value).toFixed(1)}℃`,\n          title: amdgpu.map((i) => `${i.name}: ${parseFloat(i.value).toFixed(1)}℃`).join('\\n'),\n          type: 'gpu',\n        });\n      }\n    }\n\n    // NVME温度处理\n    if (nvme.length) {\n      const composite = nvme.find((i) => i.name.includes('composite'));\n      if (composite) {\n        data.push({\n          label: 'NVME',\n          value: `${parseFloat(composite.value).toFixed(1)}℃`,\n          title: nvme.map((i) => `${i.name}: ${parseFloat(i.value).toFixed(1)}℃`).join('\\n'),\n          type: 'nvme',\n        });\n      }\n    }\n\n    // 其他温度处理\n    other.forEach((i) => {\n      data.push({\n        label: i.label,\n        value: `${parseFloat(i.value).toFixed(1)}℃`,\n        type: i.type || 'other',\n      });\n    });\n  }\n  return {\n    list: data,\n  };\n});\n\nconst {\n  billAndPlan,\n} = handleServerBillAndPlan({\n  props,\n});\n\nconst billPlanData = computed(() => ['billing', 'remainingTime', 'bandwidth', 'traffic'].map((i) => {\n  if (billAndPlan.value[i]) {\n    return {\n      label: billAndPlan.value[i].label,\n      value: billAndPlan.value[i].value,\n    };\n  }\n  return null;\n}).filter((i) => i));\n\nconst tagList = computed(() => {\n  const list = [];\n  const {\n    networkRoute,\n    extra,\n    IPv4,\n    IPv6,\n  } = props?.info?.PublicNote?.planDataMod || {};\n  if (networkRoute) {\n    list.push(...networkRoute?.split?.(','));\n  }\n  if (extra) {\n    list.push(...extra?.split?.(','));\n  }\n  if (IPv4 === '1' && IPv6 === '1') {\n    list.push('双栈IP');\n  } else if (IPv4 === '1') {\n    list.push('仅IPv4');\n  } else if (IPv6 === '1') {\n    list.push('仅IPv6');\n  }\n  return list;\n});\n\nconst systemOSLabel = computed(() => {\n  if (props?.info?.Host?.Platform) {\n    return hostUtils.getSystemOSLabel(props.info.Host.Platform);\n  }\n  return '';\n});\n\nconst bootTime = computed(() => {\n  if (props?.info?.Host?.BootTime) {\n    return dayjs(props.info.Host.BootTime * 1000).format('YYYY.MM.DD HH:mm:ss');\n  }\n  return '-';\n});\n\nconst lastActive = computed(() => {\n  if (props?.info?.Host?.BootTime && props?.info?.LastActive) {\n    return dayjs(props.info.LastActive).format('YYYY.MM.DD HH:mm:ss');\n  }\n  return '-';\n});\n\n/**\n * 计算流量\n */\nconst transfer = computed(() => {\n  const stats = {\n    in: 0,\n    out: 0,\n    total: 0,\n  };\n  if (props?.info?.State?.NetInTransfer) {\n    stats.total += props.info.State.NetInTransfer;\n    stats.in = props.info.State.NetInTransfer;\n  }\n  if (props?.info?.State?.NetOutTransfer) {\n    stats.total += props.info.State.NetOutTransfer;\n    stats.out = props.info.State.NetOutTransfer;\n  }\n  const result = {\n    in: hostUtils.calcTransfer(stats.in),\n    out: hostUtils.calcTransfer(stats.out),\n    total: hostUtils.calcTransfer(stats.total),\n    stats,\n  };\n  return result;\n});\n\nconst hideConns = computed(() => {\n  const tcp = props.info?.State?.TcpConnCount;\n  const udp = props.info?.State?.UdpConnCount;\n  return (tcp == null) && (udp == null);\n});\nconst tcpConnCount = computed(() => props.info?.State?.TcpConnCount);\nconst udpConnCount = computed(() => props.info?.State?.UdpConnCount);\nconst processCount = computed(() => props.info?.State?.ProcessCount);\n</script>\n\n<style lang=\"scss\" scoped>\n.server-info-box {\n  --server-info-item-size: 24px;\n\n  @media screen and (max-width: 480px) {\n    --server-info-item-size: 30px;\n  }\n\n  .server-info-group {\n    width: 100%;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    gap: 20px;\n    font-size: 14px;\n\n    .server-info-label {\n      width: 2.4em;\n      text-align: center;\n      line-height: var(--server-info-item-size);\n      color: #ccc;\n    }\n\n    .server-info-content {\n      flex: 1;\n      display: flex;\n      justify-content: flex-end;\n      align-items: center;\n      line-height: 18px;\n      text-align: right;\n      cursor: default;\n    }\n  }\n\n  .server-info-item-group {\n    display: flex;\n    justify-content: flex-end;\n    flex-wrap: wrap;\n    gap: 0 12px;\n\n    &.temperature--other {\n      // 移动端不显示\n      @media screen and (max-width: 768px) {\n        display: none;\n      }\n    }\n  }\n\n  .server-info-item {\n    display: flex;\n    gap: 0.2em;\n    align-items: center;\n\n    .server-info-item-icon {\n      width: 24px;\n      height: 16px;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      font-size: 16px;\n      color: #ccc;\n    }\n  }\n\n  .server-info-item-value {\n    color: #00fff0;\n  }\n\n  .transfer--in {\n    .server-info-item-value {\n      color: #ddd;\n    }\n    .text-value {\n      color: var(--transfer-in-color);\n    }\n  }\n\n  .transfer--out {\n    .server-info-item-value {\n      color: #ddd;\n    }\n    .text-value {\n      color: var(--transfer-out-color);\n    }\n  }\n\n  .server-info--temperature {\n    .server-info-item {\n      .server-info-item-label {\n        max-width: 4.5em;\n        text-overflow: ellipsis;\n        white-space: nowrap;\n        overflow: hidden;\n      }\n    }\n  }\n\n  .server-info--order-link {\n    padding: 10px 0 0;\n  }\n  .buy-btn {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    height: 36px;\n    padding: 0 10px;\n    gap: 5px;\n    line-height: 1;\n    font-weight: bold;\n    color: var(--list-item-buy-link-color);\n    border: 2px solid var(--list-item-buy-link-color);\n    border-radius: 8px;\n    transition: all 150ms ease;\n    cursor: pointer;\n\n    &:hover {\n      color: #111;\n      border-color: var(--list-item-buy-link-color);\n      background-color: var(--list-item-buy-link-color);\n    }\n\n    @media screen and (max-width: 768px) {\n      cursor: default;\n    }\n\n    .icon {\n      font-size: 18px;\n      font-weight: normal;\n    }\n  }\n\n  .server-info-tag-list {\n    display: flex;\n    gap: 6px;\n\n    .server-info-tag-item {\n      height: 18px;\n      padding: 0 5px 0 6px;\n      line-height: 18px;\n      font-size: 12px;\n      color: var(--public-note-tag-color);\n      background: var(--public-note-tag-bg);\n      text-shadow: 1px 1px 2px rgba(#000, 0.2);\n      border-radius: 4px;\n\n      &.has-sarasa-term {\n        line-height: 20px;\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-detail/server-monitor.vue",
    "content": "<template>\n  <dot-dot-box\n    v-if=\"monitorData.length\"\n    class=\"server-monitor-group\"\n    :class=\"{\n      'chart-type--multi': config.nazhua.monitorChartTypeToggle && monitorChartType === 'multi',\n      'chart-type--single': config.nazhua.monitorChartTypeToggle && monitorChartType === 'single',\n    }\"\n    padding=\"16px 20px\"\n  >\n    <div class=\"module-head-group\">\n      <div class=\"left-box\">\n        <span class=\"module-title\">\n          网络监控\n        </span>\n      </div>\n      <div class=\"right-box\">\n        <div\n          v-if=\"config.nazhua.monitorChartTypeToggle\"\n          class=\"chart-type-switch-group\"\n          title=\"监控折线图是否聚合\"\n          @click=\"switchChartType\"\n        >\n          <span class=\"label-text\">聚合</span>\n          <div\n            class=\"switch-box\"\n            :class=\"{\n              active: monitorChartType === 'multi',\n            }\"\n          >\n            <span class=\"switch-dot\" />\n          </div>\n        </div>\n        <div\n          class=\"refresh-data-group\"\n          title=\"是否自动刷新\"\n          @click=\"switchRefresh\"\n        >\n          <span class=\"label-text\">刷新</span>\n          <div\n            class=\"switch-box\"\n            :class=\"{\n              active: refreshData,\n            }\"\n          >\n            <span class=\"switch-dot\" />\n          </div>\n        </div>\n        <div\n          class=\"peak-shaving-group\"\n          title=\"过滤太高或太低的数据\"\n          @click=\"switchPeakShaving\"\n        >\n          <span class=\"label-text\">削峰</span>\n          <div\n            class=\"switch-box\"\n            :class=\"{\n              active: peakShaving,\n            }\"\n          >\n            <span class=\"switch-dot\" />\n          </div>\n        </div>\n        <div class=\"last-update-time-group\">\n          <span class=\"last-update-time-label\">\n            最近\n          </span>\n          <div class=\"minutes\">\n            <div\n              v-for=\"minuteItem in minutes\"\n              :key=\"minuteItem.value\"\n              class=\"minute-item\"\n              :class=\"{\n                active: minuteItem.value === minute,\n              }\"\n              @click=\"toggleMinute(minuteItem.value)\"\n            >\n              <span>{{ minuteItem.label }}</span>\n            </div>\n            <div\n              class=\"active-arrow\"\n              :style=\"minuteActiveArrowStyle\"\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <template v-if=\"monitorChartType === 'single'\">\n      <div\n        class=\"monitor-chart-group\"\n        :class=\"'monitor-chart-len--' + monitorChartData.cateList.length\"\n      >\n        <div\n          v-for=\"(cateItem, index) in monitorChartData.cateList\"\n          :key=\"cateItem.id\"\n          class=\"monitor-chart-item\"\n        >\n          <div class=\"cate-name-box\">\n            <popover :title=\"cateItem.title\">\n              <template #trigger>\n                <div\n                  class=\"monitor-cate-item\"\n                  :class=\"{\n                    disabled: showCates[cateItem.id] === false,\n                  }\"\n                  :style=\"{\n                    '--cate-color': cateItem.color,\n                  }\"\n                >\n                  <span class=\"cate-legend\" />\n                  <span\n                    class=\"cate-name\"\n                  >\n                    {{ cateItem.name }}\n                  </span>\n                  <span\n                    v-if=\"cateItem.avg !== 0\"\n                    class=\"cate-avg-ms\"\n                  >\n                    {{ cateItem.avg }}ms\n                  </span>\n                  <span\n                    v-if=\"cateItem.over !== 0\"\n                    class=\"cate-over-rate\"\n                  >\n                    {{ cateItem.over }}%\n                  </span>\n                </div>\n              </template>\n            </popover>\n          </div>\n          <line-chart\n            :date-list=\"monitorChartData.dateList\"\n            :value-list=\"[monitorChartData.valueList[index]]\"\n            :size=\"240\"\n            :connect-nulls=\"false\"\n          />\n        </div>\n      </div>\n    </template>\n    <template v-else>\n      <div class=\"monitor-cate-group\">\n        <template\n          v-for=\"cateItem in monitorChartData.cateList\"\n          :key=\"cateItem.id\"\n        >\n          <popover :title=\"cateItem.title\">\n            <template #trigger>\n              <div\n                class=\"monitor-cate-item\"\n                :class=\"{\n                  disabled: showCates[cateItem.id] === false,\n                }\"\n                :style=\"{\n                  '--cate-color': cateItem.color,\n                }\"\n                @click=\"toggleShowCate(cateItem.id)\"\n                @touchstart=\"handleTouchStart(cateItem.id)\"\n                @touchend=\"handleTouchEnd(cateItem.id)\"\n                @touchmove=\"handleTouchMove(cateItem.id)\"\n              >\n                <span class=\"cate-legend\" />\n                <span\n                  class=\"cate-name\"\n                >\n                  {{ cateItem.name }}\n                </span>\n                <span\n                  v-if=\"cateItem.avg !== 0\"\n                  class=\"cate-avg-ms\"\n                >\n                  {{ cateItem.avg }}ms\n                </span>\n              </div>\n            </template>\n          </popover>\n        </template>\n      </div>\n\n      <line-chart\n        :date-list=\"monitorChartData.dateList\"\n        :value-list=\"monitorChartData.valueList\"\n        :connect-nulls=\"false\"\n      />\n    </template>\n  </dot-dot-box>\n</template>\n\n<script setup>\n/**\n * 服务器监控\n */\nimport {\n  ref,\n  computed,\n  onMounted,\n  onUnmounted,\n} from 'vue';\nimport { useStore } from 'vuex';\nimport config from '@/config';\nimport request from '@/utils/request';\nimport validate from '@/utils/validate';\nimport { isTsdbEnabled, hasTsdb } from '@/utils/tsdb';\n\nimport LineChart from '@/components/charts/line.vue';\n\nimport {\n  getThreshold,\n  getLineColor,\n} from '@/views/composable/server-monitor';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst store = useStore();\n\nconst userLogin = computed(() => store.state.profile?.username);\nconst minute = ref(1440);\nconst baseMinutes = [{\n  label: '30分钟',\n  value: 30,\n}, {\n  label: '1小时',\n  value: 60,\n}, {\n  label: '3小时',\n  value: 180,\n}, {\n  label: '6小时',\n  value: 360,\n}, {\n  label: '12小时',\n  value: 720,\n}, {\n  label: '24小时',\n  value: 1440,\n}];\nconst minutes = computed(() => {\n  if (!userLogin.value || !hasTsdb(store)) {\n    return baseMinutes;\n  }\n  return [\n    ...baseMinutes,\n    {\n      label: '7天',\n      value: 10080,\n    },\n    {\n      label: '30天',\n      value: 43200,\n    },\n  ];\n});\nconst localData = {\n  peakShaving: window.localStorage.getItem('nazhua_monitor_peak_shaving'),\n  refreshData: window.localStorage.getItem('nazhua_monitor_refresh_data'),\n  chartType: window.localStorage.getItem('nazhua_monitor_chart_type'),\n};\nlocalData.peakShaving = validate.isSet(localData.peakShaving) ? localData.peakShaving === 'true' : false;\nlocalData.refreshData = validate.isSet(localData.refreshData) ? localData.refreshData === 'true' : true;\n\nconst peakShaving = ref(localData.peakShaving);\nconst refreshData = ref(localData.refreshData);\nconst showCates = ref({});\nconst monitorData = ref([]);\nconst longPressTimer = ref(null);\n\nconst chartType = validate.isSet(localData.chartType)\n  ? ref(localData.chartType)\n  : ref(config.nazhua.monitorChartType === 'single' ? 'single' : 'multi');\nconst monitorChartType = computed(() => {\n  if (config.nazhua.monitorChartTypeToggle) {\n    return chartType.value;\n  }\n  return config.nazhua.monitorChartType;\n});\n\n// 服务器时间（后面来自接口）\nconst nowServerTime = computed(() => store.state.serverTime || Date.now());\n// const nowServerTime = computed(() => Date.now());\n// console.log(store.state.serverTime);\nconst acceptShowTime = computed(() => (Math.floor(nowServerTime.value / 60000) - minute.value) * 60000);\n\nconst minuteActiveArrowStyle = computed(() => {\n  const index = minutes.value.findIndex((i) => i.value === minute.value);\n  return {\n    left: `calc(${index} * var(--minute-item-width))`,\n  };\n});\n\nconst monitorChartData = computed(() => {\n  /**\n   * 处理监控数据以生成分类的平均延迟随时间变化的列表。\n   *\n   * @returns {Object} 返回一个对象，包含：\n   * - cateList {Array}: 唯一监控名称的列表。\n   * - dateList {Array}: 排序后的唯一时间戳列表。\n   * - valueList {Array}: 包含以下内容的对象列表：\n   *   - name {String}: 监控名称。\n   *   - data {Array}: [时间戳, 平均延迟] 对的数组。\n   */\n  const cateList = [];\n  const cateMap = {};\n  const dateSet = new Set();\n  let valueList = [];\n  monitorData.value.forEach((i) => {\n    const dateMap = new Map();\n    const {\n      monitor_name,\n      monitor_id,\n      created_at,\n      avg_delay,\n    } = i;\n    if (!cateMap[monitor_name]) {\n      cateMap[monitor_name] = {\n        id: monitor_id,\n      };\n    }\n    const cateDelayMap = new Map();\n    const cateAcceptTimeMap = new Map();\n    const cateCreateTime = new Set();\n\n    const isPeriodRange = minute.value === 10080 || minute.value === 43200;\n\n    // 实际数据的最早时间戳\n    let earliestTimestamp = nowServerTime.value;\n    created_at.forEach((time, index) => {\n      if (time < earliestTimestamp) {\n        earliestTimestamp = time;\n      }\n      const status = isPeriodRange || time >= acceptShowTime.value;\n\n      // 允许显示的数据，记录到cateAcceptTime\n      if (status) {\n        if (import.meta.env.VITE_MONITOR_DEBUG === '1' && cateAcceptTimeMap.has(time)) {\n          console.log(`${monitor_name} ${time} 重复，值对比： ${avg_delay[index]} vs ${cateAcceptTimeMap.get(time)}`);\n        }\n        cateAcceptTimeMap.set(time, avg_delay[index]);\n      }\n    });\n    if (import.meta.env.VITE_MONITOR_DEBUG === '1') {\n      console.log(`${monitor_name} created_at`, earliestTimestamp);\n      console.log(`${monitor_name} created_at`, JSON.parse(JSON.stringify(created_at)));\n      console.log(`${monitor_name} avg_delay`, JSON.parse(JSON.stringify(avg_delay)));\n    }\n\n    // 允许显示的最早时间戳，用于生成显示时间范围内的数据；7d/30d 仅用数据边界\n    const actualStartTime = isPeriodRange\n      ? earliestTimestamp\n      : Math.max(acceptShowTime.value, earliestTimestamp);\n\n    // 显示时间范围内的分钟数\n    const allMintues = Math.floor((Date.now() - actualStartTime) / 60000);\n\n    // 合成分钟数据\n    for (let j = 0; j < allMintues; j += 1) {\n      const time = actualStartTime + j * 60000;\n      // 记录创建时间\n      cateCreateTime.add(time);\n      // 记录延迟数据\n      const timeProp = cateAcceptTimeMap.get(time);\n      cateDelayMap.set(time, timeProp ?? undefined);\n    }\n\n    // 计算削峰阈值\n    const {\n      median,\n      tolerancePercent,\n    } = peakShaving.value ? getThreshold(Array.from(cateDelayMap.values())) : {};\n\n    // 合成分钟数据\n    cateCreateTime.values().forEach((time) => {\n      const avgDelay = cateDelayMap.get(time) * 1;\n\n      // 只对有效的延迟值进行削峰判断\n      if (peakShaving.value) {\n        // 削峰过滤：根据中位数和动态容差百分比判断异常值\n        const threshold = median * tolerancePercent;\n        // 当偏离中位数超过阈值时，视为异常值\n        if (Math.abs(avgDelay - median) > threshold) {\n          dateMap.set(time, null);\n          return;\n        }\n      }\n      // 无数据或无效数据的情况，设置为undefined\n      if (Number.isNaN(avgDelay)) {\n        dateMap.set(time, undefined);\n      } else {\n        dateMap.set(time, (avgDelay).toFixed(2) * 1);\n      }\n    });\n\n    const lineData = [];\n    const validatedData = [];\n    const overValidatedData = [];\n    let delayTotal = 0;\n    dateMap.forEach((val, key) => {\n      const time = parseInt(key, 10); // 时间戳\n      lineData.push([time, val || null]);\n      if (val) {\n        dateSet.add(time);\n        validatedData.push([time, val]);\n        delayTotal += val;\n      }\n      if (val !== undefined) {\n        overValidatedData.push([time, val]);\n      }\n    });\n\n    if (import.meta.env.VITE_MONITOR_DEBUG === '1') {\n      cateMap[monitor_name].origin = {\n        cateCreateTime,\n        cateDelayMap,\n        cateAcceptTimeMap,\n        dateMap,\n        lineData,\n        validatedData,\n        overValidatedData,\n        delayTotal,\n      };\n    }\n\n    const id = monitor_id;\n    // 计算平均延迟\n    const avgDelay = delayTotal / validatedData.length || 0;\n\n    if (lineData && lineData.length) {\n      if (!validate.hasOwn(showCates.value, id)) {\n        showCates.value[id] = true;\n      }\n      const color = getLineColor(id);\n      // 成功率 = 有效数据点 / 所有数据点\n      const over = overValidatedData.length > 0 ? overValidatedData.length / lineData.length : 0;\n      const validRate = 1 - ((validatedData.length > 0 && overValidatedData.length > 0)\n        ? validatedData.length / overValidatedData.length : 0);\n      const cateItem = {\n        id,\n        name: monitor_name,\n        color,\n        avg: avgDelay.toFixed(2) * 1,\n        over: (over * 100).toFixed(2) * 1,\n        validRate: (validRate * 100).toFixed(2) * 1,\n      };\n      const titles = [\n        cateItem.name,\n        cateItem.avg === 0 ? '' : `平均延迟：${cateItem.avg}ms`,\n        `成功率：${cateItem.over}%`,\n      ];\n      if (peakShaving.value) {\n        titles.push(`削峰率: ${cateItem.validRate}%`);\n      }\n      cateItem.title = titles.filter((s) => s).join('\\n');\n      cateList.push(cateItem);\n      valueList.push({\n        id,\n        name: monitor_name,\n        data: lineData,\n        itemStyle: {\n          color,\n        },\n        lineStyle: {\n          color,\n        },\n      });\n    }\n  });\n\n  const dateList = Array.from(dateSet).sort((a, b) => a - b);\n  valueList = valueList.filter((i) => showCates.value[i.id]);\n\n  if (import.meta.env.VITE_MONITOR_DEBUG === '1') {\n    window._cateMap = cateMap;\n    console.log(window._cateMap);\n    console.log(dateList, cateList, valueList);\n  }\n  return {\n    dateList,\n    cateList,\n    valueList,\n  };\n});\n\nfunction switchPeakShaving() {\n  peakShaving.value = !peakShaving.value;\n  window.localStorage.setItem('nazhua_monitor_peak_shaving', peakShaving.value);\n}\n\nfunction switchRefresh() {\n  refreshData.value = !refreshData.value;\n  window.localStorage.setItem('nazhua_monitor_refresh_data', refreshData.value);\n}\n\nfunction switchChartType() {\n  chartType.value = chartType.value === 'single' ? 'multi' : 'single';\n  window.localStorage.setItem('nazhua_monitor_chart_type', chartType.value);\n}\n\nfunction getTsdbPeriod() {\n  if (minute.value === 10080) return '7d';\n  if (minute.value === 43200) return '30d';\n  return '1d';\n}\n\nasync function loadMonitor() {\n  let url;\n  if (config.nazhua.nezhaVersion === 'v1') {\n    if (hasTsdb(store)) {\n      url = config.nazhua.v1ApiMonitorPath.replace('{id}', props.info.ID);\n      if (isTsdbEnabled(store)) {\n        const period = getTsdbPeriod();\n        url += url.includes('?') ? `&period=${period}` : `?period=${period}`;\n      }\n    } else {\n      url = config.nazhua.v1ApiMonitorPathFallback.replace('{id}', props.info.ID);\n    }\n  } else {\n    url = config.nazhua.apiMonitorPath.replace('{id}', props.info.ID);\n  }\n  try {\n    const res = await request({ url });\n    const list = config.nazhua.nezhaVersion === 'v1' ? res?.data?.data : res?.data?.result;\n    if (Array.isArray(list)) {\n      monitorData.value = list;\n    }\n  } catch (err) {\n    console.error(err);\n  }\n}\n\nasync function toggleMinute(value) {\n  minute.value = value;\n  if (value === 10080 || value === 43200) {\n    await loadMonitor();\n  }\n}\n\nfunction toggleShowCate(id) {\n  if (window.innerWidth < 768) {\n    return;\n  }\n  showCates.value[id] = !showCates.value[id];\n}\n\nfunction handleTouchStart(id) {\n  longPressTimer.value = setTimeout(() => {\n    showCates.value[id] = !showCates.value[id];\n  }, 500);\n}\n\nfunction handleTouchEnd() {\n  if (longPressTimer.value) {\n    clearTimeout(longPressTimer.value);\n    longPressTimer.value = null;\n  }\n}\n\nfunction handleTouchMove() {\n  if (longPressTimer.value) {\n    clearTimeout(longPressTimer.value);\n    longPressTimer.value = null;\n  }\n}\n\nlet loadMonitorTimer = null;\nasync function setTimeLoadMonitor(force = false) {\n  if (loadMonitorTimer) {\n    clearTimeout(loadMonitorTimer);\n  }\n  if (refreshData.value || force) {\n    await loadMonitor();\n  }\n  let monitorRefreshTime = parseInt(config.nazhua.monitorRefreshTime, 10);\n  // 0 为不刷新\n  if (monitorRefreshTime === 0) {\n    return;\n  }\n  // 非数字 强制为30\n  if (Number.isNaN(monitorRefreshTime)) {\n    monitorRefreshTime = 30;\n  }\n  // 最小 10 秒\n  const sTime = Math.min(monitorRefreshTime, 10);\n  loadMonitorTimer = setTimeout(() => {\n    setTimeLoadMonitor();\n  }, sTime * 1000);\n}\n\nonMounted(() => {\n  setTimeLoadMonitor(true);\n});\n\nonUnmounted(() => {\n  if (loadMonitorTimer) {\n    clearTimeout(loadMonitorTimer);\n  }\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-monitor-group {\n  --line-chart-size: 300px;\n\n  &.chart-type--single {\n    --line-chart-size: 240px;\n  }\n\n  .monitor-cate-item {\n    --cate-item-height: 28px;\n    --cate-item-font-size: 14px;\n    --cate-color: #fff;\n\n    display: flex;\n    align-items: center;\n    width: var(--cate-item-width);\n    height: var(--cate-item-height);\n    gap: 6px;\n    padding: 0 6px;\n    font-size: var(--cate-item-font-size);\n    border-radius: 4px;\n    cursor: pointer;\n\n    @media screen and (max-width: 768px) {\n      cursor: default;\n    }\n\n    .cate-legend {\n      width: 0.5em;\n      height: 0.5em;\n      background: var(--cate-color);\n    }\n\n    .cate-name {\n      // flex: 1;\n      height: var(--cate-item-height);\n      line-height: calc(var(--cate-item-height) + 2px);\n      color: #eee;\n    }\n\n    .cate-avg-ms {\n      height: var(--cate-item-height);\n      line-height: calc(var(--cate-item-height) + 2px);\n      text-align: right;\n      color: #fff;\n    }\n\n    .cate-over-rate {\n      height: var(--cate-item-height);\n      line-height: calc(var(--cate-item-height) + 2px);\n      text-align: right;\n      color: #fffbd8;\n    }\n\n    &.disabled {\n      filter: grayscale(1) brightness(0.8);\n      opacity: 0.5;\n    }\n  }\n}\n\n.module-head-group {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  justify-content: space-between;\n  gap: 10px;\n\n  @media screen and (min-width: 768px) {\n    position: sticky;\n    top: var(--layout-header-height);\n    z-index: 1000;\n  }\n\n  .module-title {\n    width: max-content;\n    height: 30px;\n    line-height: 30px;\n    font-size: 16px;\n    color: #eee;\n  }\n\n  .right-box {\n    display: flex;\n    flex-wrap: wrap;\n    align-items: center;\n    gap: 12px;\n  }\n\n  .peak-shaving-group,\n  .refresh-data-group,\n  .chart-type-switch-group {\n    display: flex;\n    align-items: center;\n    gap: 4px;\n    cursor: pointer;\n\n    @media screen and (max-width: 1024px) {\n      cursor: default;\n    }\n\n    .switch-box {\n      position: relative;\n      width: 30px;\n      height: 16px;\n      background: #999;\n      border-radius: 10px;\n      transition: backgroundColor 0.3s;\n\n      .switch-dot {\n        position: absolute;\n        top: 2px;\n        left: 2px;\n        width: 12px;\n        height: 12px;\n        background: #fff;\n        border-radius: 50%;\n        transition: left 0.3s;\n      }\n\n      &.active {\n        background-color: #4caf50;\n\n        .switch-dot {\n          left: 16px;\n          box-shadow: 1px 1px 2px rgba(#000, 0.4);\n        }\n      }\n    }\n\n    .label-text {\n      color: #ddd;\n      font-size: 12px;\n    }\n  }\n\n  .last-update-time-group {\n    --minute-item-width: 50px;\n    --minute-item-height: 20px;\n    display: flex;\n    align-items: center;\n    gap: 4px;\n\n    .last-update-time-label {\n      color: #ddd;\n      height: var(--minute-item-height);\n      line-height: var(--minute-item-height);\n      font-size: 12px;\n    }\n\n    @media screen and (max-width: 660px) {\n      --minute-item-width: 46px;\n    }\n\n    @media screen and (max-width: 600px) {\n      --minute-item-width: 46px;\n    }\n\n    @media screen and (max-width: 400px) {\n      .last-update-time-label {\n        display: none;\n      }\n    }\n\n    @media screen and (max-width: 330px) {\n      margin-left: -12px;\n    }\n\n    @media screen and (max-width: 320px) {\n      margin-left: -18px;\n    }\n  }\n  .minutes {\n    position: relative;\n    display: flex;\n    align-items: center;\n    // padding: 0 10px;\n    height: var(--minute-item-height);\n    background: rgba(#fff, 0.2);\n    border-radius: calc(var(--minute-item-height) / 2);\n\n    .minute-item {\n      position: relative;\n      z-index: 10;\n      width: var(--minute-item-width);\n      height: var(--minute-item-height);\n      line-height: var(--minute-item-height);\n      font-size: 12px;\n      text-align: center;\n      cursor: pointer;\n      color: #aaa;\n      transition: color 0.3s;\n\n      &.active {\n        color: #fff;\n        text-shadow: 1px 1px 2px rgba(#000, 0.6);\n      }\n    }\n\n    .active-arrow {\n      position: absolute;\n      top: 0;\n      left: 0;\n      width: var(--minute-item-width);\n      height: var(--minute-item-height);\n      border-radius: calc(var(--minute-item-height) / 2);\n      background: #4caf50;\n      // opacity: 0.5;\n      transition: left 0.3s;\n      z-index: 1;\n    }\n  }\n}\n\n.monitor-cate-group {\n  --gap-size: 0;\n  margin: 10px 0;\n  display: flex;\n  flex-wrap: wrap;\n  gap: var(--gap-size);\n  margin-right: calc(var(--gap-size) * -1);\n}\n\n.monitor-chart-group {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 10px 0;\n\n  .monitor-chart-item {\n    width: 50%;\n    height: calc(var(--line-chart-size) + 28px);\n  }\n\n  @media screen and (max-width: 768px) {\n    .monitor-chart-item {\n      width: 100%;\n    }\n  }\n\n  .cate-name-box {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n  }\n\n  &.monitor-chart-len--1 {\n    .monitor-chart-item {\n      width: 100%;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-detail/server-name.vue",
    "content": "<template>\n  <dot-dot-box\n    class=\"server-head\"\n    padding=\"16px\"\n  >\n    <div class=\"server-flag-box\">\n      <server-flag :info=\"info\" />\n    </div>\n    <div class=\"server-name-and-slogan\">\n      <div class=\"server-name-group\">\n        <span class=\"server-name\">\n          {{ info.Name }}\n        </span>\n        <span\n          v-if=\"cpuAndMemAndDisk\"\n          class=\"cpu-mem-group\"\n        >\n          <span\n            class=\"system-os-icon\"\n          >\n            <span :class=\"platformLogoIconClassName\" />\n          </span>\n          <span class=\"core-mem\">{{ cpuAndMemAndDisk }}</span>\n        </span>\n      </div>\n      <div\n        v-if=\"slogan\"\n        class=\"slogan-content\"\n      >\n        <span>“{{ slogan }}”</span>\n      </div>\n      <div\n        v-else-if=\"cpuInfo\"\n        class=\"cpu-model-info\"\n      >\n        <span\n          v-if=\"cpuInfo.company\"\n          class=\"cpu-company\"\n          :class=\"'cpu-company--' + cpuInfo.company.toLowerCase()\"\n        >\n          {{ cpuInfo.company }}\n        </span>\n        <span\n          v-if=\"cpuInfo.model\"\n          class=\"cpu-model\"\n        >\n          {{ cpuInfo.model }}\n        </span>\n        <span\n          v-if=\"cpuInfo.modelNum\"\n          class=\"cpu-model-num\"\n        >\n          {{ cpuInfo.modelNum }}\n        </span>\n      </div>\n    </div>\n  </dot-dot-box>\n</template>\n\n<script setup>\n/**\n * 单节点\n */\nimport {\n  computed,\n} from 'vue';\nimport * as hostUtils from '@/utils/host';\nimport handleServerInfo from '@/views/composable/server-info';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\n/**\n * XCore XGB\n */\nconst { cpuAndMemAndDisk } = handleServerInfo({\n  props,\n});\n\nconst slogan = computed(() => props.info?.PublicNote?.customData?.slogan);\nconst cpuInfo = computed(() => hostUtils.getCPUInfo(props.info?.Host?.CPU?.[0]));\nconst platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconClassName(props.info?.Host?.Platform));\n</script>\n\n<style lang=\"scss\" scoped>\n.server-head {\n  display: flex;\n  gap: 12px;\n  transition: 0.3s;\n\n  .server-flag-box {\n    --flag-size: 72px;\n    position: relative;\n    width: calc(var(--flag-size) * 1.33333333);\n    height: var(--flag-size);\n    border-radius: 12px;\n    overflow: hidden;\n\n    .server-flag {\n      position: absolute;\n      top: 50%;\n      left: 50%;\n      width: calc(var(--flag-size) * 1.33333333);\n      height: var(--flag-size);\n      line-height: var(--flag-size);\n      font-size: var(--flag-size);\n      transform: translate(-50%, -50%);\n    }\n\n    @media screen and (max-width: 500px) {\n      --flag-size: 40px;\n      border-radius: 6px;\n    }\n  }\n\n  .server-name-and-slogan {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    // justify-content: space-between;\n    gap: 4px;\n    padding: 5px 0;\n\n    @media screen and (max-width: 500px) {\n      padding: 0;\n    }\n  }\n\n  .server-name-group {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    color: #eee;\n\n    .server-name {\n      line-height: 30px;\n      font-size: 24px;\n      font-weight: bold;\n      color: #fff;\n    }\n\n    .system-os-icon {\n      height: 24px;\n      line-height: 22px;\n      font-size: 20px;\n    }\n\n    .core-mem {\n      line-height: 30px;\n      font-size: 16px;\n      font-weight: bold;\n    }\n\n    @media screen and (max-width: 500px) {\n      display: block;\n\n      .server-name {\n        line-height: 24px;\n        font-size: 16px;\n      }\n    }\n  }\n\n  .cpu-mem-group {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    @media screen and (max-width: 500px) {\n      display: none;\n    }\n  }\n\n  .slogan-content {\n    color: #ccc;\n    line-height: 18px;\n    font-size: 14px;\n    @media screen and (max-width: 500px) {\n      line-height: 16px;\n      font-size: 12px;\n    }\n  }\n\n  .cpu-model-info {\n    display: flex;\n    align-items: center;\n    gap: 6px;\n    padding-left: 2px;\n    line-height: 24px;\n    color: #ddd;\n\n    .cpu-company {\n      height: 22px;\n      line-height: 22px;\n      padding: 0 5px;\n      color: #111;\n      background: #e0fcff;\n\n      &--intel {\n        text-transform: lowercase;\n        color: #fff;\n        background: #0068b5;\n        font-family: Arial, \"Helvetica Neue\", Helvetica, sans-serif;\n        font-weight: 600;\n      }\n\n      &--amd {\n        font-weight: bold;\n        font-family: Arial, \"Helvetica Neue\", Helvetica, sans-serif;\n      }\n\n      &--apple {\n        font-weight: 600;\n        font-family: PingFang SC, Arial, \"Helvetica Neue\", Helvetica, sans-serif;\n        border-radius: 3px;\n      }\n    }\n\n    .cpu-model {\n      color: #e0fcff;\n    }\n\n    .cpu-model-num {\n      color: #c7eeff;\n    }\n\n    @media screen and (max-width: 500px) {\n      padding-left: 0;\n      margin-top: -7px;\n      line-height: 16px;\n      .cpu-company {\n        height: 16px;\n        line-height: 16px;\n        padding: 0 3px;\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-detail/server-status-box.vue",
    "content": "<template>\n  <dot-dot-box\n    padding=\"15px\"\n    class=\"server-status-and-real-time\"\n    :class=\"{\n      'status-type--progress': componentName === 'progress',\n    }\"\n  >\n    <div\n      class=\"server-status-group\"\n      :class=\"'type--' + componentName + ' status-list--' + serverStatusList.length\"\n    >\n      <component\n        :is=\"componentMaps[componentName]\"\n        v-for=\"item in serverStatusList\"\n        :key=\"item.type\"\n        :type=\"item.type\"\n        :used=\"item.used\"\n        :colors=\"item.colors\"\n        :val-text=\"item.valText\"\n        :label=\"item.label\"\n        :content=\"item.content\"\n      />\n    </div>\n    <server-list-item-real-time :info=\"info\" />\n  </dot-dot-box>\n</template>\n\n<script setup>\n/**\n * 服务器状态组\n */\n\nimport config from '@/config';\n\nimport handleServerStatus from '@/views/composable/server-status';\n\nimport ServerListItemRealTime from '@/views/components/server/server-real-time.vue';\nimport ServerStatusDonut from '@/views/components/server/server-status-donut.vue';\nimport ServerStatusProgress from '@/views/components/server/server-status-progress.vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst componentMaps = {\n  donut: ServerStatusDonut,\n  progress: ServerStatusProgress,\n};\n\nconst componentName = [\n  'donut',\n  'progress',\n].includes(config.nazhua.detailServerStatusType) ? config.nazhua.detailServerStatusType : 'donut';\n\nconst {\n  serverStatusList,\n} = handleServerStatus({\n  props,\n  statusListTpl: 'cpu,mem,swap,disk',\n  statusListItemContent: true,\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-status-and-real-time {\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n\n  --real-time-value-font-size: 36px;\n  --real-time-text-font-size: 16px;\n  --real-time-label-font-size: 16px;\n\n  &.status-type--progress {\n    --real-time-value-font-size: 24px;\n    --real-time-text-font-size: 14px;\n    --real-time-label-font-size: 14px;\n\n    @media screen and (max-width: 1024px) {\n      --real-time-value-font-size: 24px;\n    }\n  }\n\n  @media screen and (max-width: 1024px) {\n    --real-time-value-font-size: 30px;\n  }\n\n  @media screen and (max-width: 720px) {\n    --real-time-value-font-size: 24px;\n    --real-time-text-font-size: 14px;\n    --real-time-label-font-size: 14px;\n  }\n\n  @media screen and (max-width: 320px) {\n    --real-time-value-font-size: 20px;\n    --real-time-text-font-size: 12px;\n    --real-time-label-font-size: 12px;\n  }\n}\n\n.server-status-group {\n  display: flex;\n  flex-wrap: wrap;\n\n  &.type--donut {\n    &.status-list--3 {\n      --server-status-size: 200px;\n      --server-status-val-text-font-size: 32px;\n      --server-status-label-font-size: 18px;\n      --server-status-content-font-size: 16px;\n    }\n\n    &.status-list--4 {\n      --server-status-size: 180px;\n      --server-status-val-text-font-size: 28px;\n      --server-status-label-font-size: 16px;\n      --server-status-content-font-size: 16px;\n    }\n\n    @media screen and (max-width: 800px) {\n      // gap: 10px 20px;\n\n      &.status-list--4 {\n        --server-status-size: 160px;\n        --server-status-val-text-font-size: 26px;\n        --server-status-label-font-size: 15px;\n        --server-status-content-font-size: 14px;\n      }\n    }\n\n    @media screen and (max-width: 720px) {\n      gap: 0;\n    }\n\n    @media screen and (max-width: 480px) {\n      &.status-list--3 {\n        --server-status-size: 100px;\n        --server-status-val-text-font-size: 14px;\n        --server-status-label-font-size: 12px;\n        --server-status-content-font-size: 12px;\n      }\n\n      &.status-list--4 {\n        padding: 0 10px;\n        gap: 10px 0;\n        --server-status-size: 120px;\n        --server-status-val-text-font-size: 16px;\n        --server-status-label-font-size: 14px;\n        --server-status-content-font-size: 14px;\n      }\n    }\n\n    @media screen and (max-width: 400px) {\n      &.status-list--3 {\n        --server-status-size: 90px;\n        --server-status-val-text-font-size: 12px;\n        --server-status-label-font-size: 12px;\n        --server-status-content-font-size: 12px;\n      }\n    }\n\n    @media screen and (max-width: 320px) {\n      &.status-list--3 {\n        --server-status-size: 90px;\n      }\n      &.status-list--4 {\n        padding: 0;\n        --server-status-size: 100px;\n        --server-status-val-text-font-size: 14px;\n        --server-status-label-font-size: 12px;\n        --server-status-content-font-size: 12px;\n      }\n    }\n  }\n\n  &.type--progress {\n    padding: 0 5px;\n    gap: 10px;\n\n    --progress-bar-height: 24px;\n\n    @media screen and (max-width: 350px) {\n      --progress-bar-height: 16px;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/card/server-list-item-bill.vue",
    "content": "<template>\n  <div\n    v-if=\"show\"\n    class=\"server-list-item-bill\"\n    :class=\"{\n      'dot-dot-box--hide': $config.nazhua?.hideDotBG === true,\n    }\"\n  >\n    <div class=\"left-box\">\n      <div\n        v-if=\"billAndPlan.remainingTime\"\n        class=\"remaining-time-info\"\n      >\n        <span class=\"icon\">\n          <span class=\"ri-hourglass-fill\" />\n        </span>\n        <span\n          v-if=\"billAndPlan.remainingTime.type !== 'infinity'\"\n          class=\"text\"\n        >\n          <span class=\"text-item label-text\">{{ billAndPlan.remainingTime.label }}</span>\n          <span class=\"text-item value-text\">{{ billAndPlan.remainingTime.value }}</span>\n        </span>\n        <span\n          v-else\n          class=\"text\"\n        >\n          <span class=\"text-item value-text\">{{ billAndPlan.remainingTime.value }}</span>\n        </span>\n      </div>\n      <div\n        v-else-if=\"tagList\"\n        class=\"tag-list\"\n      >\n        <span\n          v-for=\"(tagItem, index) in tagList\"\n          :key=\"`${tagItem}_${index}`\"\n          class=\"tag-item\"\n          :class=\"{\n            'has-sarasa-term': $hasSarasaTerm && config.nazhua.disableSarasaTermSC !== true,\n          }\"\n        >\n          {{ tagItem }}\n        </span>\n      </div>\n    </div>\n    <div class=\"billing-and-order-link\">\n      <div\n        v-if=\"billAndPlan.billing\"\n        class=\"billing-info\"\n      >\n        <span class=\"text\">\n          <span class=\"text-item value-text\">{{ billAndPlan.billing.value }}</span>\n          <template v-if=\"!billAndPlan.billing.isFree && billAndPlan.billing.cycleLabel\">\n            <span class=\"text-item\">/</span>\n            <span class=\"text-item label-text\">{{ billAndPlan.billing.cycleLabel }}</span>\n          </template>\n        </span>\n      </div>\n      <div\n        v-if=\"showBuyBtn\"\n        class=\"buy-btn\"\n        @click.stop=\"toBuy\"\n      >\n        <span class=\"icon\">\n          <span :class=\"buyBtnIcon\" />\n        </span>\n        <span class=\"text\">{{ buyBtnText }}</span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 套餐信息\n */\nimport {\n  computed,\n} from 'vue';\n\nimport config from '@/config';\n\nimport handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst {\n  billAndPlan,\n} = handleServerBillAndPlan({\n  props,\n});\n\nconst buyBtnIcon = computed(() => {\n  if (props.info?.PublicNote?.customData?.buyBtnIcon) {\n    return props.info?.PublicNote?.customData?.buyBtnIcon;\n  }\n  return config.nazhua.buyBtnIcon || 'ri-shopping-bag-3-line';\n});\nconst buyBtnText = computed(() => {\n  if (props.info?.PublicNote?.customData?.buyBtnText) {\n    return props.info?.PublicNote?.customData?.buyBtnText;\n  }\n  return config.nazhua.buyBtnText || '购买';\n});\nconst showBuyBtn = computed(() => {\n  if (config.nazhua.hideListItemLink === true) {\n    return false;\n  }\n  return !!props.info?.PublicNote?.customData?.orderLink;\n});\n\nfunction toBuy() {\n  const decodeUrl = decodeURIComponent(props.info?.PublicNote?.customData?.orderLink);\n  window.open(decodeUrl, '_blank');\n}\n\nconst tagList = computed(() => {\n  const list = [];\n  const {\n    networkRoute,\n    extra,\n    IPv4,\n    IPv6,\n  } = props?.info?.PublicNote?.planDataMod || {};\n  if (networkRoute) {\n    list.push(...networkRoute.split(','));\n  }\n  if (extra) {\n    list.push(...extra.split(','));\n  }\n  if (IPv4 === '1' && IPv6 === '1') {\n    list.push('双栈IP');\n  } else if (IPv4 === '1') {\n    list.push('仅IPv4');\n  } else if (IPv6 === '1') {\n    list.push('仅IPv6');\n  }\n  // 列表最多显示5个标签\n  return list.slice(0, 5);\n});\n\nconst show = computed(() => {\n  const checks = [\n    billAndPlan.value.remainingTime,\n    billAndPlan.value.billing,\n    tagList.value.length > 0,\n    showBuyBtn.value,\n  ];\n  return checks.some((item) => item);\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-list-item-bill {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 20px;\n  border-bottom-left-radius: var(--list-item-border-radius);\n  border-bottom-right-radius: var(--list-item-border-radius);\n  background: rgba(#000, 0.3);\n  box-shadow: 0 -2px 4px rgba(#000, 0.5);\n\n  --list-item-bill-height: 40px;\n  --list-item-bill-font-size: 14px;\n  --list-item-bill-icon-font-size: 16px;\n\n  height: var(--list-item-bill-height);\n  font-size: var(--list-item-bill-font-size);\n\n  @media screen and (max-width: 720px) {\n    --list-item-bill-height: 30px;\n    --list-item-bill-font-size: 12px;\n    --list-item-bill-icon-font-size: 14px;\n  }\n\n  &.dot-dot-box--hide {\n    box-shadow: none;\n    border-top: 1px solid rgba(#ddd, 0.1);\n  }\n\n  .left-box {\n    display: flex;\n  }\n\n  .remaining-time-info {\n    display: flex;\n    align-items: center;\n    padding-left: 8px;\n\n    .icon {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      width: calc(var(--list-item-bill-height) * 0.75);\n      height: calc(var(--list-item-bill-height) * 0.75);\n      line-height: 1;\n      font-size: var(--list-item-bill-icon-font-size);\n      color: #74dbef;\n    }\n\n    .text {\n      display: flex;\n      align-items: center;\n      line-height: var(--list-item-bill-height);\n      color: #ddd;\n    }\n\n    .value-text {\n      color: #74dbef;\n    }\n\n    @media screen and (max-width: 720px) {\n      padding-left: 6px;\n    }\n  }\n\n  .tag-list {\n    display: flex;\n    gap: 6px;\n    padding-left: 15px;\n    // 折行隐藏\n    height: 18px;\n    overflow: hidden;\n\n    .tag-item {\n      height: 18px;\n      padding: 0 4px;\n      line-height: 18px;\n      font-size: 12px;\n      color: var(--public-note-tag-color);\n      background: var(--public-note-tag-bg);\n      text-shadow: 1px 1px 2px rgba(#000, 0.2);\n      border-radius: 4px;\n\n      &.has-sarasa-term {\n        line-height: 20px;\n      }\n    }\n  }\n\n  .billing-and-order-link {\n    display: flex;\n    align-items: center;\n    height: 40px;\n    padding-right: 15px;\n    gap: 10px;\n\n    .billing-info {\n      line-height: 30px;\n      color: var(--list-item-price-color);\n    }\n\n    .buy-btn {\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      height: 30px;\n      padding: 0 6px;\n      gap: 5px;\n      line-height: 1;\n      font-weight: bold;\n      color: var(--list-item-buy-link-color);\n      border: 2px solid var(--list-item-buy-link-color);\n      border-radius: 8px;\n      transition: all 150ms ease;\n      cursor: pointer;\n\n      &:hover {\n        color: #111;\n        border-color: var(--list-item-buy-link-color);\n        background-color: var(--list-item-buy-link-color);\n      }\n\n      @media screen and (max-width: 768px) {\n        cursor: default;\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/card/server-list-item-status.vue",
    "content": "<template>\n  <div\n    class=\"server-list-item-status\"\n    :class=\"classNames\"\n  >\n    <component\n      :is=\"componentMaps[componentName]\"\n      v-for=\"item in serverStatusList\"\n      :key=\"item.type\"\n      :type=\"item.type\"\n      :used=\"item.used\"\n      :colors=\"item.colors\"\n      :val-text=\"item.valText\"\n      :label=\"item.label\"\n    />\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器状态盒子\n */\n\nimport {\n  computed,\n} from 'vue';\n\nimport config from '@/config';\n\nimport handleServerStatus from '@/views/composable/server-status';\nimport ServerStatusDonut from '@/views/components/server/server-status-donut.vue';\nimport ServerStatusProgress from '@/views/components/server/server-status-progress.vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst componentMaps = {\n  donut: ServerStatusDonut,\n  progress: ServerStatusProgress,\n};\n\nconst componentName = computed(() => {\n  const name = [\n    'donut',\n    'progress',\n  ].includes(config.nazhua.listServerStatusType) ? config.nazhua.listServerStatusType : 'donut';\n  return config.nazhua.listServerItemType === 'server-status' ? 'progress' : name;\n});\n\nconst {\n  serverStatusList,\n} = handleServerStatus({\n  props,\n  statusListTpl: 'cpu,mem,disk',\n  statusListItemContent: false,\n});\n\nconst classNames = computed(() => {\n  const names = {};\n  names[`type--${componentName.value}`] = true;\n  names[`len--${serverStatusList.value?.length}`] = true;\n  return names;\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-list-item-status {\n  display: flex;\n  justify-content: space-between;\n  padding: 0 5px;\n\n  &.type--progress {\n    flex-wrap: wrap;\n    gap: 10px;\n\n    --progress-bar-width: calc(50% - 5px);\n    --progress-bar-height: 20px;\n\n    @media screen and (max-width: 400px) {\n      --progress-bar-height: 16px;\n      padding: 0 10px;\n    }\n\n    &.len--3 {\n      --progress-bar-width: calc((100% - 20px) / 3);\n    }\n  }\n\n  &.type--donut {\n    --server-status-size: 120px;\n    --server-status-val-text-font-size: 20px;\n    --server-status-label-font-size: 14px;\n    // 针对1440px以下的屏幕(新Mac笔记本或者windows缩放)\n    @media screen and (max-width: 1440px) {\n      padding: 0;\n      --server-status-size: 110px;\n      --server-status-val-text-font-size: 18px;\n      --server-status-label-font-size: 14px;\n    }\n    // 针对1280px以下的屏幕(Mac居多)\n    @media screen and (max-width: 1280px) {\n      padding: 0 8px;\n      --server-status-size: 100px;\n      --server-status-val-text-font-size: 16px;\n      --server-status-label-font-size: 12px;\n    }\n    // 针对1024px以下的屏幕(平板居多)\n    @media screen and (max-width: 1024px) {\n      // padding: 0 8px;\n      --server-status-size: 120px;\n      --server-status-val-text-font-size: 20px;\n      --server-status-label-font-size: 16px;\n    }\n    @media screen and (max-width: 800px) {\n      padding: 0 8px;\n      --server-status-size: 100px;\n      --server-status-val-text-font-size: 16px;\n      --server-status-label-font-size: 12px;\n    }\n    @media screen and (max-width: 375px) {\n      padding: 0;\n      --server-status-size: 90px;\n      --server-status-val-text-font-size: 14px;\n      --server-status-label-font-size: 12px;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/card/server-list-item.vue",
    "content": "<template>\n  <dot-dot-box\n    border-radius=\"var(--list-item-border-radius)\"\n    :padding=\"0\"\n    class=\"server-list-item\"\n    :class=\"{\n      'server-list-item--offline': info.online === -1,\n    }\"\n  >\n    <div\n      class=\"server-info-group server-list-item-head\"\n      :class=\"{\n        'dot-dot-box--hide': $config.nazhua?.hideDotBG === true,\n      }\"\n      @click=\"openDetail\"\n    >\n      <div class=\"server-name-group left-box\">\n        <server-flag :info=\"info\" />\n        <span class=\"server-name\">\n          {{ info.Name }}\n        </span>\n      </div>\n      <div class=\"right-box\">\n        <div\n          v-if=\"cpuAndMemAndDisk\"\n          class=\"cpu-mem-group\"\n        >\n          <span :class=\"platformLogoIconClassName\" />\n          <span class=\"core-mem\">{{ cpuAndMemAndDisk }}</span>\n        </div>\n      </div>\n    </div>\n    <div\n      v-if=\"$config.nazhua.hideListItemStatusDonut !== true && $config.nazhua.hideListItemStat !== true\"\n      class=\"server-list-item-main\"\n      @click=\"openDetail\"\n    >\n      <server-list-item-status\n        v-if=\"$config.nazhua.hideListItemStatusDonut !== true\"\n        :info=\"info\"\n      />\n      <server-real-time\n        v-if=\"$config.nazhua.hideListItemStat !== true\"\n        :info=\"info\"\n        :server-real-time-list-tpls=\"serverRealTimeListTpls\"\n      />\n    </div>\n    <server-list-item-bill\n      v-if=\"$config.nazhua.hideListItemBill !== true\"\n      :info=\"info\"\n    />\n  </dot-dot-box>\n</template>\n\n<script setup>\n/**\n * 单节点\n */\n\nimport {\n  computed,\n} from 'vue';\nimport {\n  useRouter,\n} from 'vue-router';\nimport config from '@/config';\nimport * as hostUtils from '@/utils/host';\n\nimport handleServerInfo from '@/views/composable/server-info';\nimport ServerRealTime from '@/views/components/server/server-real-time.vue';\nimport ServerListItemStatus from './server-list-item-status.vue';\nimport ServerListItemBill from './server-list-item-bill.vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst router = useRouter();\n\n/**\n * XCore XGB\n */\nconst { cpuAndMemAndDisk } = handleServerInfo({\n  props,\n});\n\nconst platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconClassName(props.info?.Host?.Platform));\n\nconst serverRealTimeListTpls = computed(() => {\n  if (config.nazhua?.listServerRealTimeShowLoad || config.nazhua.listServerItemType === 'server-status') {\n    return 'D-A-T,T-A-U,L-A-P,I-A-O';\n  }\n  return 'duration,transfer,inSpeed,outSpeed';\n});\n\nfunction openDetail() {\n  router.push({\n    name: 'ServerDetail',\n    params: {\n      serverId: props.info.ID,\n    },\n  });\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.server-list-item {\n  --list-item-border-radius: 12px;\n  width: var(--list-item-width);\n  color: #fff;\n  transition: 0.3s;\n\n  .server-info-group {\n    --list-item-head-height: 50px;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    gap: 10px;\n    padding: 0 15px;\n    border-top-left-radius: var(--list-item-border-radius);\n    border-top-right-radius: var(--list-item-border-radius);\n    background: rgba(#000, 0.3);\n    box-shadow: 0 2px 4px rgba(#000, 0.5);\n    cursor: pointer;\n\n    @media screen and (max-width: 768px) {\n      cursor: default;\n      --list-item-head-height: 40px;\n    }\n\n    &.dot-dot-box--hide {\n      box-shadow: none;\n      border-bottom: 1px solid rgba(#ddd, 0.1);\n    }\n\n    &.server-list-item-head {\n      flex-wrap: wrap;\n      overflow: hidden;\n      height: var(--list-item-head-height, 50px);\n    }\n\n    .left-box,\n    .right-box {\n      display: flex;\n      align-items: center;\n      gap: 8px;\n      cursor: default;\n    }\n\n    .server-flag {\n      width: calc(18px * 1.5);\n      height: 30px;\n      line-height: 30px;\n      font-size: 18px;\n      text-align: center;\n    }\n\n    .server-name {\n      height: 30px;\n      line-height: 32px;\n      font-size: 14px;\n      font-weight: bold;\n    }\n\n    .cpu-mem-group {\n      display: flex;\n      align-items: center;\n      gap: 4px;\n    }\n\n    .core-mem {\n      height: 30px;\n      line-height: 32px;\n      font-weight: bold;\n    }\n  }\n\n  &.server-list-item--offline {\n    filter: grayscale(1);\n    .server-info-group {\n      .server-name {\n        color: #666;\n      }\n    }\n  }\n}\n\n.server-list-item-main {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  padding: 15px 10px;\n\n  --real-time-value-font-size: 24px;\n  --real-time-text-font-size: 12px;\n  --real-time-label-font-size: 14px;\n\n  font-size: var(--real-time-label-font-size);\n\n  @media screen and (max-width: 1280px) {\n    padding: 10px 0 15px;\n\n    --real-time-value-font-size: 20px;\n  }\n\n  @media screen and (max-width: 1024px) {\n    --real-time-value-font-size: 24px;\n  }\n\n  @media screen and (max-width: 800px) {\n    --real-time-value-font-size: 20px;\n  }\n\n  @media screen and (max-width: 720px) {\n    --real-time-value-font-size: 24px;\n    --real-time-text-font-size: 12px;\n    --real-time-label-font-size: 12px;\n\n    padding: 5px 0;\n  }\n\n  @media screen and (max-width: 320px) {\n    --real-time-value-font-size: 20px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/row/server-list-column.vue",
    "content": "<template>\n  <div\n    class=\"list-column\"\n    :class=\"`list-column--${prop}`\"\n    :style=\"columnStyle\"\n  >\n    <div\n      ref=\"columnContentRef\"\n      class=\"list-column-content\"\n    >\n      <span class=\"item-label\">{{ label }}</span>\n      <div class=\"item-content\">\n        <template v-if=\"slotContent\">\n          <slot />\n        </template>\n        <template v-if=\"slotValue\">\n          <span class=\"item-text item-value\">\n            <slot name=\"value\" />\n          </span>\n          <span class=\"item-text item-unit\">\n            <slot name=\"unit\" />\n          </span>\n        </template>\n        <template v-else>\n          <span class=\"item-text item-value\">{{ value }}</span>\n          <span\n            v-if=\"unit\"\n            class=\"item-text item-unit\"\n          >{{ unit }}</span>\n        </template>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器信息列表列\n */\n\nimport {\n  computed,\n  ref,\n  onMounted,\n  onBeforeUnmount,\n} from 'vue';\nimport {\n  useStore,\n} from 'vuex';\n\nconst props = defineProps({\n  prop: {\n    type: String,\n    default: '',\n  },\n  label: {\n    type: String,\n    default: '',\n  },\n  value: {\n    type: [String, Number],\n    default: '',\n  },\n  unit: {\n    type: String,\n    default: '',\n  },\n  width: {\n    type: [String, Number],\n    default: null,\n  },\n  slotContent: {\n    type: [String, Boolean],\n    default: false,\n  },\n  slotValue: {\n    type: [String, Boolean],\n    default: false,\n  },\n});\n\nconst store = useStore();\n\nconst columnContentRef = ref(null);\nlet resizeObserver = null;\n\nconst columnWidth = computed(() => store.state?.serverListColumnWidths?.[props.prop]);\n\nonMounted(() => {\n  if (columnContentRef.value) {\n    resizeObserver = new ResizeObserver((entries) => {\n      entries.forEach((entry) => {\n        let { width } = entry.contentRect;\n        width = Math.ceil(width);\n        store.dispatch('setServerListColumnWidth', {\n          prop: props.prop,\n          width: width > 40 ? width : 40,\n        });\n      });\n    });\n\n    resizeObserver.observe(columnContentRef.value);\n  }\n});\n\nonBeforeUnmount(() => {\n  if (resizeObserver) {\n    resizeObserver.disconnect();\n    resizeObserver = null;\n  }\n});\n\nconst columnStyle = computed(() => {\n  const style = {};\n  if (props.width) {\n    const width = parseInt(props.width, 10);\n    if (Number.isNaN(width) === false) {\n      style.width = `${width}px`;\n    }\n  } else if (columnWidth.value > 0) {\n    style.width = `${columnWidth.value}px`;\n  }\n  return style;\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.list-column {\n  --list-column-label-height: 16px;\n  --list-column-value-height: 24px;\n  position: relative;\n  width: auto;\n  height: calc(var(--list-column-label-height) + var(--list-column-value-height) + 10px);\n\n  .list-column-content {\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    transform: translate(-50%, -50%);\n\n    display: flex;\n    flex-direction: column;\n    justify-content: center;\n    align-items: center;\n    width: max-content;\n    height: var(--list-item-height);\n\n    .item-label {\n      padding-top: 6px; // 视觉修正\n      line-height: var(--list-column-label-height);\n      font-size: 12px;\n      color: #bbb;\n    }\n    .item-content {\n      line-height: var(--list-column-value-height);\n      font-size: 14px;\n    }\n  }\n\n  &--duration {\n    .item-value {\n      color: var(--duration-color);\n    }\n  }\n\n  &--load {\n    .item-value {\n      color: var(--load-color);\n    }\n  }\n\n  &--transfer {\n    .item-value {\n      color: var(--transfer-color);\n    }\n  }\n\n  &--inTransfer {\n    .item-value {\n      color: var(--transfer-in-color);\n    }\n  }\n\n  &--outTransfer {\n    .item-value {\n      color: var(--transfer-out-color);\n    }\n  }\n\n  &--speeds {\n    .item-value {\n      color: var(--net-speed-color);\n    }\n  }\n\n  &--inSpeed {\n    .item-value {\n      color: var(--net-speed-in-color);\n    }\n  }\n\n  &--outSpeed {\n    .item-value {\n      color: var(--net-speed-out-color);\n    }\n  }\n\n  &--remaining-time {\n    .value-text {\n      color: #74dbef;\n    }\n  }\n\n  &--billing {\n    .value-text {\n      color: var(--list-item-price-color);\n    }\n  }\n\n  &--tcp {\n    .item-value {\n      color: var(--conn-tcp-color);\n    }\n  }\n\n  &--udp {\n    .item-value {\n      color: var(--conn-udp-color);\n    }\n  }\n\n  &--conns {\n    .item-value {\n      color: var(--conn-color);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/row/server-list-item-bill.vue",
    "content": "<template>\n  <server-list-column\n    v-if=\"extraFields?.remainingTime\"\n    prop=\"remaining-time\"\n    label=\"剩余\"\n    :value=\"billAndPlan?.remainingTime?.value || '-'\"\n  />\n  <server-list-column\n    v-if=\"extraFields?.billing\"\n    prop=\"billing\"\n    label=\"费用\"\n    :value=\"billAndPlan?.billing?.value || '-'\"\n  />\n  <server-list-column\n    v-if=\"extraFields?.orderLink\"\n    prop=\"order-link\"\n    label=\"链接\"\n    :slot-content=\"true\"\n  >\n    <span\n      v-if=\"showBuyBtn\"\n      class=\"order-link\"\n      @click=\"toBuy\"\n    >\n      {{ buyBtnText }}\n    </span>\n    <span v-else>-</span>\n  </server-list-column>\n</template>\n\n<script setup>\n/**\n * 套餐信息\n */\nimport {\n  inject,\n  computed,\n} from 'vue';\n\nimport config from '@/config';\n\nimport handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';\n\nimport ServerListColumn from './server-list-column.vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst filterServerList = inject('filterServerList', {\n  value: null,\n});\n\nconst extraFields = computed(() => filterServerList.value?.fields || {});\n\nconst {\n  billAndPlan,\n} = handleServerBillAndPlan({\n  props,\n});\n\nconst buyBtnText = computed(() => {\n  if (props.info?.PublicNote?.customData?.buyBtnText) {\n    return props.info?.PublicNote?.customData?.buyBtnText;\n  }\n  return config.nazhua.buyBtnText || '购买';\n});\nconst showBuyBtn = computed(() => !!props.info?.PublicNote?.customData?.orderLink);\n\nfunction toBuy() {\n  const decodeUrl = decodeURIComponent(props.info?.PublicNote?.customData?.orderLink);\n  window.open(decodeUrl, '_blank');\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.order-link {\n  color: var(--list-item-buy-link-color);\n  cursor: pointer;\n\n  &:hover {\n    color: var(--list-item-buy-link-color-hover);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/row/server-list-item-real-time.vue",
    "content": "<template>\n  <server-list-column\n    v-for=\"item in serverRealTimeList\"\n    :key=\"item.key\"\n    :prop=\"item.key\"\n    :label=\"item.label\"\n    :value=\"item.show ? item?.value : '-'\"\n    :unit=\"item.show ? item?.unit : ''\"\n  />\n</template>\n\n<script setup>\n/**\n * 服务器数据统计\n */\nimport {\n  inject,\n} from 'vue';\nimport handleServerRealTime from '@/views/composable/server-real-time';\n\nimport ServerListColumn from './server-list-column.vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n  serverRealTimeListTpls: {\n    type: String,\n    default: undefined,\n  },\n});\n\nconst currentTime = inject('currentTime', {\n  value: Date.now(),\n});\n\nconst {\n  serverRealTimeList,\n} = handleServerRealTime({\n  props,\n  currentTime,\n  serverRealTimeListTpls: props.serverRealTimeListTpls,\n});\n</script>\n"
  },
  {
    "path": "src/views/components/server-list/row/server-list-item-status-progress.vue",
    "content": "<template>\n  <div\n    class=\"server-list-item-status-progress\"\n    :class=\"'server-status--' + type\"\n    :title=\"valPercent\"\n  >\n    <span class=\"progress-label\">\n      {{ label }}\n    </span>\n    <div class=\"progress-bar\">\n      <div class=\"progress-bar-box\">\n        <div\n          class=\"progress-bar-inner\"\n          :style=\"progressStyle\"\n        />\n        <span\n          class=\"progress-bar-used\"\n        >\n          {{ valText }}\n        </span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器状态进度调单项\n */\n\nimport {\n  computed,\n} from 'vue';\n\nconst props = defineProps({\n  type: {\n    type: String,\n    default: '',\n  },\n  size: {\n    type: Number,\n    default: 100,\n  },\n  used: {\n    type: [Number, String],\n    default: 1,\n  },\n  colors: {\n    type: Object,\n    default: () => ({}),\n  },\n  valText: {\n    type: String,\n    default: '',\n  },\n  valPercent: {\n    type: String,\n    default: '',\n  },\n  label: {\n    type: String,\n    default: '',\n  },\n  content: {\n    type: [String, Object],\n    default: '',\n  },\n});\n\nconst progressStyle = computed(() => {\n  const style = {};\n  style.width = `${Math.min(props.used, 100)}%`;\n  const color = typeof props.colors === 'string' ? props.colors : props.colors?.used;\n  if (color) {\n    if (Array.isArray(color)) {\n      style.background = `linear-gradient(-35deg, ${color.join(',')})`;\n    } else {\n      style.backgroundColor = color;\n    }\n  }\n  return style;\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-list-item-status-progress {\n  --progress-label-height: 16px;\n  --progress-bar-height: 24px;\n  --progress-bar-box-height: 14px;\n\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n  height: var(--list-item-height);\n\n  .progress-label {\n    padding-top: 6px; // 视觉修正\n    line-height: var(--progress-label-height);\n    font-size: 12px;\n    color: #ccc;\n  }\n\n  .progress-bar {\n    display: flex;\n    align-items: center;\n    width: 100%;\n    height: var(--progress-bar-height);\n  }\n\n  .progress-bar-box {\n    position: relative;\n    width: 100%;\n    height: var(--progress-bar-box-height);\n    background: rgba(255, 255, 255, 0.2);\n    border-radius: calc(var(--progress-bar-box-height) / 2);\n    overflow: hidden;\n  }\n\n  .progress-bar-inner {\n    position: absolute;\n    top: 0;\n    left: 0;\n    bottom: 0;\n    background-color: #08f;\n    border-radius: calc(var(--progress-bar-box-height) / 2);\n    box-shadow: 2px 0 2px rgba(#000, 0.2);\n  }\n\n  .progress-bar-used {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    line-height: var(--progress-bar-box-height);\n    font-size: 12px;\n    text-align: center;\n    text-shadow: 1px 1px 2px rgba(#000, 0.8), 0 0 1px rgba(#fff, 0.5);\n    cursor: default;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/row/server-list-item-status.vue",
    "content": "<template>\n  <div\n    v-for=\"item in serverStatusList\"\n    :key=\"item.type\"\n    class=\"list-column-item list-column-item--status\"\n    :class=\"`list-column-item--status-${componentName} list-column-item--status-type-${item.type}`\"\n  >\n    <component\n      :is=\"componentMaps[componentName]\"\n      :type=\"item.type\"\n      :used=\"item.used\"\n      :colors=\"item.colors\"\n      :val-text=\"item.valPercent\"\n      :val-percent=\"`${item.label}使用${item.valText}`\"\n      :label=\"item.label\"\n    />\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器状态盒子\n */\n\nimport config from '@/config';\n\nimport handleServerStatus from '@/views/composable/server-status';\nimport ServerStatusDonut from '@/views/components/server/server-status-donut.vue';\nimport ServerStatusProgress from './server-list-item-status-progress.vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst componentMaps = {\n  donut: ServerStatusDonut,\n  progress: ServerStatusProgress,\n};\n\nconst componentName = [\n  'donut',\n  'progress',\n].includes(config.nazhua.listServerStatusType) ? config.nazhua.listServerStatusType : 'donut';\n\nconst {\n  serverStatusList,\n} = handleServerStatus({\n  props,\n  statusListTpl: 'cpu,mem,disk',\n  statusListItemContent: false,\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.list-column-item {\n  &--status-progress {\n    width: 72px;\n    padding: 0 3px;\n  }\n\n  &--status-donut {\n    --server-status-size: 66px;\n    --server-status-label-scale: 0.8;\n    --server-status-val-text-font-size: 16px;\n    --server-status-label-font-size: 12px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/row/server-list-item.vue",
    "content": "<template>\n  <dot-dot-box\n    border-radius=\"var(--list-item-border-radius)\"\n    padding=\"var(--list-item-padding)\"\n    class=\"server-list-row-item\"\n    :class=\"{\n      'server-list-row-item--offline': info.online === -1,\n    }\"\n    @click=\"openDetail\"\n  >\n    <div class=\"list-column-item list-column-item--server-flag\">\n      <server-flag :info=\"info\" />\n    </div>\n    <div class=\"list-column-item list-column-item--server-name\">\n      <span\n        class=\"server-name\"\n        :title=\"info.Name\"\n      >\n        {{ info.Name }}\n      </span>\n    </div>\n    <server-list-column\n      prop=\"server-flag\"\n      label=\"地区\"\n      :value=\"info?.Host?.CountryCode?.toUpperCase() || 'UN'\"\n    />\n    <server-list-column\n      prop=\"server-system\"\n      label=\"系统\"\n      :value=\"platformSystemLabel || '-'\"\n    />\n    <server-list-column\n      prop=\"cpu-mem\"\n      label=\"配置\"\n      :value=\"cpuAndMemAndDisk || '-'\"\n    />\n    <server-list-item-status\n      v-if=\"$config.nazhua.hideListItemStatusDonut !== true\"\n      :info=\"info\"\n    />\n    <server-list-item-real-time\n      v-if=\"$config.nazhua.hideListItemStat !== true\"\n      :info=\"info\"\n      server-real-time-list-tpls=\"load,conns,speeds,transfer,duration\"\n    />\n    <server-list-item-bill\n      v-if=\"$config.nazhua.hideListItemBill !== true\"\n      :info=\"info\"\n    />\n  </dot-dot-box>\n</template>\n\n<script setup>\n/**\n * 单节点\n */\n\nimport {\n  computed,\n} from 'vue';\nimport {\n  useRouter,\n} from 'vue-router';\nimport * as hostUtils from '@/utils/host';\n\nimport handleServerInfo from '@/views/composable/server-info';\nimport ServerListColumn from './server-list-column.vue';\nimport ServerListItemStatus from './server-list-item-status.vue';\nimport ServerListItemRealTime from './server-list-item-real-time.vue';\nimport ServerListItemBill from './server-list-item-bill.vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst router = useRouter();\n\n/**\n * XCore XGB\n */\nconst { cpuAndMemAndDisk } = handleServerInfo({\n  props,\n});\n\nconst platformSystemLabel = computed(() => hostUtils.getSystemOSLabel(props.info?.Host?.Platform, true));\n\nfunction openDetail() {\n  router.push({\n    name: 'ServerDetail',\n    params: {\n      serverId: props.info.ID,\n    },\n  });\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.server-list-row-item {\n  --list-item-height: 64px;\n  --list-item-border-radius: 8px;\n  --list-item-gap: 0;\n  --list-item-padding: 0 20px;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  height: var(--list-item-height);\n  gap: var(--list-item-gap);\n  transition: 0.3s;\n\n  &--offline {\n    filter: grayscale(1);\n  }\n\n  @media (max-width: 1280px) {\n    --list-item-padding: 0 10px;\n  }\n}\n\n.list-column-item {\n  display: flex;\n  align-items: center;\n  overflow: hidden;\n\n  &--server-flag {\n    --server-flag-size: 24px;\n    width: calc(var(--server-flag-size) * 1.5);\n    .server-flag {\n      width: calc(var(--server-flag-size) * 1.5);\n      height: var(--server-flag-size);\n      line-height: var(--server-flag-size);\n      font-size: var(--server-flag-size);\n    }\n\n    @media (max-width: 1280px) {\n      display: none;\n    }\n\n    @media (max-width: 1024px) {\n      display: block;\n    }\n  }\n  &--server-name {\n    width: 220px;\n\n    .server-name {\n      height: 32px;\n      line-height: 34px;\n      font-size: 16px;\n      font-weight: bold;\n      width: 100%;\n      text-overflow: ellipsis;\n      white-space: nowrap;\n      overflow: hidden;\n    }\n\n    @media (max-width: 1280px) {\n      width: 180px;\n    }\n\n    @media (max-width: 1024px) {\n      width: 300px;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-list-warp.vue",
    "content": "<template>\n  <transition-group\n    v-if=\"showTransition\"\n    name=\"list\"\n    tag=\"div\"\n    class=\"server-list-container\"\n    :class=\"{\n      'server-list--row': showListRow,\n      'server-list--card': showListCard,\n      'server-list--status': showListByServerStatus,\n    }\"\n  >\n    <slot />\n  </transition-group>\n  <div\n    v-else\n    class=\"server-list-container\"\n    :class=\"{\n      'server-list--row': showListRow,\n      'server-list--card': showListCard,\n      'server-list--status': showListByServerStatus,\n    }\"\n  >\n    <slot />\n  </div>\n</template>\n\n<script setup>\n/**\n * 服务器列表\n */\n\ndefineProps({\n  showTransition: {\n    type: Boolean,\n    default: true,\n  },\n  showListRow: {\n    type: Boolean,\n    default: false,\n  },\n  showListCard: {\n    type: Boolean,\n    default: false,\n  },\n  showListByServerStatus: {\n    type: Boolean,\n    default: false,\n  },\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-list-container.server-list--card {\n  --list-padding: 20px;\n  --list-gap-size: 20px;\n  --list-item-num: 3;\n  --list-item-width: calc(\n    (\n      var(--list-container-width)\n      - (var(--list-padding) * 2)\n      - (\n        var(--list-gap-size)\n        * (var(--list-item-num) - 1)\n        )\n    )\n    / var(--list-item-num)\n  );\n  position: relative;\n  display: flex;\n  flex-wrap: wrap;\n  gap: var(--list-gap-size);\n  padding: 0 var(--list-padding);\n  width: var(--list-container-width);\n  margin: auto;\n\n  // 针对1440px以下的屏幕\n  @media screen and (max-width: 1440px) {\n    --list-gap-size: 10px;\n  }\n\n  @media screen and (max-width: 1024px) {\n    --list-item-num: 2;\n  }\n\n  @media screen and (max-width: 680px) {\n    --list-item-num: 1;\n  }\n}\n\n.server-list-container.server-list--row {\n  --list-padding: 20px;\n  --list-gap-size: 12px;\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  gap: var(--list-gap-size);\n  width: var(--list-container-width);\n  padding: 0 var(--list-padding);\n  margin: auto;\n}\n\n.server-list-container.server-list--status {\n  --list-padding: 20px;\n  --list-gap-size: 12px;\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  gap: var(--list-gap-size);\n  width: var(--list-container-width);\n  padding: 0 var(--list-padding);\n  margin: auto;\n}\n\n.list-move,\n.list-enter-active,\n.list-leave-active {\n  transition: all 0.3s ease;\n}\n.list-enter-from {\n  opacity: 0;\n  transform: translateY(-30px);\n}\n.list-leave-to {\n  opacity: 0;\n  transform: translateY(30px);\n}\n.list-leave-active {\n  position: absolute;\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-option-box.vue",
    "content": "<template>\n  <div\n    class=\"server-option-box\"\n    :class=\"{\n      'server-option-box--light-background': lightBackground,\n      'server-option-box--mobile-hide': !mobileShow,\n    }\"\n  >\n    <div\n      v-for=\"item in options\"\n      :key=\"item.key\"\n      class=\"server-option-item\"\n      :class=\"{\n        'has-icon': item.icon,\n        active: activeValue === item.value,\n      }\"\n      :title=\"item?.title || false\"\n      @click=\"toggleModelValue(item)\"\n    >\n      <i\n        v-if=\"item.icon\"\n        class=\"option-icon\"\n        :class=\"item.icon\"\n        :title=\"item.label\"\n      />\n      <span\n        v-else\n        class=\"option-label\"\n      >{{ item.label }}</span>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 过滤栏\n */\nimport {\n  computed,\n} from 'vue';\nimport config from '@/config';\n\nconst props = defineProps({\n  modelValue: {\n    type: [String, Number],\n    default: '',\n  },\n  options: {\n    type: Array,\n    default: () => [],\n  },\n  acceptEmpty: {\n    type: Boolean,\n    default: true,\n  },\n  mobileShow: {\n    type: Boolean,\n    default: true,\n  },\n});\n\nconst emits = defineEmits([\n  'update:modelValue',\n]);\n\nconst lightBackground = computed(() => config.nazhua.lightBackground);\n\nconst activeValue = computed({\n  get: () => props.modelValue,\n  set: (val) => {\n    emits('update:modelValue', val);\n  },\n});\n\nfunction toggleModelValue(item) {\n  if (activeValue.value === item.value) {\n    if (props.acceptEmpty) {\n      activeValue.value = '';\n    }\n  } else {\n    activeValue.value = item.value;\n  }\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.server-option-box {\n  display: flex;\n  flex-wrap: wrap;\n  padding: 0 var(--list-padding);\n  gap: 8px;\n\n  @media screen and (max-width: 768px) {\n    &--mobile-hide {\n      display: none;\n    }\n  }\n\n  .server-option-item {\n    display: flex;\n    align-items: center;\n    height: 36px;\n    padding: 0 15px;\n    line-height: 1.2;\n    border-radius: 6px;\n    background: rgba(#000, 0.3);\n    transition: all 0.3s linear;\n    cursor: pointer;\n\n    &.has-icon {\n      padding: 0 10px;\n    }\n\n    @media screen and (max-width: 768px) {\n      height: 30px;\n      padding: 0 10px;\n      border-radius: 3px;\n      background-color: rgba(#000, 0.8);\n      cursor: default;\n    }\n\n    .option-icon {\n      line-height: 1;\n      font-size: 18px;\n    }\n\n    .option-label {\n      color: #fff;\n      font-weight: bold;\n      transition: all 0.3s linear;\n    }\n\n    @media screen and (min-width: 768px) {\n      &:hover {\n        .option-label {\n          color: var(--option-high-color);\n        }\n      }\n    }\n\n    &.active {\n      background: var(--option-high-color-active);\n\n      .option-label {\n        color: #fff;\n      }\n    }\n  }\n\n  @media screen and (min-width: 768px) {\n    &--light-background {\n      .server-option-item {\n        background: rgba(#000, 0.5);\n\n        &:hover {\n          background: rgba(#000, 0.8);\n        }\n\n        &.active {\n          background: var(--option-high-color-active);\n        }\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-sort-box.vue",
    "content": "<template>\n  <div\n    class=\"server-sort-box\"\n    :class=\"{\n      'server-sort-box--light-background': lightBackground,\n      'server-sort-box--mobile-hide': !mobileShow,\n    }\"\n  >\n    <div\n      ref=\"triggerRef\"\n      class=\"sort-select-wrapper\"\n      @click=\"toggleDropdown\"\n    >\n      <div class=\"sort-select-selected\">\n        <span class=\"sort-select-selected-value\">{{ selectedLabel }}</span>\n        <span\n          class=\"sort-select-selected-icon\"\n          @click.stop=\"toggleOrder\"\n        >\n          <span\n            v-if=\"activeValue.order === 'desc'\"\n            class=\"ri-arrow-down-line\"\n          />\n          <span\n            v-else\n            class=\"ri-arrow-up-line\"\n          />\n        </span>\n      </div>\n    </div>\n\n    <!-- 下拉菜单 -->\n    <Teleport to=\"body\">\n      <server-sort-dropdown-menu\n        ref=\"dropdownMenuRef\"\n        :visible=\"isDropdownOpen\"\n        :options=\"options\"\n        :active-value=\"activeValue.prop\"\n        :dropdown-style=\"dropdownStyle\"\n        :light-background=\"lightBackground\"\n        :is-mobile=\"isMobile\"\n        @select=\"handleSelectItem\"\n      />\n    </Teleport>\n  </div>\n</template>\n\n<script setup>\n/**\n * 过滤栏\n */\nimport {\n  computed,\n  ref,\n  onMounted,\n  onUnmounted,\n  nextTick,\n} from 'vue';\nimport config from '@/config';\nimport ServerSortDropdownMenu from './server-sort-dropdown-menu.vue';\n\nconst props = defineProps({\n  modelValue: {\n    type: Object,\n    default: () => ({\n      prop: 'DisplayIndex',\n      order: 'desc',\n    }),\n  },\n  options: {\n    type: Array,\n    default: () => [],\n  },\n  acceptEmpty: {\n    type: Boolean,\n    default: true,\n  },\n  mobileShow: {\n    type: Boolean,\n    default: true,\n  },\n});\n\nconst emits = defineEmits([\n  'update:modelValue',\n  'change',\n]);\n\nconst lightBackground = computed(() => config.nazhua.lightBackground);\n\n// 设备检测（用于判断是否小屏，小屏时居中显示）\nconst isMobile = ref(window.innerWidth < 768);\n\n// PC端下拉菜单相关\nconst isDropdownOpen = ref(false);\nconst triggerRef = ref(null);\nconst dropdownMenuRef = ref(null);\nconst dropdownStyle = ref({});\n\nconst activeValue = computed({\n  get: () => props.modelValue,\n  set: (val) => {\n    emits('update:modelValue', val);\n    emits('change', val);\n  },\n});\n\n// 获取当前选中项的label\nconst selectedLabel = computed(() => {\n  const selectedOption = props.options.find((opt) => opt.value === activeValue.value.prop);\n  return selectedOption ? selectedOption.label : '排序';\n});\n\n// 更新下拉菜单位置\nfunction updateDropdownPosition() {\n  if (!triggerRef.value || !dropdownMenuRef.value) return;\n\n  // 使用 nextTick 确保 DOM 已更新\n  nextTick(() => {\n    const dropdownRef = dropdownMenuRef.value?.dropdownRef;\n\n    if (!dropdownRef) return;\n\n    // 小屏设备：居中显示\n    if (isMobile.value) {\n      dropdownStyle.value = {\n        position: 'fixed',\n        top: '50%',\n        left: '50%',\n        transform: 'translate(-50%, -50%)',\n        visibility: 'visible',\n      };\n      return;\n    }\n\n    // 大屏设备：相对定位\n    const triggerRect = triggerRef.value.getBoundingClientRect();\n\n    // 先设置一个初始位置，确保元素在视口中可见\n    let top = triggerRect.bottom + 8;\n    let { left } = triggerRect;\n\n    // 设置初始位置\n    dropdownStyle.value = {\n      position: 'fixed',\n      top: `${top}px`,\n      left: `${left}px`,\n      visibility: 'hidden', // 先隐藏，避免闪烁\n    };\n\n    // 再次使用 nextTick 确保样式已应用\n    nextTick(() => {\n      const dropdownRect = dropdownRef.getBoundingClientRect();\n\n      // 防止超出右边界\n      if (left + dropdownRect.width > window.innerWidth) {\n        left = window.innerWidth - dropdownRect.width - 10;\n      }\n\n      // 防止超出下边界，如果超出则向上展开\n      if (top + dropdownRect.height > window.innerHeight) {\n        top = triggerRect.top - dropdownRect.height - 8;\n      }\n\n      // 防止超出左边界\n      if (left < 10) {\n        left = 10;\n      }\n\n      // 更新最终位置并显示\n      dropdownStyle.value = {\n        position: 'fixed',\n        top: `${top}px`,\n        left: `${left}px`,\n        visibility: 'visible',\n      };\n    });\n  });\n}\n\n// 切换下拉菜单显示状态\nfunction toggleDropdown(event) {\n  event.stopPropagation(); // 阻止事件冒泡，防止立即被 handleDocumentClick 关闭\n  isDropdownOpen.value = !isDropdownOpen.value;\n  if (isDropdownOpen.value) {\n    nextTick(() => {\n      updateDropdownPosition();\n    });\n  }\n}\n\n// 切换升序/降序\nfunction toggleOrder(event) {\n  event.stopPropagation(); // 阻止事件冒泡，避免触发下拉菜单\n  if (!activeValue.value.prop) return; // 如果没有选中排序字段，则不切换\n\n  activeValue.value = {\n    prop: activeValue.value.prop,\n    order: activeValue.value.order === 'desc' ? 'asc' : 'desc',\n  };\n  emits('change', activeValue.value);\n}\n\n// PC端选择项\nfunction handleSelectItem(item) {\n  if (activeValue.value.prop === item.value) {\n    if (props.acceptEmpty) {\n      activeValue.value = {\n        prop: '',\n        order: 'desc',\n      };\n    }\n  } else {\n    activeValue.value = {\n      prop: item.value,\n      order: activeValue.value.order || 'desc',\n    };\n  }\n  isDropdownOpen.value = false;\n  emits('change', activeValue.value);\n}\n\n// 点击外部关闭下拉菜单\nfunction handleDocumentClick(event) {\n  if (!isDropdownOpen.value) return;\n\n  const dropdownRef = dropdownMenuRef.value?.dropdownRef;\n\n  if (\n    triggerRef.value\n    && !triggerRef.value.contains(event.target)\n    && dropdownRef\n    && !dropdownRef.contains(event.target)\n  ) {\n    isDropdownOpen.value = false;\n  }\n}\n\n// 窗口resize处理\nfunction handleResize() {\n  isMobile.value = window.innerWidth < 768;\n\n  // 如果下拉菜单打开，更新位置\n  if (isDropdownOpen.value) {\n    nextTick(() => {\n      updateDropdownPosition();\n    });\n  }\n}\n\nonMounted(() => {\n  window.addEventListener('resize', handleResize);\n  document.addEventListener('click', handleDocumentClick);\n});\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize);\n  document.removeEventListener('click', handleDocumentClick);\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-sort-box {\n  display: flex;\n  flex-wrap: wrap;\n  padding: 0 var(--list-padding);\n  gap: 8px;\n  position: relative;\n\n  @media screen and (max-width: 768px) {\n    &--mobile-hide {\n      display: none;\n    }\n  }\n\n  // PC端触发元素\n  .sort-select-wrapper {\n    position: relative;\n\n    @media screen and (min-width: 768px) {\n      cursor: pointer;\n    }\n  }\n\n  .sort-select-selected {\n    display: flex;\n    align-items: center;\n    height: 36px;\n    padding: 0 15px;\n    line-height: 1.2;\n    border-radius: 6px;\n    background: rgba(#000, 0.3);\n    transition: all 0.3s linear;\n\n    @media screen and (min-width: 768px) {\n      cursor: pointer;\n    }\n\n    @media screen and (max-width: 768px) {\n      height: 30px;\n      padding: 0 10px;\n      border-radius: 3px;\n      background-color: rgba(#000, 0.8);\n    }\n\n    .sort-select-selected-value {\n      color: #fff;\n      font-weight: bold;\n    }\n\n    .sort-select-selected-icon {\n      margin-left: 8px;\n      color: #fff;\n      display: flex;\n      align-items: center;\n      padding: 2px 4px;\n      border-radius: 3px;\n      transition: all 0.2s linear;\n\n      @media screen and (min-width: 768px) {\n        cursor: pointer;\n\n        &:hover {\n          background: rgba(#fff, 0.1);\n        }\n\n        &:active {\n          background: rgba(#fff, 0.2);\n        }\n      }\n    }\n  }\n\n  // PC端浅色背景样式\n  &--light-background {\n    .sort-select-selected {\n      background: rgba(#000, 0.5);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-sort-dropdown-menu.vue",
    "content": "<template>\n  <div\n    v-show=\"visible\"\n    ref=\"dropdownRef\"\n    class=\"server-sort-select-dropdown\"\n    :class=\"{\n      'server-sort-select-dropdown--light-background': lightBackground,\n      'server-sort-select-dropdown--mobile': isMobile,\n    }\"\n    :style=\"dropdownStyle\"\n  >\n    <div class=\"sort-select-options\">\n      <div\n        v-for=\"item in options\"\n        :key=\"item.value\"\n        class=\"server-sort-item\"\n        :class=\"{\n          active: activeValue === item.value,\n        }\"\n        :title=\"item?.title || false\"\n        @click.stop=\"handleSelect(item, $event)\"\n      >\n        <span class=\"option-label\">{{ item.label }}</span>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref } from 'vue';\n\ndefineProps({\n  visible: {\n    type: Boolean,\n    default: false,\n  },\n  options: {\n    type: Array,\n    default: () => [],\n  },\n  activeValue: {\n    type: String,\n    default: '',\n  },\n  dropdownStyle: {\n    type: Object,\n    default: () => ({}),\n  },\n  lightBackground: {\n    type: Boolean,\n    default: false,\n  },\n  isMobile: {\n    type: Boolean,\n    default: false,\n  },\n});\n\nconst emits = defineEmits(['select']);\n\nconst dropdownRef = ref(null);\n\nfunction handleSelect(item, event) {\n  event.stopPropagation();\n  emits('select', item);\n}\n\ndefineExpose({\n  dropdownRef,\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.server-sort-select-dropdown {\n  z-index: 500;\n  background: rgba(#000, 0.8);\n  border-radius: 6px;\n  padding: 10px;\n  min-width: 150px;\n  max-height: 300px;\n  overflow-y: auto;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n\n  // 小屏居中显示样式\n  &--mobile {\n    min-width: 280px;\n    max-width: 90vw;\n    max-height: 70vh;\n    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);\n  }\n}\n\n.sort-select-options {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.server-sort-item {\n  display: flex;\n  align-items: center;\n  height: 36px;\n  padding: 0 15px;\n  line-height: 1.2;\n  border-radius: 6px;\n  background: rgba(#000, 0.3);\n  transition: all 0.3s linear;\n  cursor: pointer;\n\n  .option-label {\n    color: #fff;\n    font-weight: bold;\n    transition: all 0.3s linear;\n  }\n\n  &:hover {\n    .option-label {\n      color: var(--option-high-color);\n    }\n  }\n\n  &.active {\n    background: var(--option-high-color-active);\n\n    .option-label {\n      color: #fff;\n    }\n  }\n}\n\n// 浅色背景样式\n.server-sort-select-dropdown--light-background {\n  .server-sort-item {\n    background: rgba(#000, 0.5);\n\n    &:hover {\n      background: rgba(#000, 0.8);\n    }\n\n    &.active {\n      background: var(--option-high-color-active);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-status/main.vue",
    "content": "<template>\n  <dot-dot-box\n    v-if=\"tableData\"\n    border-radius=\"6px\"\n    class=\"server-status\"\n  >\n    <table class=\"server-status-table\">\n      <thead class=\"server-status-table-header\">\n        <tr class=\"server-status-table-header-row\">\n          <template\n            v-for=\"column in tableData.columnProps\"\n            :key=\"`th_${column.prop}`\"\n          >\n            <template v-if=\"['billing', 'remainingTime'].includes(column.prop)\">\n              <server-status-th\n                v-if=\"tableData.showBilling && column.prop === 'billing'\"\n                :column=\"column\"\n              />\n              <server-status-th\n                v-if=\"tableData.showRemainingTime && column.prop === 'remainingTime'\"\n                :column=\"column\"\n              />\n            </template>\n            <template v-else>\n              <server-status-th\n                :column=\"column\"\n              />\n            </template>\n          </template>\n        </tr>\n      </thead>\n      <tbody class=\"server-status-table-body\">\n        <tr\n          v-for=\"itemData in tableData.list\"\n          :key=\"itemData.info.ID\"\n          class=\"server-status-table-body-row\"\n          :class=\"{\n            'server-status-table-body-row--offline': itemData.info?.online === -1,\n            'server-status-table-body-row--online': itemData.info?.online === 1,\n            [`server-item--${itemData.info?.ID}`]: true,\n          }\"\n          @click=\"openDetail(itemData.info)\"\n        >\n          <template\n            v-for=\"column in itemData.columnData\"\n            :key=\"`td_${itemData.info?.ID}_${column.prop}`\"\n          >\n            <template v-if=\"['billing', 'remainingTime'].includes(column.prop)\">\n              <server-status-td\n                v-if=\"tableData.showBilling && column.prop === 'billing'\"\n                :column=\"column\"\n              />\n              <server-status-td\n                v-if=\"tableData.showRemainingTime && column.prop === 'remainingTime'\"\n                :column=\"column\"\n              />\n            </template>\n            <template v-else>\n              <server-status-td\n                :column=\"column\"\n              />\n            </template>\n          </template>\n        </tr>\n      </tbody>\n    </table>\n  </dot-dot-box>\n</template>\n\n<script setup>\n/**\n * ServerStatus风格的列表\n */\n\nimport {\n  computed,\n} from 'vue';\nimport {\n  useRouter,\n} from 'vue-router';\n\nimport config from '@/config';\n\nimport {\n  handleServerListColumn,\n} from './server-status';\n\nimport ServerStatusTh from './table/th.vue';\nimport ServerStatusTd from './table/td.vue';\n\nconst props = defineProps({\n  serverList: {\n    type: Array,\n    default: () => [],\n  },\n});\n\nconst router = useRouter();\n\n// eslint-disable-next-line max-len, vue/max-len\nconst DEFAULT_COLUMNS_STR = 'status,name,country,system,config,duration,speeds,transfer,load,cpu,mem,disk,billing,remainingTime';\n\nconst tableData = computed(() => {\n  const columnTpls = config.nazhua.serverStatusColumnsTpl || DEFAULT_COLUMNS_STR;\n  return handleServerListColumn(props.serverList, columnTpls);\n});\n\nfunction openDetail(info) {\n  router.push({\n    name: 'ServerDetail',\n    params: {\n      serverId: info.ID,\n    },\n  });\n}\n\n</script>\n\n<style lang=\"scss\" scoped>\n.server-status {\n  --server-status-cell-padding: 0 5px;\n  --server-status-td-height: 32px;\n\n  --progress-bar-height: 18px;\n}\n.server-status-table {\n  width: 100%;\n  border-collapse: collapse;\n\n  .server-status-table-body-row {\n    @media screen and (min-width: 1025px) {\n      cursor: pointer;\n      background-color: rgba(255, 255, 255, 0);\n      transition: background-color 500ms ease-in-out;\n      &:hover {\n        background-color: rgba(255, 255, 255, 0.1);\n      }\n    }\n    &--offline td:not(.server-status-td--status) {\n      filter: grayscale(1);\n      opacity: 0.75;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-status/server-info/conns.vue",
    "content": "<template>\n  <div\n    v-if=\"!hideConns\"\n    class=\"conn-group\"\n  >\n    <div class=\"conn--tcp\">\n      {{ tcpConnCount }}\n    </div>\n    <div class=\"split-line\">\n      |\n    </div>\n    <div class=\"conn--udp\">\n      {{ udpConnCount }}\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 连接信息\n * 仅当接口/WS 返回了 tcp 或 udp 数据时显示\n */\n\nimport {\n  computed,\n} from 'vue';\n\nconst props = defineProps({\n  realTimeData: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst hideConns = computed(() => {\n  const { item } = props.realTimeData?.conns || {};\n  const tcpVal = item?.data?.tcp?.value;\n  const udpVal = item?.data?.udp?.value;\n  return (tcpVal == null) && (udpVal == null);\n});\n\nconst tcpConnCount = computed(() => {\n  const { item } = props.realTimeData?.conns || {};\n  const { value } = item?.data?.tcp || {};\n  return value || '-';\n});\nconst udpConnCount = computed(() => {\n  const { item } = props.realTimeData?.conns || {};\n  const { value } = item?.data?.udp || {};\n  return value || '-';\n});\n\n</script>\n\n<style lang=\"scss\" scoped>\n.conn-group {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 5px;\n  width: 100%;\n  .conn--tcp {\n    flex: 1;\n    text-align: right;\n    color: var(--conn-tcp-color);\n  }\n\n  .conn--udp {\n    flex: 1;\n    text-align: left;\n    color: var(--conn-udp-color);\n  }\n\n  .split-line {\n    width: 6px;\n    text-align: center;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-status/server-info/country.vue",
    "content": "<template>\n  <div class=\"country-content\">\n    <server-flag :info=\"info\" />\n    <span class=\"country-label\">\n      {{ countryLabel }}\n    </span>\n  </div>\n</template>\n\n<script setup>\n/**\n * 地区信息\n */\n\nimport {\n  computed,\n} from 'vue';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst countryLabel = computed(() => props.info?.Host?.CountryCode?.toUpperCase() || 'UN');\n</script>\n\n<style lang=\"scss\" scoped>\n.country-content {\n  display: flex;\n  align-items: center;\n  gap: 5px;\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-status/server-info/net-speed.vue",
    "content": "<template>\n  <div class=\"net-speed-group\">\n    <div class=\"net-speed--in\">\n      {{ inSpeed }}\n    </div>\n    <div class=\"split-line\">\n      |\n    </div>\n    <div class=\"net-speed--out\">\n      {{ outSpeed }}\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 网速信息\n */\n\nimport {\n  computed,\n} from 'vue';\n\nconst props = defineProps({\n  realTimeData: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst inSpeed = computed(() => {\n  const { item } = props.realTimeData?.speeds || {};\n  if (item?.data?.in) {\n    const { value, unit } = item.data.in;\n    return `${value}${unit}`;\n  }\n  return '-';\n});\nconst outSpeed = computed(() => {\n  const { item } = props.realTimeData?.speeds || {};\n  if (item?.data?.out) {\n    const { value, unit } = item.data.out;\n    return `${value}${unit}`;\n  }\n  return '-';\n});\n\n</script>\n\n<style lang=\"scss\" scoped>\n.net-speed-group {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 5px;\n  width: 100%;\n  .net-speed--in {\n    flex: 1;\n    text-align: right;\n    color: var(--net-speed-in-color);\n  }\n\n  .net-speed--out {\n    flex: 1;\n    text-align: left;\n    color: var(--net-speed-out-color);\n  }\n\n  .split-line {\n    width: 6px;\n    text-align: center;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-status/server-info/status-icon.vue",
    "content": "<template>\n  <div class=\"status-icon-box\">\n    <div\n      class=\"status-icon\"\n      :class=\"{\n        online: info.online === 1,\n        offline: info.online === -1,\n      }\"\n    />\n  </div>\n</template>\n\n<script setup>\n/**\n * 状态图标\n */\ndefineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.status-icon-box {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.status-icon {\n  width: 12px;\n  height: 12px;\n  border-radius: 50%;\n}\n\n.status-icon.online {\n  background-image: linear-gradient(rgba(77, 133, 58, 1) 0, rgba(54, 126, 54, 1) 100%);\n}\n\n.status-icon.offline {\n  background-image: linear-gradient(rgba(155, 37, 34, 1) 0, rgba(161, 38, 35, 1) 100%);\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-status/server-info/system-os.vue",
    "content": "<template>\n  <div class=\"system-os-content\">\n    <span class=\"system-icon\">\n      <span :class=\"platformLogoIconClassName\" />\n    </span>\n    <span class=\"system-label\">\n      {{ systemOSLabel }}\n    </span>\n  </div>\n</template>\n\n<script setup>\n/**\n * 系统信息\n */\n\nimport {\n  computed,\n} from 'vue';\nimport * as hostUtils from '@/utils/host';\n\nconst props = defineProps({\n  info: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst platformLogoIconClassName = computed(() => hostUtils.getPlatformLogoIconClassName(props.info?.Host?.Platform));\nconst systemOSLabel = computed(() => hostUtils.getSystemOSLabel(props.info?.Host?.Platform, true));\n</script>\n\n<style lang=\"scss\" scoped>\n.system-os-content {\n  display: flex;\n  align-items: center;\n  gap: 5px;\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-status/server-info/transfer.vue",
    "content": "<template>\n  <div class=\"transfer-group\">\n    <div class=\"transfer--in\">\n      {{ transferIn }}\n    </div>\n    <div class=\"split-line\">\n      |\n    </div>\n    <div class=\"transfer--out\">\n      {{ transferOut }}\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 流量信息\n */\n\nimport {\n  computed,\n} from 'vue';\n\nconst props = defineProps({\n  realTimeData: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\nconst transferIn = computed(() => {\n  const { item } = props.realTimeData?.transfer || {};\n  if (item?.data?.in) {\n    const { value, unit } = item.data.in;\n    return `${value}${unit}`;\n  }\n  return '-';\n});\nconst transferOut = computed(() => {\n  const { item } = props.realTimeData?.transfer || {};\n  if (item?.data?.out) {\n    const { value, unit } = item.data.out;\n    return `${value}${unit}`;\n  }\n  return '-';\n});\n\n</script>\n\n<style lang=\"scss\" scoped>\n.transfer-group {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 5px;\n  width: 100%;\n  .transfer--in {\n    flex: 1;\n    text-align: right;\n    color: var(--transfer-in-color);\n  }\n\n  .transfer--out {\n    flex: 1;\n    text-align: left;\n    color: var(--transfer-out-color);\n  }\n\n  .split-line {\n    width: 6px;\n    text-align: center;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-status/server-status.js",
    "content": "/**\n * ServerStatus风格的列表列配置\n */\nimport {\n  h,\n} from 'vue';\n\n// import * as hostUtils from '@/utils/host';\nimport handleServerStatus from '@/views/composable/server-status';\nimport handleServerInfo from '@/views/composable/server-info';\nimport handleServerRealTime from '@/views/composable/server-real-time';\nimport handleServerBillAndPlan from '@/views/composable/server-bill-and-plan';\n\nimport ServerStatusProgress from '@/views/components/server/server-status-progress.vue';\nimport StatusIcon from '@/views/components/server-list/server-status/server-info/status-icon.vue';\nimport SystemOS from '@/views/components/server-list/server-status/server-info/system-os.vue';\nimport Country from '@/views/components/server-list/server-status/server-info/country.vue';\nimport NetSpeed from '@/views/components/server-list/server-status/server-info/net-speed.vue';\nimport Transfer from '@/views/components/server-list/server-status/server-info/transfer.vue';\nimport Conns from '@/views/components/server-list/server-status/server-info/conns.vue';\n\nconst COLUMN_MAP = Object.freeze({\n  status: {\n    label: '状态',\n    width: 40,\n  },\n  name: {\n    label: '名称',\n    minWidth: 100,\n    align: 'left',\n  },\n  config: {\n    label: '规格',\n    width: 80,\n    align: 'left',\n  },\n  system: {\n    label: '系统',\n    width: 90,\n    align: 'left',\n  },\n  country: {\n    label: '地区',\n    width: 60,\n    align: 'left',\n  },\n  duration: {\n    label: '在线',\n    width: 60,\n    align: 'left',\n  },\n  load: {\n    label: '负载',\n    width: 45,\n    align: 'center',\n  },\n  speeds: {\n    label: '网速',\n    width: 122,\n    align: 'center',\n  },\n  inSpeed: {\n    label: '入网',\n    width: 60,\n    align: 'left',\n  },\n  outSpeed: {\n    label: '出网',\n    width: 60,\n    align: 'left',\n  },\n  transfer: {\n    label: '流量',\n    width: 122,\n    align: 'center',\n  },\n  inTransfer: {\n    label: '入网流量',\n    width: 60,\n    align: 'left',\n  },\n  outTransfer: {\n    label: '出网流量',\n    width: 60,\n    align: 'left',\n  },\n  conns: {\n    label: '连接',\n    width: 72,\n    align: 'center',\n  },\n  tcp: {\n    label: 'TCP',\n    width: 40,\n    align: 'left',\n  },\n  udp: {\n    label: 'UDP',\n    width: 40,\n    align: 'left',\n  },\n  cpu: {\n    label: 'CPU',\n    width: 80,\n    align: 'center',\n  },\n  cpuText: {\n    valProp: 'cpu',\n    label: 'CPU',\n    width: 40,\n    align: 'center',\n  },\n  mem: {\n    label: '内存',\n    width: 80,\n    align: 'center',\n  },\n  memText: {\n    valProp: 'mem',\n    label: '内存',\n    width: 40,\n    align: 'center',\n  },\n  swap: {\n    label: '交换',\n    width: 80,\n    align: 'center',\n  },\n  swapText: {\n    valProp: 'swap',\n    label: '交换',\n    width: 40,\n    align: 'center',\n  },\n  disk: {\n    label: '硬盘',\n    width: 80,\n    align: 'center',\n  },\n  diskText: {\n    valProp: 'disk',\n    label: '硬盘',\n    width: 40,\n    align: 'center',\n  },\n  billing: {\n    label: '价格',\n    width: 100,\n    align: 'right',\n  },\n  remainingTime: {\n    label: '剩余',\n    width: 70,\n    align: 'right',\n  },\n});\n\n/**\n * 默认列配置\n */\n// eslint-disable-next-line max-len, vue/max-len\nconst DEFAULT_COLUMNS = 'status,name,country,system,config,duration,speeds,transfer,load,cpu,mem,disk,billing,remainingTime';\n\n/**\n * 需要实时更新的数据\n */\nconst RELD_TIME_DATA = [\n  'speeds', 'inSpeed', 'outSpeed',\n  'transfer', 'inTransfer', 'outTransfer',\n  'conns', 'tcp', 'udp',\n  'duration', 'load',\n];\n\n/**\n * 获取列配置\n * @param {string} columnsTpls 列配置模板\n * @returns {Object} 列配置\n * @property {Array} columns 列配置\n */\nexport const getColumnPropsConfig = (tpls = DEFAULT_COLUMNS) => {\n  const tplList = tpls.split(',');\n  const columnList = [];\n  tplList.forEach((tpl) => {\n    if (COLUMN_MAP[tpl]) {\n      columnList.push({\n        prop: tpl,\n        ...COLUMN_MAP[tpl],\n      });\n    }\n  });\n  return columnList;\n};\n\n/**\n * 将服务器数据转换为表格数据\n * @param {Object} server 服务器数据\n * @returns {Object} 表格数据\n */\nexport const handleServerItemData = (params) => {\n  const {\n    column,\n    server,\n    realTimeData,\n    progressData,\n    billAndPlan,\n  } = params || {};\n  switch (column.prop) {\n    case 'status':\n      return {\n        type: 'component',\n        component: h(StatusIcon, { info: server }),\n        originalData: params,\n      };\n    case 'name':\n      return {\n        type: 'text',\n        value: server.Name,\n        originalData: params,\n      };\n    case 'config':\n    {\n      const { cpuAndMemAndDisk } = handleServerInfo({\n        props: {\n          info: server,\n        },\n        originalData: params,\n      });\n      return {\n        type: 'text',\n        value: cpuAndMemAndDisk,\n        originalData: params,\n      };\n    }\n    case 'system':\n      return {\n        type: 'component',\n        component: h(SystemOS, { info: server }),\n        originalData: params,\n      };\n    case 'country':\n      return {\n        type: 'component',\n        component: h(Country, { info: server }),\n        originalData: params,\n      };\n    case 'speeds':\n      return {\n        type: 'component',\n        component: h(NetSpeed, { realTimeData }),\n        originalData: params,\n      };\n    case 'transfer':\n      return {\n        type: 'component',\n        component: h(Transfer, { realTimeData }),\n        originalData: params,\n      };\n    case 'conns':\n      return {\n        type: 'component',\n        component: h(Conns, { realTimeData }),\n        originalData: params,\n      };\n    case 'cpu':\n    case 'mem':\n    case 'disk':\n    case 'swap':\n    {\n      const progressItem = progressData[column.prop];\n      return {\n        type: 'component',\n        component: h(ServerStatusProgress, {\n          type: column.prop,\n          used: progressItem?.used || 0,\n          colors: progressItem?.colors || {},\n          valText: progressItem?.valPercent || '',\n        }),\n        originalData: params,\n      };\n    }\n    case 'cpuText':\n    case 'memText':\n    case 'diskText':\n    case 'swapText':\n    {\n      const progressItem = progressData[column.valProp];\n      return {\n        prop: column.prop,\n        type: 'text',\n        value: parseFloat(progressItem?.used || 0).toFixed(1),\n        unit: '%',\n        text: progressItem?.valPercent || '',\n        originalData: params,\n      };\n    }\n    case 'billing':\n    {\n      const item = billAndPlan?.value?.billing;\n      const texts = [];\n      if (item?.value) {\n        texts.push(item.value || '-');\n      }\n      if (item?.cycleLabel) {\n        texts.push(item.cycleLabel);\n      }\n      return {\n        prop: column.prop,\n        type: 'text',\n        text: texts.length ? texts.join('/') : '-',\n        originalData: params,\n      };\n    }\n    case 'remainingTime':\n    {\n      const item = billAndPlan?.value?.remainingTime;\n      return {\n        prop: column.prop,\n        type: 'text',\n        text: item?.value || '-',\n        originalData: params,\n      };\n    }\n    default: {\n      if (RELD_TIME_DATA.includes(column.prop) && realTimeData[column.prop]) {\n        const item = realTimeData[column.prop];\n        return {\n          prop: column.prop,\n          type: 'text',\n          text: item?.text,\n          value: item?.value,\n          unit: item?.unit,\n          originalData: params,\n        };\n      }\n      return {\n        prop: column.prop,\n        type: 'text',\n        value: '-',\n        originalData: params,\n      };\n    }\n  }\n};\n\n/**\n * 将服务器数据转换为表格数据\n * @param {Object} server 服务器数据\n * @param {Array} columns 列配置\n * @returns {Array} 表格数据\n */\nexport const handleServerListColumn = (serverList, columnTpls = DEFAULT_COLUMNS) => {\n  const columnProps = getColumnPropsConfig(columnTpls);\n  const tpls = columnProps.map((column) => column.valProp || column.prop).join(',');\n  const hasBilling = columnTpls.includes('billing');\n  const hasRemainingTime = columnTpls.includes('remainingTime');\n  let showBilling = false;\n  let showRemainingTime = false;\n  const list = serverList.map((server) => {\n    // 负载\\网速\\流量\\在线等\n    const realTimeResult = handleServerRealTime({\n      props: {\n        info: server,\n      },\n      serverRealTimeListTpls: tpls,\n    });\n    const realTimeData = {};\n    realTimeResult?.serverRealTimeList?.value?.forEach?.((item) => {\n      if (item.show) {\n        const text = [item.value];\n        if (item.unit) {\n          text.push(item.unit);\n        }\n        realTimeData[item.key] = {\n          value: item.value,\n          unit: item.unit,\n          text: text.join(''),\n          item,\n        };\n      } else {\n        realTimeData[item.key] = {\n          text: '-',\n          item,\n        };\n      }\n    });\n    // CPU\\内存\\硬盘\\交换 进度条\n    const {\n      serverStatusList,\n    } = handleServerStatus({\n      props: {\n        info: server,\n      },\n      statusListTpl: tpls,\n      statusListItemContent: false,\n    });\n    const progressData = {};\n    serverStatusList.value?.forEach?.((item) => {\n      progressData[item.type] = item;\n    });\n    let billAndPlan = null;\n    if (hasBilling || hasRemainingTime) {\n      const result = handleServerBillAndPlan({\n        props: {\n          info: server,\n        },\n      });\n      billAndPlan = result.billAndPlan;\n      if (billAndPlan?.value?.billing) {\n        showBilling = true;\n      }\n      if (billAndPlan?.value?.remainingTime) {\n        showRemainingTime = true;\n      }\n    }\n\n    const columnData = [];\n    columnProps.forEach((columnItem) => {\n      columnData.push({\n        ...columnItem,\n        data: handleServerItemData({\n          column: columnItem,\n          server,\n          realTimeData,\n          progressData,\n          billAndPlan,\n        }),\n      });\n    });\n\n    return {\n      info: server,\n      columnData,\n      computedData: {\n        realTimeData,\n        progressData,\n        billAndPlan,\n      },\n    };\n  });\n  return {\n    list,\n    columnProps,\n    showBilling,\n    showRemainingTime,\n  };\n};\n"
  },
  {
    "path": "src/views/components/server-list/server-status/table/td.vue",
    "content": "<template>\n  <td\n    class=\"server-status-td server-status-body-td\"\n    :class=\"columnClass\"\n    :style=\"columnStyle\"\n  >\n    <div\n      class=\"server-status-td-content\"\n      :class=\"'server-status-td-content--' + tdContent.prop\"\n    >\n      <template\n        v-if=\"tdContent.type === 'text'\"\n      >\n        <span\n          v-if=\"isSet(tdContent.value)\"\n          class=\"text--value\"\n        >\n          {{ tdContent.value }}\n        </span>\n        <span\n          v-if=\"isSet(tdContent.unit)\"\n          class=\"text--unit\"\n        >\n          {{ tdContent.unit }}\n        </span>\n        <span\n          v-if=\"!isSet(tdContent.value) && isSet(tdContent.text)\"\n          class=\"text\"\n        >\n          {{ tdContent.text }}\n        </span>\n      </template>\n      <template\n        v-if=\"tdContent.type === 'component'\"\n      >\n        <component :is=\"tdContent.component\" />\n      </template>\n    </div>\n  </td>\n</template>\n\n<script setup>\n/**\n * 自定义TD组件\n */\n\nimport {\n  computed,\n} from 'vue';\n\nconst props = defineProps({\n  column: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\n// 计算css的长度单位\nconst getCssLengthUnit = (value) => {\n  if (typeof value === 'number') {\n    return `${value}px`;\n  }\n  return value;\n};\n\nconst columnClass = computed(() => {\n  const className = {\n    [`server-status-td--${props.column.prop}`]: true,\n  };\n  if (props.column.align) {\n    className[`server-status-td--align-${props.column.align}`] = true;\n  }\n  return className;\n});\n\nconst columnStyle = computed(() => {\n  const style = {};\n  if (props.column.width) {\n    style.width = getCssLengthUnit(props.column.width);\n  }\n  if (props.column.minWidth) {\n    style.minWidth = getCssLengthUnit(props.column.minWidth);\n  }\n  return style;\n});\n\nconst tdContent = computed(() => {\n  if (['text', 'component'].includes(props.column.data.type)) {\n    return props.column.data;\n  }\n  return '';\n});\n\nfunction isSet(value) {\n  return value !== undefined && value !== null && value !== '';\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.server-status-td {\n  height: var(--server-status-td-height);\n  padding: var(--server-status-cell-padding);\n\n  --td-content-justify-content: center;\n\n  &--align-center {\n    --td-content-justify-content: center;\n  }\n  &--align-right {\n    --td-content-justify-content: flex-end;\n  }\n  &--align-left {\n    --td-content-justify-content: flex-start;\n  }\n\n  .server-status-td-content {\n    display: flex;\n    align-items: center;\n    justify-content: var(--td-content-justify-content);\n    width: 100%;\n    line-height: var(--server-status-td-height);\n\n    &--transfer {\n      .text--value {\n        color: var(--transfer-color);\n      }\n    }\n\n    &--inTransfer {\n      .text--value {\n        color: var(--transfer-in-color);\n      }\n    }\n    &--outTransfer {\n      .text--value {\n        color: var(--transfer-out-color);\n      }\n    }\n\n    &--inSpeed {\n      .text--value {\n        color: var(--net-speed-in-color);\n      }\n    }\n\n    &--outSpeed {\n      .text--value {\n        color: var(--net-speed-out-color);\n      }\n    }\n\n    &--tcp {\n      .text--value {\n        color: var(--conn-tcp-color);\n      }\n    }\n\n    &--udp {\n      .text--value {\n        color: var(--conn-udp-color);\n      }\n    }\n\n    &--load {\n      .text--value {\n        color: var(--load-color);\n      }\n    }\n\n    &--duration {\n      .text--value {\n        color: var(--duration-color);\n      }\n    }\n\n    &--cpuText {\n      .text--value {\n        color: var(--cpu-text-color);\n      }\n    }\n\n    &--memText {\n      .text--value {\n        color: var(--mem-text-color);\n      }\n    }\n\n    &--swapText {\n      .text--value {\n        color: var(--swap-text-color);\n      }\n    }\n\n    &--diskText {\n      .text--value {\n        color: var(--disk-text-color);\n      }\n    }\n\n    &--billing {\n      font-size: 12px;\n    }\n\n    &--remainingTime {\n      font-size: 12px;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/components/server-list/server-status/table/th.vue",
    "content": "<template>\n  <th\n    class=\"server-status-th\"\n    :class=\"columnClass\"\n    :style=\"columnStyle\"\n  >\n    {{ column.label }}\n  </th>\n</template>\n\n<script setup>\n/**\n * 自定义TH组件\n */\n\nimport {\n  computed,\n} from 'vue';\n\nconst props = defineProps({\n  column: {\n    type: Object,\n    default: () => ({}),\n  },\n});\n\n// 计算css的长度单位\nconst getCssLengthUnit = (value) => {\n  if (typeof value === 'number') {\n    return `${value}px`;\n  }\n  return value;\n};\n\nconst columnClass = computed(() => {\n  const className = {};\n  if (props.column.align) {\n    className[`server-status-th--align-${props.column.align}`] = true;\n  }\n  return className;\n});\n\nconst columnStyle = computed(() => {\n  const style = {};\n  if (props.column.width) {\n    style.width = getCssLengthUnit(props.column.width);\n  }\n  if (props.column.minWidth) {\n    style.minWidth = getCssLengthUnit(props.column.minWidth);\n  }\n  return style;\n});\n\n</script>\n\n<style lang=\"scss\" scoped>\n.server-status-th {\n  padding: var(--server-status-cell-padding);\n\n  text-align: center;\n\n  &--align-center {\n    text-align: center;\n  }\n  &--align-right {\n    text-align: right;\n  }\n  &--align-left {\n    text-align: left;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/composable/server-bill-and-plan.js",
    "content": "import {\n  computed,\n} from 'vue';\nimport dayjs from 'dayjs';\nimport config from '@/config';\nimport validate from '@/utils/validate';\nimport * as dateUtils from '@/utils/date';\n\nexport default (params) => {\n  const {\n    props,\n  } = params || {};\n  /**\n   * 账单和计划\n   */\n  const billAndPlan = computed(() => {\n    const obj = {\n      billing: null,\n      remainingTime: null,\n      bandwidth: null,\n      traffic: null,\n    };\n    if (props?.info?.PublicNote) {\n      const {\n        billingDataMod,\n        planDataMod,\n      } = props.info.PublicNote;\n      // 默认1个月\n      let months = 1;\n      // 套餐资费\n      let cycleLabel;\n      if (validate.isSet(billingDataMod?.cycle)) {\n        switch (billingDataMod.cycle.toLowerCase()) {\n          case '月':\n          case 'm':\n          case 'mo':\n          case 'month':\n          case 'monthly':\n            cycleLabel = '月';\n            months = 1;\n            break;\n          case '年':\n          case 'y':\n          case 'yr':\n          case 'year':\n          case 'annual':\n            cycleLabel = '年';\n            months = 12;\n            break;\n          case '季':\n          case 'quarterly':\n            cycleLabel = '季';\n            months = 3;\n            break;\n          case '半':\n          case '半年':\n          case 'h':\n          case 'half':\n          case 'semi-annually':\n            cycleLabel = '半年';\n            months = 6;\n            break;\n          default:\n            cycleLabel = billingDataMod.cycle;\n            break;\n        }\n      }\n      if (validate.isSet(billingDataMod?.amount)) {\n        let isFree = false;\n        let amountValue = billingDataMod.amount;\n        let label;\n        if (billingDataMod.amount.toString() === '-1') {\n          amountValue = '按量';\n          label = cycleLabel ? `每${cycleLabel}` : '';\n        } else if (billingDataMod.amount.toString() === '0') {\n          amountValue = config.nazhua.freeAmount || '免费';\n          isFree = true;\n        } else {\n          label = cycleLabel ? `${cycleLabel}付` : '';\n        }\n        obj.billing = {\n          label,\n          value: amountValue,\n          cycleLabel,\n          months,\n          isFree,\n        };\n      }\n      // 剩余时间\n      if (validate.isSet(billingDataMod?.endDate)) {\n        const {\n          endDate,\n          autoRenewal,\n        } = billingDataMod;\n        const nowTime = new Date().getTime();\n        const endTime = dayjs(endDate).valueOf();\n        if (endDate.indexOf('0000-00-00') === 0) {\n          obj.remainingTime = {\n            label: '剩余',\n            value: config.nazhua.infinityCycle || '长期有效',\n            type: 'infinity',\n          };\n        } else if (autoRenewal === '1') {\n          // 自动续费时间计算，cycleType 为 1 时为月，为 12 时为年\n          // 判断endDate是否超过当前时间，超过则显示剩余时间\n          if (endTime > nowTime) {\n            const diff = dayjs(endTime).diff(dayjs(), 'day') + 1;\n            obj.remainingTime = {\n              label: '剩余',\n              value: `${diff}天`,\n              value2: diff,\n              type: 'autoRenewal-endTime',\n            };\n          } else {\n            // endDate如果早于当前时间，按照cycleType计算出超过当前时间的结束时间\n            const nextTime = dateUtils.getNextCycleTime(endTime, months, nowTime);\n            const diff = dayjs(nextTime).diff(dayjs(), 'day') + 1;\n            obj.remainingTime = {\n              label: '剩余',\n              value: `${diff}天`,\n              value2: diff,\n              type: 'autoRenewal-nextTime',\n            };\n          }\n        } else if (endTime > nowTime) {\n          const diff = dayjs(endTime).diff(dayjs(), 'day') + 1;\n          obj.remainingTime = {\n            label: '剩余',\n            value: `${diff}天`,\n            value2: diff,\n            type: 'endTime',\n          };\n        } else {\n          obj.remainingTime = {\n            label: '剩余',\n            value: '已过期',\n            type: 'expired',\n          };\n        }\n      }\n      // 带宽、流量\n      if (planDataMod) {\n        if (planDataMod.bandwidth) {\n          obj.bandwidth = {\n            label: '带宽',\n            value: planDataMod.bandwidth,\n          };\n        }\n        if (planDataMod.trafficVol) {\n          let trafficTypeLabel = '双向';\n          if (planDataMod.trafficType === '1') {\n            trafficTypeLabel = '单向出';\n          } else if (planDataMod.trafficType === '3') {\n            trafficTypeLabel = '单向取最大';\n          }\n          obj.traffic = {\n            label: `${trafficTypeLabel}流量`,\n            value: planDataMod.trafficVol,\n          };\n        }\n      }\n    }\n    return obj;\n  });\n\n  return {\n    billAndPlan,\n  };\n};\n"
  },
  {
    "path": "src/views/composable/server-info.js",
    "content": "import {\n  computed,\n} from 'vue';\nimport * as hostUtils from '@/utils/host';\n\nexport default (params) => {\n  const {\n    props,\n  } = params || {};\n  const cpuAndMemAndDisk = computed(() => {\n    let cpuInfo;\n    let memInfo;\n    let distInfo;\n    if (props.info?.Host?.CPU?.[0]) {\n      cpuInfo = hostUtils.getCPUInfo(props.info.Host.CPU[0]);\n    }\n    if (props.info?.Host?.MemTotal) {\n      memInfo = hostUtils.calcBinary(props.info.Host.MemTotal);\n    }\n    if (props.info?.Host?.DiskTotal) {\n      distInfo = hostUtils.calcBinary(props.info.Host.DiskTotal);\n    }\n    const text = [];\n    if (cpuInfo) {\n      text.push(`${cpuInfo.cores}C`);\n    }\n    if (memInfo) {\n      if (memInfo.m > 900) {\n        text.push(`${Math.round(memInfo.g)}G`);\n      } else {\n        text.push(`${(memInfo.g).toFixed(1) * 1}G`);\n      }\n    }\n    if (distInfo) {\n      if (distInfo.g > 900) {\n        text.push(`${Math.round(distInfo.t)}T`);\n      } else {\n        text.push(`${Math.ceil(distInfo.g)}G`);\n      }\n    }\n    return text.join('');\n  });\n\n  return {\n    cpuAndMemAndDisk,\n  };\n};\n"
  },
  {
    "path": "src/views/composable/server-monitor.js",
    "content": "import uniqolor from 'uniqolor';\n\n/**\n * 计算数据的统计信息，使用截尾中位数作为基准值\n * 根据平均延迟的不同范围，使用不同的容差百分比进行削峰\n *\n * @param {number[]} data - 要计算的数据数组\n * @returns {{median: number, tolerancePercent: number, min: number, max: number}}\n *          返回包含统计信息的对象\n * @property {number} median - 截尾中位数(去掉极端值后的中位数)\n * @property {number} tolerancePercent - 根据中位数计算的容差百分比\n * @property {number} min - 最小值\n * @property {number} max - 最大值\n */\nexport function getThreshold(data) {\n  // 过滤掉null和0的数据，只对有效延迟值计算统计量\n  const filteredData = data.filter((value) => value !== 0 && value !== null);\n\n  if (filteredData.length === 0) {\n    return {\n      median: 0,\n      tolerancePercent: 0.2,\n      min: 0,\n      max: 0,\n    };\n  }\n\n  // 排序数据\n  const sortedData = [...filteredData].sort((a, b) => Math.ceil(a) - Math.ceil(b));\n  const len = sortedData.length;\n\n  // 计算需要裁剪的数量（10%）\n  const trimCount = Math.floor(len * 0.1);\n\n  // 用于计算中位数的数据：如果10%的数量>=1，则去掉最大和最小的10%\n  let dataForMedian;\n  if (trimCount >= 1) {\n    // 截尾：去掉最小的10%和最大的10%\n    dataForMedian = sortedData.slice(trimCount, len - trimCount);\n  } else {\n    // 数据量太少，不裁剪\n    dataForMedian = sortedData;\n  }\n\n  // 计算截尾中位数\n  const medianLen = dataForMedian.length;\n  const median = medianLen % 2 === 0\n    ? (dataForMedian[medianLen / 2 - 1] + dataForMedian[medianLen / 2]) / 2\n    : dataForMedian[Math.floor(medianLen / 2)];\n\n  // 根据中位数确定容差百分比，延迟越小容差越大\n  let tolerancePercent;\n  if (median <= 10) {\n    tolerancePercent = 0.50; // 50%\n  } else if (median <= 30) {\n    tolerancePercent = 0.35; // 35%\n  } else if (median <= 50) {\n    tolerancePercent = 0.25; // 25%\n  } else if (median <= 100) {\n    tolerancePercent = 0.20; // 20%\n  } else {\n    tolerancePercent = 0.15; // 15%\n  }\n\n  const min = sortedData[0];\n  const max = sortedData[len - 1];\n\n  // console.log(min, max, median, sortedData);\n\n  return {\n    median,\n    tolerancePercent,\n    min,\n    max,\n  };\n}\n\n/**\n * - 处理相对固定折线的颜色\n */\nconst lineColorMap = {};\nconst lineColors = [];\nconst defaultColors = [\n  '#5470C6', '#91CC75', '#FAC858', '#EE6666',\n  '#73C0DE', '#3BA272', '#FC8452', '#9A60B4',\n  '#EA7CCC', '#C23531', '#2F4554', '#61A0A8',\n  '#D48265', '#91C7AE', '#749F83', '#CA8622',\n  '#BDA29A', '#6E7074', '#546570', '#C4CCD3',\n];\n\n/**\n * 将十六进制颜色转换为 RGB 数组\n * @param {string} hex - 十六进制颜色字符串\n * @returns {number[]} 返回包含 RGB 数组的对象\n */\nfunction hexToRgb(hex) {\n  // 去掉可能的前缀 \"#\"\n  hex = hex.replace(/^#/, '');\n  // 将字符串拆分为 r, g, b 三个部分\n  const bigint = parseInt(hex, 16);\n  const r = Math.floor(bigint / (256 * 256)) % 256;\n  const g = Math.floor(bigint / 256) % 256;\n  const b = bigint % 256;\n  return [r, g, b];\n}\n\n/**\n * 计算两个 RGB 颜色之间的距离\n * @param {number[]} color1 - 第一个颜色的 RGB 数组\n * @param {number[]} color2 - 第二个颜色的 RGB 数组\n * @returns {number} 返回两个颜色之间的距离\n */\nfunction rgbDistance(color1, color2) {\n  const [r1, g1, b1] = color1;\n  const [r2, g2, b2] = color2;\n  return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2);\n}\n\n/**\n * 获取一个随机颜色\n * @returns {string} 返回一个随机颜色的字符串\n */\nfunction getColor(count = 0, len = 0) {\n  // 如果尝试次数超过 3 次，返回固定颜色组里面的颜色\n  if (count > 3) {\n    return defaultColors[len % defaultColors.length];\n  }\n  const { color } = uniqolor.random({\n    saturation: [75, 90],\n    lightness: [65, 70],\n    differencePoint: 100,\n  });\n  if (lineColors.includes(color)) {\n    return getColor(count + 1, len);\n  }\n  if (lineColors.some((i) => rgbDistance(\n    hexToRgb(i),\n    hexToRgb(color),\n  ) < 50)) {\n    return getColor(count + 1, len);\n  }\n  return color;\n}\n\n/**\n * 获取线的颜色\n * @param {string} name - 线的名称\n * @returns {string} 返回线的颜色\n */\nexport function getLineColor(name) {\n  // 如果已经有了对应的颜色，直接返回\n  if (lineColorMap[name]) {\n    return lineColorMap[name];\n  }\n  const color = getColor(0, lineColors.length);\n  lineColorMap[name] = color;\n  lineColors.push(color);\n  return color;\n}\n"
  },
  {
    "path": "src/views/composable/server-real-time.js",
    "content": "import {\n  computed,\n} from 'vue';\nimport dayjs from 'dayjs';\nimport validate from '@/utils/validate';\nimport * as dateUtils from '@/utils/date';\nimport * as hostUtils from '@/utils/host';\n\nexport default (params) => {\n  const {\n    props,\n    currentTime,\n    serverRealTimeListTpls = 'duration,transfer,inSpeed,outSpeed',\n  } = params || {};\n  if (!props?.info) {\n    return {};\n  }\n  /**\n   * 计算在线时长\n   */\n  const duration = computed(() => {\n    if (props.info?.Host?.BootTime) {\n      const lastActive = dayjs(props.info.LastActive)?.valueOf?.();\n      const data = dateUtils.duration2(props.info.Host.BootTime * 1000, lastActive || currentTime.value);\n      if (data.days > 0) {\n        return {\n          value: data.days,\n          unit: data.$unit.day,\n        };\n      }\n      if (data.hours > 0) {\n        return {\n          value: data.hours,\n          unit: data.$unit.hour,\n        };\n      }\n      if (data.minutes > 0) {\n        return {\n          value: data.minutes,\n          unit: data.$unit.minute,\n        };\n      }\n      return {\n        value: data.seconds,\n        unit: data.$unit.second,\n      };\n    }\n    return null;\n  });\n\n  /**\n   * 计算流量\n   */\n  const transfer = computed(() => {\n    const stats = {\n      in: null,\n      out: null,\n      total: null,\n    };\n    let total = 0;\n    if (props.info?.State?.NetInTransfer) {\n      total += props.info.State.NetInTransfer;\n      stats.in = hostUtils.calcBinary(props.info.State.NetInTransfer);\n    }\n    if (props.info?.State?.NetOutTransfer) {\n      total += props.info.State.NetOutTransfer;\n      stats.out = hostUtils.calcBinary(props.info.State.NetOutTransfer);\n    }\n    stats.total = hostUtils.calcBinary(total);\n\n    const result = {\n      value: 0,\n      unit: '',\n      statType: '',\n      statTypeLabel: '',\n      stats,\n    };\n\n    let ruleStat;\n    ruleStat = total;\n    result.statType = 'Total';\n    result.statTypeLabel = '双向';\n    if (props.info?.PublicNote && validate.isSet(props.info.PublicNote?.planDataMod?.trafficType)) {\n      const {\n        trafficType = 2,\n      } = props.info.PublicNote.planDataMod;\n      switch (+trafficType) {\n        case 1:\n          ruleStat = props.info.State.NetOutTransfer;\n          result.statType = 'Out';\n          result.statTypeLabel = '单向出';\n          break;\n        case 3:\n          if (props.info?.State?.NetOutTransfer >= props.info?.State?.NetInTransfer) {\n            ruleStat = props.info.State.NetOutTransfer;\n            result.statType = 'MaxOut';\n            result.statTypeLabel = '最大出';\n          } else if (props.info?.State?.NetOutTransfer < props.info?.State?.NetInTransfer) {\n            ruleStat = props.info.State.NetInTransfer;\n            result.statType = 'MaxIn';\n            result.statTypeLabel = '最大入';\n          }\n          break;\n        default:\n      }\n    }\n\n    const ruleStats = hostUtils.calcBinary(ruleStat);\n    if (ruleStats.t > 1) {\n      result.value = (ruleStats.t).toFixed(2) * 1;\n      result.unit = 'T';\n    } else if (ruleStats.g > 1) {\n      result.value = (ruleStats.g).toFixed(2) * 1;\n      result.unit = 'G';\n    } else if (ruleStats.m > 1) {\n      result.value = (ruleStats.m).toFixed(1) * 1;\n      result.unit = 'M';\n    } else {\n      result.value = (ruleStats.k).toFixed(1) * 1;\n      result.unit = 'K';\n    }\n    return result;\n  });\n\n  const inTransfer = computed(() => {\n    const inStats = hostUtils.calcBinary(props.info?.State?.NetInTransfer || 0);\n    const result = {\n      value: 0,\n      unit: '',\n    };\n    if (inStats.p > 1) {\n      result.value = (inStats.p).toFixed(1) * 1;\n      result.unit = 'P';\n    } else if (inStats.t > 1) {\n      result.value = (inStats.t).toFixed(1) * 1;\n      result.unit = 'T';\n    } else if (inStats.g > 1) {\n      result.value = (inStats.g).toFixed(1) * 1;\n      result.unit = 'G';\n    } else if (inStats.m > 1) {\n      result.value = (inStats.m).toFixed(1) * 1;\n      result.unit = 'M';\n    } else {\n      result.value = (inStats.k).toFixed(1) * 1;\n      result.unit = 'K';\n    }\n    return result;\n  });\n\n  const outTransfer = computed(() => {\n    const outStats = hostUtils.calcBinary(props.info?.State?.NetOutTransfer || 0);\n    const result = {\n      value: 0,\n      unit: '',\n    };\n    if (outStats.p > 1) {\n      result.value = (outStats.p).toFixed(1) * 1;\n      result.unit = 'P';\n    } else if (outStats.t > 1) {\n      result.value = (outStats.t).toFixed(1) * 1;\n      result.unit = 'T';\n    } else if (outStats.g > 1) {\n      result.value = (outStats.g).toFixed(1) * 1;\n      result.unit = 'G';\n    } else if (outStats.m > 1) {\n      result.value = (outStats.m).toFixed(1) * 1;\n      result.unit = 'M';\n    } else {\n      result.value = (outStats.k).toFixed(1) * 1;\n      result.unit = 'K';\n    }\n    return result;\n  });\n\n  /**\n   * 计算入向网速\n   */\n  const netInSpeed = computed(() => {\n    const inSpeed = hostUtils.calcBinary(props.info?.State?.NetInSpeed || 0);\n    const result = {\n      value: 0,\n      unit: '',\n    };\n    if (inSpeed.g > 1) {\n      result.value = (inSpeed.g).toFixed(1) * 1;\n      result.unit = 'G';\n    } else if (inSpeed.m > 1) {\n      result.value = (inSpeed.m).toFixed(1) * 1;\n      result.unit = 'M';\n    } else {\n      result.value = (inSpeed.k).toFixed(1) * 1;\n      result.unit = 'K';\n    }\n    return result;\n  });\n\n  /**\n   * 计算出向网速\n   */\n  const netOutSpeed = computed(() => {\n    const outSpeed = hostUtils.calcBinary(props.info?.State?.NetOutSpeed || 0);\n    const result = {\n      value: 0,\n      unit: '',\n    };\n    if (outSpeed.g > 1) {\n      result.value = (outSpeed.g).toFixed(1) * 1;\n      result.unit = 'G';\n    } else if (outSpeed.m > 1) {\n      result.value = (outSpeed.m).toFixed(1) * 1;\n      result.unit = 'M';\n    } else {\n      result.value = (outSpeed.k).toFixed(1) * 1;\n      result.unit = 'K';\n    }\n    return result;\n  });\n\n  const serverRealTimeList = computed(() => serverRealTimeListTpls.split(',').map((key) => {\n    switch (key) {\n      case 'duration':\n        return {\n          key,\n          label: '在线',\n          value: duration.value?.value,\n          unit: duration.value?.unit,\n          show: validate.isSet(duration.value?.value),\n        };\n      case 'transfer':\n        return {\n          key,\n          label: `${transfer.value.statTypeLabel}流量`,\n          value: transfer.value?.value,\n          unit: transfer.value?.unit,\n          show: validate.isSet(transfer.value?.value),\n          data: {\n            in: {\n              value: inTransfer.value?.value,\n              unit: inTransfer.value?.unit,\n              show: validate.isSet(inTransfer.value?.value),\n            },\n            out: {\n              value: outTransfer.value?.value,\n              unit: outTransfer.value?.unit,\n              show: validate.isSet(outTransfer.value?.value),\n            },\n          },\n        };\n      case 'inTransfer':\n        return {\n          key,\n          label: '入网流量',\n          value: inTransfer.value?.value,\n          unit: inTransfer.value?.unit,\n          show: validate.isSet(inTransfer.value?.value),\n        };\n      case 'outTransfer':\n        return {\n          key,\n          label: '出网流量',\n          value: outTransfer.value?.value,\n          unit: outTransfer.value?.unit,\n          show: validate.isSet(outTransfer.value?.value),\n        };\n      case 'inSpeed':\n        return {\n          key,\n          label: '入网',\n          value: netInSpeed.value?.value,\n          unit: netInSpeed.value?.unit,\n          show: validate.isSet(netInSpeed.value?.value),\n        };\n      case 'outSpeed':\n        return {\n          key,\n          label: '出网',\n          value: netOutSpeed.value?.value,\n          unit: netOutSpeed.value?.unit,\n          show: validate.isSet(netOutSpeed.value?.value),\n        };\n      case 'speeds':\n        return {\n          key,\n          label: '网速',\n          value: [\n            `${netInSpeed.value?.value}${netInSpeed.value?.unit}`,\n            `${netOutSpeed.value?.value}${netOutSpeed.value?.unit}`,\n          ].join('|'),\n          show: validate.isSet(netInSpeed.value?.value) && validate.isSet(netOutSpeed.value?.value),\n          data: {\n            in: {\n              value: netInSpeed.value?.value,\n              unit: netInSpeed.value?.unit,\n              show: validate.isSet(netInSpeed.value?.value),\n            },\n            out: {\n              value: netOutSpeed.value?.value,\n              unit: netOutSpeed.value?.unit,\n              show: validate.isSet(netOutSpeed.value?.value),\n            },\n          },\n        };\n      case 'load':\n        return {\n          key,\n          label: '负载',\n          value: (props.info.State?.Load1 || 0).toFixed(2),\n          show: validate.isSet(props.info.State?.Load1),\n        };\n      case 'loads':\n      {\n        const loads = [];\n        loads.push((props.info.State?.Load1 || 0).toFixed(2));\n        loads.push((props.info.State?.Load5 || 0).toFixed(2));\n        loads.push((props.info.State?.Load15 || 0).toFixed(2));\n        return {\n          key,\n          label: '负载',\n          value: loads.join(','),\n          show: loads.some((load) => validate.isSet(load)),\n          data: {\n            load1: {\n              value: (props.info.State?.Load1 || 0).toFixed(2),\n              show: validate.isSet(props.info.State?.Load1),\n            },\n            load5: {\n              value: (props.info.State?.Load5 || 0).toFixed(2),\n              show: validate.isSet(props.info.State?.Load5),\n            },\n            load15: {\n              value: (props.info.State?.Load15 || 0).toFixed(2),\n              show: validate.isSet(props.info.State?.Load15),\n            },\n          },\n        };\n      }\n      case 'conns':\n        return {\n          key,\n          label: '连接',\n          value: `${props.info.State?.TcpConnCount || 0}|${props.info.State?.UdpConnCount || 0}`,\n          show: validate.isSet(props.info.State?.TcpConnCount) || validate.isSet(props.info.State?.UdpConnCount),\n          data: {\n            tcp: {\n              value: props.info.State?.TcpConnCount || 0,\n              show: validate.isSet(props.info.State?.TcpConnCount),\n            },\n            udp: {\n              value: props.info.State?.UdpConnCount || 0,\n              show: validate.isSet(props.info.State?.UdpConnCount),\n            },\n          },\n        };\n      case 'tcp':\n        return {\n          key,\n          label: 'TCP',\n          value: props.info.State?.TcpConnCount || 0,\n          show: validate.isSet(props.info.State?.TcpConnCount),\n        };\n      case 'udp':\n        return {\n          key,\n          label: 'UDP',\n          value: props.info.State?.UdpConnCount || 0,\n          show: validate.isSet(props.info.State?.UdpConnCount),\n        };\n      // 入网和出网\n      case 'I-A-O':\n        return {\n          key,\n          label: '网速',\n          values: [\n            {\n              key: 'in',\n              label: '入网',\n              value: netInSpeed.value?.value,\n              unit: netInSpeed.value?.unit,\n              show: validate.isSet(netInSpeed.value?.value),\n            },\n            {\n              key: 'out',\n              label: '出网',\n              value: netOutSpeed.value?.value,\n              unit: netOutSpeed.value?.unit,\n              show: validate.isSet(netOutSpeed.value?.value),\n            },\n          ],\n          show: validate.isSet(netInSpeed.value?.value) && validate.isSet(netOutSpeed.value?.value),\n        };\n      // 负载和进程\n      case 'L-A-P':\n        return {\n          key,\n          label: '负载',\n          values: [\n            {\n              key: 'load',\n              label: '负载',\n              value: (props.info.State?.Load1 || 0).toFixed(2),\n              show: validate.isSet(props.info.State?.Load1),\n            },\n            {\n              key: 'process',\n              label: '进程',\n              value: props.info.State?.ProcessCount || 0,\n              show: validate.isSet(props.info.State?.ProcessCount),\n            },\n          ],\n          show: validate.isSet(props.info.State?.Load1) || validate.isSet(props.info.State?.ProcessCount),\n        };\n      // 连接 TCP和UDP\n      case 'T-A-U':\n        return {\n          key,\n          label: '连接',\n          values: [\n            {\n              key: 'tcp',\n              label: 'TCP',\n              value: (props.info.State?.TcpConnCount || 0).toString().padEnd(3, ' '),\n              show: validate.isSet(props.info.State?.TcpConnCount),\n            },\n            {\n              key: 'udp',\n              label: 'UDP',\n              value: (props.info.State?.UdpConnCount || 0).toString().padEnd(3, ' '),\n              show: validate.isSet(props.info.State?.UdpConnCount),\n            },\n          ],\n          show: validate.isSet(props.info.State?.TcpConnCount) || validate.isSet(props.info.State?.UdpConnCount),\n        };\n      // 在线和流量\n      case 'D-A-T':\n        return {\n          key,\n          label: '统计',\n          values: [\n            {\n              key: 'duration',\n              label: '在线',\n              value: duration.value?.value,\n              unit: duration.value?.unit,\n              show: validate.isSet(duration.value?.value),\n            },\n            {\n              key: 'transfer',\n              label: '流量',\n              title: `${transfer.value.statTypeLabel}流量`,\n              value: transfer.value?.value,\n              unit: transfer.value?.unit,\n              show: validate.isSet(transfer.value?.value),\n            },\n          ],\n          show: validate.isSet(duration.value?.value) || validate.isSet(transfer.value?.value),\n        };\n      default:\n    }\n    return null;\n  }).filter((item) => item));\n\n  return {\n    duration,\n    transfer,\n    netInSpeed,\n    netOutSpeed,\n    serverRealTimeList,\n  };\n};\n"
  },
  {
    "path": "src/views/composable/server-sort.js",
    "content": "/**\n * 服务器排序选项\n */\nexport const serverSortOptions = () => [{\n  label: '排序值',\n  value: 'DisplayIndex',\n}, {\n  label: '主机名称',\n  value: 'Name',\n}, {\n  label: '国家地区',\n  value: 'Host.CountryCode',\n}, {\n  label: '系统平台',\n  value: 'Host.Platform',\n}, {\n  label: '在线时长',\n  value: 'Host.BootTime',\n}, {\n  label: '入网速度',\n  value: 'State.NetInSpeed',\n}, {\n  label: '出网速度',\n  value: 'State.NetOutSpeed',\n}, {\n  label: '入网流量',\n  value: 'State.NetInTransfer',\n}, {\n  label: '出网流量',\n  value: 'State.NetOutTransfer',\n}, {\n  label: '合计流量',\n  value: '$.TotalTransfer',\n}, {\n  label: 'TCP连接',\n  value: 'State.TcpConnCount',\n}, {\n  label: 'UDP连接',\n  value: 'State.UdpConnCount',\n}, {\n  label: '总连接数',\n  value: '$.TotalConnCount',\n}, {\n  label: '1分钟负载',\n  value: 'State.Load1',\n}, {\n  label: 'CPU占用',\n  value: 'State.CPU',\n}, {\n  label: '核心数量',\n  value: '$.CPU',\n}, {\n  label: '内存占用',\n  value: 'State.MemUsed',\n}, {\n  label: '内存大小',\n  value: 'Host.MemTotal',\n}, {\n  label: '交换占用',\n  value: 'State.SwapUsed',\n}, {\n  label: '交换大小',\n  value: 'Host.SwapTotal',\n}, {\n  label: '硬盘占用',\n  value: 'State.DiskUsed',\n}, {\n  label: '硬盘大小',\n  value: 'Host.DiskTotal',\n}];\n\n/**\n * 服务器排序处理\n */\nexport function serverSortHandler(a, b, sortby, order) {\n  let aValue;\n  let bValue;\n  const hasDot = sortby.includes('.');\n  if (!hasDot) {\n    aValue = a[sortby];\n    bValue = b[sortby];\n  } else {\n    const [sortby1, sortby2] = sortby.split('.');\n    if (sortby1 !== '$') {\n      switch (sortby2) {\n        case 'BootTime':\n        {\n          const currentTime = Date.now();\n          aValue = currentTime - a.Host.BootTime * 1000;\n          bValue = currentTime - b.Host.BootTime * 1000;\n          break;\n        }\n        default:\n        {\n          aValue = a[sortby1][sortby2];\n          bValue = b[sortby1][sortby2];\n          break;\n        }\n      }\n    } else {\n      switch (sortby2) {\n        case 'TotalTransfer':\n        {\n          aValue = a.State.NetInTransfer + a.State.NetOutTransfer;\n          bValue = b.State.NetInTransfer + b.State.NetOutTransfer;\n          break;\n        }\n        case 'TotalConnCount':\n        {\n          aValue = a.State.TcpConnCount + a.State.UdpConnCount;\n          bValue = b.State.TcpConnCount + b.State.UdpConnCount;\n          break;\n        }\n        case 'CPU':\n        {\n          aValue = a.Host.CPU.length;\n          bValue = b.Host.CPU.length;\n          break;\n        }\n        default:\n      }\n    }\n  }\n  if (order === 'desc') {\n    return bValue - aValue;\n  }\n  return aValue - bValue;\n}\n"
  },
  {
    "path": "src/views/composable/server-status.js",
    "content": "import {\n  computed,\n} from 'vue';\nimport config from '@/config';\nimport validate from '@/utils/validate';\nimport * as hostUtils from '@/utils/host';\n\nfunction getColor(type, mode) {\n  const colors = {\n    cpu: {\n      linear: ['#0088FF', '#72B7FF'],\n      default: '#0088FF',\n      simple: '#007B43',\n    },\n    mem: {\n      linear: ['#2B6939', '#0AA344'],\n      default: '#0AA344',\n      simple: '#007B43',\n    },\n    swap: {\n      linear: ['#FF8C00', '#F38100'],\n      default: '#FF8C00',\n      simple: '#007B43',\n    },\n    disk: {\n      linear: ['#00848F', '#70F3FF'],\n      default: '#70F3FF',\n      simple: '#007B43',\n    },\n  };\n  return colors[type][mode];\n}\n\nexport default (params) => {\n  const {\n    props,\n    statusListTpl = 'cpu,mem,disk',\n  } = params || {};\n  if (!props?.info) {\n    return {};\n  }\n\n  const lightBackground = computed(() => config.nazhua.lightBackground);\n  const serverStatusColorMode = computed(() => {\n    if (config.nazhua.simpleColorMode) {\n      return 'simple';\n    }\n    if (config.nazhua.serverStatusLinear || lightBackground.value) {\n      return 'linear';\n    }\n    return 'default';\n  });\n\n  const cpuInfo = computed(() => {\n    if (props.info?.Host?.CPU?.[0]) {\n      return hostUtils.getCPUInfo(props.info.Host.CPU[0]);\n    }\n    return {};\n  });\n\n  const useMemAndTotalMem = computed(() => {\n    const used = hostUtils.calcBinary(props.info?.State?.MemUsed || 0);\n    const total = hostUtils.calcBinary(props.info?.Host?.MemTotal || 1);\n    const usePercent = ((props.info?.State?.MemUsed / props.info?.Host?.MemTotal) * 100).toFixed(2) * 1 || 0;\n    return {\n      used,\n      total,\n      usePercent,\n    };\n  });\n\n  const useSwapAndTotalSwap = computed(() => {\n    if (!props.info?.Host?.SwapTotal || props.info?.Host?.SwapTotal === 0) {\n      return null;\n    }\n    const used = hostUtils.calcBinary(props.info?.State?.SwapUsed || 0);\n    const total = hostUtils.calcBinary(props.info?.Host?.SwapTotal || 1);\n    const usePercent = ((props.info?.State?.SwapUsed / props.info?.Host?.SwapTotal) * 100).toFixed(2) * 1 || 0;\n    return {\n      used,\n      total,\n      usePercent,\n    };\n  });\n\n  const useDiskAndTotalDisk = computed(() => {\n    const used = hostUtils.calcBinary(props.info?.State?.DiskUsed || 0);\n    const total = hostUtils.calcBinary(props.info?.Host?.DiskTotal || 1);\n    const usePercent = ((props.info?.State?.DiskUsed / props.info?.Host?.DiskTotal) * 100).toFixed(2) * 1 || 0;\n    return {\n      used,\n      total,\n      usePercent,\n    };\n  });\n\n  /**\n   * 状态列表\n   */\n  const serverStatusList = computed(() => statusListTpl.split(',').map((i) => {\n    const totalColor = lightBackground.value ? 'rgba(125, 125, 125, 0.5)' : 'rgba(255, 255, 255, 0.25)';\n    switch (i) {\n      case 'cpu':\n      {\n        const CoresVal = cpuInfo.value?.cores ? `${cpuInfo.value?.cores}C` : '-';\n        const usedColor = getColor('cpu', serverStatusColorMode.value);\n        const valPercent = `${(props.info.State?.CPU || 0).toFixed(1) * 1}%`;\n        const valText = valPercent;\n        return {\n          type: 'cpu',\n          used: (props.info.State?.CPU || 0).toFixed(1) * 1,\n          colors: {\n            used: usedColor,\n            total: totalColor,\n          },\n          valText,\n          valPercent,\n          label: 'CPU',\n          content: {\n            default: cpuInfo.value?.core || CoresVal,\n            mobile: CoresVal,\n          },\n        };\n      }\n      case 'mem':\n      {\n        let valText;\n        if (useMemAndTotalMem.value.used.g >= 10 && useMemAndTotalMem.value.total.g >= 10) {\n          valText = `${(useMemAndTotalMem.value.used.g).toFixed(1) * 1}G`;\n        } else {\n          valText = `${Math.ceil(useMemAndTotalMem.value.used.m)}M`;\n        }\n        let contentVal;\n        if (useMemAndTotalMem.value.total.g > 4) {\n          contentVal = `${(useMemAndTotalMem.value.total.g).toFixed(1) * 1}G`;\n        } else {\n          contentVal = `${Math.ceil(useMemAndTotalMem.value.total.m)}M`;\n        }\n        const usedColor = getColor('mem', serverStatusColorMode.value);\n        return {\n          type: 'mem',\n          used: useMemAndTotalMem.value.usePercent,\n          colors: {\n            used: usedColor,\n            total: totalColor,\n          },\n          valText,\n          valPercent: `${useMemAndTotalMem.value.usePercent.toFixed(1) * 1}%`,\n          label: '内存',\n          content: {\n            default: `运行内存${contentVal}`,\n            mobile: `内存${contentVal}`,\n          },\n        };\n      }\n      case 'swap':\n      {\n        if (!useSwapAndTotalSwap.value) {\n          return null;\n        }\n        let valText;\n        if (useSwapAndTotalSwap.value.used.g >= 10 && useSwapAndTotalSwap.value.total.g >= 10) {\n          valText = `${(useSwapAndTotalSwap.value.used.g).toFixed(1) * 1}G`;\n        } else {\n          valText = `${Math.ceil(useSwapAndTotalSwap.value.used.m)}M`;\n        }\n        let contentVal;\n        if (useSwapAndTotalSwap.value.total.g > 4) {\n          contentVal = `${(useSwapAndTotalSwap.value.total.g).toFixed(1) * 1}G`;\n        } else {\n          contentVal = `${Math.ceil(useSwapAndTotalSwap.value.total.m)}M`;\n        }\n        const usedColor = getColor('swap', serverStatusColorMode.value);\n        return {\n          type: 'swap',\n          used: useSwapAndTotalSwap.value.usePercent,\n          colors: {\n            used: usedColor,\n            total: totalColor,\n          },\n          valText,\n          valPercent: `${useSwapAndTotalSwap.value.usePercent.toFixed(1) * 1}%`,\n          label: '交换',\n          content: {\n            default: `交换内存${contentVal}`,\n            mobile: `交换${contentVal}`,\n          },\n        };\n      }\n      case 'disk':\n      {\n        let valText;\n        if (useDiskAndTotalDisk.value.used.t >= 1 && useDiskAndTotalDisk.value.total.t >= 1) {\n          valText = `${(useDiskAndTotalDisk.value.used.t).toFixed(1) * 1}T`;\n        } else {\n          valText = `${Math.ceil(useDiskAndTotalDisk.value.used.g)}G`;\n        }\n        let contentValue;\n        if (useDiskAndTotalDisk.value.total.t >= 1) {\n          contentValue = `${(useDiskAndTotalDisk.value.total.t).toFixed(1) * 1}T`;\n        } else {\n          contentValue = `${Math.ceil(useDiskAndTotalDisk.value.total.g)}G`;\n        }\n        const usedColor = getColor('disk', serverStatusColorMode.value);\n        return {\n          type: 'disk',\n          used: useDiskAndTotalDisk.value.usePercent,\n          colors: {\n            used: usedColor,\n            total: totalColor,\n          },\n          valText,\n          valPercent: `${useDiskAndTotalDisk.value.usePercent.toFixed(1) * 1}%`,\n          label: '磁盘',\n          content: {\n            default: `磁盘容量${contentValue}`,\n            mobile: `磁盘${contentValue}`,\n          },\n        };\n      }\n      default:\n    }\n    return null;\n  }).filter((i) => validate.isSet(i)));\n\n  return {\n    cpuInfo,\n    useMemAndTotalMem,\n    useSwapAndTotalSwap,\n    useDiskAndTotalDisk,\n    serverStatusList,\n  };\n};\n"
  },
  {
    "path": "src/views/detail.vue",
    "content": "<template>\n  <div\n    v-if=\"info\"\n    class=\"detail-container\"\n    :class=\"{\n      'server--offline': info?.online !== 1,\n    }\"\n  >\n    <template v-if=\"showWorldMap && worldMapPosition === 'top'\">\n      <world-map\n        :width=\"worldMapWidth\"\n        :locations=\"locations\"\n      />\n    </template>\n    <server-name\n      :key=\"`${info.ID}_name`\"\n      :info=\"info\"\n    />\n    <server-status-box\n      :key=\"`${info.ID}_status`\"\n      :info=\"info\"\n    />\n    <server-info-box\n      :key=\"`${info.ID}_info`\"\n      :info=\"info\"\n    />\n    <server-monitor\n      :key=\"`${info.ID}_monitor`\"\n      :info=\"info\"\n    />\n    <template v-if=\"showWorldMap && worldMapPosition === 'bottom'\">\n      <world-map\n        :width=\"worldMapWidth\"\n        :locations=\"locations\"\n      />\n    </template>\n  </div>\n</template>\n\n<script setup>\n/**\n * 单节点详情\n */\n\nimport {\n  ref,\n  computed,\n  onMounted,\n  onUnmounted,\n  watch,\n} from 'vue';\nimport {\n  useStore,\n} from 'vuex';\nimport {\n  useRouter,\n} from 'vue-router';\n\nimport config from '@/config';\nimport {\n  alias2code,\n  locationCode2Info,\n} from '@/utils/world-map';\nimport pageTitle from '@/utils/page-title';\n\nimport WorldMap from '@/components/world-map/world-map.vue';\nimport ServerName from './components/server-detail/server-name.vue';\nimport ServerStatusBox from './components/server-detail/server-status-box.vue';\nimport ServerInfoBox from './components/server-detail/server-info-box.vue';\nimport ServerMonitor from './components/server-detail/server-monitor.vue';\n\nconst props = defineProps({\n  serverId: {\n    type: [String, Number],\n    default: null,\n  },\n});\n\nconst store = useStore();\nconst router = useRouter();\n\nconst worldMapWidth = ref(900);\nconst info = computed(() => store.state?.serverList?.find?.((i) => +i.ID === +props.serverId));\nconst dataInit = computed(() => store.state.init);\n\nconst locations = computed(() => {\n  const arr = [];\n  let aliasCode;\n  let locationCode;\n  if (info?.value?.PublicNote?.customData?.location) {\n    aliasCode = info?.value?.PublicNote?.customData?.location;\n    locationCode = info?.value?.PublicNote?.customData?.location;\n  } else if (info?.value?.Host?.CountryCode) {\n    aliasCode = info.value.Host.CountryCode.toUpperCase();\n  }\n  const code = alias2code(aliasCode) || locationCode;\n  if (code) {\n    const {\n      x,\n      y,\n      name,\n    } = locationCode2Info(code) || {};\n    arr.push({\n      key: code,\n      x,\n      y,\n      code,\n      size: 4,\n      label: `${name}`,\n      servers: [info.value],\n    });\n  }\n  return arr;\n});\n\nconst showWorldMap = computed(() => {\n  if (config.nazhua?.hideWorldMap) {\n    return false;\n  }\n  if (config.nazhua?.hideDetailWorldMap) {\n    return false;\n  }\n  if (info.value?.ID && locations.value.length === 0) {\n    return false;\n  }\n  return true;\n});\n\nconst worldMapPosition = computed(() => {\n  if (Object.keys(config.nazhua).includes('detailWorldMapPosition')) {\n    return config.nazhua.detailWorldMapPosition;\n  }\n  return 'top';\n});\n\nfunction handleWorldMapWidth() {\n  worldMapWidth.value = Math.max(\n    Math.min(\n      document.querySelector('.detail-container')?.offsetWidth - 40,\n      window.innerWidth - 40,\n      900,\n    ),\n    300, // 防止奇葩情况\n  );\n}\n\nwatch(() => info.value, (oldValue, newValue) => {\n  if (!oldValue && newValue && router.currentRoute.value.name === 'ServerDetail') {\n    pageTitle(newValue?.Name, '节点详情');\n    handleWorldMapWidth();\n  }\n});\n\nwatch(() => dataInit.value, () => {\n  if (dataInit.value && !info.value) {\n    router.replace({\n      name: 'Home',\n    });\n  }\n});\n\nonMounted(() => {\n  if (info.value) {\n    pageTitle(info.value?.Name, '节点详情');\n    handleWorldMapWidth();\n  }\n  window.addEventListener('resize', handleWorldMapWidth);\n});\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleWorldMapWidth);\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.detail-container {\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n  width: var(--detail-container-width);\n  padding: 20px;\n  margin: auto;\n\n  &.server--offline {\n    filter: grayscale(1);\n  }\n}\n</style>\n"
  },
  {
    "path": "src/views/home.vue",
    "content": "<template>\n  <div\n    class=\"index-container\"\n    :class=\"indexContainerClass\"\n  >\n    <div class=\"scroll-container\">\n      <div\n        v-if=\"worldMapPosition === 'top' && showWorldMap\"\n        class=\"world-map-box top-world-map\"\n      >\n        <world-map\n          :locations=\"serverLocations || []\"\n          :width=\"worldMapWidth\"\n        />\n      </div>\n      <div\n        v-if=\"showFilter\"\n        class=\"filter-group\"\n        :class=\"{\n          'list-is--row': showListRow,\n          'list-is--card': showListCard,\n          'list-is--server-status': showListRowByServerStatus,\n        }\"\n      >\n        <div class=\"left-box\">\n          <server-option-box\n            v-if=\"showTag && serverGroupOptions.length\"\n            v-model=\"filterFormData.tag\"\n            :options=\"serverGroupOptions\"\n          />\n        </div>\n        <div class=\"right-box\">\n          <server-sort-box\n            v-if=\"showSort\"\n            v-model=\"sortData\"\n            :options=\"sortOptions\"\n          />\n          <server-option-box\n            v-if=\"onlineOptions.length\"\n            v-model=\"filterFormData.online\"\n            :options=\"onlineOptions\"\n          />\n          <server-option-box\n            v-if=\"config.nazhua.listServerItemTypeToggle\"\n            v-model=\"listType\"\n            :options=\"listTypeOptions\"\n            :accept-empty=\"false\"\n            :mobile-show=\"false\"\n          />\n        </div>\n      </div>\n      <!-- 列表模式 -->\n      <server-list-warp\n        v-if=\"showListRow\"\n        :show-transition=\"showTransition\"\n        :show-list-row=\"showListRow\"\n      >\n        <server-row-item\n          v-for=\"item in filterServerList.list\"\n          :key=\"item.ID\"\n          :info=\"item\"\n        />\n      </server-list-warp>\n      <!-- ServerStatus模式 -->\n      <server-list-warp\n        v-if=\"showListRowByServerStatus\"\n        :show-transition=\"showTransition\"\n        :show-list-by-server-status=\"showListRowByServerStatus\"\n      >\n        <server-status-main\n          :server-list=\"filterServerList.list\"\n        />\n      </server-list-warp>\n      <!-- 卡片模式 -->\n      <server-list-warp\n        v-if=\"showListCard\"\n        :show-transition=\"showTransition\"\n        :show-list-card=\"showListCard\"\n      >\n        <server-card-item\n          v-for=\"item in filterServerList.list\"\n          :key=\"item.ID\"\n          :info=\"item\"\n        />\n      </server-list-warp>\n      <div\n        v-if=\"worldMapPosition === 'bottom' && showWorldMap\"\n        class=\"world-map-box bottom-world-map\"\n      >\n        <world-map\n          :locations=\"serverLocations || []\"\n          :width=\"worldMapWidth\"\n        />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\n/**\n * 首页\n */\n\nimport {\n  ref,\n  provide,\n  computed,\n  onMounted,\n  onUnmounted,\n  onActivated,\n  onDeactivated,\n  nextTick,\n  watch,\n} from 'vue';\nimport {\n  useStore,\n} from 'vuex';\n\nimport config from '@/config';\nimport {\n  alias2code,\n  locationCode2Info,\n  count2size,\n} from '@/utils/world-map';\nimport uuid from '@/utils/uuid';\nimport validate from '@/utils/validate';\n\nimport WorldMap from '@/components/world-map/world-map.vue';\nimport ServerOptionBox from './components/server-list/server-option-box.vue';\nimport ServerSortBox from './components/server-list/server-sort-box.vue';\nimport ServerListWarp from './components/server-list/server-list-warp.vue';\nimport ServerCardItem from './components/server-list/card/server-list-item.vue';\nimport ServerRowItem from './components/server-list/row/server-list-item.vue';\nimport ServerStatusMain from './components/server-list/server-status/main.vue';\n\nimport {\n  serverSortOptions,\n  serverSortHandler,\n} from './composable/server-sort';\n\nconst store = useStore();\nconst worldMapWidth = ref();\nconst windowWidth = ref(window.innerWidth);\n\nconst listType = ref(config.nazhua.listServerItemType || 'card');\n\nconst showTransition = computed(() => {\n  // 强制开启\n  if (config.nazhua.forceTransition) {\n    return true;\n  }\n  // 安卓设备不开启 -> 部分安卓浏览器渲染动画会卡顿\n  if (window.navigator.userAgent.includes('Android')) {\n    return false;\n  }\n  // 服务器数量小于7时，不开启\n  return store.state.serverList.length < 7;\n});\nconst showListRow = computed(() => {\n  if (windowWidth.value > 1024) {\n    if (config.nazhua.listServerItemTypeToggle) {\n      return listType.value === 'row';\n    }\n    return config.nazhua.listServerItemType === 'row';\n  }\n  return false;\n});\nconst showListRowByServerStatus = computed(() => {\n  if (windowWidth.value > 1024) {\n    if (config.nazhua.listServerItemTypeToggle) {\n      return listType.value === 'server-status';\n    }\n    return config.nazhua.listServerItemType === 'server-status';\n  }\n  return false;\n});\nconst showListCard = computed(() => {\n  if (windowWidth.value > 1024) {\n    if (config.nazhua.listServerItemTypeToggle) {\n      return listType.value === 'card';\n    }\n    return config.nazhua.listServerItemType === 'card';\n  }\n  return true;\n});\n\nconst indexContainerClass = computed(() => {\n  const className = {};\n  if (showListRow.value) {\n    className['list-is--row'] = true;\n  }\n  if (showListCard.value) {\n    className['list-is--card'] = true;\n  }\n  if (showListRowByServerStatus.value) {\n    className['list-is--server-status'] = true;\n  }\n  return className;\n});\n\nconst showFilter = computed(() => config.nazhua.hideFilter !== true);\nconst filterFormData = ref({\n  tag: '',\n  online: '',\n});\n// 是否显示标签\nconst showTag = computed(() => {\n  if (config.nazhua.hideGroup === true) {\n    return false;\n  }\n  // hideTag与hideGroup是相同的配置，兼容旧版\n  if (config.nazhua.hideTag === true) {\n    return false;\n  }\n  return true;\n});\n\n// 服务器列表\nconst serverList = computed(() => store.state.serverList);\n// 服务器总数\nconst serverCount = computed(() => store.state.serverCount);\n// 分组标签\nconst serverGroupOptions = computed(() => {\n  const options = [];\n  store.state.serverGroup.forEach((i) => {\n    if (i.servers && i.servers.length > 0) {\n      options.push({\n        key: uuid(),\n        label: i.name,\n        value: i.name,\n        title: `${i.servers.length}台`,\n      });\n    }\n  });\n  return options;\n});\n\nconst onlineOptions = computed(() => {\n  if (serverCount.value?.total !== serverCount.value?.online) {\n    return [{\n      key: 'online',\n      label: '在线',\n      value: '1',\n      title: `${serverCount.value.online}台`,\n    }, {\n      key: 'offline',\n      label: '离线',\n      value: '-1',\n      title: `${serverCount.value.offline}台`,\n    }];\n  }\n  return [];\n});\n\n/**\n * 筛选离线时，离线数量变为0时，自动清空在线筛选\n */\nwatch(() => serverCount.value, () => {\n  if (filterFormData.value.online === '-1' && serverCount.value.offline === 0) {\n    filterFormData.value.online = '';\n  }\n  if (filterFormData.value.online === '1' && serverCount.value.online === 0) {\n    filterFormData.value.online = '';\n  }\n}, {\n  immediate: true,\n});\n\nconst listTypeOptions = computed(() => [{\n  key: 'card',\n  label: '卡片模式',\n  value: 'card',\n  icon: 'ri-gallery-view-2',\n}, {\n  key: 'row',\n  label: '列表模式',\n  value: 'row',\n  icon: 'ri-list-view',\n}, {\n  key: 'server-status',\n  label: 'ServerStatus模式',\n  value: 'server-status',\n  icon: 'ri-server-line',\n}]);\n\n/**\n * 排序处理\n */\nconst showSort = computed(() => config.nazhua.hideSort !== true);\nconst sortData = ref({\n  prop: 'DisplayIndex',\n  order: 'desc',\n});\nconst sortOptions = computed(() => serverSortOptions());\n\nconst filterServerList = computed(() => {\n  const fields = {};\n  const locationMap = {};\n\n  const list = serverList.value.filter((i) => {\n    const isFilterArr = [];\n    if (filterFormData.value.tag) {\n      const group = store.state.serverGroup.find((o) => o.name === filterFormData.value.tag);\n      isFilterArr.push((group?.servers || []).includes(i.ID));\n    }\n    if (filterFormData.value.online) {\n      isFilterArr.push(i.online === (filterFormData.value.online * 1));\n    }\n    const status = isFilterArr.length ? isFilterArr.every((o) => o) : true;\n    if (!status) {\n      return false;\n    }\n\n    // 判断是否有字段\n    if (i.PublicNote) {\n      const {\n        billingDataMod,\n        planDataMod,\n        customData,\n      } = i.PublicNote;\n      if (validate.isSet(billingDataMod?.amount)) {\n        fields.billing = true;\n      }\n      if (validate.isSet(billingDataMod?.endDate)) {\n        fields.remainingTime = true;\n      }\n      if (validate.isSet(planDataMod?.bandwidth)) {\n        fields.bandwidth = true;\n      }\n      if (validate.isSet(customData?.orderLink) && config.nazhua.hideListItemLink !== true) {\n        fields.orderLink = true;\n      }\n    }\n\n    // 位置\n    if (i.online === 1) {\n      let aliasCode;\n      let locationCode;\n      if (i?.PublicNote?.customData?.location) {\n        aliasCode = i.PublicNote.customData.location;\n        locationCode = i.PublicNote.customData.location;\n      } else if (i?.Host?.CountryCode) {\n        aliasCode = i.Host.CountryCode.toUpperCase();\n      }\n      const code = alias2code(aliasCode) || locationCode;\n      if (code) {\n        if (!locationMap[code]) {\n          locationMap[code] = [];\n        }\n        locationMap[code].push(i);\n      }\n    }\n\n    return true;\n  });\n  list.sort((a, b) => serverSortHandler(a, b, sortData.value.prop, sortData.value.order));\n  return {\n    fields,\n    list,\n    locationMap,\n  };\n});\nprovide('filterServerList', filterServerList);\n\n/**\n * 解构服务器列表的位置数据\n */\nconst serverLocations = computed(() => {\n  const locations = [];\n  Object.entries(filterServerList.value.locationMap).forEach(([code, servers]) => {\n    const {\n      x,\n      y,\n      name,\n    } = locationCode2Info(code) || {};\n    if (x && y) {\n      locations.push({\n        key: code,\n        x,\n        y,\n        code,\n        size: count2size(servers.length),\n        label: `${name},${servers.length}台`,\n        servers,\n      });\n    }\n  });\n  return locations;\n});\n\nconst showWorldMap = computed(() => {\n  if (config.nazhua?.hideWorldMap) {\n    return false;\n  }\n  if (config.nazhua?.hideHomeWorldMap) {\n    return false;\n  }\n  if (serverList.value.length > 0 && serverLocations.value.length === 0) {\n    return false;\n  }\n  return true;\n});\n\nconst worldMapPosition = computed(() => {\n  if (Object.keys(config.nazhua).includes('homeWorldMapPosition')) {\n    return config.nazhua.homeWorldMapPosition;\n  }\n  return 'top';\n});\n\n/**\n * 处理窗口大小变化\n */\nfunction handleResize() {\n  const serverListContainer = document.querySelector('.server-list-container');\n  if (serverListContainer) {\n    worldMapWidth.value = serverListContainer.clientWidth - 40;\n  }\n  windowWidth.value = window.innerWidth;\n}\n\nonMounted(() => {\n  handleResize();\n  window.addEventListener('resize', handleResize);\n});\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize);\n});\n\nconst scrollPosition = ref(0);\n\nonDeactivated(() => {\n  // 保存滚动位置\n  scrollPosition.value = document.documentElement.scrollTop || document.body.scrollTop;\n});\n\nonActivated(() => {\n  // 如果有保存的位置，则恢复到该位置\n  if (scrollPosition.value > 0) {\n    nextTick(() => {\n      window.scrollTo({\n        top: scrollPosition.value,\n        behavior: 'instant',\n      });\n    });\n  }\n});\n</script>\n\n<style lang=\"scss\" scoped>\n.index-container {\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n\n  .scroll-container {\n    display: flex;\n    flex-direction: column;\n    gap: 10px;\n    padding: 20px 0;\n  }\n\n  .world-map-box {\n    position: relative;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n  }\n\n  .bottom-world-map {\n    margin-top: 30px;\n  }\n\n  &.list-is--server-status {\n    --list-container-width: 1300px;\n\n    // 针对1440px以下的屏幕\n    @media screen and (max-width: 1440px) {\n      --list-container-width: 1300px;\n    }\n\n    // 针对1280px以下的屏幕\n    @media screen and (max-width: 1280px) {\n      --list-container-width: 1200px;\n    }\n  }\n}\n\n.filter-group {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: space-between;\n  gap: 10px 20px;\n  width: var(--list-container-width);\n  padding: 0 20px;\n  margin: auto;\n\n  .left-box {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 12px;\n  }\n\n  .right-box {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 12px;\n  }\n}\n</style>\n"
  },
  {
    "path": "src/ws/index.js",
    "content": "import config from '@/config';\nimport MessageSubscribe from '@/utils/subscribe';\nimport v1TransformV0 from '@/utils/transform-v1-2-v0';\n\nimport WSService, { WS_CONNECTION_STATUS } from './service';\n\n/**\n * 获取不同版本的WebSocket路径\n */\nfunction getWsApiPath() {\n  let url = config?.nazhua?.wsPath;\n  if (config?.nazhua?.nezhaVersion === 'v1') {\n    url = config?.nazhua?.v1WsPath;\n  }\n  const a = document.createElement('a');\n  a.href = url;\n  return a.href.replace(/^http/, 'ws');\n}\n\nconst msg = new MessageSubscribe();\nconst wsService = new WSService({\n  wsUrl: getWsApiPath(),\n  onConnect: () => {\n    msg.emit('connect');\n  },\n  onClose: () => {\n    msg.emit('close');\n  },\n  onError: (error) => {\n    msg.emit('error', error);\n  },\n  onMessage: (data) => {\n    // 消息体包含.now和.servers 粗暴的判定为服务器列表项信息\n    if (data?.now && data?.servers) {\n      if (config.nazhua.nezhaVersion === 'v1') {\n        msg.emit('servers', {\n          now: data.now,\n          servers: data?.servers?.map?.((server) => {\n            const item = v1TransformV0(server);\n            return item;\n          }) || [],\n        });\n      } else {\n        msg.emit('servers', data);\n      }\n    } else {\n      msg.emit('message', data);\n    }\n  },\n});\n\nfunction restart() {\n  if (wsService.connected !== WS_CONNECTION_STATUS.DISCONNECTED) {\n    wsService.close();\n  }\n  wsService.active();\n}\n\nexport {\n  wsService,\n  msg,\n  restart,\n};\n\nexport default (actived) => {\n  if (wsService.connected === WS_CONNECTION_STATUS.CONNECTED) {\n    if (actived) {\n      actived();\n    }\n    return;\n  }\n  msg.once('connect', () => {\n    if (actived) {\n      actived();\n    }\n  });\n  // 如果已经连接中，则不再连接\n  if (wsService.connected === WS_CONNECTION_STATUS.CONNECTING) {\n    return;\n  }\n  wsService.active();\n};\n"
  },
  {
    "path": "src/ws/service.js",
    "content": "// WebSocket 连接状态常量\nexport const WS_CONNECTION_STATUS = {\n  DISCONNECTED: 0, // 未连接\n  CONNECTING: 1, // 连接中\n  CONNECTED: 2, // 已连接\n  CLOSED: -1, // 已关闭\n};\n\nclass WSService {\n  constructor(options) {\n    const {\n      wsUrl,\n      onConnect,\n      onClose,\n      onError,\n      onMessage,\n      onMessageError,\n    } = options || {};\n\n    this.debug = options?.debug || false;\n\n    if (!wsUrl.startsWith('ws')) {\n      throw new Error('WebSocket URL must start with ws:// or wss://');\n    }\n    this.$wsUrl = wsUrl;\n    this.$on = {\n      close: onClose || (() => {}),\n      error: onError || (() => {}),\n      connect: onConnect || (() => {}),\n      message: onMessage || (() => {}),\n      messageError: onMessageError || (() => {}),\n    };\n\n    // 单例模式：防止重复创建 WebSocket 连接\n    // 如果检测到已有实例，触发错误回调并返回，避免资源浪费\n    if (WSService.instance) {\n      this.$on.error(new Error('WebSocket connection already exists'));\n      return;\n    }\n\n    WSService.instance = this;\n    this.connected = WS_CONNECTION_STATUS.DISCONNECTED;\n    this.ws = undefined;\n    this.evt = (event) => {\n      if (this.debug) {\n        console.log('Message from server ', event.data);\n      }\n      try {\n        const data = JSON.parse(event.data);\n        this.$on.message(data, event);\n      } catch (error) {\n        console.error('socket message error', error);\n        if (this.debug) {\n          console.log('message', event.data);\n        }\n        this.$on.messageError(error, event);\n      }\n    };\n  }\n\n  get isConnected() {\n    return this.connected === WS_CONNECTION_STATUS.CONNECTED;\n  }\n\n  active() {\n    // 如果已经连接中或已连接，则不再连接\n    if (this.connected > WS_CONNECTION_STATUS.DISCONNECTED) {\n      console.warn('WebSocket connection already exists or is connecting');\n      return;\n    }\n\n    // 标记为正在连接中\n    this.connected = WS_CONNECTION_STATUS.CONNECTING;\n\n    // 创建 WebSocket 连接\n    this.ws = new WebSocket(this.$wsUrl);\n    this.ws.addEventListener('open', (event) => {\n      if (this.debug) {\n        console.log('socket connected', event);\n      }\n      this.connected = WS_CONNECTION_STATUS.CONNECTED;\n      this.$on.connect(event);\n    });\n    this.ws.addEventListener('close', (event) => {\n      if (this.debug) {\n        console.log('socket closed', event);\n      }\n      this.connected = WS_CONNECTION_STATUS.CLOSED;\n      WSService.instance = null; // 清除实例引用\n      this.$on.close(event);\n    });\n    this.ws.addEventListener('message', this.evt);\n    this.ws.addEventListener('error', (event) => {\n      console.log('socket error', event);\n      WSService.instance = null; // 清除实例引用\n      this.$on.error(event);\n    });\n  }\n\n  send(data) {\n    this?.ws?.send?.(JSON.stringify(data));\n  }\n\n  close() {\n    this.ws?.close?.();\n  }\n}\n\nexport default WSService;\n"
  },
  {
    "path": "vite.config.js",
    "content": "import path from 'path';\nimport dotenv from 'dotenv';\nimport { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport babel from 'vite-plugin-babel';\nimport eslintPlugin from 'vite-plugin-eslint';\nimport svgLoader from 'vite-svg-loader';\nimport packageJson from './package';\n\nlet proxy;\nif (process.env.NODE_ENV === 'development') {\n  dotenv.config({\n    path: '.env.development.local',\n  });\n\n  proxy = {\n    '/api': {\n      target: process.env.API_HOST,\n      changeOrigin: true,\n    },\n    '/ws': {\n      target: process.env.PROXY_WS_HOST || process.env.WS_HOST,\n      changeOrigin: true,\n      ws: true,\n      rewrite: (e) => {\n        if (process.env.PROXY_WS_HOST) {\n          return `/proxy?wsPath=${process.env.WS_HOST}`;\n        }\n        return e;\n      },\n    },\n    '/api/v1/ws/server': {\n      target: process.env.PROXY_WS_HOST || process.env.WS_HOST,\n      changeOrigin: true,\n      ws: true,\n      rewrite: (e) => {\n        if (process.env.PROXY_WS_HOST) {\n          return `/proxy?wsPath=${process.env.WS_HOST}`;\n        }\n        return e;\n      },\n    },\n  };\n\n  if (process.env.VITE_BASE_PATH === '/' || !process.env.VITE_BASE_PATH) {\n    proxy['/nezha/'] = {\n      target: process.env.NEZHA_HOST,\n      changeOrigin: true,\n      rewrite: (e) => e.replace(/^\\/nezha/, ''),\n    };\n  }\n}\n\n// 读取版本号\nprocess.env.VITE_APP_VERSION = process.env.VERSION || packageJson.version;\n\n// https://vite.dev/config/\nexport default defineConfig({\n  base: process.env.VITE_BASE_PATH || '/',\n  server: {\n    host: '0.0.0.0',\n    port: 3000,\n    hmr: {\n      overlay: false,\n    },\n    proxy,\n  },\n  css: {\n    preprocessorOptions: {\n      scss: {\n        api: 'modern-compiler',\n      },\n    },\n  },\n  plugins: [\n    vue(),\n    babel({\n      babelConfig: {\n        babelrc: false,\n        configFile: false,\n        plugins: [\n          '@babel/plugin-proposal-optional-chaining',\n          '@babel/plugin-proposal-nullish-coalescing-operator',\n        ],\n      },\n    }),\n    eslintPlugin({\n      include: ['src/**/*.js', 'src/**/*.vue', 'src/*.js', 'src/*.vue'],\n    }),\n    svgLoader(),\n  ],\n  build: {\n    assetsInlineLimit: 8192, // 8KB 以下的资源会被内联\n    rollupOptions: {\n      output: {\n        manualChunks(id) {\n          if (id.includes('node_modules')) {\n            return 'vendor';\n          }\n          if (id.includes('.svg')) {\n            return 'svg';\n          }\n          return 'default';\n        },\n      },\n    },\n  },\n  resolve: {\n    alias: (() => {\n      const maps = {\n        '@': path.resolve(__dirname, './src/'),\n        '~@': path.resolve(__dirname, './src/'),\n      };\n      return maps;\n    })(),\n  },\n});\n"
  }
]