diff --git a/.clippy.toml b/.clippy.toml index a1db40ca1..d5454d32f 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1,2 +1,2 @@ avoid-breaking-exported-api = true -cognitive-complexity-threshold = 25 \ No newline at end of file +cognitive-complexity-threshold = 25 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 30c2a2e6f..ebce301f4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,8 +1,8 @@ name: 问题反馈 / Bug report -title: "[BUG] " +title: '[BUG] ' description: 反馈你遇到的问题 / Report the issue you are experiencing -labels: ["bug"] -type: "Bug" +labels: ['bug'] +type: 'Bug' body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index dc5a460d9..d30728a51 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,8 +1,8 @@ name: 功能请求 / Feature request -title: "[Feature] " +title: '[Feature] ' description: 提出你的功能请求 / Propose your feature request -labels: ["enhancement"] -type: "Feature" +labels: ['enhancement'] +type: 'Feature' body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/i18n_request.yml b/.github/ISSUE_TEMPLATE/i18n_request.yml index c6b5e225b..6ea51bd33 100644 --- a/.github/ISSUE_TEMPLATE/i18n_request.yml +++ b/.github/ISSUE_TEMPLATE/i18n_request.yml @@ -1,8 +1,8 @@ name: I18N / 多语言相关 -title: "[I18N] " +title: '[I18N] ' description: 用于多语言翻译、国际化相关问题或建议 / For issues or suggestions related to translations and internationalization -labels: ["I18n"] -type: "Task" +labels: ['I18n'] +type: 'Task' body: - type: markdown diff --git a/.github/workflows/alpha.yml b/.github/workflows/alpha.yml index 19097201c..72c928f71 100644 --- a/.github/workflows/alpha.yml +++ b/.github/workflows/alpha.yml @@ -27,7 +27,7 @@ env: RUST_BACKTRACE: short HUSKY: 0 concurrency: - group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}" + group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}' jobs: check_alpha_tag: @@ -249,7 +249,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.TAG_NAME }} - name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" + name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}' body_path: release.txt prerelease: true token: ${{ secrets.GITHUB_TOKEN }} @@ -307,7 +307,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm @@ -325,7 +325,7 @@ jobs: - name: Tauri build uses: tauri-apps/tauri-action@v0 env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -337,8 +337,8 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} with: tagName: ${{ env.TAG_NAME }} - releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" - releaseBody: "More new features are now supported." + releaseName: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}' + releaseBody: 'More new features are now supported.' releaseDraft: false prerelease: true tauriScript: pnpm @@ -377,7 +377,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - name: Install pnpm uses: pnpm/action-setup@v4 @@ -453,7 +453,7 @@ jobs: fi pnpm build --target ${{ matrix.target }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -468,7 +468,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.TAG_NAME }} - name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" + name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}' prerelease: true token: ${{ secrets.GITHUB_TOKEN }} files: | @@ -505,7 +505,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm @@ -531,7 +531,7 @@ jobs: id: build uses: tauri-apps/tauri-action@v0 env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -563,7 +563,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.TAG_NAME }} - name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" + name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}' prerelease: true token: ${{ secrets.GITHUB_TOKEN }} files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup* diff --git a/.github/workflows/autobuild.yml b/.github/workflows/autobuild.yml index 23f1803cd..12dd79f92 100644 --- a/.github/workflows/autobuild.yml +++ b/.github/workflows/autobuild.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: schedule: # UTC+8 12:00, 18:00 -> UTC 4:00, 10:00 - - cron: "0 4,10 * * *" + - cron: '0 4,10 * * *' permissions: write-all env: TAG_NAME: autobuild @@ -13,7 +13,7 @@ env: RUST_BACKTRACE: short HUSKY: 0 concurrency: - group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}" + group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}' cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: @@ -46,7 +46,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - name: Install dependencies run: pnpm install --frozen-lockfile @@ -105,7 +105,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.TAG_NAME }} - name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" + name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}' body_path: release.txt prerelease: true token: ${{ secrets.GITHUB_TOKEN }} @@ -147,7 +147,7 @@ jobs: - name: Install Rust Stable uses: dtolnay/rust-toolchain@master with: - toolchain: "1.91.0" + toolchain: '1.91.0' targets: ${{ matrix.target }} - name: Add Rust Target @@ -157,8 +157,8 @@ jobs: uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/dev' }} - prefix-key: "v1-rust" - key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + prefix-key: 'v1-rust' + key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' workspaces: | . -> target cache-all-crates: true @@ -196,14 +196,14 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" - cache: "pnpm" + node-version: '24.14.0' + cache: 'pnpm' - name: Pnpm Cache uses: actions/cache@v5 with: path: ~/.pnpm-store - key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}' restore-keys: | pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }} @@ -225,7 +225,7 @@ jobs: - name: Tauri build for Windows-macOS-Linux uses: tauri-apps/tauri-action@v0 env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -237,8 +237,8 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} with: tagName: ${{ env.TAG_NAME }} - releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" - releaseBody: "More new features are now supported." + releaseName: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}' + releaseBody: 'More new features are now supported.' releaseDraft: false prerelease: true tauriScript: pnpm @@ -269,7 +269,7 @@ jobs: - name: Install Rust Stable uses: dtolnay/rust-toolchain@master with: - toolchain: "1.91.0" + toolchain: '1.91.0' targets: ${{ matrix.target }} - name: Add Rust Target @@ -279,8 +279,8 @@ jobs: uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/dev' }} - prefix-key: "v1-rust" - key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + prefix-key: 'v1-rust' + key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' workspaces: | . -> target cache-all-crates: true @@ -294,14 +294,14 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" - cache: "pnpm" + node-version: '24.14.0' + cache: 'pnpm' - name: Pnpm Cache uses: actions/cache@v5 with: path: ~/.pnpm-store - key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}' restore-keys: | pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }} @@ -313,7 +313,7 @@ jobs: - name: Release ${{ env.TAG_CHANNEL }} Version run: pnpm release-version autobuild-latest - - name: "Setup for linux" + - name: 'Setup for linux' run: |- sudo ls -lR /etc/apt/ @@ -376,7 +376,7 @@ jobs: fi pnpm build --target ${{ matrix.target }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -391,7 +391,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.TAG_NAME }} - name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" + name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}' prerelease: true token: ${{ secrets.GITHUB_TOKEN }} files: | @@ -424,8 +424,8 @@ jobs: uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/dev' }} - prefix-key: "v1-rust" - key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + prefix-key: 'v1-rust' + key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' workspaces: | . -> target cache-all-crates: true @@ -439,14 +439,14 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" - cache: "pnpm" + node-version: '24.14.0' + cache: 'pnpm' - name: Pnpm Cache uses: actions/cache@v5 with: path: ~/.pnpm-store - key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}' restore-keys: | pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }} @@ -476,7 +476,7 @@ jobs: id: build uses: tauri-apps/tauri-action@v0 env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -509,7 +509,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.TAG_NAME }} - name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" + name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}' prerelease: true token: ${{ secrets.GITHUB_TOKEN }} files: target/${{ matrix.target }}/release/bundle/nsis/*setup* @@ -541,7 +541,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4.4.0 name: Install pnpm diff --git a/.github/workflows/check-commit-needs-build.yml b/.github/workflows/check-commit-needs-build.yml index f78c0aab2..6088f258a 100644 --- a/.github/workflows/check-commit-needs-build.yml +++ b/.github/workflows/check-commit-needs-build.yml @@ -4,36 +4,36 @@ on: workflow_dispatch: inputs: tag_name: - description: "Release tag name to check against (default: autobuild)" + description: 'Release tag name to check against (default: autobuild)' required: false - default: "autobuild" + default: 'autobuild' type: string force_build: - description: "Force build regardless of checks" + description: 'Force build regardless of checks' required: false default: false type: boolean workflow_call: inputs: tag_name: - description: "Release tag name to check against (default: autobuild)" + description: 'Release tag name to check against (default: autobuild)' required: false - default: "autobuild" + default: 'autobuild' type: string force_build: - description: "Force build regardless of checks" + description: 'Force build regardless of checks' required: false default: false type: boolean outputs: should_run: - description: "Whether the build should run" + description: 'Whether the build should run' value: ${{ jobs.check_commit.outputs.should_run }} last_tauri_commit: - description: "The last commit hash with Tauri-related changes" + description: 'The last commit hash with Tauri-related changes' value: ${{ jobs.check_commit.outputs.last_tauri_commit }} autobuild_version: - description: "The generated autobuild version string" + description: 'The generated autobuild version string' value: ${{ jobs.check_commit.outputs.autobuild_version }} permissions: diff --git a/.github/workflows/clean-old-assets.yml b/.github/workflows/clean-old-assets.yml index cb21eb213..4393cb318 100644 --- a/.github/workflows/clean-old-assets.yml +++ b/.github/workflows/clean-old-assets.yml @@ -4,24 +4,24 @@ on: workflow_dispatch: inputs: tag_name: - description: "Release tag name to clean (default: autobuild)" + description: 'Release tag name to clean (default: autobuild)' required: false - default: "autobuild" + default: 'autobuild' type: string dry_run: - description: "Dry run mode (only show what would be deleted)" + description: 'Dry run mode (only show what would be deleted)' required: false default: false type: boolean workflow_call: inputs: tag_name: - description: "Release tag name to clean (default: autobuild)" + description: 'Release tag name to clean (default: autobuild)' required: false - default: "autobuild" + default: 'autobuild' type: string dry_run: - description: "Dry run mode (only show what would be deleted)" + description: 'Dry run mode (only show what would be deleted)' required: false default: false type: boolean diff --git a/.github/workflows/cross_check.yaml b/.github/workflows/cross_check.yaml index c225c536e..2bb702aec 100644 --- a/.github/workflows/cross_check.yaml +++ b/.github/workflows/cross_check.yaml @@ -16,7 +16,7 @@ jobs: cargo-check: # Treat all Rust compiler warnings as errors env: - RUSTFLAGS: "-D warnings" + RUSTFLAGS: '-D warnings' strategy: fail-fast: false matrix: @@ -43,7 +43,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 1475bffe4..90ec109c3 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -4,22 +4,22 @@ on: workflow_dispatch: inputs: run_windows: - description: "运行 Windows" + description: '运行 Windows' required: false type: boolean default: true run_macos_aarch64: - description: "运行 macOS aarch64" + description: '运行 macOS aarch64' required: false type: boolean default: true run_windows_arm64: - description: "运行 Windows ARM64" + description: '运行 Windows ARM64' required: false type: boolean default: true run_linux_amd64: - description: "运行 Linux amd64" + description: '运行 Linux amd64' required: false type: boolean default: true @@ -32,7 +32,7 @@ env: RUST_BACKTRACE: short HUSKY: 0 concurrency: - group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}" + group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}' cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: @@ -80,8 +80,8 @@ jobs: uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/dev' }} - prefix-key: "v1-rust" - key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + prefix-key: 'v1-rust' + key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' workspaces: | . -> target cache-all-crates: true @@ -103,14 +103,14 @@ jobs: if: github.event.inputs[matrix.input] == 'true' uses: actions/setup-node@v6 with: - node-version: "24.14.0" - cache: "pnpm" + node-version: '24.14.0' + cache: 'pnpm' - name: Pnpm Cache uses: actions/cache@v5 with: path: ~/.pnpm-store - key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}' restore-keys: | pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }} lookup-only: true @@ -137,7 +137,7 @@ jobs: if: github.event.inputs[matrix.input] == 'true' uses: tauri-apps/tauri-action@v0 env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index d61506a9f..0fe6c48fe 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -47,8 +47,8 @@ jobs: - uses: actions/setup-node@v6 if: steps.check_frontend.outputs.frontend == 'true' with: - node-version: "24.14.0" - cache: "pnpm" + node-version: '24.14.0' + cache: 'pnpm' - name: Restore pnpm cache if: steps.check_frontend.outputs.frontend == 'true' diff --git a/.github/workflows/lint-clippy.yml b/.github/workflows/lint-clippy.yml index e13019f61..780bc7578 100644 --- a/.github/workflows/lint-clippy.yml +++ b/.github/workflows/lint-clippy.yml @@ -59,8 +59,8 @@ jobs: uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/dev' }} - prefix-key: "v1-rust" - key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + prefix-key: 'v1-rust' + key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' workspaces: | . -> target cache-all-crates: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96f07020b..461288960 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: push: # -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新 tags: - - "v*.*.*" + - 'v*.*.*' permissions: write-all env: CARGO_INCREMENTAL: 0 @@ -15,7 +15,7 @@ env: HUSKY: 0 concurrency: # only allow per workflow per commit (and not pr) to run at a time - group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}" + group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}' cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: @@ -129,7 +129,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.TAG_NAME }} - name: "Clash Verge Rev ${{ env.TAG_NAME }}" + name: 'Clash Verge Rev ${{ env.TAG_NAME }}' body_path: release.txt draft: false prerelease: ${{ contains(github.ref_name, '-rc') }} @@ -162,7 +162,7 @@ jobs: - name: Install Rust Stable uses: dtolnay/rust-toolchain@master with: - toolchain: "1.91.0" + toolchain: '1.91.0' targets: ${{ matrix.target }} - name: Add Rust Target @@ -172,8 +172,8 @@ jobs: uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/dev' }} - prefix-key: "v1-rust" - key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + prefix-key: 'v1-rust' + key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' workspaces: | . -> target cache-all-crates: true @@ -197,7 +197,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm @@ -220,7 +220,7 @@ jobs: # 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本 uses: tauri-apps/tauri-action@v0.6.2 env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -232,8 +232,8 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} with: tagName: ${{ github.ref_name }} - releaseName: "Clash Verge Rev ${{ github.ref_name }}" - releaseBody: "Draft release, will be updated later." + releaseName: 'Clash Verge Rev ${{ github.ref_name }}' + releaseBody: 'Draft release, will be updated later.' releaseDraft: true prerelease: ${{ contains(github.ref_name, '-rc') }} tauriScript: pnpm @@ -261,7 +261,7 @@ jobs: - name: Install Rust Stable uses: dtolnay/rust-toolchain@master with: - toolchain: "1.91.0" + toolchain: '1.91.0' targets: ${{ matrix.target }} - name: Add Rust Target @@ -271,8 +271,8 @@ jobs: uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/dev' }} - prefix-key: "v1-rust" - key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + prefix-key: 'v1-rust' + key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' workspaces: | . -> target cache-all-crates: true @@ -281,7 +281,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - name: Install pnpm uses: pnpm/action-setup@v4 @@ -293,7 +293,7 @@ jobs: pnpm i pnpm run prebuild ${{ matrix.target }} - - name: "Setup for linux" + - name: 'Setup for linux' run: |- sudo ls -lR /etc/apt/ @@ -323,14 +323,14 @@ jobs: patchelf:${{ matrix.arch }} \ librsvg2-dev:${{ matrix.arch }} - - name: "Install aarch64 tools" + - name: 'Install aarch64 tools' if: matrix.target == 'aarch64-unknown-linux-gnu' run: | sudo apt install -y \ gcc-aarch64-linux-gnu \ g++-aarch64-linux-gnu - - name: "Install armv7 tools" + - name: 'Install armv7 tools' if: matrix.target == 'armv7-unknown-linux-gnueabihf' run: | sudo apt install -y \ @@ -356,7 +356,7 @@ jobs: fi pnpm build --target ${{ matrix.target }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -371,8 +371,8 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: v${{env.VERSION}} - name: "Clash Verge Rev v${{env.VERSION}}" - body: "See release notes for detailed changelog." + name: 'Clash Verge Rev v${{env.VERSION}}' + body: 'See release notes for detailed changelog.' token: ${{ secrets.GITHUB_TOKEN }} prerelease: ${{ contains(github.ref_name, '-rc') }} files: | @@ -400,7 +400,7 @@ jobs: - name: Install Rust Stable uses: dtolnay/rust-toolchain@master with: - toolchain: "1.91.0" + toolchain: '1.91.0' targets: ${{ matrix.target }} - name: Add Rust Target @@ -410,8 +410,8 @@ jobs: uses: Swatinem/rust-cache@v2 with: save-if: ${{ github.ref == 'refs/heads/dev' }} - prefix-key: "v1-rust" - key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" + prefix-key: 'v1-rust' + key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}' workspaces: | . -> target cache-all-crates: true @@ -420,7 +420,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm @@ -450,7 +450,7 @@ jobs: id: build uses: tauri-apps/tauri-action@v0.6.2 env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} @@ -482,8 +482,8 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: v${{steps.build.outputs.appVersion}} - name: "Clash Verge Rev v${{steps.build.outputs.appVersion}}" - body: "See release notes for detailed changelog." + name: 'Clash Verge Rev v${{steps.build.outputs.appVersion}}' + body: 'See release notes for detailed changelog.' token: ${{ secrets.GITHUB_TOKEN }} prerelease: ${{ contains(github.ref_name, '-rc') }} files: target/${{ matrix.target }}/release/bundle/nsis/*setup* @@ -505,7 +505,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm @@ -531,7 +531,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm @@ -593,7 +593,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm diff --git a/.github/workflows/updater.yml b/.github/workflows/updater.yml index 1660e0dd9..dab3588d8 100644 --- a/.github/workflows/updater.yml +++ b/.github/workflows/updater.yml @@ -15,7 +15,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm @@ -39,7 +39,7 @@ jobs: - name: Install Node uses: actions/setup-node@v6 with: - node-version: "24.14.0" + node-version: '24.14.0' - uses: pnpm/action-setup@v4 name: Install pnpm diff --git a/.prettierignore b/.prettierignore index a92f98bd4..c317bde60 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,4 @@ src-tauri/target/ src-tauri/gen/ target +Cargo.lock diff --git a/.prettierrc b/.prettierrc index 909174425..6cf386b1e 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,16 +1,5 @@ { - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "semi": true, - "singleQuote": false, - "jsxSingleQuote": false, - "trailingComma": "all", - "bracketSpacing": true, - "bracketSameLine": false, - "arrowParens": "always", - "proseWrap": "preserve", - "htmlWhitespaceSensitivity": "css", - "endOfLine": "auto", - "embeddedLanguageFormatting": "auto" + "semi": false, + "singleQuote": true, + "plugins": ["prettier-plugin-toml"] } diff --git a/Cargo.toml b/Cargo.toml index 55d824fd6..ffb5aa371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,6 @@ members = [ ] resolver = "2" - [profile.release] panic = "abort" codegen-units = 1 diff --git a/crates/clash-verge-i18n/locales/jp.yml b/crates/clash-verge-i18n/locales/jp.yml index 1f5aab472..21c1b0c0e 100644 --- a/crates/clash-verge-i18n/locales/jp.yml +++ b/crates/clash-verge-i18n/locales/jp.yml @@ -5,7 +5,7 @@ notifications: body: ダッシュボードの表示状態が更新されました。 clashModeChanged: title: モード切り替え - body: "{mode} に切り替えました。" + body: '{mode} に切り替えました。' systemProxyToggled: title: システムプロキシ body: システムプロキシの状態が更新されました。 diff --git a/crates/clash-verge-i18n/locales/ko.yml b/crates/clash-verge-i18n/locales/ko.yml index f4cb39312..01cb981c8 100644 --- a/crates/clash-verge-i18n/locales/ko.yml +++ b/crates/clash-verge-i18n/locales/ko.yml @@ -5,7 +5,7 @@ notifications: body: 대시보드 표시 상태가 업데이트되었습니다. clashModeChanged: title: 모드 전환 - body: "{mode}(으)로 전환되었습니다." + body: '{mode}(으)로 전환되었습니다.' systemProxyToggled: title: 시스템 프록시 body: 시스템 프록시 상태가 업데이트되었습니다. diff --git a/crates/clash-verge-i18n/locales/tr.yml b/crates/clash-verge-i18n/locales/tr.yml index 5a1087f03..271c4b00d 100644 --- a/crates/clash-verge-i18n/locales/tr.yml +++ b/crates/clash-verge-i18n/locales/tr.yml @@ -5,7 +5,7 @@ notifications: body: Gösterge panelinin görünürlüğü güncellendi. clashModeChanged: title: Mod Değişimi - body: "{mode} moduna geçildi." + body: '{mode} moduna geçildi.' systemProxyToggled: title: Sistem Vekil'i body: Sistem vekil'i durumu güncellendi. diff --git a/crates/clash-verge-i18n/locales/tt.yml b/crates/clash-verge-i18n/locales/tt.yml index 38f8c908a..83e7d2946 100644 --- a/crates/clash-verge-i18n/locales/tt.yml +++ b/crates/clash-verge-i18n/locales/tt.yml @@ -5,7 +5,7 @@ notifications: body: Идарә панеленең күренеше яңартылды. clashModeChanged: title: Режим алыштыру - body: "{mode} режимына күчтел." + body: '{mode} режимына күчтел.' systemProxyToggled: title: Системалы прокси body: Системалы прокси хәле яңартылды. diff --git a/deny.toml b/deny.toml index 7764b1675..5c457a2af 100644 --- a/deny.toml +++ b/deny.toml @@ -23,13 +23,13 @@ # dependencies not shared by any other crates, would be ignored, as the target # list here is effectively saying which targets you are building for. targets = [ - # The triple can be any string, but only the target triples built in to - # rustc (as of 1.40) can be checked against actual config expressions - #"x86_64-unknown-linux-musl", - # You can also specify which target_features you promise are enabled for a - # particular target. target_features are currently not validated against - # the actual valid features supported by the target architecture. - #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, + # The triple can be any string, but only the target triples built in to + # rustc (as of 1.40) can be checked against actual config expressions + #"x86_64-unknown-linux-musl", + # You can also specify which target_features you promise are enabled for a + # particular target. target_features are currently not validated against + # the actual valid features supported by the target architecture. + #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, ] # When creating the dependency graph used as the source of truth when checks are # executed, this field can be used to prune crates from the graph, removing them @@ -70,11 +70,11 @@ feature-depth = 1 # A list of advisory IDs to ignore. Note that ignored advisories will still # output a note when they are encountered. ignore = [ - #"RUSTSEC-0000-0000", - #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, - #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish - #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, - "RUSTSEC-2024-0415", + #"RUSTSEC-0000-0000", + #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, + #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish + #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, + "RUSTSEC-2024-0415", ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. @@ -90,9 +90,9 @@ ignore = [ # See https://spdx.org/licenses/ for list of possible licenses # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. allow = [ - #"MIT", - #"Apache-2.0", - #"Apache-2.0 WITH LLVM-exception", + #"MIT", + #"Apache-2.0", + #"Apache-2.0 WITH LLVM-exception", ] # The confidence threshold for detecting a license from license text. # The higher the value, the more closely the license text must be to the @@ -102,9 +102,9 @@ confidence-threshold = 0.85 # Allow 1 or more licenses on a per-crate basis, so that particular licenses # aren't accepted for every possible crate as with the normal allow list exceptions = [ - # Each entry is the crate and version constraint, and its specific allow - # list - #{ allow = ["Zlib"], crate = "adler32" }, + # Each entry is the crate and version constraint, and its specific allow + # list + #{ allow = ["Zlib"], crate = "adler32" }, ] # Some crates don't have (easily) machine readable licensing information, @@ -135,7 +135,7 @@ ignore = false # is only published to private registries, and ignore is true, the crate will # not have its license(s) checked registries = [ - #"https://sekretz.com/registry + #"https://sekretz.com/registry ] # This section is considered when running `cargo deny check bans`. @@ -162,16 +162,16 @@ workspace-default-features = "allow" external-default-features = "allow" # List of crates that are allowed. Use with care! allow = [ - #"ansi_term@0.11.0", - #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, ] # List of crates to deny deny = [ - #"ansi_term@0.11.0", - #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, - # Wrapper crates can optionally be specified to allow the crate when it - # is a direct dependency of the otherwise banned crate - #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, + # Wrapper crates can optionally be specified to allow the crate when it + # is a direct dependency of the otherwise banned crate + #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, ] # List of features to allow/deny @@ -199,16 +199,16 @@ deny = [ # Certain crates/versions that will be skipped when doing duplicate detection. skip = [ - #"ansi_term@0.11.0", - #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" }, ] # Similarly to `skip` allows you to skip certain crates during duplicate # detection. Unlike skip, it also includes the entire tree of transitive # dependencies starting at the specified crate, up to a certain depth, which is # by default infinite. skip-tree = [ - #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies - #{ crate = "ansi_term@0.11.0", depth = 20 }, + #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies + #{ crate = "ansi_term@0.11.0", depth = 20 }, ] # This section is considered when running `cargo deny check sources`. diff --git a/eslint.config.ts b/eslint.config.ts index c0c3f4ddb..1c9ab2027 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,148 +1,145 @@ -import eslintJS from "@eslint/js"; -import eslintReact from "@eslint-react/eslint-plugin"; -import { defineConfig } from "eslint/config"; -import configPrettier from "eslint-config-prettier"; -import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript"; -import pluginImportX from "eslint-plugin-import-x"; -import pluginPrettier from "eslint-plugin-prettier"; -import pluginReactCompiler from "eslint-plugin-react-compiler"; -import pluginReactHooks from "eslint-plugin-react-hooks"; -import pluginReactRefresh from "eslint-plugin-react-refresh"; -import pluginUnusedImports from "eslint-plugin-unused-imports"; -import globals from "globals"; -import tseslint from "typescript-eslint"; +import eslintJS from '@eslint/js' +import eslintReact from '@eslint-react/eslint-plugin' +import { defineConfig } from 'eslint/config' +import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript' +import pluginImportX from 'eslint-plugin-import-x' +import pluginReactCompiler from 'eslint-plugin-react-compiler' +import pluginReactHooks from 'eslint-plugin-react-hooks' +import pluginReactRefresh from 'eslint-plugin-react-refresh' +import pluginUnusedImports from 'eslint-plugin-unused-imports' +import globals from 'globals' +import tseslint from 'typescript-eslint' export default defineConfig([ { - files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], plugins: { js: eslintJS, // @ts-expect-error -- https://github.com/typescript-eslint/typescript-eslint/issues/11543 - "react-hooks": pluginReactHooks, - "react-compiler": pluginReactCompiler, + 'react-hooks': pluginReactHooks, + 'react-compiler': pluginReactCompiler, // @ts-expect-error -- https://github.com/un-ts/eslint-plugin-import-x/issues/421 - "import-x": pluginImportX, - "react-refresh": pluginReactRefresh, - "unused-imports": pluginUnusedImports, - prettier: pluginPrettier, + 'import-x': pluginImportX, + 'react-refresh': pluginReactRefresh, + 'unused-imports': pluginUnusedImports, }, extends: [ eslintJS.configs.recommended, tseslint.configs.recommended, - eslintReact.configs["recommended-typescript"], - configPrettier, + eslintReact.configs['recommended-typescript'], ], languageOptions: { globals: globals.browser, parserOptions: { projectService: { - allowDefaultProject: ["src/polyfills/*.js"], + allowDefaultProject: [ + 'eslint.config.ts', + 'src/polyfills/*.js', + 'src-tauri/src/enhance/builtin/*.js', + ], }, }, }, settings: { react: { - version: "detect", + version: 'detect', }, - "import-x/resolver-next": [ + 'import-x/resolver-next': [ createTypeScriptImportResolver({ - project: "./tsconfig.json", + project: './tsconfig.json', }), ], }, rules: { // React - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "error", - "react-compiler/react-compiler": "error", - "react-refresh/only-export-components": [ - "warn", + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + 'react-compiler/react-compiler': 'error', + 'react-refresh/only-export-components': [ + 'warn', { allowConstantExport: true }, ], - "@eslint-react/no-forward-ref": "off", + '@eslint-react/no-forward-ref': 'off', // React performance and production quality rules - "@eslint-react/no-array-index-key": "warn", - "@eslint-react/no-children-count": "error", - "@eslint-react/no-children-for-each": "error", - "@eslint-react/no-children-map": "error", - "@eslint-react/no-children-only": "error", - "@eslint-react/no-children-prop": "error", - "@eslint-react/no-children-to-array": "error", - "@eslint-react/no-class-component": "error", - "@eslint-react/no-clone-element": "error", - "@eslint-react/no-create-ref": "error", - "@eslint-react/no-default-props": "error", - "@eslint-react/no-direct-mutation-state": "error", - "@eslint-react/no-implicit-key": "error", - "@eslint-react/no-prop-types": "error", - "@eslint-react/no-set-state-in-component-did-mount": "error", - "@eslint-react/no-set-state-in-component-did-update": "error", - "@eslint-react/no-set-state-in-component-will-update": "error", - "@eslint-react/no-string-refs": "error", - "@eslint-react/no-unstable-context-value": "warn", - "@eslint-react/no-unstable-default-props": "warn", - "@eslint-react/no-unused-class-component-members": "error", - "@eslint-react/no-unused-state": "error", - "@eslint-react/no-useless-fragment": "warn", - "@eslint-react/prefer-destructuring-assignment": "warn", + '@eslint-react/no-array-index-key': 'warn', + '@eslint-react/no-children-count': 'error', + '@eslint-react/no-children-for-each': 'error', + '@eslint-react/no-children-map': 'error', + '@eslint-react/no-children-only': 'error', + '@eslint-react/no-children-prop': 'error', + '@eslint-react/no-children-to-array': 'error', + '@eslint-react/no-class-component': 'error', + '@eslint-react/no-clone-element': 'error', + '@eslint-react/no-create-ref': 'error', + '@eslint-react/no-default-props': 'error', + '@eslint-react/no-direct-mutation-state': 'error', + '@eslint-react/no-implicit-key': 'error', + '@eslint-react/no-prop-types': 'error', + '@eslint-react/no-set-state-in-component-did-mount': 'error', + '@eslint-react/no-set-state-in-component-did-update': 'error', + '@eslint-react/no-set-state-in-component-will-update': 'error', + '@eslint-react/no-string-refs': 'error', + '@eslint-react/no-unstable-context-value': 'warn', + '@eslint-react/no-unstable-default-props': 'warn', + '@eslint-react/no-unused-class-component-members': 'error', + '@eslint-react/no-unused-state': 'error', + '@eslint-react/no-useless-fragment': 'warn', + '@eslint-react/prefer-destructuring-assignment': 'warn', // TypeScript - "@typescript-eslint/no-explicit-any": "off", + '@typescript-eslint/no-explicit-any': 'off', // unused-imports 代替 no-unused-vars - "@typescript-eslint/no-unused-vars": "off", - "unused-imports/no-unused-imports": "error", - "unused-imports/no-unused-vars": [ - "warn", + '@typescript-eslint/no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', { - vars: "all", - varsIgnorePattern: "^_", - args: "after-used", - argsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^ignore", + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^ignore', }, ], // Import - "import-x/no-unresolved": "error", - "import-x/order": [ - "warn", + 'import-x/no-unresolved': 'error', + 'import-x/order': [ + 'warn', { groups: [ - "builtin", - "external", - "internal", - "parent", - "sibling", - "index", + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index', ], - "newlines-between": "always", + 'newlines-between': 'always', alphabetize: { - order: "asc", + order: 'asc', caseInsensitive: true, }, }, ], // 其他常见 - "prefer-const": "warn", - "no-case-declarations": "error", - "no-fallthrough": "error", - "no-empty": ["warn", { allowEmptyCatch: true }], - - // Prettier 格式化问题 - "prettier/prettier": "warn", + 'prefer-const': 'warn', + 'no-case-declarations': 'error', + 'no-fallthrough': 'error', + 'no-empty': ['warn', { allowEmptyCatch: true }], }, }, { - files: ["scripts/**/*.{js,mjs,cjs}", "scripts-workflow/**/*.{js,mjs,cjs}"], + files: ['scripts/**/*.{js,mjs,cjs}'], languageOptions: { globals: { @@ -151,4 +148,4 @@ export default defineConfig([ }, }, }, -]); +]) diff --git a/package.json b/package.json index c62081f89..cbed670f6 100644 --- a/package.json +++ b/package.json @@ -99,10 +99,8 @@ "commander": "^14.0.3", "cross-env": "^10.1.0", "eslint": "^10.0.3", - "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import-x": "^4.16.1", - "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react-compiler": "19.1.0-rc.2", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -115,6 +113,7 @@ "lint-staged": "^16.3.2", "node-fetch": "^3.3.2", "prettier": "^3.8.1", + "prettier-plugin-toml": "^2.0.6", "sass": "^1.97.3", "tar": "^7.5.10", "terser": "^5.46.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 770b6dd12..d6e31fd76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,18 +198,12 @@ importers: eslint: specifier: ^10.0.3 version: 10.0.3(jiti@2.6.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@10.0.3(jiti@2.6.1)) eslint-import-resolver-typescript: specifier: ^4.4.4 version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1)) eslint-plugin-import-x: specifier: ^4.16.1 version: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.5.5 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1))(prettier@3.8.1) eslint-plugin-react-compiler: specifier: 19.1.0-rc.2 version: 19.1.0-rc.2(eslint@10.0.3(jiti@2.6.1)) @@ -246,6 +240,9 @@ importers: prettier: specifier: ^3.8.1 version: 3.8.1 + prettier-plugin-toml: + specifier: ^2.0.6 + version: 2.0.6(prettier@3.8.1) sass: specifier: ^1.97.3 version: 1.97.3 @@ -1407,10 +1404,6 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -1750,6 +1743,12 @@ packages: '@tanstack/virtual-core@3.13.21': resolution: {integrity: sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==} + '@taplo/core@0.2.0': + resolution: {integrity: sha512-r8bl54Zj1In3QLkiW/ex694bVzpPJ9EhwqT9xkcUVODnVUGirdB1JTsmiIv0o1uwqZiwhi8xNnTOQBRQCpizrQ==} + + '@taplo/lib@0.5.0': + resolution: {integrity: sha512-+xIqpQXJco3T+VGaTTwmhxLa51qpkQxCjRwezjFZgr+l21ExlywJFcDfTrNmL6lG6tqb0h8GyJKO3UPGPtSCWg==} + '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} @@ -2460,12 +2459,6 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-prettier@10.1.8: - resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - eslint-import-context@0.1.9: resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2501,20 +2494,6 @@ packages: eslint-import-resolver-node: optional: true - eslint-plugin-prettier@5.5.5: - resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true - eslint-plugin-react-compiler@19.1.0-rc.2: resolution: {integrity: sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==} engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0} @@ -2656,9 +2635,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3373,9 +3349,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier-linter-helpers@1.0.1: - resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} - engines: {node: '>=6.0.0'} + prettier-plugin-toml@2.0.6: + resolution: {integrity: sha512-12N/wBuHa9jd/KVy9pRP20NMKxQfQLMseQCt66lIbLaPLItvGUcSIryE1eZZMJ7loSws6Ig3M2Elc2EreNh76w==} + engines: {node: '>=16.0.0'} + peerDependencies: + prettier: ^3.0.3 prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} @@ -3652,10 +3630,6 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} - engines: {node: ^14.18.0 || >=16.0.0} - systemjs@6.15.1: resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==} @@ -5217,8 +5191,6 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true - '@pkgr/core@0.2.9': {} - '@popperjs/core@2.11.8': {} '@rolldown/binding-android-arm64@1.0.0-rc.9': @@ -5441,6 +5413,12 @@ snapshots: '@tanstack/virtual-core@3.13.21': {} + '@taplo/core@0.2.0': {} + + '@taplo/lib@0.5.0': + dependencies: + '@taplo/core': 0.2.0 + '@tauri-apps/api@2.10.1': {} '@tauri-apps/cli-darwin-arm64@2.10.1': @@ -6122,10 +6100,6 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)): - dependencies: - eslint: 10.0.3(jiti@2.6.1) - eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: get-tsconfig: 4.13.6 @@ -6165,15 +6139,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1))(prettier@3.8.1): - dependencies: - eslint: 10.0.3(jiti@2.6.1) - prettier: 3.8.1 - prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 - optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@10.0.3(jiti@2.6.1)) - eslint-plugin-react-compiler@19.1.0-rc.2(eslint@10.0.3(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 @@ -6406,8 +6371,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-diff@1.3.0: {} - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -7248,9 +7211,10 @@ snapshots: prelude-ls@1.2.1: {} - prettier-linter-helpers@1.0.1: + prettier-plugin-toml@2.0.6(prettier@3.8.1): dependencies: - fast-diff: 1.3.0 + '@taplo/lib': 0.5.0 + prettier: 3.8.1 prettier@3.8.1: {} @@ -7562,10 +7526,6 @@ snapshots: react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) - synckit@0.11.12: - dependencies: - '@pkgr/core': 0.2.9 - systemjs@6.15.1: {} tar@7.5.10: diff --git a/scripts/cleanup-unused-i18n.mjs b/scripts/cleanup-unused-i18n.mjs index 9e64bf4a9..60fb05927 100644 --- a/scripts/cleanup-unused-i18n.mjs +++ b/scripts/cleanup-unused-i18n.mjs @@ -1,107 +1,107 @@ #!/usr/bin/env node -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' -import yaml from "js-yaml"; -import ts from "typescript"; +import yaml from 'js-yaml' +import ts from 'typescript' -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) -const FRONTEND_LOCALES_DIR = path.resolve(__dirname, "../src/locales"); +const FRONTEND_LOCALES_DIR = path.resolve(__dirname, '../src/locales') const BACKEND_LOCALES_DIR = path.resolve( __dirname, - "../crates/clash-verge-i18n/locales", -); -const DEFAULT_FRONTEND_SOURCE_DIRS = [path.resolve(__dirname, "../src")]; + '../crates/clash-verge-i18n/locales', +) +const DEFAULT_FRONTEND_SOURCE_DIRS = [path.resolve(__dirname, '../src')] const DEFAULT_BACKEND_SOURCE_DIRS = [ - path.resolve(__dirname, "../src-tauri"), - path.resolve(__dirname, "../crates"), -]; -const EXCLUDE_USAGE_DIRS = [FRONTEND_LOCALES_DIR, BACKEND_LOCALES_DIR]; -const DEFAULT_BASELINE_LANG = "en"; + path.resolve(__dirname, '../src-tauri'), + path.resolve(__dirname, '../crates'), +] +const EXCLUDE_USAGE_DIRS = [FRONTEND_LOCALES_DIR, BACKEND_LOCALES_DIR] +const DEFAULT_BASELINE_LANG = 'en' const IGNORE_DIR_NAMES = new Set([ - ".git", - ".idea", - ".turbo", - ".next", - ".cache", - ".pnpm", - "node_modules", - "dist", - "build", - "coverage", - "out", - "target", - "gen", - "packages", - "release", - "logs", - "__pycache__", -]); + '.git', + '.idea', + '.turbo', + '.next', + '.cache', + '.pnpm', + 'node_modules', + 'dist', + 'build', + 'coverage', + 'out', + 'target', + 'gen', + 'packages', + 'release', + 'logs', + '__pycache__', +]) const FRONTEND_EXTENSIONS = new Set([ - ".ts", - ".tsx", - ".js", - ".jsx", - ".mjs", - ".cjs", - ".vue", - ".json", -]); -const BACKEND_EXTENSIONS = new Set([".rs"]); + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.vue', + '.json', +]) +const BACKEND_EXTENSIONS = new Set(['.rs']) -const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); +const TS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']) -const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+$/; +const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+$/ const TEMPLATE_PREFIX_PATTERN = - /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*\.$/; + /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*\.$/ const IGNORED_KEY_PREFIXES = new Set([ - "text", - "primary", - "secondary", - "error", - "warning", - "success", - "info", - "background", - "grey", - "option", - "action", - "example", - "chrome", - "localhost", - "www", - "pac", - "V2", - "v2", - "v1", -]); + 'text', + 'primary', + 'secondary', + 'error', + 'warning', + 'success', + 'info', + 'background', + 'grey', + 'option', + 'action', + 'example', + 'chrome', + 'localhost', + 'www', + 'pac', + 'V2', + 'v2', + 'v1', +]) -const NOTICE_METHOD_NAMES = new Set(["success", "error", "info", "warning"]); +const NOTICE_METHOD_NAMES = new Set(['success', 'error', 'info', 'warning']) const NOTICE_SERVICE_IDENTIFIERS = new Set([ - "@/services/notice-service", - "./notice-service", - "../services/notice-service", -]); + '@/services/notice-service', + './notice-service', + '../services/notice-service', +]) const WHITELIST_KEYS = new Set([ - "theme.light", - "theme.dark", - "theme.system", - "_version", -]); + 'theme.light', + 'theme.dark', + 'theme.system', + '_version', +]) -const MAX_PREVIEW_ENTRIES = 40; -const dynamicKeyCache = new Map(); -const fileUsageCache = new Map(); +const MAX_PREVIEW_ENTRIES = 40 +const dynamicKeyCache = new Map() +const fileUsageCache = new Map() function resetUsageCaches() { - dynamicKeyCache.clear(); - fileUsageCache.clear(); + dynamicKeyCache.clear() + fileUsageCache.clear() } function printUsage() { @@ -116,7 +116,7 @@ Options: --report Write a JSON report to the given path --src Include an additional source directory (repeatable) --help Show this message -`); +`) } function parseArgs(argv) { @@ -128,200 +128,199 @@ function parseArgs(argv) { align: false, baseline: DEFAULT_BASELINE_LANG, keepExtra: false, - }; + } for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; + const arg = argv[i] switch (arg) { - case "--apply": - options.apply = true; - break; - case "--align": - options.align = true; - break; - case "--keep-extra": - options.keepExtra = true; - break; - case "--no-backup": - options.backup = false; - break; - case "--report": { - const next = argv[i + 1]; + case '--apply': + options.apply = true + break + case '--align': + options.align = true + break + case '--keep-extra': + options.keepExtra = true + break + case '--no-backup': + options.backup = false + break + case '--report': { + const next = argv[i + 1] if (!next) { - throw new Error("--report requires a file path"); + throw new Error('--report requires a file path') } - options.reportPath = path.resolve(process.cwd(), next); - i += 1; - break; + options.reportPath = path.resolve(process.cwd(), next) + i += 1 + break } - case "--baseline": { - const next = argv[i + 1]; + case '--baseline': { + const next = argv[i + 1] if (!next) { - throw new Error("--baseline requires a locale name (e.g. en)"); + throw new Error('--baseline requires a locale name (e.g. en)') } - options.baseline = next.replace(/\.(json|ya?ml)$/i, ""); - i += 1; - break; + options.baseline = next.replace(/\.(json|ya?ml)$/i, '') + i += 1 + break } - case "--src": - case "--source": { - const next = argv[i + 1]; + case '--src': + case '--source': { + const next = argv[i + 1] if (!next) { - throw new Error(`${arg} requires a directory path`); + throw new Error(`${arg} requires a directory path`) } - options.extraSources.push(path.resolve(process.cwd(), next)); - i += 1; - break; + options.extraSources.push(path.resolve(process.cwd(), next)) + i += 1 + break } - case "--help": - printUsage(); - process.exit(0); - break; + case '--help': + printUsage() + process.exit(0) + break default: - if (arg.startsWith("--")) { - throw new Error(`Unknown option: ${arg}`); + if (arg.startsWith('--')) { + throw new Error(`Unknown option: ${arg}`) } - throw new Error(`Unexpected positional argument: ${arg}`); + throw new Error(`Unexpected positional argument: ${arg}`) } } - return options; + return options } function getAllFiles(start, predicate) { - if (!fs.existsSync(start)) return []; + if (!fs.existsSync(start)) return [] - const stack = [start]; - const files = []; + const stack = [start] + const files = [] while (stack.length) { - const current = stack.pop(); - if (!current) continue; - const stat = fs.statSync(current); + const current = stack.pop() + if (!current) continue + const stat = fs.statSync(current) if (stat.isDirectory()) { - const entries = fs.readdirSync(current, { withFileTypes: true }); + const entries = fs.readdirSync(current, { withFileTypes: true }) for (const entry of entries) { if (entry.isSymbolicLink()) { - continue; + continue } if (IGNORE_DIR_NAMES.has(entry.name)) { - continue; + continue } - const entryPath = path.join(current, entry.name); + const entryPath = path.join(current, entry.name) if (entry.isDirectory()) { - stack.push(entryPath); + stack.push(entryPath) } else if (!predicate || predicate(entryPath)) { - files.push(entryPath); + files.push(entryPath) } } } else if (!predicate || predicate(current)) { - files.push(current); + files.push(current) } } - return files; + return files } function collectSourceFiles(sourceDirs, options = {}) { - const supportedExtensions = - options.supportedExtensions ?? FRONTEND_EXTENSIONS; - const seen = new Set(); - const files = []; + const supportedExtensions = options.supportedExtensions ?? FRONTEND_EXTENSIONS + const seen = new Set() + const files = [] for (const dir of sourceDirs) { const resolved = getAllFiles(dir, (filePath) => { - if (seen.has(filePath)) return false; - if (!supportedExtensions.has(path.extname(filePath))) return false; + if (seen.has(filePath)) return false + if (!supportedExtensions.has(path.extname(filePath))) return false if ( EXCLUDE_USAGE_DIRS.some((excluded) => filePath.startsWith(`${excluded}${path.sep}`), ) || EXCLUDE_USAGE_DIRS.includes(filePath) ) { - return false; + return false } - return true; - }); + return true + }) for (const filePath of resolved) { - seen.add(filePath); + seen.add(filePath) files.push({ path: filePath, extension: path.extname(filePath).toLowerCase(), - content: fs.readFileSync(filePath, "utf8"), - }); + content: fs.readFileSync(filePath, 'utf8'), + }) } } - files.sort((a, b) => a.path.localeCompare(b.path)); - return files; + files.sort((a, b) => a.path.localeCompare(b.path)) + return files } -function flattenLocale(obj, parent = "") { - const entries = new Map(); +function flattenLocale(obj, parent = '') { + const entries = new Map() - if (!obj || typeof obj !== "object" || Array.isArray(obj)) { - return entries; + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { + return entries } for (const [key, value] of Object.entries(obj)) { - const currentPath = parent ? `${parent}.${key}` : key; - if (value && typeof value === "object" && !Array.isArray(value)) { - const childEntries = flattenLocale(value, currentPath); + const currentPath = parent ? `${parent}.${key}` : key + if (value && typeof value === 'object' && !Array.isArray(value)) { + const childEntries = flattenLocale(value, currentPath) for (const [childKey, childValue] of childEntries) { - entries.set(childKey, childValue); + entries.set(childKey, childValue) } } else { - entries.set(currentPath, value); + entries.set(currentPath, value) } } - return entries; + return entries } function diffLocaleKeys(baselineEntries, localeEntries) { - const missing = []; - const extra = []; + const missing = [] + const extra = [] for (const key of baselineEntries.keys()) { if (!localeEntries.has(key)) { - missing.push(key); + missing.push(key) } } for (const key of localeEntries.keys()) { if (!baselineEntries.has(key)) { - extra.push(key); + extra.push(key) } } - missing.sort(); - extra.sort(); + missing.sort() + extra.sort() - return { missing, extra }; + return { missing, extra } } function determineScriptKind(extension) { switch (extension) { - case ".ts": - return ts.ScriptKind.TS; - case ".tsx": - return ts.ScriptKind.TSX; - case ".jsx": - return ts.ScriptKind.JSX; - case ".js": - case ".mjs": - case ".cjs": - return ts.ScriptKind.JS; + case '.ts': + return ts.ScriptKind.TS + case '.tsx': + return ts.ScriptKind.TSX + case '.jsx': + return ts.ScriptKind.JSX + case '.js': + case '.mjs': + case '.cjs': + return ts.ScriptKind.JS default: - return ts.ScriptKind.TS; + return ts.ScriptKind.TS } } function getNamespaceFromKey(key) { - if (!key || typeof key !== "string") return null; - const [namespace] = key.split("."); - return namespace ?? null; + if (!key || typeof key !== 'string') return null + const [namespace] = key.split('.') + return namespace ?? null } function addTemplatePrefixCandidate( @@ -329,127 +328,127 @@ function addTemplatePrefixCandidate( dynamicPrefixes, baselineNamespaces, ) { - if (!prefix || typeof prefix !== "string") return; - const normalized = prefix.trim(); - if (!normalized) return; - let candidate = normalized; - if (!candidate.endsWith(".")) { - const lastDotIndex = candidate.lastIndexOf("."); + if (!prefix || typeof prefix !== 'string') return + const normalized = prefix.trim() + if (!normalized) return + let candidate = normalized + if (!candidate.endsWith('.')) { + const lastDotIndex = candidate.lastIndexOf('.') if (lastDotIndex === -1) { - return; + return } - candidate = candidate.slice(0, lastDotIndex + 1); + candidate = candidate.slice(0, lastDotIndex + 1) } - if (!TEMPLATE_PREFIX_PATTERN.test(candidate)) return; - const namespace = getNamespaceFromKey(candidate); - if (!namespace || IGNORED_KEY_PREFIXES.has(namespace)) return; - if (!baselineNamespaces.has(namespace)) return; - dynamicPrefixes.add(candidate); + if (!TEMPLATE_PREFIX_PATTERN.test(candidate)) return + const namespace = getNamespaceFromKey(candidate) + if (!namespace || IGNORED_KEY_PREFIXES.has(namespace)) return + if (!baselineNamespaces.has(namespace)) return + dynamicPrefixes.add(candidate) } function addKeyIfValid(key, usedKeys, baselineNamespaces, options = {}) { - if (!key || typeof key !== "string") return false; - if (!KEY_PATTERN.test(key)) return false; - const namespace = getNamespaceFromKey(key); - if (!namespace || IGNORED_KEY_PREFIXES.has(namespace)) return false; + if (!key || typeof key !== 'string') return false + if (!KEY_PATTERN.test(key)) return false + const namespace = getNamespaceFromKey(key) + if (!namespace || IGNORED_KEY_PREFIXES.has(namespace)) return false if (!options.forceNamespace && !baselineNamespaces.has(namespace)) { - return false; + return false } - usedKeys.add(key); - return true; + usedKeys.add(key) + return true } function collectImportSpecifiers(sourceFile) { - const specifiers = new Map(); + const specifiers = new Map() sourceFile.forEachChild((node) => { - if (!ts.isImportDeclaration(node)) return; + if (!ts.isImportDeclaration(node)) return const moduleText = - ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text; - if (!moduleText || !node.importClause) return; + ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text + if (!moduleText || !node.importClause) return if (node.importClause.name) { - specifiers.set(node.importClause.name.text, moduleText); + specifiers.set(node.importClause.name.text, moduleText) } - const { namedBindings } = node.importClause; - if (!namedBindings) return; + const { namedBindings } = node.importClause + if (!namedBindings) return if (ts.isNamespaceImport(namedBindings)) { - specifiers.set(namedBindings.name.text, `${moduleText}.*`); - return; + specifiers.set(namedBindings.name.text, `${moduleText}.*`) + return } if (ts.isNamedImports(namedBindings)) { for (const element of namedBindings.elements) { - specifiers.set(element.name.text, moduleText); + specifiers.set(element.name.text, moduleText) } } - }); + }) - return specifiers; + return specifiers } function getCallExpressionChain(expression) { - const chain = []; - let current = expression; + const chain = [] + let current = expression while (current) { if (ts.isIdentifier(current)) { - chain.unshift(current.text); - break; + chain.unshift(current.text) + break } if (ts.isPropertyAccessExpression(current)) { - chain.unshift(current.name.text); - current = current.expression; - continue; + chain.unshift(current.name.text) + current = current.expression + continue } if (ts.isElementAccessExpression(current)) { - const argument = current.argumentExpression; + const argument = current.argumentExpression if (ts.isStringLiteralLike(argument)) { - chain.unshift(argument.text); - current = current.expression; - continue; + chain.unshift(argument.text) + current = current.expression + continue } - return []; + return [] } - break; + break } - return chain; + return chain } function classifyCallExpression(expression, importSpecifiers) { - if (!expression) return null; - const chain = getCallExpressionChain(expression); - if (chain.length === 0) return null; + if (!expression) return null + const chain = getCallExpressionChain(expression) + if (chain.length === 0) return null - const last = chain[chain.length - 1]; - const root = chain[0]; + const last = chain[chain.length - 1] + const root = chain[0] - if (last === "t") { - return { type: "translation", forceNamespace: true }; + if (last === 't') { + return { type: 'translation', forceNamespace: true } } if ( NOTICE_METHOD_NAMES.has(last) && - root === "showNotice" && + root === 'showNotice' && (!importSpecifiers || - NOTICE_SERVICE_IDENTIFIERS.has(importSpecifiers.get("showNotice") ?? "")) + NOTICE_SERVICE_IDENTIFIERS.has(importSpecifiers.get('showNotice') ?? '')) ) { - return { type: "notice", forceNamespace: true }; + return { type: 'notice', forceNamespace: true } } - return null; + return null } function resolveBindingValue(name, scopeStack) { - if (!name) return null; + if (!name) return null for (let index = scopeStack.length - 1; index >= 0; index -= 1) { - const scope = scopeStack[index]; + const scope = scopeStack[index] if (scope && scope.has(name)) { - return scope.get(name); + return scope.get(name) } } - return null; + return null } function resolveKeyFromExpression( @@ -459,27 +458,27 @@ function resolveKeyFromExpression( baselineNamespaces, importSpecifiers, ) { - if (!node) return null; + if (!node) return null if ( ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node) ) { - return node.text; + return node.text } if (ts.isTemplateExpression(node)) { addTemplatePrefixCandidate( - node.head?.text ?? "", + node.head?.text ?? '', dynamicPrefixes, baselineNamespaces, - ); + ) for (const span of node.templateSpans) { - const literalText = span.literal?.text ?? ""; - const combined = (node.head?.text ?? "") + literalText; - addTemplatePrefixCandidate(combined, dynamicPrefixes, baselineNamespaces); + const literalText = span.literal?.text ?? '' + const combined = (node.head?.text ?? '') + literalText + addTemplatePrefixCandidate(combined, dynamicPrefixes, baselineNamespaces) } - return null; + return null } if (ts.isBinaryExpression(node)) { @@ -490,23 +489,23 @@ function resolveKeyFromExpression( dynamicPrefixes, baselineNamespaces, importSpecifiers, - ); + ) const right = resolveKeyFromExpression( node.right, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, - ); + ) if (left && right) { - return `${left}${right}`; + return `${left}${right}` } if (left) { - addTemplatePrefixCandidate(left, dynamicPrefixes, baselineNamespaces); + addTemplatePrefixCandidate(left, dynamicPrefixes, baselineNamespaces) } - return null; + return null } - return null; + return null } if (ts.isParenthesizedExpression(node)) { @@ -516,7 +515,7 @@ function resolveKeyFromExpression( dynamicPrefixes, baselineNamespaces, importSpecifiers, - ); + ) } if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) { @@ -526,31 +525,31 @@ function resolveKeyFromExpression( dynamicPrefixes, baselineNamespaces, importSpecifiers, - ); + ) } if (ts.isIdentifier(node)) { - return resolveBindingValue(node.text, scopeStack); + return resolveBindingValue(node.text, scopeStack) } if (ts.isCallExpression(node)) { const classification = classifyCallExpression( node.expression, importSpecifiers, - ); - if (!classification) return null; - const firstArg = node.arguments[0]; - if (!firstArg) return null; + ) + if (!classification) return null + const firstArg = node.arguments[0] + if (!firstArg) return null return resolveKeyFromExpression( firstArg, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, - ); + ) } - return null; + return null } function collectUsedKeysFromTsFile( @@ -559,7 +558,7 @@ function collectUsedKeysFromTsFile( usedKeys, dynamicPrefixes, ) { - let sourceFile; + let sourceFile try { sourceFile = ts.createSourceFile( @@ -568,25 +567,25 @@ function collectUsedKeysFromTsFile( ts.ScriptTarget.Latest, true, determineScriptKind(file.extension), - ); + ) } catch (error) { - console.warn(`Warning: failed to parse ${file.path}: ${error.message}`); - return; + console.warn(`Warning: failed to parse ${file.path}: ${error.message}`) + return } - const importSpecifiers = collectImportSpecifiers(sourceFile); - const scopeStack = [new Map()]; + const importSpecifiers = collectImportSpecifiers(sourceFile) + const scopeStack = [new Map()] const visit = (node) => { - let scopePushed = false; + let scopePushed = false if ( ts.isBlock(node) || ts.isModuleBlock(node) || node.kind === ts.SyntaxKind.CaseBlock || ts.isCatchClause(node) ) { - scopeStack.push(new Map()); - scopePushed = true; + scopeStack.push(new Map()) + scopePushed = true } if (ts.isTemplateExpression(node)) { @@ -596,7 +595,7 @@ function collectUsedKeysFromTsFile( dynamicPrefixes, baselineNamespaces, importSpecifiers, - ); + ) } if (ts.isVariableDeclaration(node) && node.initializer) { @@ -606,10 +605,10 @@ function collectUsedKeysFromTsFile( dynamicPrefixes, baselineNamespaces, importSpecifiers, - ); + ) if (key && KEY_PATTERN.test(key)) { if (ts.isIdentifier(node.name)) { - scopeStack[scopeStack.length - 1].set(node.name.text, key); + scopeStack[scopeStack.length - 1].set(node.name.text, key) } } } @@ -618,9 +617,9 @@ function collectUsedKeysFromTsFile( const classification = classifyCallExpression( node.expression, importSpecifiers, - ); + ) if (classification) { - const [firstArg] = node.arguments; + const [firstArg] = node.arguments if (firstArg) { const key = resolveKeyFromExpression( firstArg, @@ -628,26 +627,26 @@ function collectUsedKeysFromTsFile( dynamicPrefixes, baselineNamespaces, importSpecifiers, - ); + ) if ( !addKeyIfValid(key, usedKeys, baselineNamespaces, classification) ) { addTemplatePrefixCandidate( - key ?? "", + key ?? '', dynamicPrefixes, baselineNamespaces, - ); + ) } } } } - if (ts.isJsxAttribute(node) && node.name?.text === "i18nKey") { - const initializer = node.initializer; + if (ts.isJsxAttribute(node) && node.name?.text === 'i18nKey') { + const initializer = node.initializer if (initializer && ts.isStringLiteralLike(initializer)) { addKeyIfValid(initializer.text, usedKeys, baselineNamespaces, { forceNamespace: false, - }); + }) } else if ( initializer && ts.isJsxExpression(initializer) && @@ -659,46 +658,46 @@ function collectUsedKeysFromTsFile( dynamicPrefixes, baselineNamespaces, importSpecifiers, - ); + ) addKeyIfValid(key, usedKeys, baselineNamespaces, { forceNamespace: true, - }); + }) } } - node.forEachChild(visit); + node.forEachChild(visit) if (scopePushed) { - scopeStack.pop(); + scopeStack.pop() } - }; + } - visit(sourceFile); + visit(sourceFile) } function collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys) { - const regex = /['"`]([A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+)['"`]/g; - let match; + const regex = /['"`]([A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+)['"`]/g + let match while ((match = regex.exec(file.content))) { addKeyIfValid(match[1], usedKeys, baselineNamespaces, { forceNamespace: false, - }); + }) } } function readRustStringLiteral(source, startIndex) { - const slice = source.slice(startIndex); + const slice = source.slice(startIndex) if (slice.startsWith('"')) { - const match = slice.match(/^"(?:\\.|[^"\\])*"/); - if (!match) return null; - return match[0].slice(1, -1); + const match = slice.match(/^"(?:\\.|[^"\\])*"/) + if (!match) return null + return match[0].slice(1, -1) } - if (slice.startsWith("r")) { - const match = slice.match(/^r(#+)?"([\s\S]*?)"\1/); - if (!match) return null; - return match[2]; + if (slice.startsWith('r')) { + const match = slice.match(/^r(#+)?"([\s\S]*?)"\1/) + if (!match) return null + return match[2] } - return null; + return null } function collectUsedKeysFromRustFile( @@ -707,27 +706,27 @@ function collectUsedKeysFromRustFile( usedKeys, _dynamicPrefixes, ) { - const pattern = /\b(?:[A-Za-z_][\w:]*::)?t!\s*\(/g; - let match; + const pattern = /\b(?:[A-Za-z_][\w:]*::)?t!\s*\(/g + let match while ((match = pattern.exec(file.content))) { - let index = match.index + match[0].length; + let index = match.index + match[0].length while (index < file.content.length && /\s/.test(file.content[index])) { - index += 1; + index += 1 } - const key = readRustStringLiteral(file.content, index); + const key = readRustStringLiteral(file.content, index) if (key) { addKeyIfValid(key, usedKeys, baselineNamespaces, { forceNamespace: true, - }); + }) } } - collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys); + collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys) } function collectUsedI18nKeys(sourceFiles, baselineNamespaces) { - const usedKeys = new Set(); - const dynamicPrefixes = new Set(); + const usedKeys = new Set() + const dynamicPrefixes = new Set() for (const file of sourceFiles) { if (TS_EXTENSIONS.has(file.extension)) { @@ -736,472 +735,466 @@ function collectUsedI18nKeys(sourceFiles, baselineNamespaces) { baselineNamespaces, usedKeys, dynamicPrefixes, - ); - } else if (file.extension === ".rs") { + ) + } else if (file.extension === '.rs') { collectUsedKeysFromRustFile( file, baselineNamespaces, usedKeys, dynamicPrefixes, - ); + ) } else { - collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys); + collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys) } } - return { usedKeys, dynamicPrefixes }; + return { usedKeys, dynamicPrefixes } } function alignToBaseline(baselineNode, localeNode, options) { const shouldCopyLocale = - localeNode && typeof localeNode === "object" && !Array.isArray(localeNode); + localeNode && typeof localeNode === 'object' && !Array.isArray(localeNode) if ( baselineNode && - typeof baselineNode === "object" && + typeof baselineNode === 'object' && !Array.isArray(baselineNode) ) { - const result = {}; - const baselineKeys = Object.keys(baselineNode); + const result = {} + const baselineKeys = Object.keys(baselineNode) for (const key of baselineKeys) { - const baselineValue = baselineNode[key]; - const localeValue = shouldCopyLocale ? localeNode[key] : undefined; + const baselineValue = baselineNode[key] + const localeValue = shouldCopyLocale ? localeNode[key] : undefined if ( baselineValue && - typeof baselineValue === "object" && + typeof baselineValue === 'object' && !Array.isArray(baselineValue) ) { result[key] = alignToBaseline( baselineValue, - localeValue && typeof localeValue === "object" ? localeValue : {}, + localeValue && typeof localeValue === 'object' ? localeValue : {}, options, - ); + ) } else if (localeValue === undefined) { - result[key] = baselineValue; + result[key] = baselineValue } else { - result[key] = localeValue; + result[key] = localeValue } } if (options.keepExtra && shouldCopyLocale) { const extraKeys = Object.keys(localeNode) .filter((key) => !baselineKeys.includes(key)) - .sort(); + .sort() for (const key of extraKeys) { - result[key] = localeNode[key]; + result[key] = localeNode[key] } } - return result; + return result } - return shouldCopyLocale ? localeNode : baselineNode; + return shouldCopyLocale ? localeNode : baselineNode } function logPreviewEntries(label, items) { - if (!items || items.length === 0) return; + if (!items || items.length === 0) return - const preview = items.slice(0, MAX_PREVIEW_ENTRIES); + const preview = items.slice(0, MAX_PREVIEW_ENTRIES) for (const item of preview) { - console.log(` · ${label}: ${item}`); + console.log(` · ${label}: ${item}`) } if (items.length > preview.length) { - console.log( - ` · ${label}: ... and ${items.length - preview.length} more`, - ); + console.log(` · ${label}: ... and ${items.length - preview.length} more`) } } function removeKey(target, dottedKey) { if ( !target || - typeof target !== "object" || + typeof target !== 'object' || Array.isArray(target) || - dottedKey === "" + dottedKey === '' ) { - return; + return } if (dottedKey in target) { - delete target[dottedKey]; - return; + delete target[dottedKey] + return } - const parts = dottedKey.split(".").filter((part) => part !== ""); - if (parts.length === 0) return; + const parts = dottedKey.split('.').filter((part) => part !== '') + if (parts.length === 0) return - let current = target; + let current = target for (let index = 0; index < parts.length; index += 1) { - const part = parts[index]; + const part = parts[index] if ( !current || - typeof current !== "object" || + typeof current !== 'object' || Array.isArray(current) || !(part in current) ) { - return; + return } if (index === parts.length - 1) { - delete current[part]; - return; + delete current[part] + return } - current = current[part]; + current = current[part] } } function cleanupEmptyBranches(target) { - if (!target || typeof target !== "object" || Array.isArray(target)) { - return false; + if (!target || typeof target !== 'object' || Array.isArray(target)) { + return false } for (const key of Object.keys(target)) { if (cleanupEmptyBranches(target[key])) { - delete target[key]; + delete target[key] } } - return Object.keys(target).length === 0; + return Object.keys(target).length === 0 } function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function findKeyInSources(key, sourceFiles) { if (fileUsageCache.has(key)) { - return fileUsageCache.get(key); + return fileUsageCache.get(key) } - const pattern = new RegExp(`(['"\`])${escapeRegExp(key)}\\1`); - let found = false; + const pattern = new RegExp(`(['"\`])${escapeRegExp(key)}\\1`) + let found = false for (const file of sourceFiles) { if (pattern.test(file.content)) { - found = true; - break; + found = true + break } } - fileUsageCache.set(key, found); - return found; + fileUsageCache.set(key, found) + return found } function isKeyUsed(key, usage, sourceFiles) { - if (WHITELIST_KEYS.has(key)) return true; - if (!key) return false; - if (usage.usedKeys.has(key)) return true; + if (WHITELIST_KEYS.has(key)) return true + if (!key) return false + if (usage.usedKeys.has(key)) return true if (dynamicKeyCache.has(key)) { - return dynamicKeyCache.get(key); + return dynamicKeyCache.get(key) } - const prefixes = getCandidatePrefixes(key); - let used = prefixes.some((prefix) => usage.dynamicPrefixes.has(prefix)); + const prefixes = getCandidatePrefixes(key) + let used = prefixes.some((prefix) => usage.dynamicPrefixes.has(prefix)) if (!used) { - used = findKeyInSources(key, sourceFiles); + used = findKeyInSources(key, sourceFiles) } - dynamicKeyCache.set(key, used); - return used; + dynamicKeyCache.set(key, used) + return used } function getCandidatePrefixes(key) { - if (!key.includes(".")) return []; - const parts = key.split("."); - const prefixes = []; + if (!key.includes('.')) return [] + const parts = key.split('.') + const prefixes = [] for (let index = 0; index < parts.length - 1; index += 1) { - const prefix = `${parts.slice(0, index + 1).join(".")}.`; - prefixes.push(prefix); + const prefix = `${parts.slice(0, index + 1).join('.')}.` + prefixes.push(prefix) } - return prefixes; + return prefixes } function writeReport(reportPath, data) { - const payload = JSON.stringify(data, null, 2); - fs.writeFileSync(reportPath, `${payload}\n`, "utf8"); + const payload = JSON.stringify(data, null, 2) + fs.writeFileSync(reportPath, `${payload}\n`, 'utf8') } function isPlainObject(value) { - return value && typeof value === "object" && !Array.isArray(value); + return value && typeof value === 'object' && !Array.isArray(value) } function loadFrontendLocales() { if (!fs.existsSync(FRONTEND_LOCALES_DIR)) { - throw new Error(`Locales directory not found: ${FRONTEND_LOCALES_DIR}`); + throw new Error(`Locales directory not found: ${FRONTEND_LOCALES_DIR}`) } - const entries = fs.readdirSync(FRONTEND_LOCALES_DIR, { withFileTypes: true }); - const locales = []; + const entries = fs.readdirSync(FRONTEND_LOCALES_DIR, { withFileTypes: true }) + const locales = [] for (const entry of entries) { if (entry.isFile()) { if ( /^[a-z0-9\-_]+\.json$/i.test(entry.name) && - !entry.name.endsWith(".bak") && - !entry.name.endsWith(".old") + !entry.name.endsWith('.bak') && + !entry.name.endsWith('.old') ) { - const localePath = path.join(FRONTEND_LOCALES_DIR, entry.name); - const name = path.basename(entry.name, ".json"); - const raw = fs.readFileSync(localePath, "utf8"); + const localePath = path.join(FRONTEND_LOCALES_DIR, entry.name) + const name = path.basename(entry.name, '.json') + const raw = fs.readFileSync(localePath, 'utf8') locales.push({ name, dir: FRONTEND_LOCALES_DIR, - format: "single-file", + format: 'single-file', files: [ { - namespace: "translation", + namespace: 'translation', path: localePath, }, ], data: JSON.parse(raw), - }); + }) } - continue; + continue } - if (!entry.isDirectory()) continue; - if (entry.name.startsWith(".")) continue; + if (!entry.isDirectory()) continue + if (entry.name.startsWith('.')) continue - const localeDir = path.join(FRONTEND_LOCALES_DIR, entry.name); + const localeDir = path.join(FRONTEND_LOCALES_DIR, entry.name) const namespaceEntries = fs .readdirSync(localeDir, { withFileTypes: true }) .filter( (item) => item.isFile() && - item.name.endsWith(".json") && - !item.name.endsWith(".bak") && - !item.name.endsWith(".old"), + item.name.endsWith('.json') && + !item.name.endsWith('.bak') && + !item.name.endsWith('.old'), ) .map((item) => ({ - namespace: path.basename(item.name, ".json"), + namespace: path.basename(item.name, '.json'), path: path.join(localeDir, item.name), - })); + })) - namespaceEntries.sort((a, b) => a.path.localeCompare(b.path)); + namespaceEntries.sort((a, b) => a.path.localeCompare(b.path)) - const data = {}; + const data = {} for (const file of namespaceEntries) { - const raw = fs.readFileSync(file.path, "utf8"); + const raw = fs.readFileSync(file.path, 'utf8') try { - data[file.namespace] = JSON.parse(raw); + data[file.namespace] = JSON.parse(raw) } catch (error) { - console.warn(`Warning: failed to parse ${file.path}: ${error.message}`); - data[file.namespace] = {}; + console.warn(`Warning: failed to parse ${file.path}: ${error.message}`) + data[file.namespace] = {} } } locales.push({ name: entry.name, dir: localeDir, - format: "multi-file", + format: 'multi-file', files: namespaceEntries, data, - }); + }) } - locales.sort((a, b) => a.name.localeCompare(b.name)); - return locales; + locales.sort((a, b) => a.name.localeCompare(b.name)) + return locales } function loadBackendLocales() { if (!fs.existsSync(BACKEND_LOCALES_DIR)) { - return []; + return [] } - const entries = fs.readdirSync(BACKEND_LOCALES_DIR, { withFileTypes: true }); - const locales = []; + const entries = fs.readdirSync(BACKEND_LOCALES_DIR, { withFileTypes: true }) + const locales = [] for (const entry of entries) { - if (!entry.isFile()) continue; - if (entry.name.endsWith(".bak") || entry.name.endsWith(".old")) { - continue; + if (!entry.isFile()) continue + if (entry.name.endsWith('.bak') || entry.name.endsWith('.old')) { + continue } - if (!/\.(ya?ml)$/i.test(entry.name)) continue; + if (!/\.(ya?ml)$/i.test(entry.name)) continue - const localePath = path.join(BACKEND_LOCALES_DIR, entry.name); - const name = entry.name.replace(/\.(ya?ml)$/i, ""); - const raw = fs.readFileSync(localePath, "utf8"); - let data = {}; + const localePath = path.join(BACKEND_LOCALES_DIR, entry.name) + const name = entry.name.replace(/\.(ya?ml)$/i, '') + const raw = fs.readFileSync(localePath, 'utf8') + let data = {} try { - const parsed = yaml.load(raw); - data = isPlainObject(parsed) ? parsed : {}; + const parsed = yaml.load(raw) + data = isPlainObject(parsed) ? parsed : {} } catch (error) { - console.warn(`Warning: failed to parse ${localePath}: ${error.message}`); - data = {}; + console.warn(`Warning: failed to parse ${localePath}: ${error.message}`) + data = {} } locales.push({ name, dir: BACKEND_LOCALES_DIR, - format: "yaml-file", + format: 'yaml-file', files: [ { - namespace: "translation", + namespace: 'translation', path: localePath, }, ], data, - }); + }) } - locales.sort((a, b) => a.name.localeCompare(b.name)); - return locales; + locales.sort((a, b) => a.name.localeCompare(b.name)) + return locales } function ensureBackup(localePath) { - const backupPath = `${localePath}.bak`; + const backupPath = `${localePath}.bak` if (fs.existsSync(backupPath)) { try { - fs.rmSync(backupPath); + fs.rmSync(backupPath) } catch (error) { throw new Error( - `Failed to recycle existing backup for ${path.basename( - localePath, - )}: ${error.message}`, - ); + `Failed to recycle existing backup for ${path.basename(localePath)}: ${error.message}`, + ) } } - fs.copyFileSync(localePath, backupPath); - return backupPath; + fs.copyFileSync(localePath, backupPath) + return backupPath } function backupIfNeeded(filePath, backups, options) { - if (!options.backup) return; - if (!fs.existsSync(filePath)) return; - if (backups.has(filePath)) return; - const backupPath = ensureBackup(filePath); - backups.set(filePath, backupPath); - return backupPath; + if (!options.backup) return + if (!fs.existsSync(filePath)) return + if (backups.has(filePath)) return + const backupPath = ensureBackup(filePath) + backups.set(filePath, backupPath) + return backupPath } function cleanupBackups(backups) { for (const backupPath of backups.values()) { try { if (fs.existsSync(backupPath)) { - fs.rmSync(backupPath); + fs.rmSync(backupPath) } } catch (error) { console.warn( - `Warning: failed to remove backup ${path.basename( - backupPath, - )}: ${error.message}`, - ); + `Warning: failed to remove backup ${path.basename(backupPath)}: ${error.message}`, + ) } } - backups.clear(); + backups.clear() } function toModuleIdentifier(namespace, seen) { const RESERVED = new Set([ - "default", - "function", - "var", - "let", - "const", - "import", - "export", - "class", - "enum", - ]); + 'default', + 'function', + 'var', + 'let', + 'const', + 'import', + 'export', + 'class', + 'enum', + ]) const base = - namespace.replace(/[^a-zA-Z0-9_$]/g, "_").replace(/^[^a-zA-Z_$]+/, "") || - "ns"; + namespace.replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^[^a-zA-Z_$]+/, '') || + 'ns' - let candidate = base; - let counter = 1; + let candidate = base + let counter = 1 while (RESERVED.has(candidate) || seen.has(candidate)) { - candidate = `${base}_${counter}`; - counter += 1; + candidate = `${base}_${counter}` + counter += 1 } - seen.add(candidate); - return candidate; + seen.add(candidate) + return candidate } function regenerateLocaleIndex(localeDir, namespaces) { - const seen = new Set(); - const imports = []; - const mappings = []; + const seen = new Set() + const imports = [] + const mappings = [] for (const namespace of namespaces) { - const filePath = path.join(localeDir, `${namespace}.json`); - if (!fs.existsSync(filePath)) continue; - const identifier = toModuleIdentifier(namespace, seen); - imports.push(`import ${identifier} from "./${namespace}.json";`); - mappings.push(` "${namespace}": ${identifier},`); + const filePath = path.join(localeDir, `${namespace}.json`) + if (!fs.existsSync(filePath)) continue + const identifier = toModuleIdentifier(namespace, seen) + imports.push(`import ${identifier} from "./${namespace}.json";`) + mappings.push(` "${namespace}": ${identifier},`) } - const content = `${imports.join("\n")} + const content = `${imports.join('\n')} const resources = { -${mappings.join("\n")} +${mappings.join('\n')} }; export default resources; -`; +` - fs.writeFileSync(path.join(localeDir, "index.ts"), content, "utf8"); + fs.writeFileSync(path.join(localeDir, 'index.ts'), content, 'utf8') } function writeLocale(locale, data, options) { - const backups = new Map(); - let success = false; + const backups = new Map() + let success = false try { - if (locale.format === "yaml-file") { - const target = locale.files[0].path; - backupIfNeeded(target, backups, options); - const serialized = yaml.dump(data ?? {}, { lineWidth: -1, noRefs: true }); - fs.writeFileSync(target, `${serialized.trimEnd()}\n`, "utf8"); - success = true; - return; + if (locale.format === 'yaml-file') { + const target = locale.files[0].path + backupIfNeeded(target, backups, options) + const serialized = yaml.dump(data ?? {}, { lineWidth: -1, noRefs: true }) + fs.writeFileSync(target, `${serialized.trimEnd()}\n`, 'utf8') + success = true + return } - if (locale.format === "single-file") { - const target = locale.files[0].path; - backupIfNeeded(target, backups, options); - const serialized = JSON.stringify(data, null, 2); - fs.writeFileSync(target, `${serialized}\n`, "utf8"); - success = true; - return; + if (locale.format === 'single-file') { + const target = locale.files[0].path + backupIfNeeded(target, backups, options) + const serialized = JSON.stringify(data, null, 2) + fs.writeFileSync(target, `${serialized}\n`, 'utf8') + success = true + return } - const entries = Object.entries(data); - const orderedNamespaces = entries.map(([namespace]) => namespace); + const entries = Object.entries(data) + const orderedNamespaces = entries.map(([namespace]) => namespace) const existingFiles = new Map( locale.files.map((file) => [file.namespace, file.path]), - ); - const visited = new Set(); + ) + const visited = new Set() for (const [namespace, value] of entries) { const target = existingFiles.get(namespace) ?? - path.join(locale.dir, `${namespace}.json`); - backupIfNeeded(target, backups, options); - const serialized = JSON.stringify(value ?? {}, null, 2); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, `${serialized}\n`, "utf8"); - visited.add(namespace); + path.join(locale.dir, `${namespace}.json`) + backupIfNeeded(target, backups, options) + const serialized = JSON.stringify(value ?? {}, null, 2) + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, `${serialized}\n`, 'utf8') + visited.add(namespace) } for (const [namespace, filePath] of existingFiles.entries()) { if (!visited.has(namespace) && fs.existsSync(filePath)) { - backupIfNeeded(filePath, backups, options); - fs.rmSync(filePath); + backupIfNeeded(filePath, backups, options) + fs.rmSync(filePath) } } - regenerateLocaleIndex(locale.dir, orderedNamespaces); + regenerateLocaleIndex(locale.dir, orderedNamespaces) locale.files = orderedNamespaces.map((namespace) => ({ namespace, path: path.join(locale.dir, `${namespace}.json`), - })); - success = true; + })) + success = true } finally { if (success) { - cleanupBackups(backups); + cleanupBackups(backups) } } } @@ -1217,23 +1210,23 @@ function processLocale( groupName, baselineName, ) { - const data = JSON.parse(JSON.stringify(locale.data)); - const flattened = flattenLocale(data); - const expectedTotal = baselineEntries.size; + const data = JSON.parse(JSON.stringify(locale.data)) + const flattened = flattenLocale(data) + const expectedTotal = baselineEntries.size - const { missing, extra } = diffLocaleKeys(baselineEntries, flattened); + const { missing, extra } = diffLocaleKeys(baselineEntries, flattened) - const unused = []; + const unused = [] for (const key of flattened.keys()) { if (!isKeyUsed(key, usage, sourceFiles)) { - unused.push(key); + unused.push(key) } } const sourceMissing = locale.name === baselineName ? missingFromSource.filter((key) => !flattened.has(key)) - : []; + : [] if ( unused.length === 0 && @@ -1242,51 +1235,51 @@ function processLocale( sourceMissing.length === 0 && !options.align ) { - console.log(`[${locale.name}] No issues detected 🎉`); + console.log(`[${locale.name}] No issues detected 🎉`) } else { - console.log(`[${locale.name}] Check results:`); + console.log(`[${locale.name}] Check results:`) console.log( ` unused: ${unused.length}, missing vs baseline: ${missing.length}, extra: ${extra.length}`, - ); + ) if (sourceMissing.length > 0) { - console.log(` missing in source: ${sourceMissing.length}`); - logPreviewEntries("missing-source", sourceMissing); + console.log(` missing in source: ${sourceMissing.length}`) + logPreviewEntries('missing-source', sourceMissing) } - logPreviewEntries("unused", unused); - logPreviewEntries("missing", missing); - logPreviewEntries("extra", extra); + logPreviewEntries('unused', unused) + logPreviewEntries('missing', missing) + logPreviewEntries('extra', extra) } - const removed = []; - let aligned = false; + const removed = [] + let aligned = false if (options.apply) { - let updated; + let updated if (options.align) { - aligned = true; - updated = alignToBaseline(baselineData, data, options); + aligned = true + updated = alignToBaseline(baselineData, data, options) } else { - updated = JSON.parse(JSON.stringify(data)); + updated = JSON.parse(JSON.stringify(data)) } for (const key of unused) { - removeKey(updated, key); - removed.push(key); + removeKey(updated, key) + removed.push(key) } - cleanupEmptyBranches(updated); + cleanupEmptyBranches(updated) - writeLocale(locale, updated, options); - locale.data = JSON.parse(JSON.stringify(updated)); + writeLocale(locale, updated, options) + locale.data = JSON.parse(JSON.stringify(updated)) console.log( `[${locale.name}] Locale resources updated (${removed.length} unused removed${ - aligned ? ", structure aligned" : "" + aligned ? ', structure aligned' : '' })`, - ); + ) } return { group: groupName, locale: locale.name, - file: locale.format === "multi-file" ? locale.dir : locale.files[0].path, + file: locale.format === 'multi-file' ? locale.dir : locale.files[0].path, totalKeys: flattened.size, expectedKeys: expectedTotal, unusedKeys: unused, @@ -1295,17 +1288,17 @@ function processLocale( extraKeys: extra, missingSourceKeys: sourceMissing, aligned: aligned && options.apply, - }; + } } function summarizeResults(results) { return results.reduce( (totals, result) => { - totals.totalUnused += result.unusedKeys.length; - totals.totalMissing += result.missingKeys.length; - totals.totalExtra += result.extraKeys.length; - totals.totalSourceMissing += result.missingSourceKeys.length; - return totals; + totals.totalUnused += result.unusedKeys.length + totals.totalMissing += result.missingKeys.length + totals.totalExtra += result.extraKeys.length + totals.totalSourceMissing += result.missingSourceKeys.length + return totals }, { totalUnused: 0, @@ -1313,61 +1306,59 @@ function summarizeResults(results) { totalExtra: 0, totalSourceMissing: 0, }, - ); + ) } function processLocaleGroup(group, options) { const sourceDirs = [ ...new Set([...group.sourceDirs, ...options.extraSources]), - ]; + ] - console.log(`\n[${group.label}] Scanning source directories:`); + console.log(`\n[${group.label}] Scanning source directories:`) for (const dir of sourceDirs) { - console.log(` - ${dir}`); + console.log(` - ${dir}`) } const sourceFiles = collectSourceFiles(sourceDirs, { supportedExtensions: group.supportedExtensions, - }); - const locales = group.locales; + }) + const locales = group.locales if (locales.length === 0) { - console.log(`[${group.label}] No locale files found.`); - return null; + console.log(`[${group.label}] No locale files found.`) + return null } const baselineLocale = locales.find( (item) => item.name.toLowerCase() === options.baseline.toLowerCase(), - ); + ) if (!baselineLocale) { - const available = locales.map((item) => item.name).join(", "); + const available = locales.map((item) => item.name).join(', ') throw new Error( `[${group.label}] Baseline locale "${options.baseline}" not found. Available locales: ${available}`, - ); + ) } - const baselineData = JSON.parse(JSON.stringify(baselineLocale.data)); - const baselineEntries = flattenLocale(baselineData); - const baselineNamespaces = new Set(Object.keys(baselineData)); - const usage = collectUsedI18nKeys(sourceFiles, baselineNamespaces); - const baselineKeys = new Set(baselineEntries.keys()); + const baselineData = JSON.parse(JSON.stringify(baselineLocale.data)) + const baselineEntries = flattenLocale(baselineData) + const baselineNamespaces = new Set(Object.keys(baselineData)) + const usage = collectUsedI18nKeys(sourceFiles, baselineNamespaces) + const baselineKeys = new Set(baselineEntries.keys()) const missingFromSource = Array.from(usage.usedKeys).filter( (key) => !baselineKeys.has(key), - ); - missingFromSource.sort(); + ) + missingFromSource.sort() locales.sort((a, b) => { - if (a.name === baselineLocale.name) return -1; - if (b.name === baselineLocale.name) return 1; - return a.name.localeCompare(b.name); - }); + if (a.name === baselineLocale.name) return -1 + if (b.name === baselineLocale.name) return 1 + return a.name.localeCompare(b.name) + }) - console.log( - `\n[${group.label}] Checking ${locales.length} locale files...\n`, - ); + console.log(`\n[${group.label}] Checking ${locales.length} locale files...\n`) - resetUsageCaches(); + resetUsageCaches() const results = locales.map((locale) => processLocale( locale, @@ -1380,19 +1371,19 @@ function processLocaleGroup(group, options) { group.label, baselineLocale.name, ), - ); + ) - const totals = summarizeResults(results); + const totals = summarizeResults(results) - console.log(`\n[${group.label}] Summary:`); + console.log(`\n[${group.label}] Summary:`) for (const result of results) { console.log( ` • ${result.locale}: unused=${result.unusedKeys.length}, missing=${result.missingKeys.length}, extra=${result.extraKeys.length}, missingSource=${result.missingSourceKeys.length}, total=${result.totalKeys}, expected=${result.expectedKeys}`, - ); + ) } console.log( `\n[${group.label}] Totals → unused: ${totals.totalUnused}, missing: ${totals.totalMissing}, extra: ${totals.totalExtra}, missingSource: ${totals.totalSourceMissing}`, - ); + ) return { group: group.label, @@ -1400,78 +1391,78 @@ function processLocaleGroup(group, options) { sourceDirs, totals, results, - }; + } } function main() { - const argv = process.argv.slice(2); + const argv = process.argv.slice(2) - let options; + let options try { - options = parseArgs(argv); + options = parseArgs(argv) } catch (error) { - console.error(`Error: ${error.message}`); - console.log(); - printUsage(); - process.exit(1); + console.error(`Error: ${error.message}`) + console.log() + printUsage() + process.exit(1) } const localeGroups = [ { - label: "frontend", + label: 'frontend', locales: loadFrontendLocales(), sourceDirs: DEFAULT_FRONTEND_SOURCE_DIRS, supportedExtensions: FRONTEND_EXTENSIONS, }, { - label: "backend", + label: 'backend', locales: loadBackendLocales(), sourceDirs: DEFAULT_BACKEND_SOURCE_DIRS, supportedExtensions: BACKEND_EXTENSIONS, }, - ].filter((group) => group.locales.length > 0); + ].filter((group) => group.locales.length > 0) if (localeGroups.length === 0) { - console.log("No locale files found."); - return; + console.log('No locale files found.') + return } - const groupReports = []; - const allResults = []; + const groupReports = [] + const allResults = [] for (const group of localeGroups) { - const report = processLocaleGroup(group, options); - if (!report) continue; - groupReports.push(report); - allResults.push(...report.results); + const report = processLocaleGroup(group, options) + if (!report) continue + groupReports.push(report) + allResults.push(...report.results) } if (groupReports.length > 1) { - const overallTotals = summarizeResults(allResults); + const overallTotals = summarizeResults(allResults) console.log( `\nOverall totals → unused: ${overallTotals.totalUnused}, missing: ${overallTotals.totalMissing}, extra: ${overallTotals.totalExtra}, missingSource: ${overallTotals.totalSourceMissing}`, - ); + ) } if (allResults.length === 0) { - return; + return } if (options.apply) { console.log( - "Files were updated in-place; review diffs before committing changes.", - ); + 'Files were updated in-place; review diffs before committing changes.', + ) } else { console.log( - "Run with --apply to write cleaned locale files. Backups will be created unless --no-backup is passed.", - ); + 'Run with --apply to write cleaned locale files. Backups will be created unless --no-backup is passed.', + ) if (options.align) { console.log( - "Alignment was evaluated in dry-run mode; rerun with --apply to rewrite locale files.", - ); + 'Alignment was evaluated in dry-run mode; rerun with --apply to rewrite locale files.', + ) } else { console.log( - "Pass --align to normalize locale structure/order based on the baseline locale.", - ); + 'Pass --align to normalize locale structure/order based on the baseline locale.', + ) } } @@ -1493,16 +1484,16 @@ function main() { locales: report.results.map((result) => result.locale), })), results: allResults, - }; - writeReport(options.reportPath, payload); - console.log(`Report written to ${options.reportPath}`); + } + writeReport(options.reportPath, payload) + console.log(`Report written to ${options.reportPath}`) } } try { - main(); + main() } catch (error) { - console.error("Failed to complete i18n cleanup draft."); - console.error(error); - process.exit(1); + console.error('Failed to complete i18n cleanup draft.') + console.error(error) + process.exit(1) } diff --git a/scripts/fix-alpha_version.mjs b/scripts/fix-alpha_version.mjs index 7817eb4a1..d88e918a0 100644 --- a/scripts/fix-alpha_version.mjs +++ b/scripts/fix-alpha_version.mjs @@ -1,26 +1,26 @@ -import { exec } from "child_process"; -import fs from "fs/promises"; -import path from "path"; -import { promisify } from "util"; +import { exec } from 'child_process' +import fs from 'fs/promises' +import path from 'path' +import { promisify } from 'util' /** * 为Alpha版本重命名版本号 */ -const execPromise = promisify(exec); +const execPromise = promisify(exec) /** * 标准输出HEAD hash */ async function getLatestCommitHash() { try { - const { stdout } = await execPromise("git rev-parse HEAD"); - const commitHash = stdout.trim(); + const { stdout } = await execPromise('git rev-parse HEAD') + const commitHash = stdout.trim() // 格式化,只截取前7位字符 - const formathash = commitHash.substring(0, 7); - console.log(`Found the latest commit hash code: ${commitHash}`); - return formathash; + const formathash = commitHash.substring(0, 7) + console.log(`Found the latest commit hash code: ${commitHash}`) + return formathash } catch (error) { - console.error("pnpm run fix-alpha-version ERROR", error); + console.error('pnpm run fix-alpha-version ERROR', error) } } @@ -30,38 +30,35 @@ async function getLatestCommitHash() { */ async function updatePackageVersion(newVersion) { // 获取内容根目录 - const _dirname = process.cwd(); - const packageJsonPath = path.join(_dirname, "package.json"); + const _dirname = process.cwd() + const packageJsonPath = path.join(_dirname, 'package.json') try { // 读取文件 - const data = await fs.readFile(packageJsonPath, "utf8"); - const packageJson = JSON.parse(data); + const data = await fs.readFile(packageJsonPath, 'utf8') + const packageJson = JSON.parse(data) // 获取键值替换 - let result = packageJson.version.replace("alpha", newVersion); + let result = packageJson.version.replace('alpha', newVersion) // 检查当前版本号是否已经包含了 alpha- 后缀 if (!packageJson.version.includes(`alpha-`)) { // 如果只有 alpha 而没有 alpha-,则替换为 alpha-newVersion - result = packageJson.version.replace("alpha", `alpha-${newVersion}`); + result = packageJson.version.replace('alpha', `alpha-${newVersion}`) } else { // 如果已经是 alpha-xxx 格式,则更新 xxx 部分 - result = packageJson.version.replace( - /alpha-[^-]*/, - `alpha-${newVersion}`, - ); + result = packageJson.version.replace(/alpha-[^-]*/, `alpha-${newVersion}`) } - console.log("[INFO]: Current version is: ", result); - packageJson.version = result; + console.log('[INFO]: Current version is: ', result) + packageJson.version = result // 写入版本号 await fs.writeFile( packageJsonPath, JSON.stringify(packageJson, null, 2), - "utf8", - ); - console.log(`[INFO]: Alpha version update to: ${newVersion}`); + 'utf8', + ) + console.log(`[INFO]: Alpha version update to: ${newVersion}`) } catch (error) { - console.error("pnpm run fix-alpha-version ERROR", error); + console.error('pnpm run fix-alpha-version ERROR', error) } } -const newVersion = await getLatestCommitHash(); -updatePackageVersion(newVersion).catch(console.error); +const newVersion = await getLatestCommitHash() +updatePackageVersion(newVersion).catch(console.error) diff --git a/scripts/generate-i18n-keys.mjs b/scripts/generate-i18n-keys.mjs index 133ab5ef3..d22a46e35 100644 --- a/scripts/generate-i18n-keys.mjs +++ b/scripts/generate-i18n-keys.mjs @@ -1,98 +1,98 @@ #!/usr/bin/env node -import { promises as fs } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const ROOT_DIR = path.resolve(__dirname, ".."); -const LOCALE_DIR = path.resolve(ROOT_DIR, "src/locales/en"); -const KEY_OUTPUT = path.resolve(ROOT_DIR, "src/types/generated/i18n-keys.ts"); +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const ROOT_DIR = path.resolve(__dirname, '..') +const LOCALE_DIR = path.resolve(ROOT_DIR, 'src/locales/en') +const KEY_OUTPUT = path.resolve(ROOT_DIR, 'src/types/generated/i18n-keys.ts') const RESOURCE_OUTPUT = path.resolve( ROOT_DIR, - "src/types/generated/i18n-resources.ts", -); + 'src/types/generated/i18n-resources.ts', +) const isPlainObject = (value) => - typeof value === "object" && value !== null && !Array.isArray(value); + typeof value === 'object' && value !== null && !Array.isArray(value) -const flattenKeys = (data, prefix = "") => { - const keys = []; +const flattenKeys = (data, prefix = '') => { + const keys = [] for (const [key, value] of Object.entries(data)) { - const nextPrefix = prefix ? `${prefix}.${key}` : key; + const nextPrefix = prefix ? `${prefix}.${key}` : key if (isPlainObject(value)) { - keys.push(...flattenKeys(value, nextPrefix)); + keys.push(...flattenKeys(value, nextPrefix)) } else { - keys.push(nextPrefix); + keys.push(nextPrefix) } } - return keys; -}; + return keys +} const buildType = (data, indent = 0) => { if (!isPlainObject(data)) { - return "string"; + return 'string' } - const entries = Object.entries(data).sort(([a], [b]) => a.localeCompare(b)); - const pad = " ".repeat(indent); + const entries = Object.entries(data).sort(([a], [b]) => a.localeCompare(b)) + const pad = ' '.repeat(indent) const inner = entries .map(([key, value]) => { - const typeStr = buildType(value, indent + 2); - return `${" ".repeat(indent + 2)}${JSON.stringify(key)}: ${typeStr};`; + const typeStr = buildType(value, indent + 2) + return `${' '.repeat(indent + 2)}${JSON.stringify(key)}: ${typeStr};` }) - .join("\n"); + .join('\n') return entries.length ? `{ ${inner} ${pad}}` - : "{}"; -}; + : '{}' +} const loadNamespaceJson = async () => { - const dirents = await fs.readdir(LOCALE_DIR, { withFileTypes: true }); - const namespaces = []; + const dirents = await fs.readdir(LOCALE_DIR, { withFileTypes: true }) + const namespaces = [] for (const dirent of dirents) { - if (!dirent.isFile() || !dirent.name.endsWith(".json")) continue; - const name = dirent.name.replace(/\.json$/, ""); - const filePath = path.join(LOCALE_DIR, dirent.name); - const raw = await fs.readFile(filePath, "utf8"); - const json = JSON.parse(raw); - namespaces.push({ name, json }); + if (!dirent.isFile() || !dirent.name.endsWith('.json')) continue + const name = dirent.name.replace(/\.json$/, '') + const filePath = path.join(LOCALE_DIR, dirent.name) + const raw = await fs.readFile(filePath, 'utf8') + const json = JSON.parse(raw) + namespaces.push({ name, json }) } - namespaces.sort((a, b) => a.name.localeCompare(b.name)); - return namespaces; -}; + namespaces.sort((a, b) => a.name.localeCompare(b.name)) + return namespaces +} const buildKeysFile = (keys) => { - const arrayLiteral = keys.map((key) => ` "${key}"`).join(",\n"); - return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport const translationKeys = [\n${arrayLiteral}\n] as const;\n\nexport type TranslationKey = typeof translationKeys[number];\n`; -}; + const arrayLiteral = keys.map((key) => ` "${key}"`).join(',\n') + return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport const translationKeys = [\n${arrayLiteral}\n] as const;\n\nexport type TranslationKey = typeof translationKeys[number];\n` +} const buildResourcesFile = (namespaces) => { const namespaceEntries = namespaces .map(({ name, json }) => { - const typeStr = buildType(json, 4); - return ` ${JSON.stringify(name)}: ${typeStr};`; + const typeStr = buildType(json, 4) + return ` ${JSON.stringify(name)}: ${typeStr};` }) - .join("\n"); + .join('\n') - return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport interface TranslationResources {\n translation: {\n${namespaceEntries}\n };\n}\n`; -}; + return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport interface TranslationResources {\n translation: {\n${namespaceEntries}\n };\n}\n` +} const main = async () => { - const namespaces = await loadNamespaceJson(); - const keys = namespaces.flatMap(({ name, json }) => flattenKeys(json, name)); - const keysContent = buildKeysFile(keys); - const resourcesContent = buildResourcesFile(namespaces); - await fs.mkdir(path.dirname(KEY_OUTPUT), { recursive: true }); - await fs.writeFile(KEY_OUTPUT, keysContent, "utf8"); - await fs.writeFile(RESOURCE_OUTPUT, resourcesContent, "utf8"); - console.log(`Generated ${keys.length} translation keys.`); -}; + const namespaces = await loadNamespaceJson() + const keys = namespaces.flatMap(({ name, json }) => flattenKeys(json, name)) + const keysContent = buildKeysFile(keys) + const resourcesContent = buildResourcesFile(namespaces) + await fs.mkdir(path.dirname(KEY_OUTPUT), { recursive: true }) + await fs.writeFile(KEY_OUTPUT, keysContent, 'utf8') + await fs.writeFile(RESOURCE_OUTPUT, resourcesContent, 'utf8') + console.log(`Generated ${keys.length} translation keys.`) +} main().catch((error) => { - console.error("Failed to generate i18n metadata:", error); - process.exitCode = 1; -}); + console.error('Failed to generate i18n metadata:', error) + process.exitCode = 1 +}) diff --git a/scripts/portable-fixed-webview2.mjs b/scripts/portable-fixed-webview2.mjs index 1a62be3f4..3f9ec0357 100644 --- a/scripts/portable-fixed-webview2.mjs +++ b/scripts/portable-fixed-webview2.mjs @@ -1,104 +1,104 @@ -import fs from "fs"; -import fsp from "fs/promises"; -import { createRequire } from "module"; -import path from "path"; +import fs from 'fs' +import fsp from 'fs/promises' +import { createRequire } from 'module' +import path from 'path' -import { context, getOctokit } from "@actions/github"; -import AdmZip from "adm-zip"; +import { context, getOctokit } from '@actions/github' +import AdmZip from 'adm-zip' -const target = process.argv.slice(2)[0]; -const alpha = process.argv.slice(2)[1]; +const target = process.argv.slice(2)[0] +const alpha = process.argv.slice(2)[1] const ARCH_MAP = { - "x86_64-pc-windows-msvc": "x64", - "i686-pc-windows-msvc": "x86", - "aarch64-pc-windows-msvc": "arm64", -}; + 'x86_64-pc-windows-msvc': 'x64', + 'i686-pc-windows-msvc': 'x86', + 'aarch64-pc-windows-msvc': 'arm64', +} const PROCESS_MAP = { - x64: "x64", - ia32: "x86", - arm64: "arm64", -}; -const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch]; + x64: 'x64', + ia32: 'x86', + arm64: 'arm64', +} +const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch] /// Script for ci /// 打包绿色版/便携版 (only Windows) async function resolvePortable() { - if (process.platform !== "win32") return; + if (process.platform !== 'win32') return const releaseDir = target ? `./src-tauri/target/${target}/release` - : `./src-tauri/target/release`; + : `./src-tauri/target/release` - const configDir = path.join(releaseDir, ".config"); + const configDir = path.join(releaseDir, '.config') if (!fs.existsSync(releaseDir)) { - throw new Error("could not found the release dir"); + throw new Error('could not found the release dir') } - await fsp.mkdir(configDir, { recursive: true }); - if (!fs.existsSync(path.join(configDir, "PORTABLE"))) { - await fsp.writeFile(path.join(configDir, "PORTABLE"), ""); + await fsp.mkdir(configDir, { recursive: true }) + if (!fs.existsSync(path.join(configDir, 'PORTABLE'))) { + await fsp.writeFile(path.join(configDir, 'PORTABLE'), '') } - const zip = new AdmZip(); + const zip = new AdmZip() - zip.addLocalFile(path.join(releaseDir, "Clash Verge.exe")); - zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe")); - zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe")); - zip.addLocalFolder(path.join(releaseDir, "resources"), "resources"); + zip.addLocalFile(path.join(releaseDir, 'Clash Verge.exe')) + zip.addLocalFile(path.join(releaseDir, 'verge-mihomo.exe')) + zip.addLocalFile(path.join(releaseDir, 'verge-mihomo-alpha.exe')) + zip.addLocalFolder(path.join(releaseDir, 'resources'), 'resources') zip.addLocalFolder( path.join( releaseDir, `Microsoft.WebView2.FixedVersionRuntime.133.0.3065.92.${arch}`, ), `Microsoft.WebView2.FixedVersionRuntime.133.0.3065.92.${arch}`, - ); - zip.addLocalFolder(configDir, ".config"); + ) + zip.addLocalFolder(configDir, '.config') - const require = createRequire(import.meta.url); - const packageJson = require("../package.json"); - const { version } = packageJson; + const require = createRequire(import.meta.url) + const packageJson = require('../package.json') + const { version } = packageJson - const zipFile = `Clash.Verge_${version}_${arch}_fixed_webview2_portable.zip`; - zip.writeZip(zipFile); + const zipFile = `Clash.Verge_${version}_${arch}_fixed_webview2_portable.zip` + zip.writeZip(zipFile) - console.log("[INFO]: create portable zip successfully"); + console.log('[INFO]: create portable zip successfully') // push release assets if (process.env.GITHUB_TOKEN === undefined) { - throw new Error("GITHUB_TOKEN is required"); + throw new Error('GITHUB_TOKEN is required') } - const options = { owner: context.repo.owner, repo: context.repo.repo }; - const github = getOctokit(process.env.GITHUB_TOKEN); - const tag = alpha ? "alpha" : process.env.TAG_NAME || `v${version}`; - console.log("[INFO]: upload to ", tag); + const options = { owner: context.repo.owner, repo: context.repo.repo } + const github = getOctokit(process.env.GITHUB_TOKEN) + const tag = alpha ? 'alpha' : process.env.TAG_NAME || `v${version}` + console.log('[INFO]: upload to ', tag) const { data: release } = await github.rest.repos.getReleaseByTag({ ...options, tag, - }); + }) const assets = release.assets.filter((x) => { - return x.name === zipFile; - }); + return x.name === zipFile + }) if (assets.length > 0) { - const id = assets[0].id; + const id = assets[0].id await github.rest.repos.deleteReleaseAsset({ ...options, asset_id: id, - }); + }) } - console.log(release.name); + console.log(release.name) await github.rest.repos.uploadReleaseAsset({ ...options, release_id: release.id, name: zipFile, data: zip.toBuffer(), - }); + }) } -resolvePortable().catch(console.error); +resolvePortable().catch(console.error) diff --git a/scripts/portable.mjs b/scripts/portable.mjs index e1918971b..b306b2fe7 100644 --- a/scripts/portable.mjs +++ b/scripts/portable.mjs @@ -1,53 +1,53 @@ -import fs from "fs"; -import fsp from "fs/promises"; -import { createRequire } from "module"; -import path from "path"; +import fs from 'fs' +import fsp from 'fs/promises' +import { createRequire } from 'module' +import path from 'path' -import AdmZip from "adm-zip"; +import AdmZip from 'adm-zip' -const target = process.argv.slice(2)[0]; +const target = process.argv.slice(2)[0] const ARCH_MAP = { - "x86_64-pc-windows-msvc": "x64", - "aarch64-pc-windows-msvc": "arm64", -}; + 'x86_64-pc-windows-msvc': 'x64', + 'aarch64-pc-windows-msvc': 'arm64', +} const PROCESS_MAP = { - x64: "x64", - arm64: "arm64", -}; -const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch]; + x64: 'x64', + arm64: 'arm64', +} +const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch] /// Script for ci /// 打包绿色版/便携版 (only Windows) async function resolvePortable() { - if (process.platform !== "win32") return; + if (process.platform !== 'win32') return const releaseDir = target ? `./src-tauri/target/${target}/release` - : `./src-tauri/target/release`; - const configDir = path.join(releaseDir, ".config"); + : `./src-tauri/target/release` + const configDir = path.join(releaseDir, '.config') if (!fs.existsSync(releaseDir)) { - throw new Error("could not found the release dir"); + throw new Error('could not found the release dir') } - await fsp.mkdir(configDir, { recursive: true }); - if (!fs.existsSync(path.join(configDir, "PORTABLE"))) { - await fsp.writeFile(path.join(configDir, "PORTABLE"), ""); + await fsp.mkdir(configDir, { recursive: true }) + if (!fs.existsSync(path.join(configDir, 'PORTABLE'))) { + await fsp.writeFile(path.join(configDir, 'PORTABLE'), '') } - const zip = new AdmZip(); + const zip = new AdmZip() - zip.addLocalFile(path.join(releaseDir, "clash-verge.exe")); - zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe")); - zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe")); - zip.addLocalFolder(path.join(releaseDir, "resources"), "resources"); - zip.addLocalFolder(configDir, ".config"); + zip.addLocalFile(path.join(releaseDir, 'clash-verge.exe')) + zip.addLocalFile(path.join(releaseDir, 'verge-mihomo.exe')) + zip.addLocalFile(path.join(releaseDir, 'verge-mihomo-alpha.exe')) + zip.addLocalFolder(path.join(releaseDir, 'resources'), 'resources') + zip.addLocalFolder(configDir, '.config') - const require = createRequire(import.meta.url); - const packageJson = require("../package.json"); - const { version } = packageJson; - const zipFile = `Clash.Verge_${version}_${arch}_portable.zip`; - zip.writeZip(zipFile); - console.log("[INFO]: create portable zip successfully"); + const require = createRequire(import.meta.url) + const packageJson = require('../package.json') + const { version } = packageJson + const zipFile = `Clash.Verge_${version}_${arch}_portable.zip` + zip.writeZip(zipFile) + console.log('[INFO]: create portable zip successfully') } -resolvePortable().catch(console.error); +resolvePortable().catch(console.error) diff --git a/scripts/prebuild.mjs b/scripts/prebuild.mjs index abf934565..404631c47 100644 --- a/scripts/prebuild.mjs +++ b/scripts/prebuild.mjs @@ -1,17 +1,17 @@ -import { execSync } from "child_process"; -import { createHash } from "crypto"; -import fs from "fs"; -import fsp from "fs/promises"; -import path from "path"; -import zlib from "zlib"; +import { execSync } from 'child_process' +import { createHash } from 'crypto' +import fs from 'fs' +import fsp from 'fs/promises' +import path from 'path' +import zlib from 'zlib' -import AdmZip from "adm-zip"; -import { glob } from "glob"; -import { HttpsProxyAgent } from "https-proxy-agent"; -import fetch from "node-fetch"; -import { extract } from "tar"; +import AdmZip from 'adm-zip' +import { glob } from 'glob' +import { HttpsProxyAgent } from 'https-proxy-agent' +import fetch from 'node-fetch' +import { extract } from 'tar' -import { log_debug, log_error, log_info, log_success } from "./utils.mjs"; +import { log_debug, log_error, log_info, log_success } from './utils.mjs' /** * Prebuild script with optimization features: @@ -22,56 +22,56 @@ import { log_debug, log_error, log_info, log_success } from "./utils.mjs"; * */ -const cwd = process.cwd(); -const TEMP_DIR = path.join(cwd, "node_modules/.verge"); -const FORCE = process.argv.includes("--force") || process.argv.includes("-f"); -const VERSION_CACHE_FILE = path.join(TEMP_DIR, ".version_cache.json"); -const HASH_CACHE_FILE = path.join(TEMP_DIR, ".hash_cache.json"); +const cwd = process.cwd() +const TEMP_DIR = path.join(cwd, 'node_modules/.verge') +const FORCE = process.argv.includes('--force') || process.argv.includes('-f') +const VERSION_CACHE_FILE = path.join(TEMP_DIR, '.version_cache.json') +const HASH_CACHE_FILE = path.join(TEMP_DIR, '.hash_cache.json') const PLATFORM_MAP = { - "x86_64-pc-windows-msvc": "win32", - "i686-pc-windows-msvc": "win32", - "aarch64-pc-windows-msvc": "win32", - "x86_64-apple-darwin": "darwin", - "aarch64-apple-darwin": "darwin", - "x86_64-unknown-linux-gnu": "linux", - "i686-unknown-linux-gnu": "linux", - "aarch64-unknown-linux-gnu": "linux", - "armv7-unknown-linux-gnueabihf": "linux", - "riscv64gc-unknown-linux-gnu": "linux", - "loongarch64-unknown-linux-gnu": "linux", -}; + 'x86_64-pc-windows-msvc': 'win32', + 'i686-pc-windows-msvc': 'win32', + 'aarch64-pc-windows-msvc': 'win32', + 'x86_64-apple-darwin': 'darwin', + 'aarch64-apple-darwin': 'darwin', + 'x86_64-unknown-linux-gnu': 'linux', + 'i686-unknown-linux-gnu': 'linux', + 'aarch64-unknown-linux-gnu': 'linux', + 'armv7-unknown-linux-gnueabihf': 'linux', + 'riscv64gc-unknown-linux-gnu': 'linux', + 'loongarch64-unknown-linux-gnu': 'linux', +} const ARCH_MAP = { - "x86_64-pc-windows-msvc": "x64", - "i686-pc-windows-msvc": "ia32", - "aarch64-pc-windows-msvc": "arm64", - "x86_64-apple-darwin": "x64", - "aarch64-apple-darwin": "arm64", - "x86_64-unknown-linux-gnu": "x64", - "i686-unknown-linux-gnu": "ia32", - "aarch64-unknown-linux-gnu": "arm64", - "armv7-unknown-linux-gnueabihf": "arm", - "riscv64gc-unknown-linux-gnu": "riscv64", - "loongarch64-unknown-linux-gnu": "loong64", -}; + 'x86_64-pc-windows-msvc': 'x64', + 'i686-pc-windows-msvc': 'ia32', + 'aarch64-pc-windows-msvc': 'arm64', + 'x86_64-apple-darwin': 'x64', + 'aarch64-apple-darwin': 'arm64', + 'x86_64-unknown-linux-gnu': 'x64', + 'i686-unknown-linux-gnu': 'ia32', + 'aarch64-unknown-linux-gnu': 'arm64', + 'armv7-unknown-linux-gnueabihf': 'arm', + 'riscv64gc-unknown-linux-gnu': 'riscv64', + 'loongarch64-unknown-linux-gnu': 'loong64', +} -const arg1 = process.argv.slice(2)[0]; -const arg2 = process.argv.slice(2)[1]; -const target = arg1 === "--force" || arg1 === "-f" ? arg2 : arg1; +const arg1 = process.argv.slice(2)[0] +const arg2 = process.argv.slice(2)[1] +const target = arg1 === '--force' || arg1 === '-f' ? arg2 : arg1 const { platform, arch } = target ? { platform: PLATFORM_MAP[target], arch: ARCH_MAP[target] } - : process; + : process const SIDECAR_HOST = target ? target - : execSync("rustc -vV") + : execSync('rustc -vV') .toString() - .match(/(?<=host: ).+(?=\s*)/g)[0]; + .match(/(?<=host: ).+(?=\s*)/g)[0] -const RESOURCES_DIR = path.join(cwd, "src-tauri", "resources"); -const SIDECAR_DIR = path.join(cwd, "src-tauri", "sidecar"); +const RESOURCES_DIR = path.join(cwd, 'src-tauri', 'resources') +const SIDECAR_DIR = path.join(cwd, 'src-tauri', 'sidecar') // Linux service binaries are bundled as externalBin sidecars (see tauri.linux.conf.json) -const SERVICE_DIR = platform === "linux" ? SIDECAR_DIR : RESOURCES_DIR; +const SERVICE_DIR = platform === 'linux' ? SIDECAR_DIR : RESOURCES_DIR // ======================= // Version Cache @@ -79,36 +79,36 @@ const SERVICE_DIR = platform === "linux" ? SIDECAR_DIR : RESOURCES_DIR; async function loadVersionCache() { try { if (fs.existsSync(VERSION_CACHE_FILE)) { - const data = await fsp.readFile(VERSION_CACHE_FILE, "utf-8"); - return JSON.parse(data); + const data = await fsp.readFile(VERSION_CACHE_FILE, 'utf-8') + return JSON.parse(data) } } catch (err) { - log_debug("Failed to load version cache:", err.message); + log_debug('Failed to load version cache:', err.message) } - return {}; + return {} } async function saveVersionCache(cache) { try { - await fsp.mkdir(TEMP_DIR, { recursive: true }); - await fsp.writeFile(VERSION_CACHE_FILE, JSON.stringify(cache, null, 2)); - log_debug("Version cache saved"); + await fsp.mkdir(TEMP_DIR, { recursive: true }) + await fsp.writeFile(VERSION_CACHE_FILE, JSON.stringify(cache, null, 2)) + log_debug('Version cache saved') } catch (err) { - log_debug("Failed to save version cache:", err.message); + log_debug('Failed to save version cache:', err.message) } } async function getCachedVersion(key) { - const cache = await loadVersionCache(); - const cached = cache[key]; + const cache = await loadVersionCache() + const cached = cache[key] if (cached && Date.now() - cached.timestamp < 3600000) { - log_info(`Using cached version for ${key}: ${cached.version}`); - return cached.version; + log_info(`Using cached version for ${key}: ${cached.version}`) + return cached.version } - return null; + return null } async function setCachedVersion(key, version) { - const cache = await loadVersionCache(); - cache[key] = { version, timestamp: Date.now() }; - await saveVersionCache(cache); + const cache = await loadVersionCache() + cache[key] = { version, timestamp: Date.now() } + await saveVersionCache(cache) } // ======================= @@ -116,54 +116,54 @@ async function setCachedVersion(key, version) { // ======================= async function calculateFileHash(filePath) { try { - const fileBuffer = await fsp.readFile(filePath); - const hashSum = createHash("sha256"); - hashSum.update(fileBuffer); - return hashSum.digest("hex"); + const fileBuffer = await fsp.readFile(filePath) + const hashSum = createHash('sha256') + hashSum.update(fileBuffer) + return hashSum.digest('hex') } catch (ignoreErr) { - return null; + return null } } async function loadHashCache() { try { if (fs.existsSync(HASH_CACHE_FILE)) { - const data = await fsp.readFile(HASH_CACHE_FILE, "utf-8"); - return JSON.parse(data); + const data = await fsp.readFile(HASH_CACHE_FILE, 'utf-8') + return JSON.parse(data) } } catch (err) { - log_debug("Failed to load hash cache:", err.message); + log_debug('Failed to load hash cache:', err.message) } - return {}; + return {} } async function saveHashCache(cache) { try { - await fsp.mkdir(TEMP_DIR, { recursive: true }); - await fsp.writeFile(HASH_CACHE_FILE, JSON.stringify(cache, null, 2)); - log_debug("Hash cache saved"); + await fsp.mkdir(TEMP_DIR, { recursive: true }) + await fsp.writeFile(HASH_CACHE_FILE, JSON.stringify(cache, null, 2)) + log_debug('Hash cache saved') } catch (err) { - log_debug("Failed to save hash cache:", err.message); + log_debug('Failed to save hash cache:', err.message) } } async function hasFileChanged(filePath, targetPath) { - if (FORCE) return true; - if (!fs.existsSync(targetPath)) return true; - const hashCache = await loadHashCache(); - const sourceHash = await calculateFileHash(filePath); - const targetHash = await calculateFileHash(targetPath); - if (!sourceHash || !targetHash) return true; - const cacheKey = targetPath; - const cachedHash = hashCache[cacheKey]; + if (FORCE) return true + if (!fs.existsSync(targetPath)) return true + const hashCache = await loadHashCache() + const sourceHash = await calculateFileHash(filePath) + const targetHash = await calculateFileHash(targetPath) + if (!sourceHash || !targetHash) return true + const cacheKey = targetPath + const cachedHash = hashCache[cacheKey] if (cachedHash === sourceHash && sourceHash === targetHash) { - return false; + return false } - return true; + return true } async function updateHashCache(targetPath) { - const hashCache = await loadHashCache(); - const hash = await calculateFileHash(targetPath); + const hashCache = await loadHashCache() + const hash = await calculateFileHash(targetPath) if (hash) { - hashCache[targetPath] = hash; - await saveHashCache(hashCache); + hashCache[targetPath] = hash + await saveHashCache(hashCache) } } @@ -171,111 +171,109 @@ async function updateHashCache(targetPath) { // Meta maps (stable & alpha) // ======================= const META_ALPHA_VERSION_URL = - "https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt"; -const META_ALPHA_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha`; -let META_ALPHA_VERSION; + 'https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha/version.txt' +const META_ALPHA_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha` +let META_ALPHA_VERSION const META_VERSION_URL = - "https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt"; -const META_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download`; -let META_VERSION; + 'https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt' +const META_URL_PREFIX = `https://github.com/MetaCubeX/mihomo/releases/download` +let META_VERSION const META_ALPHA_MAP = { - "win32-x64": "mihomo-windows-amd64-v2", - "win32-ia32": "mihomo-windows-386", - "win32-arm64": "mihomo-windows-arm64", - "darwin-x64": "mihomo-darwin-amd64-v1-go122", - "darwin-arm64": "mihomo-darwin-arm64-go122", - "linux-x64": "mihomo-linux-amd64-v2", - "linux-ia32": "mihomo-linux-386", - "linux-arm64": "mihomo-linux-arm64", - "linux-arm": "mihomo-linux-armv7", - "linux-riscv64": "mihomo-linux-riscv64", - "linux-loong64": "mihomo-linux-loong64", -}; + 'win32-x64': 'mihomo-windows-amd64-v2', + 'win32-ia32': 'mihomo-windows-386', + 'win32-arm64': 'mihomo-windows-arm64', + 'darwin-x64': 'mihomo-darwin-amd64-v1-go122', + 'darwin-arm64': 'mihomo-darwin-arm64-go122', + 'linux-x64': 'mihomo-linux-amd64-v2', + 'linux-ia32': 'mihomo-linux-386', + 'linux-arm64': 'mihomo-linux-arm64', + 'linux-arm': 'mihomo-linux-armv7', + 'linux-riscv64': 'mihomo-linux-riscv64', + 'linux-loong64': 'mihomo-linux-loong64', +} const META_MAP = { - "win32-x64": "mihomo-windows-amd64-v2", - "win32-ia32": "mihomo-windows-386", - "win32-arm64": "mihomo-windows-arm64", - "darwin-x64": "mihomo-darwin-amd64-v2-go122", - "darwin-arm64": "mihomo-darwin-arm64-go122", - "linux-x64": "mihomo-linux-amd64-v2", - "linux-ia32": "mihomo-linux-386", - "linux-arm64": "mihomo-linux-arm64", - "linux-arm": "mihomo-linux-armv7", - "linux-riscv64": "mihomo-linux-riscv64", - "linux-loong64": "mihomo-linux-loong64", -}; + 'win32-x64': 'mihomo-windows-amd64-v2', + 'win32-ia32': 'mihomo-windows-386', + 'win32-arm64': 'mihomo-windows-arm64', + 'darwin-x64': 'mihomo-darwin-amd64-v2-go122', + 'darwin-arm64': 'mihomo-darwin-arm64-go122', + 'linux-x64': 'mihomo-linux-amd64-v2', + 'linux-ia32': 'mihomo-linux-386', + 'linux-arm64': 'mihomo-linux-arm64', + 'linux-arm': 'mihomo-linux-armv7', + 'linux-riscv64': 'mihomo-linux-riscv64', + 'linux-loong64': 'mihomo-linux-loong64', +} // ======================= // Fetch latest versions // ======================= async function getLatestAlphaVersion() { if (!FORCE) { - const cached = await getCachedVersion("META_ALPHA_VERSION"); + const cached = await getCachedVersion('META_ALPHA_VERSION') if (cached) { - META_ALPHA_VERSION = cached; - return; + META_ALPHA_VERSION = cached + return } } - const options = {}; + const options = {} const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || - process.env.https_proxy; - if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy); + process.env.https_proxy + if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy) try { const response = await fetch(META_ALPHA_VERSION_URL, { ...options, - method: "GET", - }); + method: 'GET', + }) if (!response.ok) throw new Error( `Failed to fetch ${META_ALPHA_VERSION_URL}: ${response.status}`, - ); - META_ALPHA_VERSION = (await response.text()).trim(); - log_info(`Latest alpha version: ${META_ALPHA_VERSION}`); - await setCachedVersion("META_ALPHA_VERSION", META_ALPHA_VERSION); + ) + META_ALPHA_VERSION = (await response.text()).trim() + log_info(`Latest alpha version: ${META_ALPHA_VERSION}`) + await setCachedVersion('META_ALPHA_VERSION', META_ALPHA_VERSION) } catch (err) { - log_error("Error fetching latest alpha version:", err.message); - process.exit(1); + log_error('Error fetching latest alpha version:', err.message) + process.exit(1) } } async function getLatestReleaseVersion() { if (!FORCE) { - const cached = await getCachedVersion("META_VERSION"); + const cached = await getCachedVersion('META_VERSION') if (cached) { - META_VERSION = cached; - return; + META_VERSION = cached + return } } - const options = {}; + const options = {} const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || - process.env.https_proxy; - if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy); + process.env.https_proxy + if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy) try { const response = await fetch(META_VERSION_URL, { ...options, - method: "GET", - }); + method: 'GET', + }) if (!response.ok) - throw new Error( - `Failed to fetch ${META_VERSION_URL}: ${response.status}`, - ); - META_VERSION = (await response.text()).trim(); - log_info(`Latest release version: ${META_VERSION}`); - await setCachedVersion("META_VERSION", META_VERSION); + throw new Error(`Failed to fetch ${META_VERSION_URL}: ${response.status}`) + META_VERSION = (await response.text()).trim() + log_info(`Latest release version: ${META_VERSION}`) + await setCachedVersion('META_VERSION', META_VERSION) } catch (err) { - log_error("Error fetching latest release version:", err.message); - process.exit(1); + log_error('Error fetching latest release version:', err.message) + process.exit(1) } } @@ -283,432 +281,430 @@ async function getLatestReleaseVersion() { // Validate availability // ======================= if (!META_MAP[`${platform}-${arch}`]) { - throw new Error(`clash meta unsupported platform "${platform}-${arch}"`); + throw new Error(`clash meta unsupported platform "${platform}-${arch}"`) } if (!META_ALPHA_MAP[`${platform}-${arch}`]) { - throw new Error( - `clash meta alpha unsupported platform "${platform}-${arch}"`, - ); + throw new Error(`clash meta alpha unsupported platform "${platform}-${arch}"`) } // ======================= // Build meta objects // ======================= function clashMetaAlpha() { - const name = META_ALPHA_MAP[`${platform}-${arch}`]; - const isWin = platform === "win32"; - const urlExt = isWin ? "zip" : "gz"; + const name = META_ALPHA_MAP[`${platform}-${arch}`] + const isWin = platform === 'win32' + const urlExt = isWin ? 'zip' : 'gz' return { - name: "verge-mihomo-alpha", - targetFile: `verge-mihomo-alpha-${SIDECAR_HOST}${isWin ? ".exe" : ""}`, - exeFile: `${name}${isWin ? ".exe" : ""}`, + name: 'verge-mihomo-alpha', + targetFile: `verge-mihomo-alpha-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, + exeFile: `${name}${isWin ? '.exe' : ''}`, zipFile: `${name}-${META_ALPHA_VERSION}.${urlExt}`, downloadURL: `${META_ALPHA_URL_PREFIX}/${name}-${META_ALPHA_VERSION}.${urlExt}`, - }; + } } function clashMeta() { - const name = META_MAP[`${platform}-${arch}`]; - const isWin = platform === "win32"; - const urlExt = isWin ? "zip" : "gz"; + const name = META_MAP[`${platform}-${arch}`] + const isWin = platform === 'win32' + const urlExt = isWin ? 'zip' : 'gz' return { - name: "verge-mihomo", - targetFile: `verge-mihomo-${SIDECAR_HOST}${isWin ? ".exe" : ""}`, - exeFile: `${name}${isWin ? ".exe" : ""}`, + name: 'verge-mihomo', + targetFile: `verge-mihomo-${SIDECAR_HOST}${isWin ? '.exe' : ''}`, + exeFile: `${name}${isWin ? '.exe' : ''}`, zipFile: `${name}-${META_VERSION}.${urlExt}`, downloadURL: `${META_URL_PREFIX}/${META_VERSION}/${name}-${META_VERSION}.${urlExt}`, - }; + } } // ======================= // download helper (增强:status + magic bytes) // ======================= async function downloadFile(url, outPath) { - const options = {}; + const options = {} const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || - process.env.https_proxy; - if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy); + process.env.https_proxy + if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy) const response = await fetch(url, { ...options, - method: "GET", - headers: { "Content-Type": "application/octet-stream" }, - }); + method: 'GET', + headers: { 'Content-Type': 'application/octet-stream' }, + }) if (!response.ok) { - const body = await response.text().catch(() => ""); + const body = await response.text().catch(() => '') // 将 body 写到文件以便排查(可通过临时目录查看) - await fsp.mkdir(path.dirname(outPath), { recursive: true }); - await fsp.writeFile(outPath, body); - throw new Error(`Failed to download ${url}: status ${response.status}`); + await fsp.mkdir(path.dirname(outPath), { recursive: true }) + await fsp.writeFile(outPath, body) + throw new Error(`Failed to download ${url}: status ${response.status}`) } - const buf = Buffer.from(await response.arrayBuffer()); - await fsp.mkdir(path.dirname(outPath), { recursive: true }); + const buf = Buffer.from(await response.arrayBuffer()) + await fsp.mkdir(path.dirname(outPath), { recursive: true }) // 简单 magic 字节检查 - if (url.endsWith(".gz") || url.endsWith(".tgz")) { + if (url.endsWith('.gz') || url.endsWith('.tgz')) { if (!(buf[0] === 0x1f && buf[1] === 0x8b)) { - await fsp.writeFile(outPath, buf); + await fsp.writeFile(outPath, buf) throw new Error( `Downloaded file for ${url} is not a valid gzip (magic mismatch).`, - ); + ) } - } else if (url.endsWith(".zip")) { + } else if (url.endsWith('.zip')) { if (!(buf[0] === 0x50 && buf[1] === 0x4b)) { - await fsp.writeFile(outPath, buf); + await fsp.writeFile(outPath, buf) throw new Error( `Downloaded file for ${url} is not a valid zip (magic mismatch).`, - ); + ) } } - await fsp.writeFile(outPath, buf); - log_success(`download finished: ${url}`); + await fsp.writeFile(outPath, buf) + log_success(`download finished: ${url}`) } // ======================= // resolveSidecar (支持 zip / tgz / gz) // ======================= async function resolveSidecar(binInfo) { - const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo; - const sidecarPath = path.join(SIDECAR_DIR, targetFile); - await fsp.mkdir(SIDECAR_DIR, { recursive: true }); + const { name, targetFile, zipFile, exeFile, downloadURL } = binInfo + const sidecarPath = path.join(SIDECAR_DIR, targetFile) + await fsp.mkdir(SIDECAR_DIR, { recursive: true }) if (!FORCE && fs.existsSync(sidecarPath)) { - log_success(`"${name}" already exists, skipping download`); - return; + log_success(`"${name}" already exists, skipping download`) + return } - const tempDir = path.join(TEMP_DIR, name); - const tempZip = path.join(tempDir, zipFile); - const tempExe = path.join(tempDir, exeFile); - await fsp.mkdir(tempDir, { recursive: true }); + const tempDir = path.join(TEMP_DIR, name) + const tempZip = path.join(tempDir, zipFile) + const tempExe = path.join(tempDir, exeFile) + await fsp.mkdir(tempDir, { recursive: true }) try { if (!fs.existsSync(tempZip)) { - await downloadFile(downloadURL, tempZip); + await downloadFile(downloadURL, tempZip) } - if (zipFile.endsWith(".zip")) { - const zip = new AdmZip(tempZip); + if (zipFile.endsWith('.zip')) { + const zip = new AdmZip(tempZip) zip.getEntries().forEach((entry) => { - log_debug(`"${name}" entry: ${entry.entryName}`); - }); - zip.extractAllTo(tempDir, true); + log_debug(`"${name}" entry: ${entry.entryName}`) + }) + zip.extractAllTo(tempDir, true) // 尝试按 exeFile 重命名,否则找第一个可执行文件 if (fs.existsSync(tempExe)) { - await fsp.rename(tempExe, sidecarPath); + await fsp.rename(tempExe, sidecarPath) } else { // 搜索候选 - const files = await fsp.readdir(tempDir); + const files = await fsp.readdir(tempDir) const candidate = files.find( (f) => f === path.basename(exeFile) || - f.endsWith(".exe") || - !f.includes("."), - ); + f.endsWith('.exe') || + !f.includes('.'), + ) if (!candidate) - throw new Error(`Expected binary not found in ${tempDir}`); - await fsp.rename(path.join(tempDir, candidate), sidecarPath); + throw new Error(`Expected binary not found in ${tempDir}`) + await fsp.rename(path.join(tempDir, candidate), sidecarPath) } - if (platform !== "win32") execSync(`chmod 755 ${sidecarPath}`); - log_success(`unzip finished: "${name}"`); - } else if (zipFile.endsWith(".tgz")) { - await extract({ cwd: tempDir, file: tempZip }); - const files = await fsp.readdir(tempDir); - log_debug(`"${name}" extracted files:`, files); + if (platform !== 'win32') execSync(`chmod 755 ${sidecarPath}`) + log_success(`unzip finished: "${name}"`) + } else if (zipFile.endsWith('.tgz')) { + await extract({ cwd: tempDir, file: tempZip }) + const files = await fsp.readdir(tempDir) + log_debug(`"${name}" extracted files:`, files) // 优先寻找给定 exeFile 或已知前缀 let extracted = files.find( (f) => f === path.basename(exeFile) || - f.startsWith("虚空终端-") || - !f.includes("."), - ); - if (!extracted) extracted = files[0]; - if (!extracted) throw new Error(`Expected file not found in ${tempDir}`); - await fsp.rename(path.join(tempDir, extracted), sidecarPath); - execSync(`chmod 755 ${sidecarPath}`); - log_success(`tgz processed: "${name}"`); + f.startsWith('虚空终端-') || + !f.includes('.'), + ) + if (!extracted) extracted = files[0] + if (!extracted) throw new Error(`Expected file not found in ${tempDir}`) + await fsp.rename(path.join(tempDir, extracted), sidecarPath) + execSync(`chmod 755 ${sidecarPath}`) + log_success(`tgz processed: "${name}"`) } else { // .gz - const readStream = fs.createReadStream(tempZip); - const writeStream = fs.createWriteStream(sidecarPath); + const readStream = fs.createReadStream(tempZip) + const writeStream = fs.createWriteStream(sidecarPath) await new Promise((resolve, reject) => { readStream .pipe(zlib.createGunzip()) - .on("error", (e) => { - log_error(`gunzip error for ${name}:`, e.message); - reject(e); + .on('error', (e) => { + log_error(`gunzip error for ${name}:`, e.message) + reject(e) }) .pipe(writeStream) - .on("finish", () => { - if (platform !== "win32") execSync(`chmod 755 ${sidecarPath}`); - resolve(); + .on('finish', () => { + if (platform !== 'win32') execSync(`chmod 755 ${sidecarPath}`) + resolve() }) - .on("error", (e) => { - log_error(`write stream error for ${name}:`, e.message); - reject(e); - }); - }); - log_success(`gz binary processed: "${name}"`); + .on('error', (e) => { + log_error(`write stream error for ${name}:`, e.message) + reject(e) + }) + }) + log_success(`gz binary processed: "${name}"`) } } catch (err) { - await fsp.rm(sidecarPath, { recursive: true, force: true }); - throw err; + await fsp.rm(sidecarPath, { recursive: true, force: true }) + throw err } finally { - await fsp.rm(tempDir, { recursive: true, force: true }); + await fsp.rm(tempDir, { recursive: true, force: true }) } } async function resolveResource(binInfo) { - const { file, downloadURL, localPath, dir } = binInfo; - const baseDir = dir ?? RESOURCES_DIR; - const targetPath = path.join(baseDir, file); + const { file, downloadURL, localPath, dir } = binInfo + const baseDir = dir ?? RESOURCES_DIR + const targetPath = path.join(baseDir, file) if (!FORCE && fs.existsSync(targetPath) && !downloadURL && !localPath) { - log_success(`"${file}" already exists, skipping`); - return; + log_success(`"${file}" already exists, skipping`) + return } if (downloadURL) { if (!FORCE && fs.existsSync(targetPath)) { - log_success(`"${file}" already exists, skipping download`); - return; + log_success(`"${file}" already exists, skipping download`) + return } - await fsp.mkdir(baseDir, { recursive: true }); - await downloadFile(downloadURL, targetPath); - await updateHashCache(targetPath); + await fsp.mkdir(baseDir, { recursive: true }) + await downloadFile(downloadURL, targetPath) + await updateHashCache(targetPath) } if (localPath) { if (!(await hasFileChanged(localPath, targetPath))) { - return; + return } - await fsp.mkdir(baseDir, { recursive: true }); - await fsp.copyFile(localPath, targetPath); - await updateHashCache(targetPath); - log_success(`Copied file: ${file}`); + await fsp.mkdir(baseDir, { recursive: true }) + await fsp.copyFile(localPath, targetPath) + await updateHashCache(targetPath) + log_success(`Copied file: ${file}`) } - log_success(`${file} finished`); + log_success(`${file} finished`) } // SimpleSC.dll (win plugin) const resolvePlugin = async () => { const url = - "https://nsis.sourceforge.io/mediawiki/images/e/ef/NSIS_Simple_Service_Plugin_Unicode_1.30.zip"; - const tempDir = path.join(TEMP_DIR, "SimpleSC"); + 'https://nsis.sourceforge.io/mediawiki/images/e/ef/NSIS_Simple_Service_Plugin_Unicode_1.30.zip' + const tempDir = path.join(TEMP_DIR, 'SimpleSC') const tempZip = path.join( tempDir, - "NSIS_Simple_Service_Plugin_Unicode_1.30.zip", - ); - const tempDll = path.join(tempDir, "SimpleSC.dll"); - const pluginDir = path.join(process.env.APPDATA || "", "Local/NSIS"); - const pluginPath = path.join(pluginDir, "SimpleSC.dll"); - await fsp.mkdir(pluginDir, { recursive: true }); - await fsp.mkdir(tempDir, { recursive: true }); - if (!FORCE && fs.existsSync(pluginPath)) return; + 'NSIS_Simple_Service_Plugin_Unicode_1.30.zip', + ) + const tempDll = path.join(tempDir, 'SimpleSC.dll') + const pluginDir = path.join(process.env.APPDATA || '', 'Local/NSIS') + const pluginPath = path.join(pluginDir, 'SimpleSC.dll') + await fsp.mkdir(pluginDir, { recursive: true }) + await fsp.mkdir(tempDir, { recursive: true }) + if (!FORCE && fs.existsSync(pluginPath)) return try { if (!fs.existsSync(tempZip)) { - await downloadFile(url, tempZip); + await downloadFile(url, tempZip) } - const zip = new AdmZip(tempZip); + const zip = new AdmZip(tempZip) zip .getEntries() - .forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName)); - zip.extractAllTo(tempDir, true); + .forEach((entry) => log_debug(`"SimpleSC" entry`, entry.entryName)) + zip.extractAllTo(tempDir, true) if (fs.existsSync(tempDll)) { - await fsp.cp(tempDll, pluginPath, { recursive: true, force: true }); - log_success(`unzip finished: "SimpleSC"`); + await fsp.cp(tempDll, pluginPath, { recursive: true, force: true }) + log_success(`unzip finished: "SimpleSC"`) } else { // 如果 dll 名称不同,尝试找到 dll - const files = await fsp.readdir(tempDir); - const dll = files.find((f) => f.toLowerCase().endsWith(".dll")); + const files = await fsp.readdir(tempDir) + const dll = files.find((f) => f.toLowerCase().endsWith('.dll')) if (dll) { await fsp.cp(path.join(tempDir, dll), pluginPath, { recursive: true, force: true, - }); - log_success(`unzip finished: "SimpleSC" (found ${dll})`); + }) + log_success(`unzip finished: "SimpleSC" (found ${dll})`) } else { - throw new Error("SimpleSC.dll not found in zip"); + throw new Error('SimpleSC.dll not found in zip') } } } finally { - await fsp.rm(tempDir, { recursive: true, force: true }); + await fsp.rm(tempDir, { recursive: true, force: true }) } -}; +} // service chmod (保留并使用 glob) const resolveServicePermission = async () => { const serviceExecutables = [ - "clash-verge-service*", - "clash-verge-service-install*", - "clash-verge-service-uninstall*", - ]; - const hashCache = await loadHashCache(); - let hasChanges = false; + 'clash-verge-service*', + 'clash-verge-service-install*', + 'clash-verge-service-uninstall*', + ] + const hashCache = await loadHashCache() + let hasChanges = false for (const f of serviceExecutables) { - const files = glob.sync(path.join(SERVICE_DIR, f)); + const files = glob.sync(path.join(SERVICE_DIR, f)) for (const filePath of files) { if (fs.existsSync(filePath)) { - const currentHash = await calculateFileHash(filePath); - const cacheKey = `${filePath}_chmod`; + const currentHash = await calculateFileHash(filePath) + const cacheKey = `${filePath}_chmod` if (!FORCE && hashCache[cacheKey] === currentHash) { - continue; + continue } try { - execSync(`chmod 755 ${filePath}`); - log_success(`chmod finished: "${filePath}"`); + execSync(`chmod 755 ${filePath}`) + log_success(`chmod finished: "${filePath}"`) } catch (e) { - log_error(`chmod failed for ${filePath}:`, e.message); + log_error(`chmod failed for ${filePath}:`, e.message) } - hashCache[cacheKey] = currentHash; - hasChanges = true; + hashCache[cacheKey] = currentHash + hasChanges = true } } } if (hasChanges) { - await saveHashCache(hashCache); + await saveHashCache(hashCache) } -}; +} // ======================= // Other resource resolvers (service, mmdb, geosite, geoip, enableLoopback) // ======================= -const SERVICE_URL = `https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download/${SIDECAR_HOST}`; +const SERVICE_URL = `https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download/${SIDECAR_HOST}` const resolveService = () => { - const ext = platform === "win32" ? ".exe" : ""; - const suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; + const ext = platform === 'win32' ? '.exe' : '' + const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : '' return resolveResource({ - file: "clash-verge-service" + suffix + ext, + file: 'clash-verge-service' + suffix + ext, dir: SERVICE_DIR, downloadURL: `${SERVICE_URL}/clash-verge-service${ext}`, - }); -}; + }) +} const resolveInstall = () => { - const ext = platform === "win32" ? ".exe" : ""; - const suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; + const ext = platform === 'win32' ? '.exe' : '' + const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : '' return resolveResource({ - file: "clash-verge-service-install" + suffix + ext, + file: 'clash-verge-service-install' + suffix + ext, dir: SERVICE_DIR, downloadURL: `${SERVICE_URL}/clash-verge-service-install${ext}`, - }); -}; + }) +} const resolveUninstall = () => { - const ext = platform === "win32" ? ".exe" : ""; - const suffix = platform === "linux" ? "-" + SIDECAR_HOST : ""; + const ext = platform === 'win32' ? '.exe' : '' + const suffix = platform === 'linux' ? '-' + SIDECAR_HOST : '' return resolveResource({ - file: "clash-verge-service-uninstall" + suffix + ext, + file: 'clash-verge-service-uninstall' + suffix + ext, dir: SERVICE_DIR, downloadURL: `${SERVICE_URL}/clash-verge-service-uninstall${ext}`, - }); -}; + }) +} const resolveMmdb = () => resolveResource({ - file: "Country.mmdb", + file: 'Country.mmdb', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/country.mmdb`, - }); + }) const resolveGeosite = () => resolveResource({ - file: "geosite.dat", + file: 'geosite.dat', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat`, - }); + }) const resolveGeoIP = () => resolveResource({ - file: "geoip.dat", + file: 'geoip.dat', downloadURL: `https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat`, - }); + }) const resolveEnableLoopback = () => resolveResource({ - file: "enableLoopback.exe", + file: 'enableLoopback.exe', downloadURL: `https://github.com/Kuingsmile/uwp-tool/releases/download/latest/enableLoopback.exe`, - }); + }) const resolveSetDnsScript = () => resolveResource({ - file: "set_dns.sh", - localPath: path.join(cwd, "scripts/set_dns.sh"), - }); + file: 'set_dns.sh', + localPath: path.join(cwd, 'scripts/set_dns.sh'), + }) const resolveUnSetDnsScript = () => resolveResource({ - file: "unset_dns.sh", - localPath: path.join(cwd, "scripts/unset_dns.sh"), - }); + file: 'unset_dns.sh', + localPath: path.join(cwd, 'scripts/unset_dns.sh'), + }) // ======================= // Tasks // ======================= const tasks = [ { - name: "verge-mihomo-alpha", + name: 'verge-mihomo-alpha', func: () => getLatestAlphaVersion().then(() => resolveSidecar(clashMetaAlpha())), retry: 5, }, { - name: "verge-mihomo", + name: 'verge-mihomo', func: () => getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())), retry: 5, }, - { name: "plugin", func: resolvePlugin, retry: 5, winOnly: true }, - { name: "service", func: resolveService, retry: 5 }, - { name: "install", func: resolveInstall, retry: 5 }, - { name: "uninstall", func: resolveUninstall, retry: 5 }, - { name: "mmdb", func: resolveMmdb, retry: 5 }, - { name: "geosite", func: resolveGeosite, retry: 5 }, - { name: "geoip", func: resolveGeoIP, retry: 5 }, + { name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true }, + { name: 'service', func: resolveService, retry: 5 }, + { name: 'install', func: resolveInstall, retry: 5 }, + { name: 'uninstall', func: resolveUninstall, retry: 5 }, + { name: 'mmdb', func: resolveMmdb, retry: 5 }, + { name: 'geosite', func: resolveGeosite, retry: 5 }, + { name: 'geoip', func: resolveGeoIP, retry: 5 }, { - name: "enableLoopback", + name: 'enableLoopback', func: resolveEnableLoopback, retry: 5, winOnly: true, }, { - name: "service_chmod", + name: 'service_chmod', func: resolveServicePermission, retry: 5, - unixOnly: platform === "linux" || platform === "darwin", + unixOnly: platform === 'linux' || platform === 'darwin', }, { - name: "set_dns_script", + name: 'set_dns_script', func: resolveSetDnsScript, retry: 5, macosOnly: true, }, { - name: "unset_dns_script", + name: 'unset_dns_script', func: resolveUnSetDnsScript, retry: 5, macosOnly: true, }, -]; +] async function runTask() { - const task = tasks.shift(); - if (!task) return; - if (task.unixOnly && platform === "win32") return runTask(); - if (task.winOnly && platform !== "win32") return runTask(); - if (task.macosOnly && platform !== "darwin") return runTask(); - if (task.linuxOnly && platform !== "linux") return runTask(); + const task = tasks.shift() + if (!task) return + if (task.unixOnly && platform === 'win32') return runTask() + if (task.winOnly && platform !== 'win32') return runTask() + if (task.macosOnly && platform !== 'darwin') return runTask() + if (task.linuxOnly && platform !== 'linux') return runTask() for (let i = 0; i < task.retry; i++) { try { - await task.func(); - break; + await task.func() + break } catch (err) { - log_error(`task::${task.name} try ${i} ==`, err.message); - if (i === task.retry - 1) throw err; + log_error(`task::${task.name} try ${i} ==`, err.message) + if (i === task.retry - 1) throw err } } - return runTask(); + return runTask() } -runTask(); +runTask() diff --git a/scripts/publish-version.mjs b/scripts/publish-version.mjs index b983932da..3d4437e34 100644 --- a/scripts/publish-version.mjs +++ b/scripts/publish-version.mjs @@ -1,66 +1,66 @@ // scripts/publish-version.mjs -import { spawn } from "child_process"; -import { existsSync } from "fs"; -import path from "path"; +import { spawn } from 'child_process' +import { existsSync } from 'fs' +import path from 'path' -const rootDir = process.cwd(); -const scriptPath = path.join(rootDir, "scripts", "release-version.mjs"); +const rootDir = process.cwd() +const scriptPath = path.join(rootDir, 'scripts', 'release-version.mjs') if (!existsSync(scriptPath)) { - console.error("release-version.mjs not found!"); - process.exit(1); + console.error('release-version.mjs not found!') + process.exit(1) } -const versionArg = process.argv[2]; +const versionArg = process.argv[2] if (!versionArg) { - console.error("Usage: pnpm publish-version "); - process.exit(1); + console.error('Usage: pnpm publish-version ') + process.exit(1) } // 1. 调用 release-version.mjs const runRelease = () => new Promise((resolve, reject) => { - const child = spawn("node", [scriptPath, versionArg], { stdio: "inherit" }); - child.on("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error("release-version failed")); - }); - }); + const child = spawn('node', [scriptPath, versionArg], { stdio: 'inherit' }) + child.on('exit', (code) => { + if (code === 0) resolve() + else reject(new Error('release-version failed')) + }) + }) // 2. 判断是否需要打 tag function isSemver(version) { - return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version); + return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version) } async function run() { - await runRelease(); + await runRelease() - let tag = null; - if (versionArg === "alpha") { + let tag = null + if (versionArg === 'alpha') { // 读取 package.json 里的主版本 - const pkg = await import(path.join(rootDir, "package.json"), { - assert: { type: "json" }, - }); - tag = `v${pkg.default.version}-alpha`; + const pkg = await import(path.join(rootDir, 'package.json'), { + assert: { type: 'json' }, + }) + tag = `v${pkg.default.version}-alpha` } else if (isSemver(versionArg)) { // 1.2.3 或 v1.2.3 - tag = versionArg.startsWith("v") ? versionArg : `v${versionArg}`; + tag = versionArg.startsWith('v') ? versionArg : `v${versionArg}` } if (tag) { // 打 tag 并推送 - const { execSync } = await import("child_process"); + const { execSync } = await import('child_process') try { - execSync(`git tag ${tag}`, { stdio: "inherit" }); - execSync(`git push origin ${tag}`, { stdio: "inherit" }); - console.log(`[INFO]: Git tag ${tag} created and pushed.`); + execSync(`git tag ${tag}`, { stdio: 'inherit' }) + execSync(`git push origin ${tag}`, { stdio: 'inherit' }) + console.log(`[INFO]: Git tag ${tag} created and pushed.`) } catch { - console.error(`[ERROR]: Failed to create or push git tag: ${tag}`); - process.exit(1); + console.error(`[ERROR]: Failed to create or push git tag: ${tag}`) + process.exit(1) } } else { - console.log("[INFO]: No git tag created for this version."); + console.log('[INFO]: No git tag created for this version.') } } -run(); +run() diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 0a6fdbed8..2586407c5 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -29,11 +29,11 @@ * Errors are logged and the process exits with code 1 on failure. */ -import { execSync } from "child_process"; -import fs from "fs/promises"; -import path from "path"; +import { execSync } from 'child_process' +import fs from 'fs/promises' +import path from 'path' -import { program } from "commander"; +import { program } from 'commander' /** * 获取当前 git 短 commit hash @@ -41,10 +41,10 @@ import { program } from "commander"; */ function getGitShortCommit() { try { - return execSync("git rev-parse --short HEAD").toString().trim(); + return execSync('git rev-parse --short HEAD').toString().trim() } catch { - console.warn("[WARN]: Failed to get git short commit, fallback to 'nogit'"); - return "nogit"; + console.warn("[WARN]: Failed to get git short commit, fallback to 'nogit'") + return 'nogit' } } @@ -55,21 +55,21 @@ function getGitShortCommit() { function getLatestTauriCommit() { try { const fullHash = execSync( - "bash ./scripts-workflow/get_latest_tauri_commit.bash", + 'bash ./scripts-workflow/get_latest_tauri_commit.bash', ) .toString() - .trim(); + .trim() const shortHash = execSync(`git rev-parse --short ${fullHash}`) .toString() - .trim(); - console.log(`[INFO]: Latest Tauri-related commit: ${shortHash}`); - return shortHash; + .trim() + console.log(`[INFO]: Latest Tauri-related commit: ${shortHash}`) + return shortHash } catch (error) { console.warn( - "[WARN]: Failed to get latest Tauri commit, fallback to current git short commit", - ); - console.warn(`[WARN]: Error details: ${error.message}`); - return getGitShortCommit(); + '[WARN]: Failed to get latest Tauri commit, fallback to current git short commit', + ) + console.warn(`[WARN]: Error details: ${error.message}`) + return getGitShortCommit() } } @@ -81,25 +81,25 @@ function getLatestTauriCommit() { * @returns {string} */ function generateShortTimestamp(withCommit = false, useTauriCommit = false) { - const now = new Date(); + const now = new Date() - const formatter = new Intl.DateTimeFormat("en-CA", { - timeZone: "Asia/Shanghai", - month: "2-digit", - day: "2-digit", - }); + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + month: '2-digit', + day: '2-digit', + }) - const parts = formatter.formatToParts(now); - const month = parts.find((part) => part.type === "month").value; - const day = parts.find((part) => part.type === "day").value; + const parts = formatter.formatToParts(now) + const month = parts.find((part) => part.type === 'month').value + const day = parts.find((part) => part.type === 'day').value if (withCommit) { const gitShort = useTauriCommit ? getLatestTauriCommit() - : getGitShortCommit(); - return `${month}${day}.${gitShort}`; + : getGitShortCommit() + return `${month}${day}.${gitShort}` } - return `${month}${day}`; + return `${month}${day}` } /** @@ -110,7 +110,7 @@ function generateShortTimestamp(withCommit = false, useTauriCommit = false) { function isValidVersion(version) { return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test( version, - ); + ) } /** @@ -119,7 +119,7 @@ function isValidVersion(version) { * @returns {string} */ function normalizeVersion(version) { - return version.startsWith("v") ? version : `v${version}`; + return version.startsWith('v') ? version : `v${version}` } /** @@ -128,9 +128,9 @@ function normalizeVersion(version) { * @returns {string} */ function getBaseVersion(version) { - let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, ""); - base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, ""); - return base; + let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, '') + base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, '') + return base } /** @@ -138,30 +138,30 @@ function getBaseVersion(version) { * @param {string} newVersion */ async function updatePackageVersion(newVersion) { - const _dirname = process.cwd(); - const packageJsonPath = path.join(_dirname, "package.json"); + const _dirname = process.cwd() + const packageJsonPath = path.join(_dirname, 'package.json') try { - const data = await fs.readFile(packageJsonPath, "utf8"); - const packageJson = JSON.parse(data); + const data = await fs.readFile(packageJsonPath, 'utf8') + const packageJson = JSON.parse(data) console.log( - "[INFO]: Current package.json version is: ", + '[INFO]: Current package.json version is: ', packageJson.version, - ); - packageJson.version = newVersion.startsWith("v") + ) + packageJson.version = newVersion.startsWith('v') ? newVersion.slice(1) - : newVersion; + : newVersion await fs.writeFile( packageJsonPath, JSON.stringify(packageJson, null, 2), - "utf8", - ); + 'utf8', + ) console.log( `[INFO]: package.json version updated to: ${packageJson.version}`, - ); + ) } catch (error) { - console.error("Error updating package.json version:", error); - throw error; + console.error('Error updating package.json version:', error) + throw error } } @@ -170,30 +170,30 @@ async function updatePackageVersion(newVersion) { * @param {string} newVersion */ async function updateCargoVersion(newVersion) { - const _dirname = process.cwd(); - const cargoTomlPath = path.join(_dirname, "src-tauri", "Cargo.toml"); + const _dirname = process.cwd() + const cargoTomlPath = path.join(_dirname, 'src-tauri', 'Cargo.toml') try { - const data = await fs.readFile(cargoTomlPath, "utf8"); - const lines = data.split("\n"); - const versionWithoutV = newVersion.startsWith("v") + const data = await fs.readFile(cargoTomlPath, 'utf8') + const lines = data.split('\n') + const versionWithoutV = newVersion.startsWith('v') ? newVersion.slice(1) - : newVersion; + : newVersion const updatedLines = lines.map((line) => { - if (line.trim().startsWith("version =")) { + if (line.trim().startsWith('version =')) { return line.replace( /version\s*=\s*"[^"]+"/, `version = "${versionWithoutV}"`, - ); + ) } - return line; - }); + return line + }) - await fs.writeFile(cargoTomlPath, updatedLines.join("\n"), "utf8"); - console.log(`[INFO]: Cargo.toml version updated to: ${versionWithoutV}`); + await fs.writeFile(cargoTomlPath, updatedLines.join('\n'), 'utf8') + console.log(`[INFO]: Cargo.toml version updated to: ${versionWithoutV}`) } catch (error) { - console.error("Error updating Cargo.toml version:", error); - throw error; + console.error('Error updating Cargo.toml version:', error) + throw error } } @@ -202,34 +202,34 @@ async function updateCargoVersion(newVersion) { * @param {string} newVersion */ async function updateTauriConfigVersion(newVersion) { - const _dirname = process.cwd(); - const tauriConfigPath = path.join(_dirname, "src-tauri", "tauri.conf.json"); + const _dirname = process.cwd() + const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json') try { - const data = await fs.readFile(tauriConfigPath, "utf8"); - const tauriConfig = JSON.parse(data); - const versionWithoutV = newVersion.startsWith("v") + const data = await fs.readFile(tauriConfigPath, 'utf8') + const tauriConfig = JSON.parse(data) + const versionWithoutV = newVersion.startsWith('v') ? newVersion.slice(1) - : newVersion; + : newVersion console.log( - "[INFO]: Current tauri.conf.json version is: ", + '[INFO]: Current tauri.conf.json version is: ', tauriConfig.version, - ); + ) // 使用完整版本信息,包含build metadata - tauriConfig.version = versionWithoutV; + tauriConfig.version = versionWithoutV await fs.writeFile( tauriConfigPath, JSON.stringify(tauriConfig, null, 2), - "utf8", - ); + 'utf8', + ) console.log( `[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`, - ); + ) } catch (error) { - console.error("Error updating tauri.conf.json version:", error); - throw error; + console.error('Error updating tauri.conf.json version:', error) + throw error } } @@ -237,15 +237,15 @@ async function updateTauriConfigVersion(newVersion) { * 获取当前版本号 */ async function getCurrentVersion() { - const _dirname = process.cwd(); - const packageJsonPath = path.join(_dirname, "package.json"); + const _dirname = process.cwd() + const packageJsonPath = path.join(_dirname, 'package.json') try { - const data = await fs.readFile(packageJsonPath, "utf8"); - const packageJson = JSON.parse(data); - return packageJson.version; + const data = await fs.readFile(packageJsonPath, 'utf8') + const packageJson = JSON.parse(data) + return packageJson.version } catch (error) { - console.error("Error getting current version:", error); - throw error; + console.error('Error getting current version:', error) + throw error } } @@ -254,62 +254,62 @@ async function getCurrentVersion() { */ async function main(versionArg) { if (!versionArg) { - console.error("Error: Version argument is required"); - process.exit(1); + console.error('Error: Version argument is required') + process.exit(1) } try { - let newVersion; + let newVersion const validTags = [ - "alpha", - "beta", - "rc", - "autobuild", - "autobuild-latest", - "deploytest", - ]; + 'alpha', + 'beta', + 'rc', + 'autobuild', + 'autobuild-latest', + 'deploytest', + ] if (validTags.includes(versionArg.toLowerCase())) { - const currentVersion = await getCurrentVersion(); - const baseVersion = getBaseVersion(currentVersion); + const currentVersion = await getCurrentVersion() + const baseVersion = getBaseVersion(currentVersion) - if (versionArg.toLowerCase() === "autobuild") { + if (versionArg.toLowerCase() === 'autobuild') { // 格式: 2.3.0+autobuild.1004.cc39b27 // 使用 Tauri 相关的最新 commit hash - newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}`; - } else if (versionArg.toLowerCase() === "autobuild-latest") { + newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}` + } else if (versionArg.toLowerCase() === 'autobuild-latest') { // 格式: 2.3.0+autobuild.1004.a1b2c3d (使用最新 Tauri 提交) - const latestTauriCommit = getLatestTauriCommit(); - newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}`; - } else if (versionArg.toLowerCase() === "deploytest") { + const latestTauriCommit = getLatestTauriCommit() + newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}` + } else if (versionArg.toLowerCase() === 'deploytest') { // 格式: 2.3.0+deploytest.1004.cc39b27 // 使用 Tauri 相关的最新 commit hash - newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}`; + newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}` } else { - newVersion = `${baseVersion}-${versionArg.toLowerCase()}`; + newVersion = `${baseVersion}-${versionArg.toLowerCase()}` } } else { if (!isValidVersion(versionArg)) { - console.error("Error: Invalid version format"); - process.exit(1); + console.error('Error: Invalid version format') + process.exit(1) } - newVersion = normalizeVersion(versionArg); + newVersion = normalizeVersion(versionArg) } - console.log(`[INFO]: Updating versions to: ${newVersion}`); - await updatePackageVersion(newVersion); - await updateCargoVersion(newVersion); - await updateTauriConfigVersion(newVersion); - console.log("[SUCCESS]: All version updates completed successfully!"); + console.log(`[INFO]: Updating versions to: ${newVersion}`) + await updatePackageVersion(newVersion) + await updateCargoVersion(newVersion) + await updateTauriConfigVersion(newVersion) + console.log('[SUCCESS]: All version updates completed successfully!') } catch (error) { - console.error("[ERROR]: Failed to update versions:", error); - process.exit(1); + console.error('[ERROR]: Failed to update versions:', error) + process.exit(1) } } program - .name("pnpm release-version") - .description("Update project version numbers") - .argument("", "version tag or full version") + .name('pnpm release-version') + .description('Update project version numbers') + .argument('', 'version tag or full version') .action(main) - .parse(process.argv); + .parse(process.argv) diff --git a/scripts/telegram.mjs b/scripts/telegram.mjs index 66f660df8..6f35b5946 100644 --- a/scripts/telegram.mjs +++ b/scripts/telegram.mjs @@ -1,97 +1,94 @@ -import { readFileSync } from "fs"; +import { readFileSync } from 'fs' -import axios from "axios"; +import axios from 'axios' -import { log_error, log_info, log_success } from "./utils.mjs"; +import { log_error, log_info, log_success } from './utils.mjs' -const CHAT_ID_RELEASE = "@clash_verge_re"; // 正式发布频道 -const CHAT_ID_TEST = "@vergetest"; // 测试频道 +const CHAT_ID_RELEASE = '@clash_verge_re' // 正式发布频道 +const CHAT_ID_TEST = '@vergetest' // 测试频道 async function sendTelegramNotification() { if (!process.env.TELEGRAM_BOT_TOKEN) { - throw new Error("TELEGRAM_BOT_TOKEN is required"); + throw new Error('TELEGRAM_BOT_TOKEN is required') } const version = process.env.VERSION || (() => { - const pkg = readFileSync("package.json", "utf-8"); - return JSON.parse(pkg).version; - })(); + const pkg = readFileSync('package.json', 'utf-8') + return JSON.parse(pkg).version + })() const downloadUrl = process.env.DOWNLOAD_URL || - `https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v${version}`; + `https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v${version}` const isAutobuild = - process.env.BUILD_TYPE === "autobuild" || version.includes("autobuild"); - const chatId = isAutobuild ? CHAT_ID_TEST : CHAT_ID_RELEASE; - const buildType = isAutobuild ? "滚动更新版" : "正式版"; + process.env.BUILD_TYPE === 'autobuild' || version.includes('autobuild') + const chatId = isAutobuild ? CHAT_ID_TEST : CHAT_ID_RELEASE + const buildType = isAutobuild ? '滚动更新版' : '正式版' - log_info(`Preparing Telegram notification for ${buildType} ${version}`); - log_info(`Target channel: ${chatId}`); - log_info(`Download URL: ${downloadUrl}`); + log_info(`Preparing Telegram notification for ${buildType} ${version}`) + log_info(`Target channel: ${chatId}`) + log_info(`Download URL: ${downloadUrl}`) // 读取发布说明和下载地址 - let releaseContent = ""; + let releaseContent = '' try { - releaseContent = readFileSync("release.txt", "utf-8"); - log_info("成功读取 release.txt 文件"); + releaseContent = readFileSync('release.txt', 'utf-8') + log_info('成功读取 release.txt 文件') } catch (error) { - log_error("无法读取 release.txt,使用默认发布说明", error); - releaseContent = "更多新功能现已支持,详细更新日志请查看发布页面。"; + log_error('无法读取 release.txt,使用默认发布说明', error) + releaseContent = '更多新功能现已支持,详细更新日志请查看发布页面。' } // Markdown 转换为 HTML function convertMarkdownToTelegramHTML(content) { return content - .split("\n") + .split('\n') .map((line) => { if (line.trim().length === 0) { - return ""; - } else if (line.startsWith("## ")) { - return `${line.replace("## ", "")}`; - } else if (line.startsWith("### ")) { - return `${line.replace("### ", "")}`; - } else if (line.startsWith("#### ")) { - return `${line.replace("#### ", "")}`; + return '' + } else if (line.startsWith('## ')) { + return `${line.replace('## ', '')}` + } else if (line.startsWith('### ')) { + return `${line.replace('### ', '')}` + } else if (line.startsWith('#### ')) { + return `${line.replace('#### ', '')}` } else { let processedLine = line.replace( /\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { - const encodedUrl = encodeURI(url); - return `${text}`; + const encodedUrl = encodeURI(url) + return `${text}` }, - ); - processedLine = processedLine.replace( - /\*\*([^*]+)\*\*/g, - "$1", - ); - return processedLine; + ) + processedLine = processedLine.replace(/\*\*([^*]+)\*\*/g, '$1') + return processedLine } }) - .join("\n"); + .join('\n') } function normalizeDetailsTags(content) { return content .replace( /\s*\s*(.*?)\s*<\/strong>\s*<\/summary>/g, - "\n$1\n", + '\n$1\n', ) - .replace(/\s*(.*?)\s*<\/summary>/g, "\n$1\n") - .replace(/<\/?details>/g, "") - .replace(/<\/?strong>/g, (m) => (m === "" ? "" : "")) - .replace(//g, "\n"); + .replace(/\s*(.*?)\s*<\/summary>/g, '\n$1\n') + .replace(/<\/?details>/g, '') + .replace(/<\/?strong>/g, (m) => (m === '' ? '' : '')) + .replace(//g, '\n') } - releaseContent = normalizeDetailsTags(releaseContent); - const formattedContent = convertMarkdownToTelegramHTML(releaseContent); + releaseContent = normalizeDetailsTags(releaseContent) + const formattedContent = convertMarkdownToTelegramHTML(releaseContent) - const releaseTitle = isAutobuild ? "滚动更新版发布" : "正式发布"; - const encodedVersion = encodeURIComponent(version); - const releaseTag = isAutobuild ? "autobuild" : `v${version}`; - const content = `🎉 Clash Verge Rev v${version} ${releaseTitle}\n\n${formattedContent}`; + const releaseTitle = isAutobuild ? '滚动更新版发布' : '正式发布' + const encodedVersion = encodeURIComponent(version) + const releaseTag = isAutobuild ? 'autobuild' : `v${version}` + const content = `🎉 Clash Verge Rev v${version} ${releaseTitle}\n\n${formattedContent}` // 发送到 Telegram try { @@ -105,22 +102,22 @@ async function sendTelegramNotification() { url: `https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${encodedVersion}`, prefer_large_media: true, }, - parse_mode: "HTML", + parse_mode: 'HTML', }, - ); - log_success(`✅ Telegram 通知发送成功到 ${chatId}`); + ) + log_success(`✅ Telegram 通知发送成功到 ${chatId}`) } catch (error) { log_error( `❌ Telegram 通知发送失败到 ${chatId}:`, error.response?.data || error.message, error, - ); - process.exit(1); + ) + process.exit(1) } } // 执行函数 sendTelegramNotification().catch((error) => { - log_error("脚本执行失败:", error); - process.exit(1); -}); + log_error('脚本执行失败:', error) + process.exit(1) +}) diff --git a/scripts/updatelog.mjs b/scripts/updatelog.mjs index 89faeabd7..9313f6f58 100644 --- a/scripts/updatelog.mjs +++ b/scripts/updatelog.mjs @@ -1,84 +1,84 @@ -import fs from "fs"; -import fsp from "fs/promises"; -import path from "path"; +import fs from 'fs' +import fsp from 'fs/promises' +import path from 'path' -const UPDATE_LOG = "Changelog.md"; +const UPDATE_LOG = 'Changelog.md' // parse the Changelog.md export async function resolveUpdateLog(tag) { - const cwd = process.cwd(); + const cwd = process.cwd() - const reTitle = /^## v[\d.]+/; - const reEnd = /^---/; + const reTitle = /^## v[\d.]+/ + const reEnd = /^---/ - const file = path.join(cwd, UPDATE_LOG); + const file = path.join(cwd, UPDATE_LOG) if (!fs.existsSync(file)) { - throw new Error("could not found Changelog.md"); + throw new Error('could not found Changelog.md') } - const data = await fsp.readFile(file, "utf-8"); + const data = await fsp.readFile(file, 'utf-8') - const map = {}; - let p = ""; + const map = {} + let p = '' - data.split("\n").forEach((line) => { + data.split('\n').forEach((line) => { if (reTitle.test(line)) { - p = line.slice(3).trim(); + p = line.slice(3).trim() if (!map[p]) { - map[p] = []; + map[p] = [] } else { - throw new Error(`Tag ${p} dup`); + throw new Error(`Tag ${p} dup`) } } else if (reEnd.test(line)) { - p = ""; + p = '' } else if (p) { - map[p].push(line); + map[p].push(line) } - }); + }) if (!map[tag]) { - throw new Error(`could not found "${tag}" in Changelog.md`); + throw new Error(`could not found "${tag}" in Changelog.md`) } - return map[tag].join("\n").trim(); + return map[tag].join('\n').trim() } export async function resolveUpdateLogDefault() { - const cwd = process.cwd(); - const file = path.join(cwd, UPDATE_LOG); + const cwd = process.cwd() + const file = path.join(cwd, UPDATE_LOG) if (!fs.existsSync(file)) { - throw new Error("could not found Changelog.md"); + throw new Error('could not found Changelog.md') } - const data = await fsp.readFile(file, "utf-8"); + const data = await fsp.readFile(file, 'utf-8') - const reTitle = /^## v[\d.]+/; - const reEnd = /^---/; + const reTitle = /^## v[\d.]+/ + const reEnd = /^---/ - let isCapturing = false; - const content = []; - let firstTag = ""; + let isCapturing = false + const content = [] + let firstTag = '' - for (const line of data.split("\n")) { + for (const line of data.split('\n')) { if (reTitle.test(line) && !isCapturing) { - isCapturing = true; - firstTag = line.slice(3).trim(); - continue; + isCapturing = true + firstTag = line.slice(3).trim() + continue } if (isCapturing) { if (reEnd.test(line)) { - break; + break } - content.push(line); + content.push(line) } } if (!firstTag) { - throw new Error("could not found any version tag in Changelog.md"); + throw new Error('could not found any version tag in Changelog.md') } - return content.join("\n").trim(); + return content.join('\n').trim() } diff --git a/scripts/updater-fixed-webview2.mjs b/scripts/updater-fixed-webview2.mjs index ea90d957c..692dbefa5 100644 --- a/scripts/updater-fixed-webview2.mjs +++ b/scripts/updater-fixed-webview2.mjs @@ -1,117 +1,117 @@ -import { context, getOctokit } from "@actions/github"; -import fetch from "node-fetch"; +import { context, getOctokit } from '@actions/github' +import fetch from 'node-fetch' -import { resolveUpdateLog } from "./updatelog.mjs"; +import { resolveUpdateLog } from './updatelog.mjs' -const UPDATE_TAG_NAME = "updater"; -const UPDATE_JSON_FILE = "update-fixed-webview2.json"; -const UPDATE_JSON_PROXY = "update-fixed-webview2-proxy.json"; +const UPDATE_TAG_NAME = 'updater' +const UPDATE_JSON_FILE = 'update-fixed-webview2.json' +const UPDATE_JSON_PROXY = 'update-fixed-webview2-proxy.json' /// generate update.json /// upload to update tag's release asset async function resolveUpdater() { if (process.env.GITHUB_TOKEN === undefined) { - throw new Error("GITHUB_TOKEN is required"); + throw new Error('GITHUB_TOKEN is required') } - const options = { owner: context.repo.owner, repo: context.repo.repo }; - const github = getOctokit(process.env.GITHUB_TOKEN); + const options = { owner: context.repo.owner, repo: context.repo.repo } + const github = getOctokit(process.env.GITHUB_TOKEN) const { data: tags } = await github.rest.repos.listTags({ ...options, per_page: 10, page: 1, - }); + }) // get the latest publish tag - const tag = tags.find((t) => t.name.startsWith("v")); + const tag = tags.find((t) => t.name.startsWith('v')) - console.log(tag); - console.log(); + console.log(tag) + console.log() const { data: latestRelease } = await github.rest.repos.getReleaseByTag({ ...options, tag: tag.name, - }); + }) const updateData = { name: tag.name, notes: await resolveUpdateLog(tag.name), // use Changelog.md pub_date: new Date().toISOString(), platforms: { - "windows-x86_64": { signature: "", url: "" }, - "windows-aarch64": { signature: "", url: "" }, - "windows-x86": { signature: "", url: "" }, - "windows-i686": { signature: "", url: "" }, + 'windows-x86_64': { signature: '', url: '' }, + 'windows-aarch64': { signature: '', url: '' }, + 'windows-x86': { signature: '', url: '' }, + 'windows-i686': { signature: '', url: '' }, }, - }; + } const promises = latestRelease.assets.map(async (asset) => { - const { name, browser_download_url } = asset; + const { name, browser_download_url } = asset // win64 url - if (name.endsWith("x64_fixed_webview2-setup.exe")) { - updateData.platforms["windows-x86_64"].url = browser_download_url; + if (name.endsWith('x64_fixed_webview2-setup.exe')) { + updateData.platforms['windows-x86_64'].url = browser_download_url } // win64 signature - if (name.endsWith("x64_fixed_webview2-setup.exe.sig")) { - const sig = await getSignature(browser_download_url); - updateData.platforms["windows-x86_64"].signature = sig; + if (name.endsWith('x64_fixed_webview2-setup.exe.sig')) { + const sig = await getSignature(browser_download_url) + updateData.platforms['windows-x86_64'].signature = sig } // win32 url - if (name.endsWith("x86_fixed_webview2-setup.exe")) { - updateData.platforms["windows-x86"].url = browser_download_url; - updateData.platforms["windows-i686"].url = browser_download_url; + if (name.endsWith('x86_fixed_webview2-setup.exe')) { + updateData.platforms['windows-x86'].url = browser_download_url + updateData.platforms['windows-i686'].url = browser_download_url } // win32 signature - if (name.endsWith("x86_fixed_webview2-setup.exe.sig")) { - const sig = await getSignature(browser_download_url); - updateData.platforms["windows-x86"].signature = sig; - updateData.platforms["windows-i686"].signature = sig; + if (name.endsWith('x86_fixed_webview2-setup.exe.sig')) { + const sig = await getSignature(browser_download_url) + updateData.platforms['windows-x86'].signature = sig + updateData.platforms['windows-i686'].signature = sig } // win arm url - if (name.endsWith("arm64_fixed_webview2-setup.exe")) { - updateData.platforms["windows-aarch64"].url = browser_download_url; + if (name.endsWith('arm64_fixed_webview2-setup.exe')) { + updateData.platforms['windows-aarch64'].url = browser_download_url } // win arm signature - if (name.endsWith("arm64_fixed_webview2-setup.exe.sig")) { - const sig = await getSignature(browser_download_url); - updateData.platforms["windows-aarch64"].signature = sig; + if (name.endsWith('arm64_fixed_webview2-setup.exe.sig')) { + const sig = await getSignature(browser_download_url) + updateData.platforms['windows-aarch64'].signature = sig } - }); + }) - await Promise.allSettled(promises); - console.log(updateData); + await Promise.allSettled(promises) + console.log(updateData) // maybe should test the signature as well // delete the null field Object.entries(updateData.platforms).forEach(([key, value]) => { if (!value.url) { - console.log(`[Error]: failed to parse release for "${key}"`); - delete updateData.platforms[key]; + console.log(`[Error]: failed to parse release for "${key}"`) + delete updateData.platforms[key] } - }); + }) // 生成一个代理github的更新文件 // 使用 https://hub.fastgit.xyz/ 做github资源的加速 - const updateDataNew = JSON.parse(JSON.stringify(updateData)); + const updateDataNew = JSON.parse(JSON.stringify(updateData)) Object.entries(updateDataNew.platforms).forEach(([key, value]) => { if (value.url) { updateDataNew.platforms[key].url = - "https://download.clashverge.dev/" + value.url; + 'https://download.clashverge.dev/' + value.url } else { - console.log(`[Error]: updateDataNew.platforms.${key} is null`); + console.log(`[Error]: updateDataNew.platforms.${key} is null`) } - }); + }) // update the update.json const { data: updateRelease } = await github.rest.repos.getReleaseByTag({ ...options, tag: UPDATE_TAG_NAME, - }); + }) // delete the old assets for (const asset of updateRelease.assets) { @@ -119,13 +119,13 @@ async function resolveUpdater() { await github.rest.repos.deleteReleaseAsset({ ...options, asset_id: asset.id, - }); + }) } if (asset.name === UPDATE_JSON_PROXY) { await github.rest.repos .deleteReleaseAsset({ ...options, asset_id: asset.id }) - .catch(console.error); // do not break the pipeline + .catch(console.error) // do not break the pipeline } } @@ -135,24 +135,24 @@ async function resolveUpdater() { release_id: updateRelease.id, name: UPDATE_JSON_FILE, data: JSON.stringify(updateData, null, 2), - }); + }) await github.rest.repos.uploadReleaseAsset({ ...options, release_id: updateRelease.id, name: UPDATE_JSON_PROXY, data: JSON.stringify(updateDataNew, null, 2), - }); + }) } // get the signature file content async function getSignature(url) { const response = await fetch(url, { - method: "GET", - headers: { "Content-Type": "application/octet-stream" }, - }); + method: 'GET', + headers: { 'Content-Type': 'application/octet-stream' }, + }) - return response.text(); + return response.text() } -resolveUpdater().catch(console.error); +resolveUpdater().catch(console.error) diff --git a/scripts/updater.mjs b/scripts/updater.mjs index 51f6af3eb..cdcec5a91 100644 --- a/scripts/updater.mjs +++ b/scripts/updater.mjs @@ -1,263 +1,263 @@ -import { getOctokit, context } from "@actions/github"; -import fetch from "node-fetch"; +import { getOctokit, context } from '@actions/github' +import fetch from 'node-fetch' -import { resolveUpdateLog, resolveUpdateLogDefault } from "./updatelog.mjs"; +import { resolveUpdateLog, resolveUpdateLogDefault } from './updatelog.mjs' // Add stable update JSON filenames -const UPDATE_TAG_NAME = "updater"; -const UPDATE_JSON_FILE = "update.json"; -const UPDATE_JSON_PROXY = "update-proxy.json"; +const UPDATE_TAG_NAME = 'updater' +const UPDATE_JSON_FILE = 'update.json' +const UPDATE_JSON_PROXY = 'update-proxy.json' // Add alpha update JSON filenames -const ALPHA_TAG_NAME = "updater-alpha"; -const ALPHA_UPDATE_JSON_FILE = "update.json"; -const ALPHA_UPDATE_JSON_PROXY = "update-proxy.json"; +const ALPHA_TAG_NAME = 'updater-alpha' +const ALPHA_UPDATE_JSON_FILE = 'update.json' +const ALPHA_UPDATE_JSON_PROXY = 'update-proxy.json' /// generate update.json /// upload to update tag's release asset async function resolveUpdater() { if (process.env.GITHUB_TOKEN === undefined) { - throw new Error("GITHUB_TOKEN is required"); + throw new Error('GITHUB_TOKEN is required') } - const options = { owner: context.repo.owner, repo: context.repo.repo }; - const github = getOctokit(process.env.GITHUB_TOKEN); + const options = { owner: context.repo.owner, repo: context.repo.repo } + const github = getOctokit(process.env.GITHUB_TOKEN) // Fetch all tags using pagination - let allTags = []; - let page = 1; - const perPage = 100; + let allTags = [] + let page = 1 + const perPage = 100 while (true) { const { data: pageTags } = await github.rest.repos.listTags({ ...options, per_page: perPage, page: page, - }); + }) - allTags = allTags.concat(pageTags); + allTags = allTags.concat(pageTags) // Break if we received fewer tags than requested (last page) if (pageTags.length < perPage) { - break; + break } - page++; + page++ } - const tags = allTags; - console.log(`Retrieved ${tags.length} tags in total`); + const tags = allTags + console.log(`Retrieved ${tags.length} tags in total`) // More flexible tag detection with regex patterns - const stableTagRegex = /^v\d+\.\d+\.\d+$/; // Matches vX.Y.Z format + const stableTagRegex = /^v\d+\.\d+\.\d+$/ // Matches vX.Y.Z format // const preReleaseRegex = /^v\d+\.\d+\.\d+-(alpha|beta|rc|pre)/i; // Matches vX.Y.Z-alpha/beta/rc format - const preReleaseRegex = /^(alpha|beta|rc|pre)$/i; // Matches exact alpha/beta/rc/pre tags + const preReleaseRegex = /^(alpha|beta|rc|pre)$/i // Matches exact alpha/beta/rc/pre tags // Get the latest stable tag and pre-release tag - const stableTag = tags.find((t) => stableTagRegex.test(t.name)); - const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name)); + const stableTag = tags.find((t) => stableTagRegex.test(t.name)) + const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name)) - console.log("All tags:", tags.map((t) => t.name).join(", ")); - console.log("Stable tag:", stableTag ? stableTag.name : "None found"); + console.log('All tags:', tags.map((t) => t.name).join(', ')) + console.log('Stable tag:', stableTag ? stableTag.name : 'None found') console.log( - "Pre-release tag:", - preReleaseTag ? preReleaseTag.name : "None found", - ); - console.log(); + 'Pre-release tag:', + preReleaseTag ? preReleaseTag.name : 'None found', + ) + console.log() // Process stable release if (stableTag) { - await processRelease(github, options, stableTag, false); + await processRelease(github, options, stableTag, false) } // Process pre-release if found if (preReleaseTag) { - await processRelease(github, options, preReleaseTag, true); + await processRelease(github, options, preReleaseTag, true) } } // Process a release (stable or alpha) and generate update files async function processRelease(github, options, tag, isAlpha) { - if (!tag) return; + if (!tag) return try { const { data: release } = await github.rest.repos.getReleaseByTag({ ...options, tag: tag.name, - }); + }) const updateData = { name: tag.name, notes: await resolveUpdateLog(tag.name).catch(() => - resolveUpdateLogDefault().catch(() => "No changelog available"), + resolveUpdateLogDefault().catch(() => 'No changelog available'), ), pub_date: new Date().toISOString(), platforms: { - win64: { signature: "", url: "" }, // compatible with older formats - linux: { signature: "", url: "" }, // compatible with older formats - darwin: { signature: "", url: "" }, // compatible with older formats - "darwin-aarch64": { signature: "", url: "" }, - "darwin-intel": { signature: "", url: "" }, - "darwin-x86_64": { signature: "", url: "" }, - "linux-x86_64": { signature: "", url: "" }, - "linux-x86": { signature: "", url: "" }, - "linux-i686": { signature: "", url: "" }, - "linux-aarch64": { signature: "", url: "" }, - "linux-armv7": { signature: "", url: "" }, - "windows-x86_64": { signature: "", url: "" }, - "windows-aarch64": { signature: "", url: "" }, - "windows-x86": { signature: "", url: "" }, - "windows-i686": { signature: "", url: "" }, + win64: { signature: '', url: '' }, // compatible with older formats + linux: { signature: '', url: '' }, // compatible with older formats + darwin: { signature: '', url: '' }, // compatible with older formats + 'darwin-aarch64': { signature: '', url: '' }, + 'darwin-intel': { signature: '', url: '' }, + 'darwin-x86_64': { signature: '', url: '' }, + 'linux-x86_64': { signature: '', url: '' }, + 'linux-x86': { signature: '', url: '' }, + 'linux-i686': { signature: '', url: '' }, + 'linux-aarch64': { signature: '', url: '' }, + 'linux-armv7': { signature: '', url: '' }, + 'windows-x86_64': { signature: '', url: '' }, + 'windows-aarch64': { signature: '', url: '' }, + 'windows-x86': { signature: '', url: '' }, + 'windows-i686': { signature: '', url: '' }, }, - }; + } const promises = release.assets.map(async (asset) => { - const { name, browser_download_url } = asset; + const { name, browser_download_url } = asset // Process all the platform URL and signature data // win64 url - if (name.endsWith("x64-setup.exe")) { - updateData.platforms.win64.url = browser_download_url; - updateData.platforms["windows-x86_64"].url = browser_download_url; + if (name.endsWith('x64-setup.exe')) { + updateData.platforms.win64.url = browser_download_url + updateData.platforms['windows-x86_64'].url = browser_download_url } // win64 signature - if (name.endsWith("x64-setup.exe.sig")) { - const sig = await getSignature(browser_download_url); - updateData.platforms.win64.signature = sig; - updateData.platforms["windows-x86_64"].signature = sig; + if (name.endsWith('x64-setup.exe.sig')) { + const sig = await getSignature(browser_download_url) + updateData.platforms.win64.signature = sig + updateData.platforms['windows-x86_64'].signature = sig } // win32 url - if (name.endsWith("x86-setup.exe")) { - updateData.platforms["windows-x86"].url = browser_download_url; - updateData.platforms["windows-i686"].url = browser_download_url; + if (name.endsWith('x86-setup.exe')) { + updateData.platforms['windows-x86'].url = browser_download_url + updateData.platforms['windows-i686'].url = browser_download_url } // win32 signature - if (name.endsWith("x86-setup.exe.sig")) { - const sig = await getSignature(browser_download_url); - updateData.platforms["windows-x86"].signature = sig; - updateData.platforms["windows-i686"].signature = sig; + if (name.endsWith('x86-setup.exe.sig')) { + const sig = await getSignature(browser_download_url) + updateData.platforms['windows-x86'].signature = sig + updateData.platforms['windows-i686'].signature = sig } // win arm url - if (name.endsWith("arm64-setup.exe")) { - updateData.platforms["windows-aarch64"].url = browser_download_url; + if (name.endsWith('arm64-setup.exe')) { + updateData.platforms['windows-aarch64'].url = browser_download_url } // win arm signature - if (name.endsWith("arm64-setup.exe.sig")) { - const sig = await getSignature(browser_download_url); - updateData.platforms["windows-aarch64"].signature = sig; + if (name.endsWith('arm64-setup.exe.sig')) { + const sig = await getSignature(browser_download_url) + updateData.platforms['windows-aarch64'].signature = sig } // darwin url (intel) - if (name.endsWith(".app.tar.gz") && !name.includes("aarch")) { - updateData.platforms.darwin.url = browser_download_url; - updateData.platforms["darwin-intel"].url = browser_download_url; - updateData.platforms["darwin-x86_64"].url = browser_download_url; + if (name.endsWith('.app.tar.gz') && !name.includes('aarch')) { + updateData.platforms.darwin.url = browser_download_url + updateData.platforms['darwin-intel'].url = browser_download_url + updateData.platforms['darwin-x86_64'].url = browser_download_url } // darwin signature (intel) - if (name.endsWith(".app.tar.gz.sig") && !name.includes("aarch")) { - const sig = await getSignature(browser_download_url); - updateData.platforms.darwin.signature = sig; - updateData.platforms["darwin-intel"].signature = sig; - updateData.platforms["darwin-x86_64"].signature = sig; + if (name.endsWith('.app.tar.gz.sig') && !name.includes('aarch')) { + const sig = await getSignature(browser_download_url) + updateData.platforms.darwin.signature = sig + updateData.platforms['darwin-intel'].signature = sig + updateData.platforms['darwin-x86_64'].signature = sig } // darwin url (aarch) - if (name.endsWith("aarch64.app.tar.gz")) { - updateData.platforms["darwin-aarch64"].url = browser_download_url; + if (name.endsWith('aarch64.app.tar.gz')) { + updateData.platforms['darwin-aarch64'].url = browser_download_url // 使linux可以检查更新 - updateData.platforms.linux.url = browser_download_url; - updateData.platforms["linux-x86_64"].url = browser_download_url; - updateData.platforms["linux-x86"].url = browser_download_url; - updateData.platforms["linux-i686"].url = browser_download_url; - updateData.platforms["linux-aarch64"].url = browser_download_url; - updateData.platforms["linux-armv7"].url = browser_download_url; + updateData.platforms.linux.url = browser_download_url + updateData.platforms['linux-x86_64'].url = browser_download_url + updateData.platforms['linux-x86'].url = browser_download_url + updateData.platforms['linux-i686'].url = browser_download_url + updateData.platforms['linux-aarch64'].url = browser_download_url + updateData.platforms['linux-armv7'].url = browser_download_url } // darwin signature (aarch) - if (name.endsWith("aarch64.app.tar.gz.sig")) { - const sig = await getSignature(browser_download_url); - updateData.platforms["darwin-aarch64"].signature = sig; - updateData.platforms.linux.signature = sig; - updateData.platforms["linux-x86_64"].signature = sig; - updateData.platforms["linux-x86"].url = browser_download_url; - updateData.platforms["linux-i686"].url = browser_download_url; - updateData.platforms["linux-aarch64"].signature = sig; - updateData.platforms["linux-armv7"].signature = sig; + if (name.endsWith('aarch64.app.tar.gz.sig')) { + const sig = await getSignature(browser_download_url) + updateData.platforms['darwin-aarch64'].signature = sig + updateData.platforms.linux.signature = sig + updateData.platforms['linux-x86_64'].signature = sig + updateData.platforms['linux-x86'].url = browser_download_url + updateData.platforms['linux-i686'].url = browser_download_url + updateData.platforms['linux-aarch64'].signature = sig + updateData.platforms['linux-armv7'].signature = sig } - }); + }) - await Promise.allSettled(promises); - console.log(updateData); + await Promise.allSettled(promises) + console.log(updateData) // maybe should test the signature as well // delete the null field Object.entries(updateData.platforms).forEach(([key, value]) => { if (!value.url) { - console.log(`[Error]: failed to parse release for "${key}"`); - delete updateData.platforms[key]; + console.log(`[Error]: failed to parse release for "${key}"`) + delete updateData.platforms[key] } - }); + }) // Generate a proxy update file for accelerated GitHub resources - const updateDataNew = JSON.parse(JSON.stringify(updateData)); + const updateDataNew = JSON.parse(JSON.stringify(updateData)) Object.entries(updateDataNew.platforms).forEach(([key, value]) => { if (value.url) { updateDataNew.platforms[key].url = - "https://download.clashverge.dev/" + value.url; + 'https://download.clashverge.dev/' + value.url } else { - console.log(`[Error]: updateDataNew.platforms.${key} is null`); + console.log(`[Error]: updateDataNew.platforms.${key} is null`) } - }); + }) // Get the appropriate updater release based on isAlpha flag - const releaseTag = isAlpha ? ALPHA_TAG_NAME : UPDATE_TAG_NAME; + const releaseTag = isAlpha ? ALPHA_TAG_NAME : UPDATE_TAG_NAME console.log( - `Processing ${isAlpha ? "alpha" : "stable"} release:`, + `Processing ${isAlpha ? 'alpha' : 'stable'} release:`, releaseTag, - ); + ) try { - let updateRelease; + let updateRelease try { // Try to get the existing release const response = await github.rest.repos.getReleaseByTag({ ...options, tag: releaseTag, - }); - updateRelease = response.data; + }) + updateRelease = response.data console.log( `Found existing ${releaseTag} release with ID: ${updateRelease.id}`, - ); + ) } catch (error) { // If release doesn't exist, create it if (error.status === 404) { console.log( `Release with tag ${releaseTag} not found, creating new release...`, - ); + ) const createResponse = await github.rest.repos.createRelease({ ...options, tag_name: releaseTag, name: isAlpha - ? "Auto-update Alpha Channel" - : "Auto-update Stable Channel", - body: `This release contains the update information for ${isAlpha ? "alpha" : "stable"} channel.`, + ? 'Auto-update Alpha Channel' + : 'Auto-update Stable Channel', + body: `This release contains the update information for ${isAlpha ? 'alpha' : 'stable'} channel.`, prerelease: isAlpha, - }); - updateRelease = createResponse.data; + }) + updateRelease = createResponse.data console.log( `Created new ${releaseTag} release with ID: ${updateRelease.id}`, - ); + ) } else { // If it's another error, throw it - throw error; + throw error } } // File names based on release type - const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE; - const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY; + const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE + const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY // Delete existing assets with these names for (const asset of updateRelease.assets) { @@ -265,13 +265,13 @@ async function processRelease(github, options, tag, isAlpha) { await github.rest.repos.deleteReleaseAsset({ ...options, asset_id: asset.id, - }); + }) } if (asset.name === proxyFile) { await github.rest.repos .deleteReleaseAsset({ ...options, asset_id: asset.id }) - .catch(console.error); // do not break the pipeline + .catch(console.error) // do not break the pipeline } } @@ -281,32 +281,29 @@ async function processRelease(github, options, tag, isAlpha) { release_id: updateRelease.id, name: jsonFile, data: JSON.stringify(updateData, null, 2), - }); + }) await github.rest.repos.uploadReleaseAsset({ ...options, release_id: updateRelease.id, name: proxyFile, data: JSON.stringify(updateDataNew, null, 2), - }); + }) console.log( - `Successfully uploaded ${isAlpha ? "alpha" : "stable"} update files to ${releaseTag}`, - ); + `Successfully uploaded ${isAlpha ? 'alpha' : 'stable'} update files to ${releaseTag}`, + ) } catch (error) { console.error( - `Failed to process ${isAlpha ? "alpha" : "stable"} release:`, + `Failed to process ${isAlpha ? 'alpha' : 'stable'} release:`, error.message, - ); + ) } } catch (error) { if (error.status === 404) { - console.log(`Release not found for tag: ${tag.name}, skipping...`); + console.log(`Release not found for tag: ${tag.name}, skipping...`) } else { - console.error( - `Failed to get release for tag: ${tag.name}`, - error.message, - ); + console.error(`Failed to get release for tag: ${tag.name}`, error.message) } } } @@ -314,11 +311,11 @@ async function processRelease(github, options, tag, isAlpha) { // get the signature file content async function getSignature(url) { const response = await fetch(url, { - method: "GET", - headers: { "Content-Type": "application/octet-stream" }, - }); + method: 'GET', + headers: { 'Content-Type': 'application/octet-stream' }, + }) - return response.text(); + return response.text() } -resolveUpdater().catch(console.error); +resolveUpdater().catch(console.error) diff --git a/scripts/utils.mjs b/scripts/utils.mjs index 3db9a14e2..65eec3637 100644 --- a/scripts/utils.mjs +++ b/scripts/utils.mjs @@ -1,11 +1,11 @@ -import clc from "cli-color"; +import clc from 'cli-color' export const log_success = (msg, ...optionalParams) => - console.log(clc.green(msg), ...optionalParams); + console.log(clc.green(msg), ...optionalParams) export const log_error = (msg, ...optionalParams) => - console.log(clc.red(msg), ...optionalParams); + console.log(clc.red(msg), ...optionalParams) export const log_info = (msg, ...optionalParams) => - console.log(clc.bgBlue(msg), ...optionalParams); -var debugMsg = clc.xterm(245); + console.log(clc.bgBlue(msg), ...optionalParams) +var debugMsg = clc.xterm(245) export const log_debug = (msg, ...optionalParams) => - console.log(debugMsg(msg), ...optionalParams); + console.log(debugMsg(msg), ...optionalParams) diff --git a/src-tauri/src/enhance/builtin/meta_guard.js b/src-tauri/src/enhance/builtin/meta_guard.js index 5dd56980f..d427974c1 100644 --- a/src-tauri/src/enhance/builtin/meta_guard.js +++ b/src-tauri/src/enhance/builtin/meta_guard.js @@ -1,8 +1,8 @@ // This function is exported for use by the Clash core -// eslint-disable-next-line no-unused-vars +// eslint-disable-next-line unused-imports/no-unused-vars function main(config, _name) { - if (config.mode === "script") { - config.mode = "rule"; + if (config.mode === 'script') { + config.mode = 'rule' } - return config; + return config } diff --git a/src-tauri/src/enhance/builtin/meta_hy_alpn.js b/src-tauri/src/enhance/builtin/meta_hy_alpn.js index b542ec95e..d7e57dd2a 100644 --- a/src-tauri/src/enhance/builtin/meta_hy_alpn.js +++ b/src-tauri/src/enhance/builtin/meta_hy_alpn.js @@ -1,12 +1,12 @@ // This function is exported for use by the Clash core -// eslint-disable-next-line no-unused-vars +// eslint-disable-next-line unused-imports/no-unused-vars function main(config, _name) { if (Array.isArray(config.proxies)) { config.proxies.forEach((p, i) => { - if (p.type === "hysteria" && typeof p.alpn === "string") { - config.proxies[i].alpn = [p.alpn]; + if (p.type === 'hysteria' && typeof p.alpn === 'string') { + config.proxies[i].alpn = [p.alpn] } - }); + }) } - return config; + return config } diff --git a/src/assets/styles/font.scss b/src/assets/styles/font.scss index 35ad2d3db..193849ba1 100644 --- a/src/assets/styles/font.scss +++ b/src/assets/styles/font.scss @@ -1,4 +1,4 @@ @font-face { - font-family: "twemoji mozilla"; - src: url("../fonts/Twemoji.Mozilla.ttf"); + font-family: 'twemoji mozilla'; + src: url('../fonts/Twemoji.Mozilla.ttf'); } diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index aa12ded7c..c597d7d7b 100644 --- a/src/assets/styles/index.scss +++ b/src/assets/styles/index.scss @@ -1,12 +1,12 @@ -@use "./layout.scss"; -@use "./page.scss"; -@use "./font.scss"; +@use './layout.scss'; +@use './page.scss'; +@use './font.scss'; body { margin: 0; font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", - "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', + 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; user-select: none; diff --git a/src/components/base/base-dialog.tsx b/src/components/base/base-dialog.tsx index 4aa4483c7..5be2a038a 100644 --- a/src/components/base/base-dialog.tsx +++ b/src/components/base/base-dialog.tsx @@ -1,4 +1,4 @@ -import { LoadingButton } from "@mui/lab"; +import { LoadingButton } from '@mui/lab' import { Button, Dialog, @@ -7,29 +7,29 @@ import { DialogTitle, type SxProps, type Theme, -} from "@mui/material"; -import { ReactNode } from "react"; +} from '@mui/material' +import { ReactNode } from 'react' interface Props { - title: ReactNode; - open: boolean; - okBtn?: ReactNode; - cancelBtn?: ReactNode; - disableEnforceFocus?: boolean; - disableOk?: boolean; - disableCancel?: boolean; - disableFooter?: boolean; - contentSx?: SxProps; - children?: ReactNode; - loading?: boolean; - onOk?: () => void; - onCancel?: () => void; - onClose?: () => void; + title: ReactNode + open: boolean + okBtn?: ReactNode + cancelBtn?: ReactNode + disableEnforceFocus?: boolean + disableOk?: boolean + disableCancel?: boolean + disableFooter?: boolean + contentSx?: SxProps + children?: ReactNode + loading?: boolean + onOk?: () => void + onCancel?: () => void + onClose?: () => void } export interface DialogRef { - open: () => void; - close: () => void; + open: () => void + close: () => void } export const BaseDialog: React.FC = ({ @@ -73,5 +73,5 @@ export const BaseDialog: React.FC = ({ )} - ); -}; + ) +} diff --git a/src/components/base/base-empty.tsx b/src/components/base/base-empty.tsx index acbe36c46..19bfcba9d 100644 --- a/src/components/base/base-empty.tsx +++ b/src/components/base/base-empty.tsx @@ -1,40 +1,40 @@ -import { InboxRounded } from "@mui/icons-material"; -import { alpha, Box, Typography } from "@mui/material"; -import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; +import { InboxRounded } from '@mui/icons-material' +import { alpha, Box, Typography } from '@mui/material' +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' -import type { TranslationKey } from "@/types/generated/i18n-keys"; +import type { TranslationKey } from '@/types/generated/i18n-keys' interface Props { - text?: ReactNode; - textKey?: TranslationKey; - extra?: ReactNode; + text?: ReactNode + textKey?: TranslationKey + extra?: ReactNode } export const BaseEmpty = ({ text, - textKey = "shared.statuses.empty", + textKey = 'shared.statuses.empty', extra, }: Props) => { - const { t } = useTranslation(); + const { t } = useTranslation() - const resolvedText: ReactNode = text !== undefined ? text : t(textKey); + const resolvedText: ReactNode = text !== undefined ? text : t(textKey) return ( ({ - width: "100%", - height: "100%", - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', color: alpha(palette.text.secondary, 0.75), })} > - - {resolvedText} + + {resolvedText} {extra} - ); -}; + ) +} diff --git a/src/components/base/base-error-boundary.tsx b/src/components/base/base-error-boundary.tsx index 0c0d06da4..635f02361 100644 --- a/src/components/base/base-error-boundary.tsx +++ b/src/components/base/base-error-boundary.tsx @@ -1,9 +1,9 @@ -import { ReactNode } from "react"; -import { ErrorBoundary, FallbackProps } from "react-error-boundary"; +import { ReactNode } from 'react' +import { ErrorBoundary, FallbackProps } from 'react-error-boundary' function ErrorFallback({ error }: FallbackProps) { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack : undefined; + const errorMessage = error instanceof Error ? error.message : String(error) + const errorStack = error instanceof Error ? error.stack : undefined return (
@@ -16,15 +16,15 @@ function ErrorFallback({ error }: FallbackProps) {
{errorStack}
- ); + ) } interface Props { - children?: ReactNode; + children?: ReactNode } export const BaseErrorBoundary = ({ children }: Props) => { return ( {children} - ); -}; + ) +} diff --git a/src/components/base/base-fieldset.tsx b/src/components/base/base-fieldset.tsx index 57ffa82a3..be33740dd 100644 --- a/src/components/base/base-fieldset.tsx +++ b/src/components/base/base-fieldset.tsx @@ -1,13 +1,13 @@ -import { Box, styled } from "@mui/material"; -import React from "react"; +import { Box, styled } from '@mui/material' +import React from 'react' type Props = { - label: string; - fontSize?: string; - width?: string; - padding?: string; - children?: React.ReactNode; -}; + label: string + fontSize?: string + width?: string + padding?: string + children?: React.ReactNode +} export const BaseFieldset: React.FC = ({ label, @@ -17,28 +17,28 @@ export const BaseFieldset: React.FC = ({ children, }: Props) => { const Fieldset = styled(Box)<{ component?: string }>(() => ({ - position: "relative", - border: "1px solid #bbb", - borderRadius: "5px", - width: width ?? "auto", - padding: padding ?? "15px", - })); + position: 'relative', + border: '1px solid #bbb', + borderRadius: '5px', + width: width ?? 'auto', + padding: padding ?? '15px', + })) - const Label = styled("legend")(({ theme }) => ({ - position: "absolute", - top: "-10px", - left: padding ?? "15px", + const Label = styled('legend')(({ theme }) => ({ + position: 'absolute', + top: '-10px', + left: padding ?? '15px', backgroundColor: theme.palette.background.paper, backgroundImage: - "linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))", + 'linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))', color: theme.palette.text.primary, - fontSize: fontSize ?? "1em", - })); + fontSize: fontSize ?? '1em', + })) return (
{children}
- ); -}; + ) +} diff --git a/src/components/base/base-loading-overlay.tsx b/src/components/base/base-loading-overlay.tsx index a115da25c..1ec53bdc1 100644 --- a/src/components/base/base-loading-overlay.tsx +++ b/src/components/base/base-loading-overlay.tsx @@ -1,35 +1,35 @@ -import { Box, CircularProgress } from "@mui/material"; -import React from "react"; +import { Box, CircularProgress } from '@mui/material' +import React from 'react' interface BaseLoadingOverlayProps { - isLoading: boolean; + isLoading: boolean } export const BaseLoadingOverlay: React.FC = ({ isLoading, }) => { - if (!isLoading) return null; + if (!isLoading) return null return ( - theme.palette.mode === "dark" - ? "rgba(0, 0, 0, 0.5)" - : "rgba(255, 255, 255, 0.7)", + theme.palette.mode === 'dark' + ? 'rgba(0, 0, 0, 0.5)' + : 'rgba(255, 255, 255, 0.7)', zIndex: 1000, }} > - ); -}; + ) +} diff --git a/src/components/base/base-loading.tsx b/src/components/base/base-loading.tsx index 0fdbebf1e..d4b03e054 100644 --- a/src/components/base/base-loading.tsx +++ b/src/components/base/base-loading.tsx @@ -1,6 +1,6 @@ -import { styled } from "@mui/material"; +import { styled } from '@mui/material' -const Loading = styled("div")` +const Loading = styled('div')` position: relative; display: flex; height: 100%; @@ -31,11 +31,11 @@ const Loading = styled("div")` transform: scale(1); } } -`; +` -const LoadingItem = styled("div")(({ theme }) => ({ +const LoadingItem = styled('div')(({ theme }) => ({ background: theme.palette.text.secondary, -})); +})) export const BaseLoading = () => { return ( @@ -44,5 +44,5 @@ export const BaseLoading = () => { - ); -}; + ) +} diff --git a/src/components/base/base-page.tsx b/src/components/base/base-page.tsx index a93ad2ca6..0ae588b62 100644 --- a/src/components/base/base-page.tsx +++ b/src/components/base/base-page.tsx @@ -1,29 +1,29 @@ -import { Typography } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; -import React, { ReactNode } from "react"; +import { Typography } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import React, { ReactNode } from 'react' -import { BaseErrorBoundary } from "./base-error-boundary"; +import { BaseErrorBoundary } from './base-error-boundary' interface Props { - title?: React.ReactNode; // the page title - header?: React.ReactNode; // something behind title - contentStyle?: React.CSSProperties; - children?: ReactNode; - full?: boolean; + title?: React.ReactNode // the page title + header?: React.ReactNode // something behind title + contentStyle?: React.CSSProperties + children?: ReactNode + full?: boolean } export const BasePage: React.FC = (props) => { - const { title, header, contentStyle, full, children } = props; - const theme = useTheme(); + const { title, header, contentStyle, full, children } = props + const theme = useTheme() - const isDark = theme.palette.mode === "dark"; + const isDark = theme.palette.mode === 'dark' return (
-
+
{title} @@ -33,12 +33,12 @@ export const BasePage: React.FC = (props) => {
@@ -48,5 +48,5 @@ export const BasePage: React.FC = (props) => {
- ); -}; + ) +} diff --git a/src/components/base/base-search-box.tsx b/src/components/base/base-search-box.tsx index 1fbb311e5..014c70523 100644 --- a/src/components/base/base-search-box.tsx +++ b/src/components/base/base-search-box.tsx @@ -1,6 +1,6 @@ -import { ClearRounded } from "@mui/icons-material"; -import { Box, SvgIcon, TextField, styled, IconButton } from "@mui/material"; -import Tooltip from "@mui/material/Tooltip"; +import { ClearRounded } from '@mui/icons-material' +import { Box, SvgIcon, TextField, styled, IconButton } from '@mui/material' +import Tooltip from '@mui/material/Tooltip' import { ChangeEvent, useCallback, @@ -8,67 +8,67 @@ import { useMemo, useRef, useState, -} from "react"; -import { useTranslation } from "react-i18next"; +} from 'react' +import { useTranslation } from 'react-i18next' -import matchCaseIcon from "@/assets/image/component/match_case.svg?react"; -import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react"; -import UseRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react"; -import { buildRegex, compileStringMatcher } from "@/utils/search-matcher"; +import matchCaseIcon from '@/assets/image/component/match_case.svg?react' +import matchWholeWordIcon from '@/assets/image/component/match_whole_word.svg?react' +import UseRegularExpressionIcon from '@/assets/image/component/use_regular_expression.svg?react' +import { buildRegex, compileStringMatcher } from '@/utils/search-matcher' export type SearchState = { - text: string; - matchCase: boolean; - matchWholeWord: boolean; - useRegularExpression: boolean; -}; + text: string + matchCase: boolean + matchWholeWord: boolean + useRegularExpression: boolean +} -type SearchOptionState = Omit; +type SearchOptionState = Omit type SearchProps = { - value?: string; - defaultValue?: string; - autoFocus?: boolean; - placeholder?: string; - matchCase?: boolean; - matchWholeWord?: boolean; - useRegularExpression?: boolean; - searchState?: Partial; - onSearch: (match: (content: string) => boolean, state: SearchState) => void; -}; + value?: string + defaultValue?: string + autoFocus?: boolean + placeholder?: string + matchCase?: boolean + matchWholeWord?: boolean + useRegularExpression?: boolean + searchState?: Partial + onSearch: (match: (content: string) => boolean, state: SearchState) => void +} const StyledTextField = styled(TextField)(({ theme }) => ({ - "& .MuiInputBase-root": { - background: theme.palette.mode === "light" ? "#fff" : undefined, - paddingRight: "4px", + '& .MuiInputBase-root': { + background: theme.palette.mode === 'light' ? '#fff' : undefined, + paddingRight: '4px', }, "& .MuiInputBase-root svg[aria-label='active'] path": { fill: theme.palette.primary.light, }, "& .MuiInputBase-root svg[aria-label='inactive'] path": { - fill: "#A7A7A7", + fill: '#A7A7A7', }, -})); +})) const useControllableState = (options: { - controlled: T | undefined; - defaultValue: T; + controlled: T | undefined + defaultValue: T }) => { - const { controlled, defaultValue } = options; - const [uncontrolled, setUncontrolled] = useState(defaultValue); - const isControlled = controlled !== undefined; + const { controlled, defaultValue } = options + const [uncontrolled, setUncontrolled] = useState(defaultValue) + const isControlled = controlled !== undefined - const value = isControlled ? controlled : uncontrolled; + const value = isControlled ? controlled : uncontrolled const setValue = useCallback( (next: T) => { - if (!isControlled) setUncontrolled(next); + if (!isControlled) setUncontrolled(next) }, [isControlled], - ); + ) - return [value, setValue] as const; -}; + return [value, setValue] as const +} export const BaseSearchBox = ({ value, @@ -81,113 +81,113 @@ export const BaseSearchBox = ({ useRegularExpression: defaultUseRegularExpression = false, onSearch, }: SearchProps) => { - const { t } = useTranslation(); - const onSearchRef = useRef(onSearch); - const lastSearchStateRef = useRef(null); + const { t } = useTranslation() + const onSearchRef = useRef(onSearch) + const lastSearchStateRef = useRef(null) const [text, setText] = useControllableState({ controlled: value, - defaultValue: defaultValue ?? "", - }); + defaultValue: defaultValue ?? '', + }) const [matchCase, setMatchCase] = useControllableState({ controlled: searchState?.matchCase, defaultValue: defaultMatchCase, - }); + }) const [matchWholeWord, setMatchWholeWord] = useControllableState({ controlled: searchState?.matchWholeWord, defaultValue: defaultMatchWholeWord, - }); + }) const [useRegularExpression, setUseRegularExpression] = useControllableState({ controlled: searchState?.useRegularExpression, defaultValue: defaultUseRegularExpression, - }); + }) const iconStyle = { style: { - height: "24px", - width: "24px", - cursor: "pointer", + height: '24px', + width: '24px', + cursor: 'pointer', } as React.CSSProperties, inheritViewBox: true, - }; + } useEffect(() => { - onSearchRef.current = onSearch; - }, [onSearch]); + onSearchRef.current = onSearch + }, [onSearch]) const emitSearch = useCallback((nextState: SearchState) => { - const prevState = lastSearchStateRef.current; + const prevState = lastSearchStateRef.current const isSameState = !!prevState && prevState.text === nextState.text && prevState.matchCase === nextState.matchCase && prevState.matchWholeWord === nextState.matchWholeWord && - prevState.useRegularExpression === nextState.useRegularExpression; - if (isSameState) return; + prevState.useRegularExpression === nextState.useRegularExpression + if (isSameState) return - const compiled = compileStringMatcher(nextState.text, nextState); - onSearchRef.current(compiled.matcher, nextState); + const compiled = compileStringMatcher(nextState.text, nextState) + onSearchRef.current(compiled.matcher, nextState) - lastSearchStateRef.current = nextState; - }, []); + lastSearchStateRef.current = nextState + }, []) useEffect(() => { - emitSearch({ text, matchCase, matchWholeWord, useRegularExpression }); - }, [emitSearch, matchCase, matchWholeWord, text, useRegularExpression]); + emitSearch({ text, matchCase, matchWholeWord, useRegularExpression }) + }, [emitSearch, matchCase, matchWholeWord, text, useRegularExpression]) const effectiveErrorMessage = useMemo(() => { - if (!useRegularExpression || !text) return ""; - const flags = matchCase ? "" : "i"; - return buildRegex(text, flags) ? "" : t("shared.validation.invalidRegex"); - }, [matchCase, t, text, useRegularExpression]); + if (!useRegularExpression || !text) return '' + const flags = matchCase ? '' : 'i' + return buildRegex(text, flags) ? '' : t('shared.validation.invalidRegex') + }, [matchCase, t, text, useRegularExpression]) const handleChangeText = ( e: ChangeEvent, ) => { - const nextText = e.target?.value ?? ""; - setText(nextText); + const nextText = e.target?.value ?? '' + setText(nextText) emitSearch({ text: nextText, matchCase, matchWholeWord, useRegularExpression, - }); - }; + }) + } const handleToggleUseRegularExpression = () => { - const next = !useRegularExpression; - setUseRegularExpression(next); + const next = !useRegularExpression + setUseRegularExpression(next) emitSearch({ text, matchCase, matchWholeWord, useRegularExpression: next, - }); - }; + }) + } const handleClearInput = () => { - setText(""); - emitSearch({ text: "", matchCase, matchWholeWord, useRegularExpression }); - }; + setText('') + emitSearch({ text: '', matchCase, matchWholeWord, useRegularExpression }) + } const handleToggleMatchCase = () => { - const next = !matchCase; - setMatchCase(next); - emitSearch({ text, matchCase: next, matchWholeWord, useRegularExpression }); - }; + const next = !matchCase + setMatchCase(next) + emitSearch({ text, matchCase: next, matchWholeWord, useRegularExpression }) + } const handleToggleMatchWholeWord = () => { - const next = !matchWholeWord; - setMatchWholeWord(next); - emitSearch({ text, matchCase, matchWholeWord: next, useRegularExpression }); - }; + const next = !matchWholeWord + setMatchWholeWord(next) + emitSearch({ text, matchCase, matchWholeWord: next, useRegularExpression }) + } return ( - + {!!text && ( - + )} - + - + - + @@ -247,5 +247,5 @@ export const BaseSearchBox = ({ }} /> - ); -}; + ) +} diff --git a/src/components/base/base-split-chip-editor.tsx b/src/components/base/base-split-chip-editor.tsx index 1b42cf1b0..be9524291 100644 --- a/src/components/base/base-split-chip-editor.tsx +++ b/src/components/base/base-split-chip-editor.tsx @@ -1,4 +1,4 @@ -import { CodeRounded, ViewModuleRounded } from "@mui/icons-material"; +import { CodeRounded, ViewModuleRounded } from '@mui/icons-material' import { Box, Button, @@ -8,51 +8,51 @@ import { TextField, Tooltip, Typography, -} from "@mui/material"; -import type { ReactNode } from "react"; -import { useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import type { ReactNode } from 'react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' -export type BaseSplitChipEditorMode = "visual" | "advanced"; +export type BaseSplitChipEditorMode = 'visual' | 'advanced' interface BaseSplitChipEditorProps { - value?: string; - onChange: (value: string) => void; - disabled?: boolean; - error?: boolean; - helperText?: ReactNode; - placeholder?: string; - rows?: number; - separator?: string; - splitPattern?: RegExp; - defaultMode?: BaseSplitChipEditorMode; - showModeToggle?: boolean; - ariaLabel?: string; - addLabel?: ReactNode; - emptyLabel?: ReactNode; - modeLabels?: Partial>; - renderHeader?: (modeToggle: ReactNode) => ReactNode; + value?: string + onChange: (value: string) => void + disabled?: boolean + error?: boolean + helperText?: ReactNode + placeholder?: string + rows?: number + separator?: string + splitPattern?: RegExp + defaultMode?: BaseSplitChipEditorMode + showModeToggle?: boolean + ariaLabel?: string + addLabel?: ReactNode + emptyLabel?: ReactNode + modeLabels?: Partial> + renderHeader?: (modeToggle: ReactNode) => ReactNode } -const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/; +const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/ const splitValue = (value: string, splitPattern: RegExp) => value .split(splitPattern) .map((item) => item.trim()) - .filter(Boolean); + .filter(Boolean) export const BaseSplitChipEditor = ({ - value = "", + value = '', onChange, disabled = false, error = false, helperText, placeholder, rows = 4, - separator = ",", + separator = ',', splitPattern = DEFAULT_SPLIT_PATTERN, - defaultMode = "visual", + defaultMode = 'visual', showModeToggle = true, ariaLabel, addLabel, @@ -60,58 +60,58 @@ export const BaseSplitChipEditor = ({ modeLabels, renderHeader, }: BaseSplitChipEditorProps) => { - const { t } = useTranslation(); - const [mode, setMode] = useState(defaultMode); - const [draft, setDraft] = useState(""); + const { t } = useTranslation() + const [mode, setMode] = useState(defaultMode) + const [draft, setDraft] = useState('') const resolvedLabels = useMemo( () => ({ - visual: modeLabels?.visual ?? t("shared.editorModes.visualization"), - advanced: modeLabels?.advanced ?? t("shared.editorModes.advanced"), - add: addLabel ?? t("shared.actions.new"), - empty: emptyLabel ?? t("shared.statuses.empty"), + visual: modeLabels?.visual ?? t('shared.editorModes.visualization'), + advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'), + add: addLabel ?? t('shared.actions.new'), + empty: emptyLabel ?? t('shared.statuses.empty'), }), [t, modeLabels, addLabel, emptyLabel], - ); + ) const values = useMemo( () => splitValue(value, splitPattern), [value, splitPattern], - ); + ) const items = useMemo(() => { - const counts = new Map(); + const counts = new Map() return values.map((item) => { - const nextCount = (counts.get(item) ?? 0) + 1; - counts.set(item, nextCount); + const nextCount = (counts.get(item) ?? 0) + 1 + counts.set(item, nextCount) return { key: `${item}-${nextCount}`, value: item, - }; - }); - }, [values]); + } + }) + }, [values]) const handleAddDraft = () => { - const nextValues = splitValue(draft, splitPattern); + const nextValues = splitValue(draft, splitPattern) if (!nextValues.length) { - return; + return } - const nextValue = [...values, ...nextValues].join(separator); - onChange(nextValue); - setDraft(""); - }; + const nextValue = [...values, ...nextValues].join(separator) + onChange(nextValue) + setDraft('') + } const handleRemoveItem = (index: number) => { - const nextValue = values.filter((_, itemIndex) => itemIndex !== index); - onChange(nextValue.join(separator)); - }; + const nextValue = values.filter((_, itemIndex) => itemIndex !== index) + onChange(nextValue.join(separator)) + } - const nextMode = mode === "visual" ? "advanced" : "visual"; + const nextMode = mode === 'visual' ? 'advanced' : 'visual' const toggleLabel = - nextMode === "visual" ? resolvedLabels.visual : resolvedLabels.advanced; - const ToggleIcon = nextMode === "visual" ? ViewModuleRounded : CodeRounded; + nextMode === 'visual' ? resolvedLabels.visual : resolvedLabels.advanced + const ToggleIcon = nextMode === 'visual' ? ViewModuleRounded : CodeRounded const resolvedAriaLabel = - ariaLabel ?? (typeof toggleLabel === "string" ? toggleLabel : undefined); + ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined) const modeToggle = showModeToggle ? ( @@ -119,26 +119,26 @@ export const BaseSplitChipEditor = ({ size="small" aria-label={resolvedAriaLabel} onClick={() => { - setMode(nextMode); - if (nextMode === "visual") { - setDraft(""); + setMode(nextMode) + if (nextMode === 'visual') { + setDraft('') } }} > - ) : null; + ) : null return ( <> {renderHeader ? renderHeader(modeToggle) : modeToggle} - {mode === "visual" ? ( - + {mode === 'visual' ? ( + setDraft(event.target.value)} onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - handleAddDraft(); + if (event.key === 'Enter') { + event.preventDefault() + handleAddDraft() } }} /> @@ -187,7 +187,7 @@ export const BaseSplitChipEditor = ({ size="small" onClick={handleAddDraft} disabled={disabled || !draft.trim()} - sx={{ minHeight: 32, padding: "2px 8px" }} + sx={{ minHeight: 32, padding: '2px 8px' }} > {resolvedLabels.add} @@ -203,14 +203,14 @@ export const BaseSplitChipEditor = ({ size="small" multiline rows={rows} - sx={{ width: "100%" }} + sx={{ width: '100%' }} value={value} helperText={helperText} onChange={(event) => { - onChange(event.target.value); + onChange(event.target.value) }} /> )} - ); -}; + ) +} diff --git a/src/components/base/base-styled-select.tsx b/src/components/base/base-styled-select.tsx index d34ff1a43..9dfdc21f3 100644 --- a/src/components/base/base-styled-select.tsx +++ b/src/components/base/base-styled-select.tsx @@ -1,4 +1,4 @@ -import { Select, SelectProps, styled } from "@mui/material"; +import { Select, SelectProps, styled } from '@mui/material' export const BaseStyledSelect = styled((props: SelectProps) => { return ( @@ -13,7 +13,7 @@ export const BaseStyledSelect = styled((props: SelectProps) => { }} {...props} /> - ); + ) })(({ theme }) => ({ - background: theme.palette.mode === "light" ? "#fff" : undefined, -})); + background: theme.palette.mode === 'light' ? '#fff' : undefined, +})) diff --git a/src/components/base/base-styled-text-field.tsx b/src/components/base/base-styled-text-field.tsx index 3d863a936..6fae8ffe3 100644 --- a/src/components/base/base-styled-text-field.tsx +++ b/src/components/base/base-styled-text-field.tsx @@ -1,8 +1,8 @@ -import { TextField, type TextFieldProps, styled } from "@mui/material"; -import { useTranslation } from "react-i18next"; +import { TextField, type TextFieldProps, styled } from '@mui/material' +import { useTranslation } from 'react-i18next' export const BaseStyledTextField = styled((props: TextFieldProps) => { - const { t } = useTranslation(); + const { t } = useTranslation() return ( { size="small" variant="outlined" spellCheck="false" - placeholder={t("shared.placeholders.filter")} + placeholder={t('shared.placeholders.filter')} sx={{ input: { py: 0.65, px: 1.25 } }} {...props} /> - ); + ) })(({ theme }) => ({ - "& .MuiInputBase-root": { - background: theme.palette.mode === "light" ? "#fff" : undefined, + '& .MuiInputBase-root': { + background: theme.palette.mode === 'light' ? '#fff' : undefined, }, -})); +})) diff --git a/src/components/base/base-switch.tsx b/src/components/base/base-switch.tsx index 302c06bd7..b6513e9f7 100644 --- a/src/components/base/base-switch.tsx +++ b/src/components/base/base-switch.tsx @@ -1,5 +1,5 @@ -import { styled } from "@mui/material/styles"; -import { default as MuiSwitch, SwitchProps } from "@mui/material/Switch"; +import { styled } from '@mui/material/styles' +import { default as MuiSwitch, SwitchProps } from '@mui/material/Switch' export const Switch = styled((props: SwitchProps) => ( ( height: 26, padding: 0, marginRight: 1, - "& .MuiSwitch-switchBase": { + '& .MuiSwitch-switchBase': { padding: 0, margin: 2, - transitionDuration: "300ms", - "&.Mui-checked": { - transform: "translateX(16px)", - color: "#fff", - "& + .MuiSwitch-track": { + transitionDuration: '300ms', + '&.Mui-checked': { + transform: 'translateX(16px)', + color: '#fff', + '& + .MuiSwitch-track': { backgroundColor: theme.palette.primary.main, opacity: 1, border: 0, }, - "&.Mui-disabled + .MuiSwitch-track": { + '&.Mui-disabled + .MuiSwitch-track': { opacity: 0.5, }, }, - "&.Mui-focusVisible .MuiSwitch-thumb": { - color: "#33cf4d", - border: "6px solid #fff", + '&.Mui-focusVisible .MuiSwitch-thumb': { + color: '#33cf4d', + border: '6px solid #fff', }, - "&.Mui-disabled .MuiSwitch-thumb": { + '&.Mui-disabled .MuiSwitch-thumb': { color: - theme.palette.mode === "light" + theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[600], }, - "&.Mui-disabled + .MuiSwitch-track": { - opacity: theme.palette.mode === "light" ? 0.7 : 0.3, + '&.Mui-disabled + .MuiSwitch-track': { + opacity: theme.palette.mode === 'light' ? 0.7 : 0.3, }, }, - "& .MuiSwitch-thumb": { - boxSizing: "border-box", + '& .MuiSwitch-thumb': { + boxSizing: 'border-box', width: 22, height: 22, }, - "& .MuiSwitch-track": { + '& .MuiSwitch-track': { borderRadius: 26 / 2, - backgroundColor: theme.palette.mode === "light" ? "#BBBBBB" : "#39393D", + backgroundColor: theme.palette.mode === 'light' ? '#BBBBBB' : '#39393D', opacity: 1, - transition: theme.transitions.create(["background-color"], { + transition: theme.transitions.create(['background-color'], { duration: 500, }), }, -})); +})) diff --git a/src/components/base/base-tooltip-icon.tsx b/src/components/base/base-tooltip-icon.tsx index 330490233..ed02946e8 100644 --- a/src/components/base/base-tooltip-icon.tsx +++ b/src/components/base/base-tooltip-icon.tsx @@ -1,24 +1,24 @@ -import { InfoRounded } from "@mui/icons-material"; +import { InfoRounded } from '@mui/icons-material' import { Tooltip, IconButton, IconButtonProps, SvgIconProps, -} from "@mui/material"; +} from '@mui/material' interface Props extends IconButtonProps { - title?: string; - icon?: React.ElementType; + title?: string + icon?: React.ElementType } export const TooltipIcon: React.FC = (props: Props) => { - const { title = "", icon: Icon = InfoRounded, ...restProps } = props; + const { title = '', icon: Icon = InfoRounded, ...restProps } = props return ( - + - ); -}; + ) +} diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 8effa81a2..054f05f68 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -1,16 +1,16 @@ -export { BaseDialog, type DialogRef } from "./base-dialog"; -export { BaseEmpty } from "./base-empty"; -export { BaseErrorBoundary } from "./base-error-boundary"; -export { BaseFieldset } from "./base-fieldset"; -export { BaseLoading } from "./base-loading"; -export { BaseLoadingOverlay } from "./base-loading-overlay"; -export { BasePage } from "./base-page"; -export { BaseSearchBox, type SearchState } from "./base-search-box"; +export { BaseDialog, type DialogRef } from './base-dialog' +export { BaseEmpty } from './base-empty' +export { BaseErrorBoundary } from './base-error-boundary' +export { BaseFieldset } from './base-fieldset' +export { BaseLoading } from './base-loading' +export { BaseLoadingOverlay } from './base-loading-overlay' +export { BasePage } from './base-page' +export { BaseSearchBox, type SearchState } from './base-search-box' export { BaseSplitChipEditor, type BaseSplitChipEditorMode, -} from "./base-split-chip-editor"; -export { BaseStyledSelect } from "./base-styled-select"; -export { BaseStyledTextField } from "./base-styled-text-field"; -export { Switch } from "./base-switch"; -export { TooltipIcon } from "./base-tooltip-icon"; +} from './base-split-chip-editor' +export { BaseStyledSelect } from './base-styled-select' +export { BaseStyledTextField } from './base-styled-text-field' +export { Switch } from './base-switch' +export { TooltipIcon } from './base-tooltip-icon' diff --git a/src/components/connection/connection-column-manager.tsx b/src/components/connection/connection-column-manager.tsx index 19a182d63..ed0f4c744 100644 --- a/src/components/connection/connection-column-manager.tsx +++ b/src/components/connection/connection-column-manager.tsx @@ -5,10 +5,10 @@ import { useSensor, useSensors, type DragEndEvent, -} from "@dnd-kit/core"; -import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { DragIndicatorRounded } from "@mui/icons-material"; +} from '@dnd-kit/core' +import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { DragIndicatorRounded } from '@mui/icons-material' import { Button, Checkbox, @@ -20,17 +20,17 @@ import { List, ListItem, ListItemText, -} from "@mui/material"; -import type { Column } from "@tanstack/react-table"; -import { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import type { Column } from '@tanstack/react-table' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' interface Props { - open: boolean; - columns: Column[]; - onClose: () => void; - onOrderChange: (order: string[]) => void; - onReset: () => void; + open: boolean + columns: Column[] + onClose: () => void + onOrderChange: (order: string[]) => void + onReset: () => void } export const ConnectionColumnManager = ({ @@ -44,34 +44,34 @@ export const ConnectionColumnManager = ({ useSensor(PointerSensor, { activationConstraint: { distance: 6 }, }), - ); - const { t } = useTranslation(); + ) + const { t } = useTranslation() - const items = useMemo(() => columns.map((column) => column.id), [columns]); + const items = useMemo(() => columns.map((column) => column.id), [columns]) const visibleCount = useMemo( () => columns.filter((column) => column.getIsVisible()).length, [columns], - ); + ) const handleDragEnd = useCallback( (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; + const { active, over } = event + if (!over || active.id === over.id) return - const order = columns.map((column) => column.id); - const oldIndex = order.indexOf(active.id as string); - const newIndex = order.indexOf(over.id as string); - if (oldIndex === -1 || newIndex === -1) return; + const order = columns.map((column) => column.id) + const oldIndex = order.indexOf(active.id as string) + const newIndex = order.indexOf(over.id as string) + if (oldIndex === -1 || newIndex === -1) return - onOrderChange(arrayMove(order, oldIndex, newIndex)); + onOrderChange(arrayMove(order, oldIndex, newIndex)) }, [columns, onOrderChange], - ); + ) return ( - {t("connections.components.columnManager.title")} + {t('connections.components.columnManager.title')} {columns.map((column) => ( - ); -}; + ) +} interface SortableColumnItemProps { - column: Column; - label: string; - dragHandleLabel: string; - disableToggle?: boolean; + column: Column + label: string + dragHandleLabel: string + disableToggle?: boolean } const SortableColumnItem = ({ @@ -135,7 +135,7 @@ const SortableColumnItem = ({ transform, transition, isDragging, - } = useSortable({ id: column.id }); + } = useSortable({ id: column.id }) const style = useMemo( () => ({ @@ -143,7 +143,7 @@ const SortableColumnItem = ({ transition, }), [transform, transition], - ); + ) return ( `1px solid ${theme.palette.divider}`, - backgroundColor: isDragging ? "action.hover" : "transparent", - display: "flex", - alignItems: "center", + backgroundColor: isDragging ? 'action.hover' : 'transparent', + display: 'flex', + alignItems: 'center', gap: 1, }} style={style} @@ -169,13 +169,13 @@ const SortableColumnItem = ({ /> - ); -}; + ) +} const getColumnLabel = (column: Column) => { - const meta = column.columnDef.meta as { label?: string } | undefined; - if (meta?.label) return meta.label; + const meta = column.columnDef.meta as { label?: string } | undefined + if (meta?.label) return meta.label - const header = column.columnDef.header; - return typeof header === "string" ? header : column.id; -}; + const header = column.columnDef.header + return typeof header === 'string' ? header : column.id +} diff --git a/src/components/connection/connection-detail.tsx b/src/components/connection/connection-detail.tsx index 33fd23a8a..62a7bc9d5 100644 --- a/src/components/connection/connection-detail.tsx +++ b/src/components/connection/connection-detail.tsx @@ -1,43 +1,43 @@ -import { Box, Button, Snackbar, useTheme } from "@mui/material"; -import { useLockFn } from "ahooks"; -import dayjs from "dayjs"; -import { useImperativeHandle, useState, type Ref } from "react"; -import { useTranslation } from "react-i18next"; -import { closeConnection } from "tauri-plugin-mihomo-api"; +import { Box, Button, Snackbar, useTheme } from '@mui/material' +import { useLockFn } from 'ahooks' +import dayjs from 'dayjs' +import { useImperativeHandle, useState, type Ref } from 'react' +import { useTranslation } from 'react-i18next' +import { closeConnection } from 'tauri-plugin-mihomo-api' -import parseTraffic from "@/utils/parse-traffic"; +import parseTraffic from '@/utils/parse-traffic' export interface ConnectionDetailRef { - open: (detail: IConnectionsItem, closed: boolean) => void; + open: (detail: IConnectionsItem, closed: boolean) => void } export function ConnectionDetail({ ref }: { ref?: Ref }) { - const [open, setOpen] = useState(false); - const [detail, setDetail] = useState(null!); - const [closed, setClosed] = useState(false); - const theme = useTheme(); + const [open, setOpen] = useState(false) + const [detail, setDetail] = useState(null!) + const [closed, setClosed] = useState(false) + const theme = useTheme() useImperativeHandle(ref, () => ({ open: (detail: IConnectionsItem, closed: boolean) => { - if (open) return; - setOpen(true); - setDetail(detail); - setClosed(closed); + if (open) return + setOpen(true) + setDetail(detail) + setClosed(closed) }, - })); + })) - const onClose = () => setOpen(false); + const onClose = () => setOpen(false) return ( }) { ) : null } /> - ); + ) } interface InnerProps { - data: IConnectionsItem; - closed: boolean; - onClose?: () => void; + data: IConnectionsItem + closed: boolean + onClose?: () => void } const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => { - const { t } = useTranslation(); - const { metadata, rulePayload } = data; - const theme = useTheme(); - const chains = [...data.chains].reverse().join(" / "); - const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule; + const { t } = useTranslation() + const { metadata, rulePayload } = data + const theme = useTheme() + const chains = [...data.chains].reverse().join(' / ') + const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule const host = metadata.host ? `${metadata.host}:${metadata.destinationPort}` - : `${metadata.remoteDestination}:${metadata.destinationPort}`; + : `${metadata.remoteDestination}:${metadata.destinationPort}` const Destination = metadata.destinationIP ? metadata.destinationIP - : metadata.remoteDestination; + : metadata.remoteDestination const information = [ - { label: t("connections.components.fields.host"), value: host }, + { label: t('connections.components.fields.host'), value: host }, { - label: t("shared.labels.downloaded"), - value: parseTraffic(data.download).join(" "), + label: t('shared.labels.downloaded'), + value: parseTraffic(data.download).join(' '), }, { - label: t("shared.labels.uploaded"), - value: parseTraffic(data.upload).join(" "), + label: t('shared.labels.uploaded'), + value: parseTraffic(data.upload).join(' '), }, { - label: t("connections.components.fields.dlSpeed"), - value: parseTraffic(data.curDownload ?? -1).join(" ") + "/s", + label: t('connections.components.fields.dlSpeed'), + value: parseTraffic(data.curDownload ?? -1).join(' ') + '/s', }, { - label: t("connections.components.fields.ulSpeed"), - value: parseTraffic(data.curUpload ?? -1).join(" ") + "/s", + label: t('connections.components.fields.ulSpeed'), + value: parseTraffic(data.curUpload ?? -1).join(' ') + '/s', }, { - label: t("connections.components.fields.chains"), + label: t('connections.components.fields.chains'), value: chains, }, - { label: t("connections.components.fields.rule"), value: rule }, + { label: t('connections.components.fields.rule'), value: rule }, { - label: t("connections.components.fields.process"), - value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ""}`, + label: t('connections.components.fields.process'), + value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ''}`, }, { - label: t("connections.components.fields.time"), + label: t('connections.components.fields.time'), value: dayjs(data.start).fromNow(), }, { - label: t("connections.components.fields.source"), + label: t('connections.components.fields.source'), value: `${metadata.sourceIP}:${metadata.sourcePort}`, }, { - label: t("connections.components.fields.destination"), + label: t('connections.components.fields.destination'), value: Destination, }, { - label: t("connections.components.fields.destinationPort"), + label: t('connections.components.fields.destinationPort'), value: `${metadata.destinationPort}`, }, { - label: t("connections.components.fields.type"), + label: t('connections.components.fields.type'), value: `${metadata.type}(${metadata.network})`, }, - ]; + ] - const onDelete = useLockFn(async () => closeConnection(data.id)); + const onDelete = useLockFn(async () => closeConnection(data.id)) return ( - + {information.map((each) => (
{each.label} @@ -142,19 +142,19 @@ const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => { ))} {!closed && ( - + )} - ); -}; + ) +} diff --git a/src/components/connection/connection-item.tsx b/src/components/connection/connection-item.tsx index 6512b5a5a..4c8ac2f58 100644 --- a/src/components/connection/connection-item.tsx +++ b/src/components/connection/connection-item.tsx @@ -1,4 +1,4 @@ -import { CloseRounded } from "@mui/icons-material"; +import { CloseRounded } from '@mui/icons-material' import { styled, ListItem, @@ -6,52 +6,52 @@ import { ListItemText, Box, alpha, -} from "@mui/material"; -import { useLockFn } from "ahooks"; -import dayjs from "dayjs"; -import { useTranslation } from "react-i18next"; -import { closeConnection } from "tauri-plugin-mihomo-api"; +} from '@mui/material' +import { useLockFn } from 'ahooks' +import dayjs from 'dayjs' +import { useTranslation } from 'react-i18next' +import { closeConnection } from 'tauri-plugin-mihomo-api' -import parseTraffic from "@/utils/parse-traffic"; +import parseTraffic from '@/utils/parse-traffic' -const Tag = styled("span")(({ theme }) => ({ - fontSize: "10px", - padding: "0 4px", +const Tag = styled('span')(({ theme }) => ({ + fontSize: '10px', + padding: '0 4px', lineHeight: 1.375, - border: "1px solid", + border: '1px solid', borderRadius: 4, borderColor: alpha(theme.palette.text.secondary, 0.35), - marginTop: "4px", - marginRight: "4px", -})); + marginTop: '4px', + marginRight: '4px', +})) interface Props { - value: IConnectionsItem; - closed: boolean; - onShowDetail?: () => void; + value: IConnectionsItem + closed: boolean + onShowDetail?: () => void } export const ConnectionItem = (props: Props) => { - const { value, closed, onShowDetail } = props; + const { value, closed, onShowDetail } = props - const { id, metadata, chains, start, curUpload, curDownload } = value; - const { t } = useTranslation(); + const { id, metadata, chains, start, curUpload, curDownload } = value + const { t } = useTranslation() - const onDelete = useLockFn(async () => closeConnection(id)); - const showTraffic = curUpload! >= 100 || curDownload! >= 100; + const onDelete = useLockFn(async () => closeConnection(id)) + const showTraffic = curUpload! >= 100 || curDownload! >= 100 return ( @@ -59,12 +59,12 @@ export const ConnectionItem = (props: Props) => { } > - + + {metadata.network} @@ -73,7 +73,7 @@ export const ConnectionItem = (props: Props) => { {!!metadata.process && {metadata.process}} {chains?.length > 0 && ( - {[...chains].reverse().join(" / ")} + {[...chains].reverse().join(' / ')} )} {dayjs(start).fromNow()} @@ -87,5 +87,5 @@ export const ConnectionItem = (props: Props) => { } /> - ); -}; + ) +} diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx index f121f2720..5ee7ac3b9 100644 --- a/src/components/connection/connection-table.tsx +++ b/src/components/connection/connection-table.tsx @@ -1,5 +1,5 @@ -import { ViewColumnRounded } from "@mui/icons-material"; -import { Box, IconButton, Tooltip } from "@mui/material"; +import { ViewColumnRounded } from '@mui/icons-material' +import { Box, IconButton, Tooltip } from '@mui/material' import { ColumnDef, ColumnOrderState, @@ -11,10 +11,10 @@ import { Updater, useReactTable, VisibilityState, -} from "@tanstack/react-table"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import dayjs from "dayjs"; -import { useLocalStorage } from "foxact/use-local-storage"; +} from '@tanstack/react-table' +import { useVirtualizer } from '@tanstack/react-virtual' +import dayjs from 'dayjs' +import { useLocalStorage } from 'foxact/use-local-storage' import { useCallback, useEffect, @@ -22,15 +22,15 @@ import { useRef, useState, type ReactNode, -} from "react"; -import { useTranslation } from "react-i18next"; +} from 'react' +import { useTranslation } from 'react-i18next' -import parseTraffic from "@/utils/parse-traffic"; -import { truncateStr } from "@/utils/truncate-str"; +import parseTraffic from '@/utils/parse-traffic' +import { truncateStr } from '@/utils/truncate-str' -import { ConnectionColumnManager } from "./connection-column-manager"; +import { ConnectionColumnManager } from './connection-column-manager' -const ROW_HEIGHT = 40; +const ROW_HEIGHT = 40 /** * Reconcile stored column order with base columns to handle added/removed fields @@ -39,68 +39,68 @@ const reconcileColumnOrder = ( storedOrder: string[], baseFields: string[], ): string[] => { - const filtered = storedOrder.filter((field) => baseFields.includes(field)); - const missing = baseFields.filter((field) => !filtered.includes(field)); - return [...filtered, ...missing]; -}; + const filtered = storedOrder.filter((field) => baseFields.includes(field)) + const missing = baseFields.filter((field) => !filtered.includes(field)) + return [...filtered, ...missing] +} type ColumnField = - | "host" - | "download" - | "upload" - | "dlSpeed" - | "ulSpeed" - | "chains" - | "rule" - | "process" - | "time" - | "source" - | "remoteDestination" - | "type"; + | 'host' + | 'download' + | 'upload' + | 'dlSpeed' + | 'ulSpeed' + | 'chains' + | 'rule' + | 'process' + | 'time' + | 'source' + | 'remoteDestination' + | 'type' const getConnectionCellValue = (field: ColumnField, each: IConnectionsItem) => { - const { metadata, rulePayload } = each; + const { metadata, rulePayload } = each switch (field) { - case "host": + case 'host': return metadata.host ? `${metadata.host}:${metadata.destinationPort}` - : `${metadata.remoteDestination}:${metadata.destinationPort}`; - case "download": - return each.download; - case "upload": - return each.upload; - case "dlSpeed": - return each.curDownload; - case "ulSpeed": - return each.curUpload; - case "chains": - return [...each.chains].reverse().join(" / "); - case "rule": - return rulePayload ? `${each.rule}(${rulePayload})` : each.rule; - case "process": - return truncateStr(metadata.process || metadata.processPath); - case "time": - return each.start; - case "source": - return `${metadata.sourceIP}:${metadata.sourcePort}`; - case "remoteDestination": + : `${metadata.remoteDestination}:${metadata.destinationPort}` + case 'download': + return each.download + case 'upload': + return each.upload + case 'dlSpeed': + return each.curDownload + case 'ulSpeed': + return each.curUpload + case 'chains': + return [...each.chains].reverse().join(' / ') + case 'rule': + return rulePayload ? `${each.rule}(${rulePayload})` : each.rule + case 'process': + return truncateStr(metadata.process || metadata.processPath) + case 'time': + return each.start + case 'source': + return `${metadata.sourceIP}:${metadata.sourcePort}` + case 'remoteDestination': return metadata.destinationIP ? `${metadata.destinationIP}:${metadata.destinationPort}` - : `${metadata.remoteDestination}:${metadata.destinationPort}`; - case "type": - return `${metadata.type}(${metadata.network})`; + : `${metadata.remoteDestination}:${metadata.destinationPort}` + case 'type': + return `${metadata.type}(${metadata.network})` default: - return ""; + return '' } -}; +} interface Props { - connections: IConnectionsItem[]; - onShowDetail: (data: IConnectionsItem) => void; - columnManagerOpen: boolean; - onOpenColumnManager: () => void; - onCloseColumnManager: () => void; + connections: IConnectionsItem[] + onShowDetail: (data: IConnectionsItem) => void + columnManagerOpen: boolean + onOpenColumnManager: () => void + onCloseColumnManager: () => void } export const ConnectionTable = (props: Props) => { @@ -110,215 +110,215 @@ export const ConnectionTable = (props: Props) => { columnManagerOpen, onOpenColumnManager, onCloseColumnManager, - } = props; - const { t } = useTranslation(); + } = props + const { t } = useTranslation() const [columnWidths, setColumnWidths] = useLocalStorage( - "connection-table-widths", + 'connection-table-widths', // server-side value, this is the default value used by server-side rendering (if any) // Do not omit (otherwise a Suspense boundary will be triggered) {}, - ); + ) const [columnVisibilityModel, setColumnVisibilityModel] = useLocalStorage( - "connection-table-visibility", + 'connection-table-visibility', {}, { serializer: JSON.stringify, deserializer: (value) => { try { - const parsed = JSON.parse(value); - if (parsed && typeof parsed === "object") return parsed; + const parsed = JSON.parse(value) + if (parsed && typeof parsed === 'object') return parsed } catch (err) { - console.warn("Failed to parse connection-table-visibility", err); + console.warn('Failed to parse connection-table-visibility', err) } - return {}; + return {} }, }, - ); + ) const [columnOrder, setColumnOrder] = useLocalStorage( - "connection-table-order", + 'connection-table-order', [], { serializer: JSON.stringify, deserializer: (value) => { try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed)) return parsed; + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) return parsed } catch (err) { - console.warn("Failed to parse connection-table-order", err); + console.warn('Failed to parse connection-table-order', err) } - return []; + return [] }, }, - ); + ) interface BaseColumn { - field: ColumnField; - headerName: string; - width?: number; - minWidth?: number; - align?: "left" | "right"; - cell?: (row: IConnectionsItem) => ReactNode; + field: ColumnField + headerName: string + width?: number + minWidth?: number + align?: 'left' | 'right' + cell?: (row: IConnectionsItem) => ReactNode } const baseColumns = useMemo(() => { return [ { - field: "host", - headerName: t("connections.components.fields.host"), + field: 'host', + headerName: t('connections.components.fields.host'), width: 180, minWidth: 140, }, { - field: "download", - headerName: t("shared.labels.downloaded"), + field: 'download', + headerName: t('shared.labels.downloaded'), width: 76, minWidth: 60, - align: "right", - cell: (row) => parseTraffic(row.download).join(" "), + align: 'right', + cell: (row) => parseTraffic(row.download).join(' '), }, { - field: "upload", - headerName: t("shared.labels.uploaded"), + field: 'upload', + headerName: t('shared.labels.uploaded'), width: 76, minWidth: 60, - align: "right", - cell: (row) => parseTraffic(row.upload).join(" "), + align: 'right', + cell: (row) => parseTraffic(row.upload).join(' '), }, { - field: "dlSpeed", - headerName: t("connections.components.fields.dlSpeed"), + field: 'dlSpeed', + headerName: t('connections.components.fields.dlSpeed'), width: 76, minWidth: 60, - align: "right", - cell: (row) => `${parseTraffic(row.curDownload).join(" ")}/s`, + align: 'right', + cell: (row) => `${parseTraffic(row.curDownload).join(' ')}/s`, }, { - field: "ulSpeed", - headerName: t("connections.components.fields.ulSpeed"), + field: 'ulSpeed', + headerName: t('connections.components.fields.ulSpeed'), width: 76, minWidth: 60, - align: "right", - cell: (row) => `${parseTraffic(row.curUpload).join(" ")}/s`, + align: 'right', + cell: (row) => `${parseTraffic(row.curUpload).join(' ')}/s`, }, { - field: "chains", - headerName: t("connections.components.fields.chains"), + field: 'chains', + headerName: t('connections.components.fields.chains'), width: 280, minWidth: 160, }, { - field: "rule", - headerName: t("connections.components.fields.rule"), + field: 'rule', + headerName: t('connections.components.fields.rule'), width: 220, minWidth: 160, }, { - field: "process", - headerName: t("connections.components.fields.process"), + field: 'process', + headerName: t('connections.components.fields.process'), width: 180, minWidth: 140, }, { - field: "time", - headerName: t("connections.components.fields.time"), + field: 'time', + headerName: t('connections.components.fields.time'), width: 100, minWidth: 80, - align: "right", + align: 'right', // cell filled later with shared relativeNow ticker }, { - field: "source", - headerName: t("connections.components.fields.source"), + field: 'source', + headerName: t('connections.components.fields.source'), width: 160, minWidth: 120, }, { - field: "remoteDestination", - headerName: t("connections.components.fields.destination"), + field: 'remoteDestination', + headerName: t('connections.components.fields.destination'), width: 160, minWidth: 120, }, { - field: "type", - headerName: t("connections.components.fields.type"), + field: 'type', + headerName: t('connections.components.fields.type'), width: 120, minWidth: 80, }, - ]; - }, [t]); + ] + }, [t]) useEffect(() => { setColumnOrder((prevValue) => { - const baseFields = baseColumns.map((col) => col.field); - const prev = Array.isArray(prevValue) ? prevValue : []; - const reconciled = reconcileColumnOrder(prev, baseFields); + const baseFields = baseColumns.map((col) => col.field) + const prev = Array.isArray(prevValue) ? prevValue : [] + const reconciled = reconcileColumnOrder(prev, baseFields) if ( reconciled.length === prev.length && reconciled.every((field, i) => field === prev[i]) ) { - return prevValue; + return prevValue } - return reconciled; - }); - }, [baseColumns, setColumnOrder]); + return reconciled + }) + }, [baseColumns, setColumnOrder]) const handleColumnVisibilityChange = useCallback( (update: Updater) => { setColumnVisibilityModel((prev) => { - const current = prev ?? {}; + const current = prev ?? {} const nextState = - typeof update === "function" ? update(current) : update; + typeof update === 'function' ? update(current) : update const visibleCount = baseColumns.reduce((count, column) => { - const isVisible = (nextState[column.field] ?? true) !== false; - return count + (isVisible ? 1 : 0); - }, 0); + const isVisible = (nextState[column.field] ?? true) !== false + return count + (isVisible ? 1 : 0) + }, 0) if (visibleCount === 0) { - return current; + return current } - const sanitized: VisibilityState = {}; + const sanitized: VisibilityState = {} baseColumns.forEach((column) => { if (nextState[column.field] === false) { - sanitized[column.field] = false; + sanitized[column.field] = false } - }); - return sanitized; - }); + }) + return sanitized + }) }, [baseColumns, setColumnVisibilityModel], - ); + ) const handleColumnOrderChange = useCallback( (update: Updater) => { setColumnOrder((prev) => { - const current = Array.isArray(prev) ? prev : []; + const current = Array.isArray(prev) ? prev : [] const nextState = - typeof update === "function" ? update(current) : update; - const baseFields = baseColumns.map((col) => col.field); - return reconcileColumnOrder(nextState, baseFields); - }); + typeof update === 'function' ? update(current) : update + const baseFields = baseColumns.map((col) => col.field) + return reconcileColumnOrder(nextState, baseFields) + }) }, [baseColumns, setColumnOrder], - ); + ) - const [sorting, setSorting] = useState([]); - const [relativeNow, setRelativeNow] = useState(() => Date.now()); + const [sorting, setSorting] = useState([]) + const [relativeNow, setRelativeNow] = useState(() => Date.now()) const columnDefs = useMemo[]>(() => { return baseColumns.map((column) => { - const baseCell: ColumnDef["cell"] = column.cell + const baseCell: ColumnDef['cell'] = column.cell ? (ctx) => column.cell?.(ctx.row.original) - : (ctx) => ctx.getValue() as ReactNode; + : (ctx) => ctx.getValue() as ReactNode - const cell: ColumnDef["cell"] = - column.field === "time" + const cell: ColumnDef['cell'] = + column.field === 'time' ? (ctx) => dayjs(ctx.getValue() as string).from(relativeNow) - : baseCell; + : baseCell return { id: column.field, @@ -327,42 +327,42 @@ export const ConnectionTable = (props: Props) => { size: column.width, minSize: column.minWidth, meta: { - align: column.align ?? "left", + align: column.align ?? 'left', field: column.field, label: column.headerName, }, cell, - } satisfies ColumnDef; - }); - }, [baseColumns, relativeNow]); + } satisfies ColumnDef + }) + }, [baseColumns, relativeNow]) useEffect(() => { - if (typeof window === "undefined") return undefined; + if (typeof window === 'undefined') return undefined const timer = window.setInterval(() => { - setRelativeNow(Date.now()); - }, 5000); + setRelativeNow(Date.now()) + }, 5000) - return () => window.clearInterval(timer); - }, []); + return () => window.clearInterval(timer) + }, []) const handleColumnSizingChange = useCallback( (updater: Updater) => { setColumnWidths((prev) => { - const prevState = prev ?? {}; + const prevState = prev ?? {} const nextState = - typeof updater === "function" ? updater(prevState) : updater; - const sanitized: ColumnSizingState = {}; + typeof updater === 'function' ? updater(prevState) : updater + const sanitized: ColumnSizingState = {} Object.entries(nextState).forEach(([key, size]) => { - if (typeof size === "number" && Number.isFinite(size)) { - sanitized[key] = size; + if (typeof size === 'number' && Number.isFinite(size)) { + sanitized[key] = size } - }); - return sanitized; - }); + }) + return sanitized + }) }, [setColumnWidths], - ); + ) const table = useReactTable({ data: connections, @@ -379,7 +379,7 @@ export const ConnectionTable = (props: Props) => { minSize: 80, enableResizing: true, }, - columnResizeMode: "onChange", + columnResizeMode: 'onChange', enableSortingRemoval: true, getRowId: (row) => row.id, getCoreRowModel: getCoreRowModel(), @@ -389,61 +389,61 @@ export const ConnectionTable = (props: Props) => { onColumnVisibilityChange: handleColumnVisibilityChange, onColumnOrderChange: handleColumnOrderChange, columns: columnDefs, - }); + }) const handleManagerOrderChange = useCallback( (order: string[]) => { - const baseFields = baseColumns.map((col) => col.field); - table.setColumnOrder(reconcileColumnOrder(order, baseFields)); + const baseFields = baseColumns.map((col) => col.field) + table.setColumnOrder(reconcileColumnOrder(order, baseFields)) }, [baseColumns, table], - ); + ) const handleResetColumns = useCallback(() => { - table.resetColumnVisibility(); - table.resetColumnOrder(); - }, [table]); + table.resetColumnVisibility() + table.resetColumnOrder() + }, [table]) - const rows = table.getRowModel().rows; - const tableContainerRef = useRef(null); + const rows = table.getRowModel().rows + const tableContainerRef = useRef(null) const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current, estimateSize: () => ROW_HEIGHT, overscan: 4, - }); + }) - const virtualRows = rowVirtualizer.getVirtualItems(); - const totalSize = rowVirtualizer.getTotalSize(); - const tableWidth = table.getTotalSize(); - const managerColumns = table.getAllLeafColumns(); + const virtualRows = rowVirtualizer.getVirtualItems() + const totalSize = rowVirtualizer.getTotalSize() + const tableWidth = table.getTotalSize() + const managerColumns = table.getAllLeafColumns() return ( <> theme.typography.fontFamily, }} > - + - theme.palette.mode === "dark" + theme.palette.mode === 'dark' ? theme.palette.background.default : theme.palette.background.paper, - "&:hover": { + '&:hover': { backgroundColor: (theme) => theme.palette.action.hover, }, }} @@ -456,25 +456,25 @@ export const ConnectionTable = (props: Props) => { sx={{ flex: 1, minHeight: 0, - overflow: "auto", - WebkitOverflowScrolling: "touch", - overscrollBehavior: "contain", + overflow: 'auto', + WebkitOverflowScrolling: 'touch', + overscrollBehavior: 'contain', borderRadius: 1, - border: "none", - "&::-webkit-scrollbar": { + border: 'none', + '&::-webkit-scrollbar': { height: 8, }, }} > { `1px solid ${theme.palette.divider}`, backgroundColor: (theme) => theme.palette.background.paper, @@ -491,12 +491,12 @@ export const ConnectionTable = (props: Props) => { > {headerGroup.headers.map((header) => { if (header.isPlaceholder) { - return null; + return null } const meta = header.column.columnDef.meta as { - align?: "left" | "right"; - field: string; - }; + align?: 'left' | 'right' + field: string + } return ( { flex: `0 0 ${header.getSize()}px`, minWidth: header.column.columnDef.minSize || 80, maxWidth: header.column.columnDef.maxSize, - display: "flex", - alignItems: "center", - position: "relative", - boxSizing: "border-box", + display: 'flex', + alignItems: 'center', + position: 'relative', + boxSizing: 'border-box', fontSize: 13, fontWeight: 600, - color: "text.secondary", - userSelect: "none", - "&:hover": { + color: 'text.secondary', + userSelect: 'none', + '&:hover': { backgroundColor: (theme) => theme.palette.action.hover, }, @@ -527,18 +527,18 @@ export const ConnectionTable = (props: Props) => { } sx={{ flex: 1, - display: "flex", - alignItems: "center", + display: 'flex', + alignItems: 'center', justifyContent: - meta?.align === "right" - ? "flex-end" - : "flex-start", + meta?.align === 'right' + ? 'flex-end' + : 'flex-start', gap: 0.5, px: 1, py: 1, cursor: header.column.getCanSort() - ? "pointer" - : "default", + ? 'pointer' + : 'default', }} > {flexRender( @@ -546,30 +546,30 @@ export const ConnectionTable = (props: Props) => { header.getContext(), )} {{ - asc: "▲", - desc: "▼", + asc: '▲', + desc: '▼', }[header.column.getIsSorted() as string] ?? null} {header.column.getCanResize() && ( event.stopPropagation()} onMouseDown={(event) => { - event.stopPropagation(); - header.getResizeHandler()(event); + event.stopPropagation() + header.getResizeHandler()(event) }} onTouchStart={(event) => { - event.stopPropagation(); - header.getResizeHandler()(event); + event.stopPropagation() + header.getResizeHandler()(event) }} sx={{ - cursor: "col-resize", - position: "absolute", + cursor: 'col-resize', + position: 'absolute', right: 0, top: 0, width: 4, - height: "100%", - transform: "translateX(50%)", - "&:hover": { + height: '100%', + transform: 'translateX(50%)', + '&:hover': { backgroundColor: (theme) => theme.palette.action.active, }, @@ -577,44 +577,44 @@ export const ConnectionTable = (props: Props) => { /> )} - ); + ) })} ))} {virtualRows.map((virtualRow) => { - const row = rows[virtualRow.index]; - if (!row) return null; + const row = rows[virtualRow.index] + if (!row) return null return ( onShowDetail(row.original)} sx={{ - display: "flex", - position: "absolute", + display: 'flex', + position: 'absolute', left: 0, right: 0, height: virtualRow.size, transform: `translateY(${virtualRow.start}px)`, borderBottom: (theme) => `1px solid ${theme.palette.divider}`, - cursor: "pointer", - "&:hover": { + cursor: 'pointer', + '&:hover': { backgroundColor: (theme) => theme.palette.action.hover, }, }} > {row.getVisibleCells().map((cell) => { const meta = cell.column.columnDef.meta as { - align?: "left" | "right"; - }; + align?: 'left' | 'right' + } return ( { flex: `0 0 ${cell.column.getSize()}px`, minWidth: cell.column.columnDef.minSize || 80, maxWidth: cell.column.columnDef.maxSize, - boxSizing: "border-box", + boxSizing: 'border-box', px: 1, fontSize: 13, - display: "flex", - alignItems: "center", + display: 'flex', + alignItems: 'center', justifyContent: - meta?.align === "right" - ? "flex-end" - : "flex-start", - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", + meta?.align === 'right' + ? 'flex-end' + : 'flex-start', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', }} > {flexRender( @@ -641,10 +641,10 @@ export const ConnectionTable = (props: Props) => { cell.getContext(), )} - ); + ) })} - ); + ) })} @@ -658,5 +658,5 @@ export const ConnectionTable = (props: Props) => { onReset={handleResetColumns} /> - ); -}; + ) +} diff --git a/src/components/home/clash-info-card.tsx b/src/components/home/clash-info-card.tsx index ed62732f5..738e4babf 100644 --- a/src/components/home/clash-info-card.tsx +++ b/src/components/home/clash-info-card.tsx @@ -1,47 +1,47 @@ -import { DeveloperBoardOutlined } from "@mui/icons-material"; -import { Divider, Stack, Typography } from "@mui/material"; -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; +import { DeveloperBoardOutlined } from '@mui/icons-material' +import { Divider, Stack, Typography } from '@mui/material' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' -import { useClash } from "@/hooks/use-clash"; -import { useAppData } from "@/providers/app-data-context"; +import { useClash } from '@/hooks/use-clash' +import { useAppData } from '@/providers/app-data-context' -import { EnhancedCard } from "./enhanced-card"; +import { EnhancedCard } from './enhanced-card' // 将毫秒转换为时:分:秒格式的函数 const formatUptime = (uptimeMs: number) => { - const hours = Math.floor(uptimeMs / 3600000); - const minutes = Math.floor((uptimeMs % 3600000) / 60000); - const seconds = Math.floor((uptimeMs % 60000) / 1000); - return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; -}; + const hours = Math.floor(uptimeMs / 3600000) + const minutes = Math.floor((uptimeMs % 3600000) / 60000) + const seconds = Math.floor((uptimeMs % 60000) / 1000) + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` +} export const ClashInfoCard = () => { - const { t } = useTranslation(); - const { version: clashVersion } = useClash(); - const { clashConfig, rules, uptime, systemProxyAddress } = useAppData(); + const { t } = useTranslation() + const { version: clashVersion } = useClash() + const { clashConfig, rules, uptime, systemProxyAddress } = useAppData() // 使用useMemo缓存格式化后的uptime,避免频繁计算 - const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]); + const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]) // 使用备忘录组件内容,减少重新渲染 const cardContent = useMemo(() => { - if (!clashConfig) return null; + if (!clashConfig) return null return ( - {t("home.components.clashInfo.fields.coreVersion")} + {t('home.components.clashInfo.fields.coreVersion')} - {clashVersion || "-"} + {clashVersion || '-'} - {t("home.components.clashInfo.fields.systemProxyAddress")} + {t('home.components.clashInfo.fields.systemProxyAddress')} {systemProxyAddress} @@ -50,16 +50,16 @@ export const ClashInfoCard = () => { - {t("home.components.clashInfo.fields.mixedPort")} + {t('home.components.clashInfo.fields.mixedPort')} - {clashConfig.mixedPort || "-"} + {clashConfig.mixedPort || '-'} - {t("home.components.clashInfo.fields.uptime")} + {t('home.components.clashInfo.fields.uptime')} {formattedUptime} @@ -68,14 +68,14 @@ export const ClashInfoCard = () => { - {t("home.components.clashInfo.fields.rulesCount")} + {t('home.components.clashInfo.fields.rulesCount')} {rules.length} - ); + ) }, [ clashConfig, clashVersion, @@ -83,16 +83,16 @@ export const ClashInfoCard = () => { formattedUptime, rules.length, systemProxyAddress, - ]); + ]) return ( } iconColor="warning" action={null} > {cardContent} - ); -}; + ) +} diff --git a/src/components/home/clash-mode-card.tsx b/src/components/home/clash-mode-card.tsx index ea48128ca..4e55c67b2 100644 --- a/src/components/home/clash-mode-card.tsx +++ b/src/components/home/clash-mode-card.tsx @@ -2,63 +2,63 @@ import { DirectionsRounded, LanguageRounded, MultipleStopRounded, -} from "@mui/icons-material"; -import { Box, Paper, Stack, Typography } from "@mui/material"; -import { useLockFn } from "ahooks"; -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { closeAllConnections } from "tauri-plugin-mihomo-api"; +} from '@mui/icons-material' +import { Box, Paper, Stack, Typography } from '@mui/material' +import { useLockFn } from 'ahooks' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { closeAllConnections } from 'tauri-plugin-mihomo-api' -import { useVerge } from "@/hooks/use-verge"; -import { useAppData } from "@/providers/app-data-context"; -import { patchClashMode } from "@/services/cmds"; -import type { TranslationKey } from "@/types/generated/i18n-keys"; +import { useVerge } from '@/hooks/use-verge' +import { useAppData } from '@/providers/app-data-context' +import { patchClashMode } from '@/services/cmds' +import type { TranslationKey } from '@/types/generated/i18n-keys' -const CLASH_MODES = ["rule", "global", "direct"] as const; -type ClashMode = (typeof CLASH_MODES)[number]; +const CLASH_MODES = ['rule', 'global', 'direct'] as const +type ClashMode = (typeof CLASH_MODES)[number] const isClashMode = (mode: string): mode is ClashMode => - (CLASH_MODES as readonly string[]).includes(mode); + (CLASH_MODES as readonly string[]).includes(mode) const MODE_META: Record< ClashMode, { label: TranslationKey; description: TranslationKey } > = { rule: { - label: "home.components.clashMode.labels.rule", - description: "home.components.clashMode.descriptions.rule", + label: 'home.components.clashMode.labels.rule', + description: 'home.components.clashMode.descriptions.rule', }, global: { - label: "home.components.clashMode.labels.global", - description: "home.components.clashMode.descriptions.global", + label: 'home.components.clashMode.labels.global', + description: 'home.components.clashMode.descriptions.global', }, direct: { - label: "home.components.clashMode.labels.direct", - description: "home.components.clashMode.descriptions.direct", + label: 'home.components.clashMode.labels.direct', + description: 'home.components.clashMode.descriptions.direct', }, -}; +} export const ClashModeCard = () => { - const { t } = useTranslation(); - const { verge } = useVerge(); - const { clashConfig, refreshClashConfig } = useAppData(); + const { t } = useTranslation() + const { verge } = useVerge() + const { clashConfig, refreshClashConfig } = useAppData() // 支持的模式列表 - const modeList = CLASH_MODES; + const modeList = CLASH_MODES // 直接使用API返回的模式,不维护本地状态 - const currentMode = clashConfig?.mode?.toLowerCase(); + const currentMode = clashConfig?.mode?.toLowerCase() const currentModeKey = - typeof currentMode === "string" && isClashMode(currentMode) + typeof currentMode === 'string' && isClashMode(currentMode) ? currentMode - : undefined; + : undefined const modeDescription = useMemo(() => { if (currentModeKey) { - return t(MODE_META[currentModeKey].description); + return t(MODE_META[currentModeKey].description) } - return t("home.components.clashMode.errors.communication"); - }, [currentModeKey, t]); + return t('home.components.clashMode.errors.communication') + }, [currentModeKey, t]) // 模式图标映射 const modeIcons = useMemo( @@ -68,87 +68,87 @@ export const ClashModeCard = () => { direct: , }), [], - ); + ) // 切换模式的处理函数 const onChangeMode = useLockFn(async (mode: ClashMode) => { - if (mode === currentModeKey) return; + if (mode === currentModeKey) return if (verge?.auto_close_connection) { - closeAllConnections(); + closeAllConnections() } try { - await patchClashMode(mode); + await patchClashMode(mode) // 使用共享的刷新方法 - refreshClashConfig(); + refreshClashConfig() } catch (error) { - console.error("Failed to change mode:", error); + console.error('Failed to change mode:', error) } - }); + }) // 按钮样式 const buttonStyles = (mode: ClashMode) => ({ - cursor: "pointer", + cursor: 'pointer', px: 2, py: 1.2, - display: "flex", - alignItems: "center", - justifyContent: "center", + display: 'flex', + alignItems: 'center', + justifyContent: 'center', gap: 1, - bgcolor: mode === currentModeKey ? "primary.main" : "background.paper", - color: mode === currentModeKey ? "primary.contrastText" : "text.primary", + bgcolor: mode === currentModeKey ? 'primary.main' : 'background.paper', + color: mode === currentModeKey ? 'primary.contrastText' : 'text.primary', borderRadius: 1.5, - transition: "all 0.2s ease-in-out", - position: "relative", - overflow: "visible", - "&:hover": { - transform: "translateY(-1px)", + transition: 'all 0.2s ease-in-out', + position: 'relative', + overflow: 'visible', + '&:hover': { + transform: 'translateY(-1px)', boxShadow: 1, }, - "&:active": { - transform: "translateY(1px)", + '&:active': { + transform: 'translateY(1px)', }, - "&::after": + '&::after': mode === currentModeKey ? { content: '""', - position: "absolute", + position: 'absolute', bottom: -16, - left: "50%", + left: '50%', width: 2, height: 16, - bgcolor: "primary.main", - transform: "translateX(-50%)", + bgcolor: 'primary.main', + transform: 'translateX(-50%)', } : {}, - }); + }) // 描述样式 const descriptionStyles = { - width: "95%", - textAlign: "center", - color: "text.secondary", + width: '95%', + textAlign: 'center', + color: 'text.secondary', p: 0.8, borderRadius: 1, - borderColor: "primary.main", + borderColor: 'primary.main', borderWidth: 1, - borderStyle: "solid", - backgroundColor: "background.paper", - wordBreak: "break-word", - hyphens: "auto", - }; + borderStyle: 'solid', + backgroundColor: 'background.paper', + wordBreak: 'break-word', + hyphens: 'auto', + } return ( - + {/* 模式选择按钮组 */} @@ -163,7 +163,7 @@ export const ClashModeCard = () => { @@ -176,12 +176,12 @@ export const ClashModeCard = () => { {/* 说明文本区域 */} @@ -189,5 +189,5 @@ export const ClashModeCard = () => { - ); -}; + ) +} diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 829a9492a..3eaa26bbd 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -10,7 +10,7 @@ import { SignalWifi1Bar as SignalWeak, SortByAlphaRounded, SortRounded, -} from "@mui/icons-material"; +} from '@mui/icons-material' import { Box, Button, @@ -25,212 +25,206 @@ import { Typography, alpha, useTheme, -} from "@mui/material"; -import { useLockFn } from "ahooks"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; -import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api"; +} from '@mui/material' +import { useLockFn } from 'ahooks' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' +import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api' -import { EnhancedCard } from "@/components/home/enhanced-card"; -import { useProfiles } from "@/hooks/use-profiles"; -import { useProxySelection } from "@/hooks/use-proxy-selection"; -import { useVerge } from "@/hooks/use-verge"; -import { useAppData } from "@/providers/app-data-context"; -import delayManager from "@/services/delay"; -import { debugLog } from "@/utils/debug"; +import { EnhancedCard } from '@/components/home/enhanced-card' +import { useProfiles } from '@/hooks/use-profiles' +import { useProxySelection } from '@/hooks/use-proxy-selection' +import { useVerge } from '@/hooks/use-verge' +import { useAppData } from '@/providers/app-data-context' +import delayManager from '@/services/delay' +import { debugLog } from '@/utils/debug' // 本地存储的键名 -const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group"; -const STORAGE_KEY_PROXY = "clash-verge-selected-proxy"; -const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type"; +const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group' +const STORAGE_KEY_PROXY = 'clash-verge-selected-proxy' +const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type' -const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5; -const AUTO_CHECK_INITIAL_DELAY_MS = 100; +const AUTO_CHECK_DEFAULT_INTERVAL_MINUTES = 5 +const AUTO_CHECK_INITIAL_DELAY_MS = 100 // 代理节点信息接口 interface ProxyOption { - name: string; + name: string } // 排序类型: 默认 | 按延迟 | 按字母 -type ProxySortType = 0 | 1 | 2; +type ProxySortType = 0 | 1 | 2 function convertDelayColor( delayValue: number, -): "success" | "warning" | "error" | "primary" | "default" { - const colorStr = delayManager.formatDelayColor(delayValue); - if (!colorStr) return "default"; +): 'success' | 'warning' | 'error' | 'primary' | 'default' { + const colorStr = delayManager.formatDelayColor(delayValue) + if (!colorStr) return 'default' - const mainColor = colorStr.split(".")[0]; + const mainColor = colorStr.split('.')[0] switch (mainColor) { - case "success": - return "success"; - case "warning": - return "warning"; - case "error": - return "error"; - case "primary": - return "primary"; + case 'success': + return 'success' + case 'warning': + return 'warning' + case 'error': + return 'error' + case 'primary': + return 'primary' default: - return "default"; + return 'default' } } function getSignalIcon(delay: number): { - icon: React.ReactElement; - text: string; - color: string; + icon: React.ReactElement + text: string + color: string } { if (delay === -2) - return { icon: , text: "测试中", color: "text.secondary" }; + return { icon: , text: '测试中', color: 'text.secondary' } if (delay === -1) - return { icon: , text: "未测试", color: "text.secondary" }; + return { icon: , text: '未测试', color: 'text.secondary' } if (delay > 1e5) - return { icon: , text: "错误", color: "error.main" }; + return { icon: , text: '错误', color: 'error.main' } if (delay === 0 || delay >= 10000) - return { icon: , text: "超时", color: "error.main" }; + return { icon: , text: '超时', color: 'error.main' } if (delay >= 500) - return { icon: , text: "延迟较高", color: "error.main" }; + return { icon: , text: '延迟较高', color: 'error.main' } if (delay >= 300) - return { icon: , text: "延迟中等", color: "warning.main" }; + return { icon: , text: '延迟中等', color: 'warning.main' } if (delay >= 200) - return { icon: , text: "延迟良好", color: "info.main" }; - return { icon: , text: "延迟极佳", color: "success.main" }; + return { icon: , text: '延迟良好', color: 'info.main' } + return { icon: , text: '延迟极佳', color: 'success.main' } } export const CurrentProxyCard = () => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const theme = useTheme(); - const { proxies, clashConfig, refreshProxy, rules } = useAppData(); - const { verge } = useVerge(); - const { current: currentProfile } = useProfiles(); - const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false; - const defaultLatencyTimeout = verge?.default_latency_timeout; + const { t } = useTranslation() + const navigate = useNavigate() + const theme = useTheme() + const { proxies, clashConfig, refreshProxy, rules } = useAppData() + const { verge } = useVerge() + const { current: currentProfile } = useProfiles() + const autoDelayEnabled = verge?.enable_auto_delay_detection ?? false + const defaultLatencyTimeout = verge?.default_latency_timeout const autoDelayIntervalMs = useMemo(() => { - const rawInterval = verge?.auto_delay_detection_interval_minutes; + const rawInterval = verge?.auto_delay_detection_interval_minutes const intervalMinutes = - typeof rawInterval === "number" && rawInterval > 0 + typeof rawInterval === 'number' && rawInterval > 0 ? rawInterval - : AUTO_CHECK_DEFAULT_INTERVAL_MINUTES; - return Math.max(1, Math.round(intervalMinutes)) * 60 * 1000; - }, [verge?.auto_delay_detection_interval_minutes]); - const currentProfileId = currentProfile?.uid || null; + : AUTO_CHECK_DEFAULT_INTERVAL_MINUTES + return Math.max(1, Math.round(intervalMinutes)) * 60 * 1000 + }, [verge?.auto_delay_detection_interval_minutes]) + const currentProfileId = currentProfile?.uid || null const getProfileStorageKey = useCallback( (baseKey: string) => currentProfileId ? `${baseKey}:${currentProfileId}` : baseKey, [currentProfileId], - ); + ) const readProfileScopedItem = useCallback( (baseKey: string) => { - if (typeof window === "undefined") return null; - const profileKey = getProfileStorageKey(baseKey); - const profileValue = localStorage.getItem(profileKey); + if (typeof window === 'undefined') return null + const profileKey = getProfileStorageKey(baseKey) + const profileValue = localStorage.getItem(profileKey) if (profileValue != null) { - return profileValue; + return profileValue } if (profileKey !== baseKey) { - const legacyValue = localStorage.getItem(baseKey); + const legacyValue = localStorage.getItem(baseKey) if (legacyValue != null) { - localStorage.removeItem(baseKey); - localStorage.setItem(profileKey, legacyValue); - return legacyValue; + localStorage.removeItem(baseKey) + localStorage.setItem(profileKey, legacyValue) + return legacyValue } } - return null; + return null }, [getProfileStorageKey], - ); + ) const writeProfileScopedItem = useCallback( (baseKey: string, value: string) => { - if (typeof window === "undefined") return; - const profileKey = getProfileStorageKey(baseKey); - localStorage.setItem(profileKey, value); + if (typeof window === 'undefined') return + const profileKey = getProfileStorageKey(baseKey) + localStorage.setItem(profileKey, value) if (profileKey !== baseKey) { - localStorage.removeItem(baseKey); + localStorage.removeItem(baseKey) } }, [getProfileStorageKey], - ); + ) // 统一代理选择器 const { handleSelectChange } = useProxySelection({ onSuccess: () => { - refreshProxy(); + refreshProxy() }, onError: (error) => { - console.error("代理切换失败", error); - refreshProxy(); + console.error('代理切换失败', error) + refreshProxy() }, - }); + }) // 判断模式 - const mode = clashConfig?.mode?.toLowerCase() || "rule"; - const isGlobalMode = mode === "global"; - const isDirectMode = mode === "direct"; + const mode = clashConfig?.mode?.toLowerCase() || 'rule' + const isGlobalMode = mode === 'global' + const isDirectMode = mode === 'direct' // Sorting type state const [sortType, setSortType] = useState(() => { - const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE); - return savedSortType ? (Number(savedSortType) as ProxySortType) : 0; - }); - const [delaySortRefresh, setDelaySortRefresh] = useState(0); + const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE) + return savedSortType ? (Number(savedSortType) as ProxySortType) : 0 + }) + const [delaySortRefresh, setDelaySortRefresh] = useState(0) const normalizePolicyName = useCallback( - (value?: string | null) => (typeof value === "string" ? value.trim() : ""), + (value?: string | null) => (typeof value === 'string' ? value.trim() : ''), [], - ); + ) const matchPolicyName = useMemo(() => { - if (!Array.isArray(rules)) return ""; + if (!Array.isArray(rules)) return '' for (let index = rules.length - 1; index >= 0; index -= 1) { - const rule = rules[index]; - if (!rule) continue; + const rule = rules[index] + if (!rule) continue if ( - typeof rule?.type === "string" && - rule.type.toUpperCase() === "MATCH" + typeof rule?.type === 'string' && + rule.type.toUpperCase() === 'MATCH' ) { - const policy = normalizePolicyName(rule.proxy); + const policy = normalizePolicyName(rule.proxy) if (policy) { - return policy; + return policy } } } - return ""; - }, [rules, normalizePolicyName]); + return '' + }, [rules, normalizePolicyName]) type ProxyGroupOption = { - name: string; - now: string; - all: string[]; - type?: string; - }; + name: string + now: string + all: string[] + type?: string + } type ProxyState = { proxyData: { - groups: ProxyGroupOption[]; - records: Record; - }; + groups: ProxyGroupOption[] + records: Record + } selection: { - group: string; - proxy: string; - }; - displayProxy: any; - }; + group: string + proxy: string + } + displayProxy: any + } const [state, setState] = useState({ proxyData: { @@ -238,57 +232,57 @@ export const CurrentProxyCard = () => { records: {}, }, selection: { - group: "", - proxy: "", + group: '', + proxy: '', }, displayProxy: null, - }); + }) - const autoCheckInProgressRef = useRef(false); + const autoCheckInProgressRef = useRef(false) const latestTimeoutRef = useRef( verge?.default_latency_timeout || 10000, - ); - const latestProxyRecordRef = useRef(null); + ) + const latestProxyRecordRef = useRef(null) useEffect(() => { - latestTimeoutRef.current = verge?.default_latency_timeout || 10000; - }, [verge?.default_latency_timeout]); + latestTimeoutRef.current = verge?.default_latency_timeout || 10000 + }, [verge?.default_latency_timeout]) useEffect(() => { if (!state.selection.proxy) { - latestProxyRecordRef.current = null; - return; + latestProxyRecordRef.current = null + return } latestProxyRecordRef.current = - state.proxyData.records?.[state.selection.proxy] || null; - }, [state.selection.proxy, state.proxyData.records]); + state.proxyData.records?.[state.selection.proxy] || null + }, [state.selection.proxy, state.proxyData.records]) // 初始化选择的组 useEffect(() => { - if (!proxies) return; + if (!proxies) return const getPrimaryGroupName = () => { - if (!proxies?.groups?.length) return ""; + if (!proxies?.groups?.length) return '' const primaryKeywords = [ - "auto", - "select", - "proxy", - "节点选择", - "自动选择", - ]; + 'auto', + 'select', + 'proxy', + '节点选择', + '自动选择', + ] const primaryGroup = proxies.groups.find((group: { name: string }) => primaryKeywords.some((keyword) => group.name.toLowerCase().includes(keyword.toLowerCase()), ), ) || - proxies.groups.filter((g: { name: string }) => g.name !== "GLOBAL")[0]; + proxies.groups.filter((g: { name: string }) => g.name !== 'GLOBAL')[0] - return primaryGroup?.name || ""; - }; + return primaryGroup?.name || '' + } - const primaryGroupName = getPrimaryGroupName(); + const primaryGroupName = getPrimaryGroupName() // 根据模式确定初始组 if (isGlobalMode) { @@ -297,72 +291,72 @@ export const CurrentProxyCard = () => { ...prev, selection: { ...prev.selection, - group: "GLOBAL", + group: 'GLOBAL', }, - })); + })) } else if (isDirectMode) { // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setState((prev) => ({ ...prev, selection: { ...prev.selection, - group: "DIRECT", + group: 'DIRECT', }, - })); + })) } else { - const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP); + const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP) // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setState((prev) => ({ ...prev, selection: { ...prev.selection, - group: savedGroup || primaryGroupName || "", + group: savedGroup || primaryGroupName || '', }, - })); + })) } - }, [isGlobalMode, isDirectMode, proxies, readProfileScopedItem]); + }, [isGlobalMode, isDirectMode, proxies, readProfileScopedItem]) // 监听代理数据变化,更新状态 useEffect(() => { - if (!proxies) return; + if (!proxies) return // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setState((prev) => { - const groupsMap = new Map(); + const groupsMap = new Map() const registerGroup = (group: any, fallbackName?: string) => { - if (!group && !fallbackName) return; + if (!group && !fallbackName) return const rawName = - typeof group?.name === "string" && group.name.length > 0 + typeof group?.name === 'string' && group.name.length > 0 ? group.name - : fallbackName; - const name = normalizePolicyName(rawName); - if (!name || groupsMap.has(name)) return; + : fallbackName + const name = normalizePolicyName(rawName) + if (!name || groupsMap.has(name)) return const rawAll = ( Array.isArray(group?.all) ? (group.all as Array) : [] - ) as Array; + ) as Array const allNames = rawAll .map((item) => - typeof item === "string" + typeof item === 'string' ? normalizePolicyName(item) : normalizePolicyName(item?.name), ) - .filter((value): value is string => value.length > 0); + .filter((value): value is string => value.length > 0) - const uniqueAll = Array.from(new Set(allNames)); - if (uniqueAll.length === 0) return; + const uniqueAll = Array.from(new Set(allNames)) + if (uniqueAll.length === 0) return groupsMap.set(name, { name, now: normalizePolicyName(group?.now), all: uniqueAll, type: group?.type, - }); - }; + }) + } if (matchPolicyName) { const matchGroup = @@ -370,50 +364,50 @@ export const CurrentProxyCard = () => { (g: { name: string }) => g.name === matchPolicyName, ) || (proxies.global?.name === matchPolicyName ? proxies.global : null) || - proxies.records?.[matchPolicyName]; - registerGroup(matchGroup, matchPolicyName); + proxies.records?.[matchPolicyName] + registerGroup(matchGroup, matchPolicyName) } - (proxies.groups || []) - .filter((g: { type?: string }) => g?.type === "Selector") - .forEach((selectorGroup: any) => registerGroup(selectorGroup)); + ;(proxies.groups || []) + .filter((g: { type?: string }) => g?.type === 'Selector') + .forEach((selectorGroup: any) => registerGroup(selectorGroup)) - const filteredGroups = Array.from(groupsMap.values()); + const filteredGroups = Array.from(groupsMap.values()) - let newProxy = ""; - let newDisplayProxy = null; - let newGroup = prev.selection.group; + let newProxy = '' + let newDisplayProxy = null + let newGroup = prev.selection.group if (isDirectMode) { - newGroup = "DIRECT"; - newProxy = "DIRECT"; - newDisplayProxy = proxies.records?.DIRECT || { name: "DIRECT" }; + newGroup = 'DIRECT' + newProxy = 'DIRECT' + newDisplayProxy = proxies.records?.DIRECT || { name: 'DIRECT' } } else if (isGlobalMode && proxies.global) { - newGroup = "GLOBAL"; - newProxy = proxies.global.now || ""; - newDisplayProxy = proxies.records?.[newProxy] || null; + newGroup = 'GLOBAL' + newProxy = proxies.global.now || '' + newDisplayProxy = proxies.records?.[newProxy] || null } else { const currentGroup = filteredGroups.find( (g: { name: string }) => g.name === prev.selection.group, - ); + ) if (!currentGroup && filteredGroups.length > 0) { - const firstGroup = filteredGroups[0]; + const firstGroup = filteredGroups[0] if (firstGroup) { - newGroup = firstGroup.name; - newProxy = firstGroup.now || firstGroup.all[0] || ""; - newDisplayProxy = proxies.records?.[newProxy] || null; + newGroup = firstGroup.name + newProxy = firstGroup.now || firstGroup.all[0] || '' + newDisplayProxy = proxies.records?.[newProxy] || null if (!isGlobalMode && !isDirectMode) { - writeProfileScopedItem(STORAGE_KEY_GROUP, newGroup); + writeProfileScopedItem(STORAGE_KEY_GROUP, newGroup) if (newProxy) { - writeProfileScopedItem(STORAGE_KEY_PROXY, newProxy); + writeProfileScopedItem(STORAGE_KEY_PROXY, newProxy) } } } } else if (currentGroup) { - newProxy = currentGroup.now || currentGroup.all[0] || ""; - newDisplayProxy = proxies.records?.[newProxy] || null; + newProxy = currentGroup.now || currentGroup.all[0] || '' + newDisplayProxy = proxies.records?.[newProxy] || null } } @@ -427,8 +421,8 @@ export const CurrentProxyCard = () => { proxy: newProxy, }, displayProxy: newDisplayProxy, - }; - }); + } + }) }, [ proxies, isGlobalMode, @@ -436,36 +430,36 @@ export const CurrentProxyCard = () => { writeProfileScopedItem, normalizePolicyName, matchPolicyName, - ]); + ]) // 使用防抖包装状态更新 - const timeoutRef = React.useRef | null>(null); + const timeoutRef = React.useRef | null>(null) const debouncedSetState = useCallback( (updateFn: (prev: ProxyState) => ProxyState) => { if (timeoutRef.current) { - clearTimeout(timeoutRef.current); + clearTimeout(timeoutRef.current) } timeoutRef.current = setTimeout(() => { - setState(updateFn); - }, 300); + setState(updateFn) + }, 300) }, [setState], - ); + ) // 处理代理组变更 const handleGroupChange = useCallback( (event: SelectChangeEvent) => { - if (isGlobalMode || isDirectMode) return; + if (isGlobalMode || isDirectMode) return - const newGroup = event.target.value; + const newGroup = event.target.value - writeProfileScopedItem(STORAGE_KEY_GROUP, newGroup); + writeProfileScopedItem(STORAGE_KEY_GROUP, newGroup) setState((prev) => { const group = prev.proxyData.groups.find( (g: { name: string }) => g.name === newGroup, - ); + ) if (group) { return { ...prev, @@ -474,7 +468,7 @@ export const CurrentProxyCard = () => { proxy: group.now, }, displayProxy: prev.proxyData.records[group.now] || null, - }; + } } return { ...prev, @@ -482,20 +476,20 @@ export const CurrentProxyCard = () => { ...prev.selection, group: newGroup, }, - }; - }); + } + }) }, [isGlobalMode, isDirectMode, writeProfileScopedItem], - ); + ) // 处理代理节点变更 const handleProxyChange = useCallback( (event: SelectChangeEvent) => { - if (isDirectMode) return; + if (isDirectMode) return - const newProxy = event.target.value; - const currentGroup = state.selection.group; - const previousProxy = state.selection.proxy; + const newProxy = event.target.value + const currentGroup = state.selection.group + const previousProxy = state.selection.proxy debouncedSetState((prev: ProxyState) => ({ ...prev, @@ -504,14 +498,14 @@ export const CurrentProxyCard = () => { proxy: newProxy, }, displayProxy: prev.proxyData.records[newProxy] || null, - })); + })) if (!isGlobalMode && !isDirectMode) { - writeProfileScopedItem(STORAGE_KEY_PROXY, newProxy); + writeProfileScopedItem(STORAGE_KEY_PROXY, newProxy) } - const skipConfigSave = isGlobalMode || isDirectMode; - handleSelectChange(currentGroup, previousProxy, skipConfigSave)(event); + const skipConfigSave = isGlobalMode || isDirectMode + handleSelectChange(currentGroup, previousProxy, skipConfigSave)(event) }, [ isDirectMode, @@ -521,70 +515,70 @@ export const CurrentProxyCard = () => { handleSelectChange, writeProfileScopedItem, ], - ); + ) // 导航到代理页面 const goToProxies = useCallback(() => { - navigate("/proxies"); - }, [navigate]); + navigate('/proxies') + }, [navigate]) // 获取要显示的代理节点 const currentProxy = useMemo(() => { - return state.displayProxy; - }, [state.displayProxy]); + return state.displayProxy + }, [state.displayProxy]) // 获取当前节点的延迟(增加非空校验) const currentDelay = currentProxy && state.selection.group ? delayManager.getDelayFix(currentProxy, state.selection.group) - : -1; + : -1 // 信号图标(增加非空校验) const signalInfo = currentProxy && state.selection.group ? getSignalIcon(currentDelay) - : { icon: , text: "未初始化", color: "text.secondary" }; + : { icon: , text: '未初始化', color: 'text.secondary' } const checkCurrentProxyDelay = useCallback(async () => { - if (autoCheckInProgressRef.current) return; - if (isDirectMode) return; + if (autoCheckInProgressRef.current) return + if (isDirectMode) return - const groupName = state.selection.group; - const proxyName = state.selection.proxy; + const groupName = state.selection.group + const proxyName = state.selection.proxy - if (!groupName || !proxyName) return; + if (!groupName || !proxyName) return - const proxyRecord = latestProxyRecordRef.current; + const proxyRecord = latestProxyRecordRef.current if (!proxyRecord) { debugLog( `[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`, - ); - return; + ) + return } - autoCheckInProgressRef.current = true; + autoCheckInProgressRef.current = true - const timeout = latestTimeoutRef.current || 10000; + const timeout = latestTimeoutRef.current || 10000 try { debugLog( `[CurrentProxyCard] 自动检测当前节点延迟,组: ${groupName}, 节点: ${proxyName}`, - ); + ) if (proxyRecord.provider) { - await healthcheckProxyProvider(proxyRecord.provider); + await healthcheckProxyProvider(proxyRecord.provider) } else { - await delayManager.checkDelay(proxyName, groupName, timeout); + await delayManager.checkDelay(proxyName, groupName, timeout) } } catch (error) { console.error( `[CurrentProxyCard] 自动检测当前节点延迟失败,组: ${groupName}, 节点: ${proxyName}`, error, - ); + ) } finally { - autoCheckInProgressRef.current = false; - refreshProxy(); + autoCheckInProgressRef.current = false + refreshProxy() if (sortType === 1) { - setDelaySortRefresh((prev) => prev + 1); + setDelaySortRefresh((prev) => prev + 1) } } }, [ @@ -594,35 +588,35 @@ export const CurrentProxyCard = () => { state.selection.proxy, sortType, setDelaySortRefresh, - ]); + ]) useEffect(() => { - if (isDirectMode) return; - if (!autoDelayEnabled) return; - if (!state.selection.group || !state.selection.proxy) return; + if (isDirectMode) return + if (!autoDelayEnabled) return + if (!state.selection.group || !state.selection.proxy) return - let disposed = false; - let intervalTimer: ReturnType | null = null; - let initialTimer: ReturnType | null = null; + let disposed = false + let intervalTimer: ReturnType | null = null + let initialTimer: ReturnType | null = null const runAndSchedule = async () => { - if (disposed) return; - await checkCurrentProxyDelay(); - if (disposed) return; - intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs); - }; + if (disposed) return + await checkCurrentProxyDelay() + if (disposed) return + intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs) + } initialTimer = setTimeout(async () => { - await checkCurrentProxyDelay(); - if (disposed) return; - intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs); - }, AUTO_CHECK_INITIAL_DELAY_MS); + await checkCurrentProxyDelay() + if (disposed) return + intervalTimer = setTimeout(runAndSchedule, autoDelayIntervalMs) + }, AUTO_CHECK_INITIAL_DELAY_MS) return () => { - disposed = true; - if (initialTimer) clearTimeout(initialTimer); - if (intervalTimer) clearTimeout(intervalTimer); - }; + disposed = true + if (initialTimer) clearTimeout(initialTimer) + if (intervalTimer) clearTimeout(intervalTimer) + } }, [ checkCurrentProxyDelay, autoDelayIntervalMs, @@ -630,19 +624,19 @@ export const CurrentProxyCard = () => { state.selection.group, state.selection.proxy, autoDelayEnabled, - ]); + ]) // 自定义渲染选择框中的值 const renderProxyValue = (selected: string) => { - if (!selected || !state.proxyData.records[selected]) return selected; + if (!selected || !state.proxyData.records[selected]) return selected const delayValue = delayManager.getDelayFix( state.proxyData.records[selected], state.selection.group, - ); + ) return ( - + {selected} { color={convertDelayColor(delayValue)} /> - ); - }; + ) + } // 排序类型变更 const handleSortTypeChange = useCallback(() => { - const newSortType = ((sortType + 1) % 3) as ProxySortType; - setSortType(newSortType); - localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType.toString()); - }, [sortType]); + const newSortType = ((sortType + 1) % 3) as ProxySortType + setSortType(newSortType) + localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType.toString()) + }, [sortType]) // 延迟测试 const handleCheckDelay = useLockFn(async () => { - const groupName = state.selection.group; - if (!groupName || isDirectMode) return; + const groupName = state.selection.group + if (!groupName || isDirectMode) return - debugLog(`[CurrentProxyCard] 开始测试所有延迟,组: ${groupName}`); + debugLog(`[CurrentProxyCard] 开始测试所有延迟,组: ${groupName}`) - const timeout = verge?.default_latency_timeout || 10000; + const timeout = verge?.default_latency_timeout || 10000 // 获取当前组的所有代理 - const proxyNames: string[] = []; - const providers: Set = new Set(); + const proxyNames: string[] = [] + const providers: Set = new Set() if (isGlobalMode && proxies?.global) { // 全局模式 const allProxies = proxies.global.all .filter((p: any) => { - const name = typeof p === "string" ? p : p.name; - return name !== "DIRECT" && name !== "REJECT"; + const name = typeof p === 'string' ? p : p.name + return name !== 'DIRECT' && name !== 'REJECT' }) - .map((p: any) => (typeof p === "string" ? p : p.name)); + .map((p: any) => (typeof p === 'string' ? p : p.name)) allProxies.forEach((name: string) => { - const proxy = state.proxyData.records[name]; + const proxy = state.proxyData.records[name] if (proxy?.provider) { - providers.add(proxy.provider); + providers.add(proxy.provider) } else { - proxyNames.push(name); + proxyNames.push(name) } - }); + }) } else { // 规则模式 - const group = state.proxyData.groups.find((g) => g.name === groupName); + const group = state.proxyData.groups.find((g) => g.name === groupName) if (group) { group.all.forEach((name: string) => { - const proxy = state.proxyData.records[name]; + const proxy = state.proxyData.records[name] if (proxy?.provider) { - providers.add(proxy.provider); + providers.add(proxy.provider) } else { - proxyNames.push(name); + proxyNames.push(name) } - }); + }) } } debugLog( `[CurrentProxyCard] 找到代理数量: ${proxyNames.length}, 提供者数量: ${providers.size}`, - ); + ) // 测试提供者的节点 if (providers.size > 0) { - debugLog(`[CurrentProxyCard] 开始测试提供者节点`); + debugLog(`[CurrentProxyCard] 开始测试提供者节点`) await Promise.allSettled( [...providers].map((p) => healthcheckProxyProvider(p)), - ); + ) } // 测试非提供者的节点 if (proxyNames.length > 0) { - const url = delayManager.getUrl(groupName); - debugLog(`[CurrentProxyCard] 测试URL: ${url}, 超时: ${timeout}ms`); + const url = delayManager.getUrl(groupName) + debugLog(`[CurrentProxyCard] 测试URL: ${url}, 超时: ${timeout}ms`) try { await Promise.race([ delayManager.checkListDelay(proxyNames, groupName, timeout), delayGroup(groupName, url, timeout), - ]); - debugLog(`[CurrentProxyCard] 延迟测试完成,组: ${groupName}`); + ]) + debugLog(`[CurrentProxyCard] 延迟测试完成,组: ${groupName}`) } catch (error) { console.error( `[CurrentProxyCard] 延迟测试出错,组: ${groupName}`, error, - ); + ) } } - refreshProxy(); + refreshProxy() if (sortType === 1) { - setDelaySortRefresh((prev) => prev + 1); + setDelaySortRefresh((prev) => prev + 1) } - }); + }) // 计算要显示的代理选项(增加非空校验) const proxyOptions = useMemo(() => { const sortWithLatency = (proxiesToSort: ProxyOption[]) => { - if (!proxiesToSort || sortType === 0) return proxiesToSort; + if (!proxiesToSort || sortType === 0) return proxiesToSort if (!state.proxyData.records || !state.selection.group) { - return proxiesToSort; + return proxiesToSort } - const list = [...proxiesToSort]; + const list = [...proxiesToSort] if (sortType === 1) { - const refreshTick = delaySortRefresh; + const refreshTick = delaySortRefresh const effectiveTimeout = - typeof defaultLatencyTimeout === "number" && defaultLatencyTimeout > 0 + typeof defaultLatencyTimeout === 'number' && defaultLatencyTimeout > 0 ? defaultLatencyTimeout - : 10000; + : 10000 const categorizeDelay = (delay: number): [number, number] => { - if (!Number.isFinite(delay)) return [5, Number.MAX_SAFE_INTEGER]; - if (delay > 1e5) return [4, delay]; + if (!Number.isFinite(delay)) return [5, Number.MAX_SAFE_INTEGER] + if (delay > 1e5) return [4, delay] if (delay === 0 || (delay >= effectiveTimeout && delay <= 1e5)) { - return [3, delay || effectiveTimeout]; + return [3, delay || effectiveTimeout] } - if (delay < 0) return [5, Number.MAX_SAFE_INTEGER]; - return [0, delay]; - }; + if (delay < 0) return [5, Number.MAX_SAFE_INTEGER] + return [0, delay] + } list.sort((a, b) => { - const recordA = state.proxyData.records[a.name]; - const recordB = state.proxyData.records[b.name]; + const recordA = state.proxyData.records[a.name] + const recordB = state.proxyData.records[b.name] const [ar, av] = recordA ? categorizeDelay( delayManager.getDelayFix(recordA, state.selection.group), ) - : [6, Number.MAX_SAFE_INTEGER]; + : [6, Number.MAX_SAFE_INTEGER] const [br, bv] = recordB ? categorizeDelay( delayManager.getDelayFix(recordB, state.selection.group), ) - : [6, Number.MAX_SAFE_INTEGER]; + : [6, Number.MAX_SAFE_INTEGER] - if (ar !== br) return ar - br; - if (av !== bv) return av - bv; - return refreshTick >= 0 ? a.name.localeCompare(b.name) : 0; - }); + if (ar !== br) return ar - br + if (av !== bv) return av - bv + return refreshTick >= 0 ? a.name.localeCompare(b.name) : 0 + }) } else { - list.sort((a, b) => a.name.localeCompare(b.name)); + list.sort((a, b) => a.name.localeCompare(b.name)) } - return list; - }; + return list + } if (isDirectMode) { - return [{ name: "DIRECT" }]; + return [{ name: 'DIRECT' }] } if (isGlobalMode && proxies?.global) { const options = proxies.global.all .filter((p: any) => { - const name = typeof p === "string" ? p : p.name; - return name !== "DIRECT" && name !== "REJECT"; + const name = typeof p === 'string' ? p : p.name + return name !== 'DIRECT' && name !== 'REJECT' }) .map((p: any) => ({ - name: typeof p === "string" ? p : p.name, - })); + name: typeof p === 'string' ? p : p.name, + })) - return sortWithLatency(options); + return sortWithLatency(options) } // 规则模式 const group = state.selection.group ? state.proxyData.groups.find((g) => g.name === state.selection.group) - : null; + : null if (group) { - const options = group.all.map((name) => ({ name })); - return sortWithLatency(options); + const options = group.all.map((name) => ({ name })) + return sortWithLatency(options) } - return []; + return [] }, [ isDirectMode, isGlobalMode, @@ -832,43 +826,43 @@ export const CurrentProxyCard = () => { sortType, delaySortRefresh, defaultLatencyTimeout, - ]); + ]) // 获取排序图标 const getSortIcon = (): React.ReactElement => { switch (sortType) { case 1: - return ; + return case 2: - return ; + return default: - return ; + return } - }; + } // 获取排序提示文本 const getSortTooltip = (): string => { switch (sortType) { case 0: - return t("proxies.page.tooltips.sortDefault"); + return t('proxies.page.tooltips.sortDefault') case 1: - return t("proxies.page.tooltips.sortDelay"); + return t('proxies.page.tooltips.sortDelay') case 2: - return t("proxies.page.tooltips.sortName"); + return t('proxies.page.tooltips.sortName') default: - return ""; + return '' } - }; + } return ( @@ -876,11 +870,11 @@ export const CurrentProxyCard = () => { } - iconColor={currentProxy ? "primary" : undefined} + iconColor={currentProxy ? 'primary' : undefined} action={ - + { sx={{ borderRadius: 1.5 }} endIcon={} > - {t("layout.components.navigation.tabs.proxies")} + {t('layout.components.navigation.tabs.proxies')} } @@ -919,9 +913,9 @@ export const CurrentProxyCard = () => { {/* 代理节点信息显示 */} { { {isGlobalMode && ( @@ -955,7 +949,7 @@ export const CurrentProxyCard = () => { {isDirectMode && ( @@ -996,13 +990,13 @@ export const CurrentProxyCard = () => { sx={{ mb: 1.5 }} > - {t("home.components.currentProxy.labels.group")} + {t('home.components.currentProxy.labels.group')} { state.proxyData.records[proxy.name], state.selection.group, ) - : -1; + : -1 return ( @@ -1064,24 +1058,24 @@ export const CurrentProxyCard = () => { label={delayManager.formatDelay(delayValue)} color={convertDelayColor(delayValue)} sx={{ - minWidth: "60px", - height: "22px", + minWidth: '60px', + height: '22px', flexShrink: 0, }} /> - ); + ) })} ) : ( - + - {t("home.components.currentProxy.labels.noActiveNode")} + {t('home.components.currentProxy.labels.noActiveNode')} )} - ); -}; + ) +} diff --git a/src/components/home/enhanced-canvas-traffic-graph.tsx b/src/components/home/enhanced-canvas-traffic-graph.tsx index d557a7622..86a628958 100644 --- a/src/components/home/enhanced-canvas-traffic-graph.tsx +++ b/src/components/home/enhanced-canvas-traffic-graph.tsx @@ -1,5 +1,5 @@ -import { Box, useTheme } from "@mui/material"; -import type { Ref } from "react"; +import { Box, useTheme } from '@mui/material' +import type { Ref } from 'react' import { memo, useCallback, @@ -9,51 +9,51 @@ import { useReducer, useRef, useState, -} from "react"; -import { useTranslation } from "react-i18next"; +} from 'react' +import { useTranslation } from 'react-i18next' -import { useTrafficGraphDataEnhanced } from "@/hooks/use-traffic-monitor"; -import { debugLog } from "@/utils/debug"; -import parseTraffic from "@/utils/parse-traffic"; +import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor' +import { debugLog } from '@/utils/debug' +import parseTraffic from '@/utils/parse-traffic' // 流量数据项接口 interface ITrafficItem { - up: number; - down: number; - timestamp?: number; + up: number + down: number + timestamp?: number } // 对外暴露的接口 export interface EnhancedCanvasTrafficGraphRef { - appendData: (data: ITrafficItem) => void; - toggleStyle: () => void; + appendData: (data: ITrafficItem) => void + toggleStyle: () => void } -type TimeRange = 1 | 5 | 10; // 分钟 +type TimeRange = 1 | 5 | 10 // 分钟 // 悬浮提示数据接口 interface TooltipData { - x: number; - y: number; - upSpeed: string; - downSpeed: string; - timestamp: string; - visible: boolean; - dataIndex: number; // 添加数据索引用于高亮 - highlightY: number; // 高亮Y轴位置 + x: number + y: number + upSpeed: string + downSpeed: string + timestamp: string + visible: boolean + dataIndex: number // 添加数据索引用于高亮 + highlightY: number // 高亮Y轴位置 } -const MAX_POINTS = 300; -const TARGET_FPS = 15; // 降低帧率减少闪烁 -const LINE_WIDTH_UP = 2.5; -const LINE_WIDTH_DOWN = 2.5; -const LINE_WIDTH_GRID = 0.5; -const ALPHA_GRADIENT = 0.15; // 降低渐变透明度 -const ALPHA_LINE = 0.9; -const PADDING_TOP = 16; -const PADDING_RIGHT = 16; // 增加右边距确保时间戳完整显示 -const PADDING_BOTTOM = 32; // 进一步增加底部空间给时间轴和统计信息 -const PADDING_LEFT = 35; // 增加左边距为Y轴标签留出空间 +const MAX_POINTS = 300 +const TARGET_FPS = 15 // 降低帧率减少闪烁 +const LINE_WIDTH_UP = 2.5 +const LINE_WIDTH_DOWN = 2.5 +const LINE_WIDTH_GRID = 0.5 +const ALPHA_GRADIENT = 0.15 // 降低渐变透明度 +const ALPHA_LINE = 0.9 +const PADDING_TOP = 16 +const PADDING_RIGHT = 16 // 增加右边距确保时间戳完整显示 +const PADDING_BOTTOM = 32 // 进一步增加底部空间给时间轴和统计信息 +const PADDING_LEFT = 35 // 增加左边距为Y轴标签留出空间 const GRAPH_CONFIG = { maxPoints: MAX_POINTS, @@ -73,27 +73,27 @@ const GRAPH_CONFIG = { bottom: PADDING_BOTTOM, left: PADDING_LEFT, }, -}; +} -const MIN_FPS = 8; -const MAX_FPS = 20; -const FPS_ADJUST_INTERVAL = 3000; // ms -const FPS_SAMPLE_WINDOW = 12; -const STALE_DATA_THRESHOLD = 2500; // ms without fresh data => drop FPS -const RESUME_FPS_TARGET = 12; -const RESUME_COOLDOWN_MS = 2000; +const MIN_FPS = 8 +const MAX_FPS = 20 +const FPS_ADJUST_INTERVAL = 3000 // ms +const FPS_SAMPLE_WINDOW = 12 +const STALE_DATA_THRESHOLD = 2500 // ms without fresh data => drop FPS +const RESUME_FPS_TARGET = 12 +const RESUME_COOLDOWN_MS = 2000 const getNow = () => - typeof performance !== "undefined" ? performance.now() : Date.now(); + typeof performance !== 'undefined' ? performance.now() : Date.now() interface EnhancedCanvasTrafficGraphProps { - ref?: Ref; + ref?: Ref } const displayDataReducer = ( _: ITrafficDataPoint[], payload: ITrafficDataPoint[], -): ITrafficDataPoint[] => payload; +): ITrafficDataPoint[] => payload /** * 稳定版Canvas流量图表组件 @@ -103,59 +103,59 @@ export const EnhancedCanvasTrafficGraph = memo( function EnhancedCanvasTrafficGraph({ ref, }: EnhancedCanvasTrafficGraphProps) { - const theme = useTheme(); - const { t } = useTranslation(); + const theme = useTheme() + const { t } = useTranslation() // 使用增强版全局流量数据管理 const { dataPoints, requestRange, samplerStats } = - useTrafficGraphDataEnhanced(); + useTrafficGraphDataEnhanced() // 基础状态 - const [timeRange, setTimeRange] = useState(10); - const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier"); + const [timeRange, setTimeRange] = useState(10) + const [chartStyle, setChartStyle] = useState<'bezier' | 'line'>('bezier') const initialFocusState = - typeof document !== "undefined" ? !document.hidden : true; - const [isWindowFocused, setIsWindowFocused] = useState(initialFocusState); + typeof document !== 'undefined' ? !document.hidden : true + const [isWindowFocused, setIsWindowFocused] = useState(initialFocusState) // 悬浮提示状态 const [tooltipData, setTooltipData] = useState({ x: 0, y: 0, - upSpeed: "", - downSpeed: "", - timestamp: "", + upSpeed: '', + downSpeed: '', + timestamp: '', visible: false, dataIndex: -1, highlightY: 0, - }); + }) // Canvas引用和渲染状态 - const canvasRef = useRef(null); - const animationFrameRef = useRef(undefined); - const lastRenderTimeRef = useRef(0); - const isInitializedRef = useRef(false); - const isWindowFocusedRef = useRef(initialFocusState); + const canvasRef = useRef(null) + const animationFrameRef = useRef(undefined) + const lastRenderTimeRef = useRef(0) + const isInitializedRef = useRef(false) + const isWindowFocusedRef = useRef(initialFocusState) const fpsControllerRef = useRef<{ - target: number; - samples: number[]; - lastAdjustTime: number; + target: number + samples: number[] + lastAdjustTime: number }>({ target: GRAPH_CONFIG.targetFPS, samples: [], lastAdjustTime: 0, - }); - const lastDataTimestampRef = useRef(0); - const resumeCooldownRef = useRef(0); - const dataStaleRef = useRef(false); + }) + const lastDataTimestampRef = useRef(0) + const resumeCooldownRef = useRef(0) + const dataStaleRef = useRef(false) // 当前显示的数据缓存 const [displayData, dispatchDisplayData] = useReducer( displayDataReducer, [], - ); - const debounceTimeoutRef = useRef(null); - const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS); + ) + const debounceTimeoutRef = useRef(null) + const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS) // 主题颜色配置 const colors = useMemo( @@ -167,193 +167,187 @@ export const EnhancedCanvasTrafficGraph = memo( background: theme.palette.background.paper, }), [theme], - ); + ) // 更新显示数据(防抖处理) const updateDisplayData = useCallback((newData: ITrafficDataPoint[]) => { if (debounceTimeoutRef.current !== null) { - window.clearTimeout(debounceTimeoutRef.current); + window.clearTimeout(debounceTimeoutRef.current) } debounceTimeoutRef.current = window.setTimeout(() => { - dispatchDisplayData(newData); - }, 50); // 50ms防抖 - }, []); + dispatchDisplayData(newData) + }, 50) // 50ms防抖 + }, []) // 监听数据变化 useEffect(() => { - updateDisplayData(dataPoints); + updateDisplayData(dataPoints) return () => { if (debounceTimeoutRef.current !== null) { - window.clearTimeout(debounceTimeoutRef.current); - debounceTimeoutRef.current = null; + window.clearTimeout(debounceTimeoutRef.current) + debounceTimeoutRef.current = null } - }; - }, [dataPoints, updateDisplayData]); + } + }, [dataPoints, updateDisplayData]) useEffect(() => { - requestRange(timeRange); - }, [requestRange, timeRange]); + requestRange(timeRange) + }, [requestRange, timeRange]) useEffect(() => { if (displayData.length === 0) { - lastDataTimestampRef.current = 0; - dataStaleRef.current = false; - fpsControllerRef.current.target = GRAPH_CONFIG.targetFPS; - fpsControllerRef.current.samples = []; - fpsControllerRef.current.lastAdjustTime = 0; + lastDataTimestampRef.current = 0 + dataStaleRef.current = false + fpsControllerRef.current.target = GRAPH_CONFIG.targetFPS + fpsControllerRef.current.samples = [] + fpsControllerRef.current.lastAdjustTime = 0 // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect - setCurrentFPS(GRAPH_CONFIG.targetFPS); - return; + setCurrentFPS(GRAPH_CONFIG.targetFPS) + return } const latestTimestamp = - displayData[displayData.length - 1]?.timestamp ?? null; + displayData[displayData.length - 1]?.timestamp ?? null if (latestTimestamp) { - lastDataTimestampRef.current = latestTimestamp; - const age = Date.now() - latestTimestamp; - const stale = age > STALE_DATA_THRESHOLD; - dataStaleRef.current = stale; + lastDataTimestampRef.current = latestTimestamp + const age = Date.now() - latestTimestamp + const stale = age > STALE_DATA_THRESHOLD + dataStaleRef.current = stale } else { - dataStaleRef.current = false; + dataStaleRef.current = false } - }, [displayData]); + }, [displayData]) const handleFocusStateChange = useCallback( (focused: boolean) => { - isWindowFocusedRef.current = focused; - setIsWindowFocused(focused); + isWindowFocusedRef.current = focused + setIsWindowFocused(focused) - const highResNow = getNow(); - lastRenderTimeRef.current = highResNow; + const highResNow = getNow() + lastRenderTimeRef.current = highResNow if (focused) { - resumeCooldownRef.current = Date.now(); - const controller = fpsControllerRef.current; + resumeCooldownRef.current = Date.now() + const controller = fpsControllerRef.current const resumeTarget = Math.max( MIN_FPS, Math.min(controller.target, RESUME_FPS_TARGET), - ); - controller.target = resumeTarget; - controller.samples = []; - controller.lastAdjustTime = 0; - setCurrentFPS(resumeTarget); + ) + controller.target = resumeTarget + controller.samples = [] + controller.lastAdjustTime = 0 + setCurrentFPS(resumeTarget) } else { - resumeCooldownRef.current = 0; + resumeCooldownRef.current = 0 } }, [setIsWindowFocused, setCurrentFPS], - ); + ) useEffect(() => { - if (typeof window === "undefined" || typeof document === "undefined") { - return; + if (typeof window === 'undefined' || typeof document === 'undefined') { + return } - const handleFocus = () => handleFocusStateChange(true); - const handleBlur = () => handleFocusStateChange(false); + const handleFocus = () => handleFocusStateChange(true) + const handleBlur = () => handleFocusStateChange(false) const handleVisibilityChange = () => - handleFocusStateChange(!document.hidden); + handleFocusStateChange(!document.hidden) - window.addEventListener("focus", handleFocus); - window.addEventListener("blur", handleBlur); - document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener('focus', handleFocus) + window.addEventListener('blur', handleBlur) + document.addEventListener('visibilitychange', handleVisibilityChange) return () => { - window.removeEventListener("focus", handleFocus); - window.removeEventListener("blur", handleBlur); - document.removeEventListener( - "visibilitychange", - handleVisibilityChange, - ); - }; - }, [handleFocusStateChange]); + window.removeEventListener('focus', handleFocus) + window.removeEventListener('blur', handleBlur) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [handleFocusStateChange]) // Y轴坐标计算 - 基于刻度范围的线性映射 const calculateY = useCallback( (value: number, height: number, data: ITrafficDataPoint[]): number => { - const padding = GRAPH_CONFIG.padding; - const topY = padding.top + 10; // 与刻度系统保持一致 - const bottomY = height - padding.bottom - 5; + const padding = GRAPH_CONFIG.padding + const topY = padding.top + 10 // 与刻度系统保持一致 + const bottomY = height - padding.bottom - 5 - if (data.length === 0) return bottomY; + if (data.length === 0) return bottomY // 获取当前的刻度范围 - const allValues = [ - ...data.map((d) => d.up), - ...data.map((d) => d.down), - ]; - const maxValue = Math.max(...allValues); - const minValue = Math.min(...allValues); + const allValues = [...data.map((d) => d.up), ...data.map((d) => d.down)] + const maxValue = Math.max(...allValues) + const minValue = Math.min(...allValues) - let topValue, bottomValue; + let topValue, bottomValue if (maxValue === 0) { - topValue = 1024; - bottomValue = 0; + topValue = 1024 + bottomValue = 0 } else { - const range = maxValue - minValue; - const padding_percent = range > 0 ? 0.1 : 0.5; + const range = maxValue - minValue + const padding_percent = range > 0 ? 0.1 : 0.5 if (range === 0) { - bottomValue = 0; - topValue = maxValue * 1.2; + bottomValue = 0 + topValue = maxValue * 1.2 } else { - bottomValue = Math.max(0, minValue - range * padding_percent); - topValue = maxValue + range * padding_percent; + bottomValue = Math.max(0, minValue - range * padding_percent) + topValue = maxValue + range * padding_percent } } // 线性映射到Y坐标 - if (topValue === bottomValue) return bottomY; + if (topValue === bottomValue) return bottomY - const ratio = (value - bottomValue) / (topValue - bottomValue); - return bottomY - ratio * (bottomY - topY); + const ratio = (value - bottomValue) / (topValue - bottomValue) + return bottomY - ratio * (bottomY - topY) }, [], - ); + ) // 鼠标悬浮处理 - 计算最近的数据点 const handleMouseMove = useCallback( (event: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas || displayData.length === 0) return; + const canvas = canvasRef.current + if (!canvas || displayData.length === 0) return - const rect = canvas.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const mouseY = event.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const mouseX = event.clientX - rect.left + const mouseY = event.clientY - rect.top - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = rect.width - padding.left - padding.right; + const padding = GRAPH_CONFIG.padding + const effectiveWidth = rect.width - padding.left - padding.right // 计算最接近的数据点索引 - const relativeMouseX = mouseX - padding.left; - const ratio = Math.max(0, Math.min(1, relativeMouseX / effectiveWidth)); - const dataIndex = Math.round(ratio * (displayData.length - 1)); + const relativeMouseX = mouseX - padding.left + const ratio = Math.max(0, Math.min(1, relativeMouseX / effectiveWidth)) + const dataIndex = Math.round(ratio * (displayData.length - 1)) if (dataIndex >= 0 && dataIndex < displayData.length) { - const dataPoint = displayData[dataIndex]; + const dataPoint = displayData[dataIndex] // 格式化流量数据 - const [upValue, upUnit] = parseTraffic(dataPoint.up); - const [downValue, downUnit] = parseTraffic(dataPoint.down); + const [upValue, upUnit] = parseTraffic(dataPoint.up) + const [downValue, downUnit] = parseTraffic(dataPoint.down) // 格式化时间戳 const timeStr = dataPoint.timestamp - ? new Date(dataPoint.timestamp).toLocaleTimeString("zh-CN", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", + ? new Date(dataPoint.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', }) - : "未知时间"; + : '未知时间' // 计算数据点对应的Y坐标位置(用于高亮) - const upY = calculateY(dataPoint.up, rect.height, displayData); - const downY = calculateY(dataPoint.down, rect.height, displayData); + const upY = calculateY(dataPoint.up, rect.height, displayData) + const downY = calculateY(dataPoint.down, rect.height, displayData) const highlightY = Math.max(dataPoint.up, dataPoint.down) === dataPoint.up ? upY - : downY; + : downY setTooltipData({ x: mouseX, @@ -364,68 +358,65 @@ export const EnhancedCanvasTrafficGraph = memo( visible: true, dataIndex, highlightY, - }); + }) } }, [displayData, calculateY], - ); + ) // 鼠标离开处理 const handleMouseLeave = useCallback(() => { - setTooltipData((prev) => ({ ...prev, visible: false })); - }, []); + setTooltipData((prev) => ({ ...prev, visible: false })) + }, []) // 获取智能Y轴刻度(三刻度系统:最小值、中间值、最大值) const getYAxisTicks = useCallback( (data: ITrafficDataPoint[], height: number) => { - if (data.length === 0) return []; + if (data.length === 0) return [] // 找到数据的最大值和最小值 - const allValues = [ - ...data.map((d) => d.up), - ...data.map((d) => d.down), - ]; - const maxValue = Math.max(...allValues); - const minValue = Math.min(...allValues); + const allValues = [...data.map((d) => d.up), ...data.map((d) => d.down)] + const maxValue = Math.max(...allValues) + const minValue = Math.min(...allValues) // 格式化流量数值 const formatTrafficValue = (bytes: number): string => { - if (bytes === 0) return "0"; - if (bytes < 1024) return `${Math.round(bytes)}B`; - if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; - }; + if (bytes === 0) return '0' + if (bytes < 1024) return `${Math.round(bytes)}B` + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` + } - const padding = GRAPH_CONFIG.padding; + const padding = GRAPH_CONFIG.padding // 强制显示三个刻度:底部、中间、顶部 - const topY = padding.top + 10; // 避免与顶部时间范围按钮重叠 - const bottomY = height - padding.bottom - 5; // 避免与底部时间轴重叠 - const middleY = (topY + bottomY) / 2; + const topY = padding.top + 10 // 避免与顶部时间范围按钮重叠 + const bottomY = height - padding.bottom - 5 // 避免与底部时间轴重叠 + const middleY = (topY + bottomY) / 2 // 计算对应的值 - let topValue, middleValue, bottomValue; + let topValue, middleValue, bottomValue if (maxValue === 0) { // 如果没有流量,显示0到一个小值的范围 - topValue = 1024; // 1KB - middleValue = 512; // 512B - bottomValue = 0; + topValue = 1024 // 1KB + middleValue = 512 // 512B + bottomValue = 0 } else { // 根据数据范围计算合适的刻度值 - const range = maxValue - minValue; - const padding_percent = range > 0 ? 0.1 : 0.5; // 如果范围为0,使用更大的边距 + const range = maxValue - minValue + const padding_percent = range > 0 ? 0.1 : 0.5 // 如果范围为0,使用更大的边距 if (range === 0) { // 所有值相同的情况 - bottomValue = 0; - middleValue = maxValue * 0.5; - topValue = maxValue * 1.2; + bottomValue = 0 + middleValue = maxValue * 0.5 + topValue = maxValue * 1.2 } else { // 正常情况 - bottomValue = Math.max(0, minValue - range * padding_percent); - topValue = maxValue + range * padding_percent; - middleValue = (bottomValue + topValue) / 2; + bottomValue = Math.max(0, minValue - range * padding_percent) + topValue = maxValue + range * padding_percent + middleValue = (bottomValue + topValue) / 2 } } @@ -446,12 +437,12 @@ export const EnhancedCanvasTrafficGraph = memo( label: formatTrafficValue(topValue), y: topY, }, - ]; + ] - return ticks; + return ticks }, [], - ); + ) // 绘制Y轴刻度线和标签 const drawYAxis = useCallback( @@ -461,60 +452,60 @@ export const EnhancedCanvasTrafficGraph = memo( height: number, data: ITrafficDataPoint[], ) => { - const padding = GRAPH_CONFIG.padding; - const ticks = getYAxisTicks(data, height); + const padding = GRAPH_CONFIG.padding + const ticks = getYAxisTicks(data, height) - if (ticks.length === 0) return; + if (ticks.length === 0) return - ctx.save(); + ctx.save() ticks.forEach((tick, index) => { - const isBottomTick = index === 0; // 最底部的刻度 - const isTopTick = index === ticks.length - 1; // 最顶部的刻度 + const isBottomTick = index === 0 // 最底部的刻度 + const isTopTick = index === ticks.length - 1 // 最顶部的刻度 // 绘制水平刻度线,只绘制关键刻度线 if (isBottomTick || isTopTick) { - ctx.strokeStyle = colors.grid; - ctx.lineWidth = isBottomTick ? 0.8 : 0.4; // 底部刻度线稍粗 - ctx.globalAlpha = isBottomTick ? 0.25 : 0.15; + ctx.strokeStyle = colors.grid + ctx.lineWidth = isBottomTick ? 0.8 : 0.4 // 底部刻度线稍粗 + ctx.globalAlpha = isBottomTick ? 0.25 : 0.15 - ctx.beginPath(); - ctx.moveTo(padding.left, tick.y); - ctx.lineTo(width - padding.right, tick.y); - ctx.stroke(); + ctx.beginPath() + ctx.moveTo(padding.left, tick.y) + ctx.lineTo(width - padding.right, tick.y) + ctx.stroke() } // 绘制Y轴标签 - ctx.fillStyle = colors.text; + ctx.fillStyle = colors.text ctx.font = - "8px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"; - ctx.globalAlpha = 0.9; - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; + "8px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" + ctx.globalAlpha = 0.9 + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' // 为标签添加更清晰的背景(仅在必要时) - if (tick.label !== "0") { - const labelWidth = ctx.measureText(tick.label).width; - ctx.globalAlpha = 0.15; - ctx.fillStyle = colors.background; + if (tick.label !== '0') { + const labelWidth = ctx.measureText(tick.label).width + ctx.globalAlpha = 0.15 + ctx.fillStyle = colors.background ctx.fillRect( padding.left - labelWidth - 8, tick.y - 5, labelWidth + 4, 10, - ); + ) } // 绘制标签文字 - ctx.globalAlpha = 0.9; - ctx.fillStyle = colors.text; - ctx.fillText(tick.label, padding.left - 4, tick.y); - }); + ctx.globalAlpha = 0.9 + ctx.fillStyle = colors.text + ctx.fillText(tick.label, padding.left - 4, tick.y) + }) - ctx.restore(); + ctx.restore() }, [colors.grid, colors.text, colors.background, getYAxisTicks], - ); + ) // 获取时间范围对应的最佳时间显示策略 const getTimeDisplayStrategy = useCallback( @@ -524,47 +515,47 @@ export const EnhancedCanvasTrafficGraph = memo( return { maxLabels: 6, // 减少到6个,更适合短时间 formatTime: (timestamp: number) => { - const date = new Date(timestamp); - const minutes = date.getMinutes().toString().padStart(2, "0"); - const seconds = date.getSeconds().toString().padStart(2, "0"); - return `${minutes}:${seconds}`; // 显示 MM:SS + const date = new Date(timestamp) + const minutes = date.getMinutes().toString().padStart(2, '0') + const seconds = date.getSeconds().toString().padStart(2, '0') + return `${minutes}:${seconds}` // 显示 MM:SS }, intervalSeconds: 10, // 每10秒一个标签,更合理 minPixelDistance: 35, // 减少间距,允许更多标签 - }; + } case 5: // 5分钟:中等密度,显示 HH:MM return { maxLabels: 6, // 6个标签比较合适 formatTime: (timestamp: number) => { - const date = new Date(timestamp); - return date.toLocaleTimeString("en-US", { + const date = new Date(timestamp) + return date.toLocaleTimeString('en-US', { hour12: false, - hour: "2-digit", - minute: "2-digit", - }); // 显示 HH:MM + hour: '2-digit', + minute: '2-digit', + }) // 显示 HH:MM }, intervalSeconds: 30, // 约30秒间隔 minPixelDistance: 38, // 减少间距,允许更多标签 - }; + } case 10: // 10分钟:标准密度,显示 HH:MM default: return { maxLabels: 8, // 保持8个 formatTime: (timestamp: number) => { - const date = new Date(timestamp); - return date.toLocaleTimeString("en-US", { + const date = new Date(timestamp) + return date.toLocaleTimeString('en-US', { hour12: false, - hour: "2-digit", - minute: "2-digit", - }); // 显示 HH:MM + hour: '2-digit', + minute: '2-digit', + }) // 显示 HH:MM }, intervalSeconds: 60, // 1分钟间隔 minPixelDistance: 40, // 减少间距,允许更多标签 - }; + } } }, [], - ); + ) // 绘制时间轴 const drawTimeAxis = useCallback( @@ -574,34 +565,34 @@ export const EnhancedCanvasTrafficGraph = memo( height: number, data: ITrafficDataPoint[], ) => { - if (data.length === 0) return; + if (data.length === 0) return - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = width - padding.left - padding.right; - const timeAxisY = height - padding.bottom + 14; + const padding = GRAPH_CONFIG.padding + const effectiveWidth = width - padding.left - padding.right + const timeAxisY = height - padding.bottom + 14 - const strategy = getTimeDisplayStrategy(timeRange); + const strategy = getTimeDisplayStrategy(timeRange) - ctx.save(); - ctx.fillStyle = colors.text; + ctx.save() + ctx.fillStyle = colors.text ctx.font = - "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"; - ctx.globalAlpha = 0.7; + "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif" + ctx.globalAlpha = 0.7 // 根据数据长度和时间范围智能选择显示间隔 - const targetLabels = Math.min(strategy.maxLabels, data.length); - const step = Math.max(1, Math.floor(data.length / (targetLabels - 1))); + const targetLabels = Math.min(strategy.maxLabels, data.length) + const step = Math.max(1, Math.floor(data.length / (targetLabels - 1))) // 使用策略中定义的最小像素间距 - const minPixelDistance = strategy.minPixelDistance || 45; + const minPixelDistance = strategy.minPixelDistance || 45 const actualStep = Math.max( step, Math.ceil((data.length * minPixelDistance) / effectiveWidth), - ); + ) // 收集要显示的时间点 const timePoints: Array<{ index: number; x: number; label: string }> = - []; + [] // 添加第一个时间点 if (data.length > 0 && data[0].timestamp) { @@ -609,7 +600,7 @@ export const EnhancedCanvasTrafficGraph = memo( index: 0, x: padding.left, label: strategy.formatTime(data[0].timestamp), - }); + }) } // 添加中间的时间点 @@ -618,21 +609,21 @@ export const EnhancedCanvasTrafficGraph = memo( i < data.length - actualStep; i += actualStep ) { - const point = data[i]; - if (!point.timestamp) continue; + const point = data[i] + if (!point.timestamp) continue - const x = padding.left + (i / (data.length - 1)) * effectiveWidth; + const x = padding.left + (i / (data.length - 1)) * effectiveWidth timePoints.push({ index: i, x, label: strategy.formatTime(point.timestamp), - }); + }) } // 添加最后一个时间点(如果不会与前面的重叠) if (data.length > 1 && data[data.length - 1].timestamp) { - const lastX = width - padding.right; - const lastPoint = timePoints[timePoints.length - 1]; + const lastX = width - padding.right + const lastPoint = timePoints[timePoints.length - 1] // 确保最后一个标签与前一个标签有足够间距 if (!lastPoint || lastX - lastPoint.x >= minPixelDistance) { @@ -640,7 +631,7 @@ export const EnhancedCanvasTrafficGraph = memo( index: data.length - 1, x: lastX, label: strategy.formatTime(data[data.length - 1].timestamp), - }); + }) } } @@ -648,59 +639,59 @@ export const EnhancedCanvasTrafficGraph = memo( timePoints.forEach((point, index) => { if (index === 0) { // 第一个标签左对齐 - ctx.textAlign = "left"; + ctx.textAlign = 'left' } else if (index === timePoints.length - 1) { // 最后一个标签右对齐 - ctx.textAlign = "right"; + ctx.textAlign = 'right' } else { // 中间标签居中对齐 - ctx.textAlign = "center"; + ctx.textAlign = 'center' } - ctx.fillText(point.label, point.x, timeAxisY); - }); + ctx.fillText(point.label, point.x, timeAxisY) + }) - ctx.restore(); + ctx.restore() }, [colors.text, timeRange, getTimeDisplayStrategy], - ); + ) // 绘制网格线 const drawGrid = useCallback( (ctx: CanvasRenderingContext2D, width: number, height: number) => { - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = width - padding.left - padding.right; - const effectiveHeight = height - padding.top - padding.bottom; + const padding = GRAPH_CONFIG.padding + const effectiveWidth = width - padding.left - padding.right + const effectiveHeight = height - padding.top - padding.bottom - ctx.save(); - ctx.strokeStyle = colors.grid; - ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid; - ctx.globalAlpha = 0.7; + ctx.save() + ctx.strokeStyle = colors.grid + ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid + ctx.globalAlpha = 0.7 // 水平网格线 - const horizontalLines = 4; + const horizontalLines = 4 for (let i = 1; i <= horizontalLines; i++) { - const y = padding.top + (effectiveHeight / (horizontalLines + 1)) * i; - ctx.beginPath(); - ctx.moveTo(padding.left, y); - ctx.lineTo(width - padding.right, y); - ctx.stroke(); + const y = padding.top + (effectiveHeight / (horizontalLines + 1)) * i + ctx.beginPath() + ctx.moveTo(padding.left, y) + ctx.lineTo(width - padding.right, y) + ctx.stroke() } // 垂直网格线 - const verticalLines = 6; + const verticalLines = 6 for (let i = 1; i <= verticalLines; i++) { - const x = padding.left + (effectiveWidth / (verticalLines + 1)) * i; - ctx.beginPath(); - ctx.moveTo(x, padding.top); - ctx.lineTo(x, height - padding.bottom); - ctx.stroke(); + const x = padding.left + (effectiveWidth / (verticalLines + 1)) * i + ctx.beginPath() + ctx.moveTo(x, padding.top) + ctx.lineTo(x, height - padding.bottom) + ctx.stroke() } - ctx.restore(); + ctx.restore() }, [colors.grid], - ); + ) // 绘制流量线条 const drawTrafficLine = useCallback( @@ -713,137 +704,137 @@ export const EnhancedCanvasTrafficGraph = memo( withGradient = false, data: ITrafficDataPoint[], ) => { - if (values.length < 2) return; + if (values.length < 2) return - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = width - padding.left - padding.right; + const padding = GRAPH_CONFIG.padding + const effectiveWidth = width - padding.left - padding.right const points = values.map((value, index) => [ padding.left + (index / (values.length - 1)) * effectiveWidth, calculateY(value, height, data), - ]); + ]) - ctx.save(); + ctx.save() // 绘制渐变填充 - if (withGradient && chartStyle === "bezier") { + if (withGradient && chartStyle === 'bezier') { const gradient = ctx.createLinearGradient( 0, padding.top, 0, height - padding.bottom, - ); + ) gradient.addColorStop( 0, `${color}${Math.round(GRAPH_CONFIG.alpha.gradient * 255) .toString(16) - .padStart(2, "0")}`, - ); - gradient.addColorStop(1, `${color}00`); + .padStart(2, '0')}`, + ) + gradient.addColorStop(1, `${color}00`) - ctx.beginPath(); - ctx.moveTo(points[0][0], points[0][1]); + ctx.beginPath() + ctx.moveTo(points[0][0], points[0][1]) - if (chartStyle === "bezier") { + if (chartStyle === 'bezier') { for (let i = 1; i < points.length; i++) { - const current = points[i]; - const next = points[i + 1] || current; - const controlX = (current[0] + next[0]) / 2; - const controlY = (current[1] + next[1]) / 2; - ctx.quadraticCurveTo(current[0], current[1], controlX, controlY); + const current = points[i] + const next = points[i + 1] || current + const controlX = (current[0] + next[0]) / 2 + const controlY = (current[1] + next[1]) / 2 + ctx.quadraticCurveTo(current[0], current[1], controlX, controlY) } } else { for (let i = 1; i < points.length; i++) { - ctx.lineTo(points[i][0], points[i][1]); + ctx.lineTo(points[i][0], points[i][1]) } } - ctx.lineTo(points[points.length - 1][0], height - padding.bottom); - ctx.lineTo(points[0][0], height - padding.bottom); - ctx.closePath(); - ctx.fillStyle = gradient; - ctx.fill(); + ctx.lineTo(points[points.length - 1][0], height - padding.bottom) + ctx.lineTo(points[0][0], height - padding.bottom) + ctx.closePath() + ctx.fillStyle = gradient + ctx.fill() } // 绘制主线条 - ctx.beginPath(); - ctx.strokeStyle = color; - ctx.lineWidth = GRAPH_CONFIG.lineWidth.up; - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - ctx.globalAlpha = GRAPH_CONFIG.alpha.line; + ctx.beginPath() + ctx.strokeStyle = color + ctx.lineWidth = GRAPH_CONFIG.lineWidth.up + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + ctx.globalAlpha = GRAPH_CONFIG.alpha.line - ctx.moveTo(points[0][0], points[0][1]); + ctx.moveTo(points[0][0], points[0][1]) - if (chartStyle === "bezier") { + if (chartStyle === 'bezier') { for (let i = 1; i < points.length; i++) { - const current = points[i]; - const next = points[i + 1] || current; - const controlX = (current[0] + next[0]) / 2; - const controlY = (current[1] + next[1]) / 2; - ctx.quadraticCurveTo(current[0], current[1], controlX, controlY); + const current = points[i] + const next = points[i + 1] || current + const controlX = (current[0] + next[0]) / 2 + const controlY = (current[1] + next[1]) / 2 + ctx.quadraticCurveTo(current[0], current[1], controlX, controlY) } } else { for (let i = 1; i < points.length; i++) { - ctx.lineTo(points[i][0], points[i][1]); + ctx.lineTo(points[i][0], points[i][1]) } } - ctx.stroke(); - ctx.restore(); + ctx.stroke() + ctx.restore() }, [calculateY, chartStyle], - ); + ) // 主绘制函数 const drawGraph = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas || displayData.length === 0) return; + const canvas = canvasRef.current + if (!canvas || displayData.length === 0) return - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const ctx = canvas.getContext('2d') + if (!ctx) return // Compute CSS size and pixel buffer size. // Note: WebView2 on Windows may return fractional CSS sizes after maximize. // We round pixel buffer to integers to avoid 1px gaps/cropping artifacts. - const rect = canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - const cssWidth = rect.width; - const cssHeight = rect.height; - const pixelWidth = Math.max(1, Math.floor(cssWidth * dpr)); - const pixelHeight = Math.max(1, Math.floor(cssHeight * dpr)); + const rect = canvas.getBoundingClientRect() + const dpr = window.devicePixelRatio || 1 + const cssWidth = rect.width + const cssHeight = rect.height + const pixelWidth = Math.max(1, Math.floor(cssWidth * dpr)) + const pixelHeight = Math.max(1, Math.floor(cssHeight * dpr)) // Keep CSS-driven sizing so the canvas stretches with its container (e.g., on maximize). - if (canvas.style.width !== "100%") { - canvas.style.width = "100%"; + if (canvas.style.width !== '100%') { + canvas.style.width = '100%' } - if (canvas.style.height !== "100%") { - canvas.style.height = "100%"; + if (canvas.style.height !== '100%') { + canvas.style.height = '100%' } if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { - canvas.width = pixelWidth; - canvas.height = pixelHeight; + canvas.width = pixelWidth + canvas.height = pixelHeight // Reset transform before scaling to avoid cumulative scaling offsets. - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.scale(dpr, dpr); // map CSS units to device pixels + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.scale(dpr, dpr) // map CSS units to device pixels } // Clear using CSS dimensions; context is already scaled by DPR. - ctx.clearRect(0, 0, cssWidth, cssHeight); + ctx.clearRect(0, 0, cssWidth, cssHeight) // 绘制Y轴刻度线(背景层) - drawYAxis(ctx, cssWidth, cssHeight, displayData); + drawYAxis(ctx, cssWidth, cssHeight, displayData) // 绘制网格 - drawGrid(ctx, cssWidth, cssHeight); + drawGrid(ctx, cssWidth, cssHeight) // 绘制时间轴 - drawTimeAxis(ctx, cssWidth, cssHeight, displayData); + drawTimeAxis(ctx, cssWidth, cssHeight, displayData) // 提取流量数据 - const upValues = displayData.map((d) => d.up); - const downValues = displayData.map((d) => d.down); + const upValues = displayData.map((d) => d.up) + const downValues = displayData.map((d) => d.down) // 绘制下载线(背景层) drawTrafficLine( @@ -854,7 +845,7 @@ export const EnhancedCanvasTrafficGraph = memo( colors.down, true, displayData, - ); + ) // 绘制上传线(前景层) drawTrafficLine( @@ -865,38 +856,38 @@ export const EnhancedCanvasTrafficGraph = memo( colors.up, true, displayData, - ); + ) // 绘制悬浮高亮线 if (tooltipData.visible && tooltipData.dataIndex >= 0) { - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = cssWidth - padding.left - padding.right; + const padding = GRAPH_CONFIG.padding + const effectiveWidth = cssWidth - padding.left - padding.right const dataX = padding.left + - (tooltipData.dataIndex / (displayData.length - 1)) * effectiveWidth; + (tooltipData.dataIndex / (displayData.length - 1)) * effectiveWidth - ctx.save(); - ctx.strokeStyle = colors.text; - ctx.lineWidth = 1; - ctx.globalAlpha = 0.6; - ctx.setLineDash([4, 4]); // 虚线效果 + ctx.save() + ctx.strokeStyle = colors.text + ctx.lineWidth = 1 + ctx.globalAlpha = 0.6 + ctx.setLineDash([4, 4]) // 虚线效果 // 绘制垂直指示线 - ctx.beginPath(); - ctx.moveTo(dataX, padding.top); - ctx.lineTo(dataX, cssHeight - padding.bottom); - ctx.stroke(); + ctx.beginPath() + ctx.moveTo(dataX, padding.top) + ctx.lineTo(dataX, cssHeight - padding.bottom) + ctx.stroke() // 绘制水平指示线(高亮Y轴位置) - ctx.beginPath(); - ctx.moveTo(padding.left, tooltipData.highlightY); - ctx.lineTo(cssWidth - padding.right, tooltipData.highlightY); - ctx.stroke(); + ctx.beginPath() + ctx.moveTo(padding.left, tooltipData.highlightY) + ctx.lineTo(cssWidth - padding.right, tooltipData.highlightY) + ctx.stroke() - ctx.restore(); + ctx.restore() } - isInitializedRef.current = true; + isInitializedRef.current = true }, [ displayData, colors, @@ -905,40 +896,40 @@ export const EnhancedCanvasTrafficGraph = memo( drawTimeAxis, drawTrafficLine, tooltipData, - ]); + ]) const collectFrameSample = useCallback( (renderDuration: number, frameBudget: number) => { - const controller = fpsControllerRef.current; - controller.samples.push(renderDuration); + const controller = fpsControllerRef.current + controller.samples.push(renderDuration) if (controller.samples.length > FPS_SAMPLE_WINDOW) { - controller.samples.shift(); + controller.samples.shift() } - const perfNow = getNow(); + const perfNow = getNow() const lastDataAge = lastDataTimestampRef.current > 0 ? Date.now() - lastDataTimestampRef.current - : null; + : null const isDataStale = - typeof lastDataAge === "number" && lastDataAge > STALE_DATA_THRESHOLD; + typeof lastDataAge === 'number' && lastDataAge > STALE_DATA_THRESHOLD - let inResumeCooldown = false; + let inResumeCooldown = false if (resumeCooldownRef.current) { - const elapsedSinceResume = Date.now() - resumeCooldownRef.current; + const elapsedSinceResume = Date.now() - resumeCooldownRef.current if (elapsedSinceResume < RESUME_COOLDOWN_MS) { - inResumeCooldown = true; + inResumeCooldown = true } else { - resumeCooldownRef.current = 0; + resumeCooldownRef.current = 0 } } if (isDataStale && controller.target !== MIN_FPS) { - controller.target = MIN_FPS; - controller.samples = []; - controller.lastAdjustTime = perfNow; - setCurrentFPS(controller.target); - return; + controller.target = MIN_FPS + controller.samples = [] + controller.lastAdjustTime = perfNow + setCurrentFPS(controller.target) + return } if ( @@ -949,47 +940,47 @@ export const EnhancedCanvasTrafficGraph = memo( controller.target = Math.min( GRAPH_CONFIG.targetFPS, controller.target + 2, - ); - controller.samples = []; - controller.lastAdjustTime = perfNow; - setCurrentFPS(controller.target); + ) + controller.samples = [] + controller.lastAdjustTime = perfNow + setCurrentFPS(controller.target) } if ( controller.lastAdjustTime !== 0 && perfNow - controller.lastAdjustTime < FPS_ADJUST_INTERVAL ) { - return; + return } - if (controller.samples.length === 0) return; + if (controller.samples.length === 0) return const avgRender = controller.samples.reduce((sum, value) => sum + value, 0) / - controller.samples.length; + controller.samples.length - let nextTarget = controller.target; + let nextTarget = controller.target if (avgRender > frameBudget * 0.75 && controller.target > MIN_FPS) { - nextTarget = Math.max(MIN_FPS, controller.target - 2); + nextTarget = Math.max(MIN_FPS, controller.target - 2) } else if ( avgRender < Math.max(4, frameBudget * 0.4) && controller.target < MAX_FPS && !inResumeCooldown ) { - nextTarget = Math.min(MAX_FPS, controller.target + 2); + nextTarget = Math.min(MAX_FPS, controller.target + 2) } - controller.samples = []; - controller.lastAdjustTime = perfNow; + controller.samples = [] + controller.lastAdjustTime = perfNow if (nextTarget !== controller.target) { - controller.target = nextTarget; - setCurrentFPS(nextTarget); + controller.target = nextTarget + setCurrentFPS(nextTarget) } }, [setCurrentFPS], - ); + ) // 受控的动画循环 useEffect(() => { @@ -999,84 +990,84 @@ export const EnhancedCanvasTrafficGraph = memo( dataStaleRef.current ) { if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = undefined; + cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = undefined } - lastRenderTimeRef.current = getNow(); - return; + lastRenderTimeRef.current = getNow() + return } const animate = (currentTime: number) => { if (!isWindowFocusedRef.current) { - lastRenderTimeRef.current = getNow(); - animationFrameRef.current = undefined; - return; + lastRenderTimeRef.current = getNow() + animationFrameRef.current = undefined + return } const lastDataAge = lastDataTimestampRef.current > 0 ? Date.now() - lastDataTimestampRef.current - : null; - const targetFPS = fpsControllerRef.current.target; - const frameBudget = 1000 / targetFPS; + : null + const targetFPS = fpsControllerRef.current.target + const frameBudget = 1000 / targetFPS if ( - typeof lastDataAge === "number" && + typeof lastDataAge === 'number' && lastDataAge > STALE_DATA_THRESHOLD ) { if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = undefined; + cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = undefined } - dataStaleRef.current = true; - return; + dataStaleRef.current = true + return } if ( currentTime - lastRenderTimeRef.current >= frameBudget || !isInitializedRef.current ) { - const drawStart = getNow(); - drawGraph(); - const drawEnd = getNow(); + const drawStart = getNow() + drawGraph() + const drawEnd = getNow() - lastRenderTimeRef.current = currentTime; - collectFrameSample(drawEnd - drawStart, frameBudget); + lastRenderTimeRef.current = currentTime + collectFrameSample(drawEnd - drawStart, frameBudget) } - animationFrameRef.current = requestAnimationFrame(animate); - }; + animationFrameRef.current = requestAnimationFrame(animate) + } - animationFrameRef.current = requestAnimationFrame(animate); + animationFrameRef.current = requestAnimationFrame(animate) return () => { if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = undefined; + cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = undefined } - }; - }, [drawGraph, displayData.length, isWindowFocused, collectFrameSample]); + } + }, [drawGraph, displayData.length, isWindowFocused, collectFrameSample]) // 切换时间范围 const handleTimeRangeClick = useCallback((event: React.MouseEvent) => { - event.stopPropagation(); + event.stopPropagation() setTimeRange((prev) => { - return prev === 1 ? 5 : prev === 5 ? 10 : 1; - }); - }, []); + return prev === 1 ? 5 : prev === 5 ? 10 : 1 + }) + }, []) // 切换图表样式 const toggleStyle = useCallback(() => { - setChartStyle((prev) => (prev === "bezier" ? "line" : "bezier")); - }, []); + setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier')) + }, []) // 兼容性方法 const appendData = useCallback((data: ITrafficItem) => { debugLog( - "[EnhancedCanvasTrafficGraphV2] appendData called (using global data):", + '[EnhancedCanvasTrafficGraphV2] appendData called (using global data):', data, - ); - }, []); + ) + }, []) // 暴露方法给父组件 useImperativeHandle( @@ -1086,34 +1077,34 @@ export const EnhancedCanvasTrafficGraph = memo( toggleStyle, }), [appendData, toggleStyle], - ); + ) // 获取时间范围文本 const getTimeRangeText = useCallback(() => { - return t("home.components.traffic.patterns.minutes", { + return t('home.components.traffic.patterns.minutes', { time: timeRange, - }); - }, [timeRange, t]); + }) + }, [timeRange, t]) return ( {/* 时间范围按钮 */} @@ -1136,20 +1127,20 @@ export const EnhancedCanvasTrafficGraph = memo( component="div" onClick={handleTimeRangeClick} sx={{ - position: "absolute", + position: 'absolute', top: 6, left: 40, // 向右移动,避免与Y轴最大值标签重叠 - fontSize: "11px", - fontWeight: "bold", - color: "text.secondary", - cursor: "pointer", - pointerEvents: "all", + fontSize: '11px', + fontWeight: 'bold', + color: 'text.secondary', + cursor: 'pointer', + pointerEvents: 'all', px: 1, py: 0.5, borderRadius: 0.5, - bgcolor: "rgba(0,0,0,0.05)", - "&:hover": { - bgcolor: "rgba(0,0,0,0.1)", + bgcolor: 'rgba(0,0,0,0.05)', + '&:hover': { + bgcolor: 'rgba(0,0,0,0.1)', }, }} > @@ -1159,63 +1150,63 @@ export const EnhancedCanvasTrafficGraph = memo( {/* 图例 */} - {t("home.components.traffic.legends.upload")} + {t('home.components.traffic.legends.upload')} - {t("home.components.traffic.legends.download")} + {t('home.components.traffic.legends.download')} {/* 样式指示器 */} - {chartStyle === "bezier" ? "Smooth" : "Linear"} + {chartStyle === 'bezier' ? 'Smooth' : 'Linear'} {/* 数据统计指示器(左下角) */} - Points: {displayData.length} | Compressed:{" "} + Points: {displayData.length} | Compressed:{' '} {samplerStats.compressedBufferSize} | FPS: {currentFPS} @@ -1223,25 +1214,25 @@ export const EnhancedCanvasTrafficGraph = memo( {tooltipData.visible && ( 200 ? "translateX(-100%)" : "translateX(0)", - boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - backdropFilter: "none", + tooltipData.x > 200 ? 'translateX(-100%)' : 'translateX(0)', + boxShadow: '0 4px 12px rgba(0,0,0,0.15)', + backdropFilter: 'none', opacity: 1, - whiteSpace: "nowrap", + whiteSpace: 'nowrap', }} > @@ -1257,8 +1248,8 @@ export const EnhancedCanvasTrafficGraph = memo( )} - ); + ) }, -); +) -EnhancedCanvasTrafficGraph.displayName = "EnhancedCanvasTrafficGraph"; +EnhancedCanvasTrafficGraph.displayName = 'EnhancedCanvasTrafficGraph' diff --git a/src/components/home/enhanced-card.tsx b/src/components/home/enhanced-card.tsx index 262846dd5..fbfd88b28 100644 --- a/src/components/home/enhanced-card.tsx +++ b/src/components/home/enhanced-card.tsx @@ -1,21 +1,15 @@ -import { Box, Typography, alpha, useTheme } from "@mui/material"; -import React, { forwardRef, ReactNode } from "react"; +import { Box, Typography, alpha, useTheme } from '@mui/material' +import React, { forwardRef, ReactNode } from 'react' // 自定义卡片组件接口 interface EnhancedCardProps { - title: ReactNode; - icon: ReactNode; - action?: ReactNode; - children: ReactNode; - iconColor?: - | "primary" - | "secondary" - | "error" - | "warning" - | "info" - | "success"; - minHeight?: number | string; - noContentPadding?: boolean; + title: ReactNode + icon: ReactNode + action?: ReactNode + children: ReactNode + iconColor?: 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success' + minHeight?: number | string + noContentPadding?: boolean } // 自定义卡片组件 @@ -26,33 +20,33 @@ export const EnhancedCard = forwardRef( icon, action, children, - iconColor = "primary", + iconColor = 'primary', minHeight, noContentPadding = false, }, ref, ) => { - const theme = useTheme(); - const isDark = theme.palette.mode === "dark"; + const theme = useTheme() + const isDark = theme.palette.mode === 'dark' // 统一的标题截断样式 const titleTruncateStyle = { minWidth: 0, - maxWidth: "100%", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - display: "block", - }; + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + display: 'block', + } return ( @@ -60,27 +54,27 @@ export const EnhancedCard = forwardRef( sx={{ px: 2, py: 1, - display: "flex", - alignItems: "center", - justifyContent: "space-between", + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', borderBottom: 1, - borderColor: "divider", + borderColor: 'divider', }} > ( {icon} - {typeof title === "string" ? ( + {typeof title === 'string' ? ( ( ( {children} - ); + ) }, -); +) diff --git a/src/components/home/enhanced-traffic-stats.tsx b/src/components/home/enhanced-traffic-stats.tsx index f4079b8fb..ff2db9a43 100644 --- a/src/components/home/enhanced-traffic-stats.tsx +++ b/src/components/home/enhanced-traffic-stats.tsx @@ -5,7 +5,7 @@ import { CloudUploadRounded, LinkRounded, MemoryRounded, -} from "@mui/icons-material"; +} from '@mui/icons-material' import { Grid, PaletteColor, @@ -13,74 +13,74 @@ import { Typography, alpha, useTheme, -} from "@mui/material"; -import { ReactNode, memo, useMemo, useRef } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { ReactNode, memo, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' -import { TrafficErrorBoundary } from "@/components/shared/traffic-error-boundary"; -import { useConnectionData } from "@/hooks/use-connection-data"; -import { useMemoryData } from "@/hooks/use-memory-data"; -import { useTrafficData } from "@/hooks/use-traffic-data"; -import { useVerge } from "@/hooks/use-verge"; -import { useVisibility } from "@/hooks/use-visibility"; -import parseTraffic from "@/utils/parse-traffic"; +import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary' +import { useConnectionData } from '@/hooks/use-connection-data' +import { useMemoryData } from '@/hooks/use-memory-data' +import { useTrafficData } from '@/hooks/use-traffic-data' +import { useVerge } from '@/hooks/use-verge' +import { useVisibility } from '@/hooks/use-visibility' +import parseTraffic from '@/utils/parse-traffic' import { EnhancedCanvasTrafficGraph, type EnhancedCanvasTrafficGraphRef, -} from "./enhanced-canvas-traffic-graph"; +} from './enhanced-canvas-traffic-graph' interface StatCardProps { - icon: ReactNode; - title: string; - value: string | number; - unit: string; - color: "primary" | "secondary" | "error" | "warning" | "info" | "success"; - onClick?: () => void; + icon: ReactNode + title: string + value: string | number + unit: string + color: 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success' + onClick?: () => void } // 全局变量类型定义 declare global { interface Window { - animationFrameId?: number; + animationFrameId?: number lastTrafficData?: { - up: number; - down: number; - }; + up: number + down: number + } } } // 统计卡片组件 - 使用memo优化 const CompactStatCard = memo( ({ icon, title, value, unit, color, onClick }: StatCardProps) => { - const theme = useTheme(); + const theme = useTheme() // 获取调色板颜色 - 使用useMemo避免重复计算 const colorValue = useMemo(() => { - const palette = theme.palette; + const palette = theme.palette if ( color in palette && palette[color as keyof typeof palette] && - "main" in (palette[color as keyof typeof palette] as PaletteColor) + 'main' in (palette[color as keyof typeof palette] as PaletteColor) ) { - return (palette[color as keyof typeof palette] as PaletteColor).main; + return (palette[color as keyof typeof palette] as PaletteColor).main } - return palette.primary.main; - }, [theme.palette, color]); + return palette.primary.main + }, [theme.palette, color]) return ( - ); + ) }, -); +) // 添加显示名称 -CompactStatCard.displayName = "CompactStatCard"; +CompactStatCard.displayName = 'CompactStatCard' export const EnhancedTrafficStats = () => { - const { t } = useTranslation(); - const theme = useTheme(); - const { verge } = useVerge(); - const trafficRef = useRef(null); - const pageVisible = useVisibility(); + const { t } = useTranslation() + const theme = useTheme() + const { verge } = useVerge() + const trafficRef = useRef(null) + const pageVisible = useVisibility() // 是否显示流量图表 - const trafficGraph = verge?.traffic_graph ?? true; + const trafficGraph = verge?.traffic_graph ?? true const { response: { data: traffic }, - } = useTrafficData({ enabled: trafficGraph && pageVisible }); + } = useTrafficData({ enabled: trafficGraph && pageVisible }) const { response: { data: memory }, - } = useMemoryData(); + } = useMemoryData() const { response: { data: connections }, - } = useConnectionData(); + } = useConnectionData() // Canvas组件现在直接从全局Hook获取数据,无需手动添加数据点 // 使用useMemo计算解析后的流量数据 const parsedData = useMemo(() => { - const [up, upUnit] = parseTraffic(traffic?.up || 0); - const [down, downUnit] = parseTraffic(traffic?.down || 0); - const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0); + const [up, upUnit] = parseTraffic(traffic?.up || 0) + const [down, downUnit] = parseTraffic(traffic?.down || 0) + const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0) const [uploadTotal, uploadTotalUnit] = parseTraffic( connections?.uploadTotal, - ); + ) const [downloadTotal, downloadTotalUnit] = parseTraffic( connections?.downloadTotal, - ); + ) return { up, @@ -187,86 +187,86 @@ export const EnhancedTrafficStats = () => { downloadTotal, downloadTotalUnit, connectionsCount: connections?.activeConnections.length, - }; - }, [traffic, memory, connections]); + } + }, [traffic, memory, connections]) // 渲染流量图表 - 使用useMemo缓存渲染结果 const trafficGraphComponent = useMemo(() => { - if (!trafficGraph || !pageVisible) return null; + if (!trafficGraph || !pageVisible) return null return ( trafficRef.current?.toggleStyle()} > -
+
- ); - }, [trafficGraph, pageVisible, theme.palette.divider]); + ) + }, [trafficGraph, pageVisible, theme.palette.divider]) // 使用useMemo计算统计卡片配置 const statCards = useMemo( () => [ { icon: , - title: t("home.components.traffic.metrics.uploadSpeed"), + title: t('home.components.traffic.metrics.uploadSpeed'), value: parsedData.up, unit: `${parsedData.upUnit}/s`, - color: "secondary" as const, + color: 'secondary' as const, }, { icon: , - title: t("home.components.traffic.metrics.downloadSpeed"), + title: t('home.components.traffic.metrics.downloadSpeed'), value: parsedData.down, unit: `${parsedData.downUnit}/s`, - color: "primary" as const, + color: 'primary' as const, }, { icon: , - title: t("home.components.traffic.metrics.activeConnections"), + title: t('home.components.traffic.metrics.activeConnections'), value: parsedData.connectionsCount, - unit: "", - color: "success" as const, + unit: '', + color: 'success' as const, }, { icon: , - title: t("shared.labels.uploaded"), + title: t('shared.labels.uploaded'), value: parsedData.uploadTotal, unit: parsedData.uploadTotalUnit, - color: "secondary" as const, + color: 'secondary' as const, }, { icon: , - title: t("shared.labels.downloaded"), + title: t('shared.labels.downloaded'), value: parsedData.downloadTotal, unit: parsedData.downloadTotalUnit, - color: "primary" as const, + color: 'primary' as const, }, { icon: , - title: t("home.components.traffic.metrics.memoryUsage"), + title: t('home.components.traffic.metrics.memoryUsage'), value: parsedData.inuse, unit: parsedData.inuseUnit, - color: "error" as const, + color: 'error' as const, onClick: undefined, }, ], [t, parsedData], - ); + ) return ( { - console.error("[EnhancedTrafficStats] 组件错误:", error, errorInfo); + console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo) }} > @@ -284,5 +284,5 @@ export const EnhancedTrafficStats = () => { ))} - ); -}; + ) +} diff --git a/src/components/home/home-profile-card.tsx b/src/components/home/home-profile-card.tsx index a3ee76f7f..065699b19 100644 --- a/src/components/home/home-profile-card.tsx +++ b/src/components/home/home-profile-card.tsx @@ -6,7 +6,7 @@ import { SpeedOutlined, StorageOutlined, UpdateOutlined, -} from "@mui/icons-material"; +} from '@mui/icons-material' import { Box, Button, @@ -17,62 +17,62 @@ import { alpha, keyframes, useTheme, -} from "@mui/material"; -import { useLockFn } from "ahooks"; -import dayjs from "dayjs"; -import { useCallback, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; +} from '@mui/material' +import { useLockFn } from 'ahooks' +import dayjs from 'dayjs' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' -import { useAppData } from "@/providers/app-data-context"; -import { openWebUrl, updateProfile } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; -import parseTraffic from "@/utils/parse-traffic"; +import { useAppData } from '@/providers/app-data-context' +import { openWebUrl, updateProfile } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import parseTraffic from '@/utils/parse-traffic' -import { EnhancedCard } from "./enhanced-card"; +import { EnhancedCard } from './enhanced-card' // 定义旋转动画 const round = keyframes` from { transform: rotate(0deg); } to { transform: rotate(360deg); } -`; +` // 辅助函数解析URL和过期时间 const parseUrl = (url?: string) => { - if (!url) return "-"; - if (url.startsWith("http")) return new URL(url).host; - return "local"; -}; + if (!url) return '-' + if (url.startsWith('http')) return new URL(url).host + return 'local' +} const parseExpire = (expire?: number) => { - if (!expire) return "-"; - return dayjs(expire * 1000).format("YYYY-MM-DD"); -}; + if (!expire) return '-' + return dayjs(expire * 1000).format('YYYY-MM-DD') +} // 使用类型定义,而不是导入 interface ProfileExtra { - upload: number; - download: number; - total: number; - expire: number; + upload: number + download: number + total: number + expire: number } interface ProfileItem { - uid: string; - type?: "local" | "remote" | "merge" | "script"; - name?: string; - desc?: string; - file?: string; - url?: string; - updated?: number; - extra?: ProfileExtra; - home?: string; - option?: any; + uid: string + type?: 'local' | 'remote' | 'merge' | 'script' + name?: string + desc?: string + file?: string + url?: string + updated?: number + extra?: ProfileExtra + home?: string + option?: any } interface HomeProfileCardProps { - current: ProfileItem | null | undefined; - onProfileUpdated?: () => void; + current: ProfileItem | null | undefined + onProfileUpdated?: () => void } // 提取独立组件减少主组件复杂度 @@ -81,23 +81,23 @@ const ProfileDetails = ({ onUpdateProfile, updating, }: { - current: ProfileItem; - onUpdateProfile: () => void; - updating: boolean; + current: ProfileItem + onUpdateProfile: () => void + updating: boolean }) => { - const { t } = useTranslation(); - const theme = useTheme(); + const { t } = useTranslation() + const theme = useTheme() const usedTraffic = useMemo(() => { - if (!current.extra) return 0; - return current.extra.upload + current.extra.download; - }, [current.extra]); + if (!current.extra) return 0 + return current.extra.upload + current.extra.download + }, [current.extra]) const trafficPercentage = useMemo(() => { if (!current.extra || !current.extra.total || current.extra.total <= 0) - return 0; - return Math.min(Math.round((usedTraffic / current.extra.total) * 100), 100); - }, [current.extra, usedTraffic]); + return 0 + return Math.min(Math.round((usedTraffic / current.extra.total) * 100), 100) + }, [current.extra, usedTraffic]) return ( @@ -109,19 +109,19 @@ const ProfileDetails = ({ variant="body2" color="text.secondary" noWrap - sx={{ display: "flex", alignItems: "center" }} + sx={{ display: 'flex', alignItems: 'center' }} > - {t("shared.labels.from")}: + {t('shared.labels.from')}: {current.home ? ( current.home && openWebUrl(current.home)} sx={{ - display: "inline-flex", - alignItems: "center", + display: 'inline-flex', + alignItems: 'center', minWidth: 0, - maxWidth: "calc(100% - 40px)", + maxWidth: 'calc(100% - 40px)', ml: 0.5, }} title={parseUrl(current.url)} @@ -129,9 +129,9 @@ const ProfileDetails = ({ - {t("shared.labels.updateTime")}:{" "} + {t('shared.labels.updateTime')}:{' '} - {dayjs(current.updated * 1000).format("YYYY-MM-DD HH:mm")} + {dayjs(current.updated * 1000).format('YYYY-MM-DD HH:mm')} @@ -199,9 +199,9 @@ const ProfileDetails = ({ - {t("shared.labels.usedTotal")}:{" "} + {t('shared.labels.usedTotal')}:{' '} - {parseTraffic(usedTraffic)} /{" "} + {parseTraffic(usedTraffic)} /{' '} {parseTraffic(current.extra.total)} @@ -211,7 +211,7 @@ const ProfileDetails = ({ - {t("shared.labels.expireTime")}:{" "} + {t('shared.labels.expireTime')}:{' '} {parseExpire(current.extra.expire)} @@ -223,7 +223,7 @@ const ProfileDetails = ({ {trafficPercentage}% @@ -241,78 +241,78 @@ const ProfileDetails = ({ )} - ); -}; + ) +} // 提取空配置组件 const EmptyProfile = ({ onClick }: { onClick: () => void }) => { - const { t } = useTranslation(); + const { t } = useTranslation() return ( - {t("profiles.page.actions.import")} {t("profiles.page.title")} + {t('profiles.page.actions.import')} {t('profiles.page.title')} - {t("profiles.components.card.labels.clickToImport")} + {t('profiles.components.card.labels.clickToImport')} - ); -}; + ) +} export const HomeProfileCard = ({ current, onProfileUpdated, }: HomeProfileCardProps) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { refreshAll } = useAppData(); + const { t } = useTranslation() + const navigate = useNavigate() + const { refreshAll } = useAppData() // 更新当前订阅 - const [updating, setUpdating] = useState(false); + const [updating, setUpdating] = useState(false) const onUpdateProfile = useLockFn(async () => { - if (!current?.uid) return; + if (!current?.uid) return - setUpdating(true); + setUpdating(true) try { - await updateProfile(current.uid, current.option); - onProfileUpdated?.(); + await updateProfile(current.uid, current.option) + onProfileUpdated?.() // 刷新首页数据 - refreshAll(); + refreshAll() } catch (err) { - showNotice.error(err, 3000); + showNotice.error(err, 3000) } finally { - setUpdating(false); + setUpdating(false) } - }); + }) // 导航到订阅页面 const goToProfiles = useCallback(() => { - navigate("/profile"); - }, [navigate]); + navigate('/profile') + }, [navigate]) // 卡片标题 const cardTitle = useMemo(() => { - if (!current) return t("profiles.page.title"); + if (!current) return t('profiles.page.title') - if (!current.home) return current.name; + if (!current.home) return current.name return ( current.home && openWebUrl(current.home)} sx={{ - color: "inherit", - textDecoration: "none", - display: "flex", - alignItems: "center", + color: 'inherit', + textDecoration: 'none', + display: 'flex', + alignItems: 'center', minWidth: 0, - maxWidth: "100%", - "& > span": { - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", + maxWidth: '100%', + '& > span': { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', flex: 1, }, }} @@ -342,18 +342,18 @@ export const HomeProfileCard = ({ fontSize="inherit" sx={{ ml: 0.5, - fontSize: "0.8rem", + fontSize: '0.8rem', opacity: 0.7, flexShrink: 0, }} /> - ); - }, [current, t]); + ) + }, [current, t]) // 卡片操作按钮 const cardAction = useMemo(() => { - if (!current) return null; + if (!current) return null return ( - ); - }, [current, goToProfiles, t]); + ) + }, [current, goToProfiles, t]) return ( )} - ); -}; + ) +} diff --git a/src/components/home/ip-info-card.tsx b/src/components/home/ip-info-card.tsx index bc2c2ac63..a0baa767a 100644 --- a/src/components/home/ip-info-card.tsx +++ b/src/components/home/ip-info-card.tsx @@ -3,12 +3,12 @@ import { RefreshOutlined, VisibilityOffOutlined, VisibilityOutlined, -} from "@mui/icons-material"; -import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material"; -import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; -import { useEffect } from "foxact/use-abortable-effect"; -import { useIntersection } from "foxact/use-intersection"; -import type { XOR } from "foxts/ts-xor"; +} from '@mui/icons-material' +import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material' +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' +import { useEffect } from 'foxact/use-abortable-effect' +import { useIntersection } from 'foxact/use-intersection' +import type { XOR } from 'foxts/ts-xor' import { memo, useCallback, @@ -16,26 +16,26 @@ import { useEffectEvent, useMemo, forwardRef, -} from "react"; -import { useTranslation } from "react-i18next"; -import useSWRImmutable from "swr/immutable"; +} from 'react' +import { useTranslation } from 'react-i18next' +import useSWRImmutable from 'swr/immutable' -import { getIpInfo } from "@/services/api"; -import { SWR_EXTERNAL_API } from "@/services/config"; +import { getIpInfo } from '@/services/api' +import { SWR_EXTERNAL_API } from '@/services/config' -import { EnhancedCard } from "./enhanced-card"; +import { EnhancedCard } from './enhanced-card' // 定义刷新时间(秒) -const IP_REFRESH_SECONDS = 300; -const COUNTDOWN_TICK_INTERVAL = 5_000; -const IP_INFO_CACHE_KEY = "cv_ip_info_cache"; +const IP_REFRESH_SECONDS = 300 +const COUNTDOWN_TICK_INTERVAL = 5_000 +const IP_INFO_CACHE_KEY = 'cv_ip_info_cache' const InfoItem = memo(({ label, value }: { label: string; value?: string }) => ( - + {label}: @@ -43,46 +43,46 @@ const InfoItem = memo(({ label, value }: { label: string; value?: string }) => ( variant="body2" sx={{ ml: 0.5, - overflow: "hidden", - textOverflow: "ellipsis", - wordBreak: "break-word", - whiteSpace: "normal", + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', + whiteSpace: 'normal', flexGrow: 1, }} > - {value || "Unknown"} + {value || 'Unknown'} -)); +)) // 获取国旗表情 const getCountryFlag = (countryCode: string | undefined) => { - if (!countryCode) return ""; + if (!countryCode) return '' const codePoints = countryCode .toUpperCase() - .split("") - .map((char) => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); -}; + .split('') + .map((char) => 127397 + char.charCodeAt(0)) + return String.fromCodePoint(...codePoints) +} type CountDownState = XOR< { - type: "countdown"; - remainingSeconds: number; + type: 'countdown' + remainingSeconds: number }, { - type: "revalidating"; + type: 'revalidating' } ->; +> const IPInfoCardContainer = forwardRef( ({ children }, ref) => { - const { t } = useTranslation(); - const { mutate } = useIPInfo(); + const { t } = useTranslation() + const { mutate } = useIPInfo() return ( } iconColor="info" ref={ref} @@ -94,41 +94,41 @@ const IPInfoCardContainer = forwardRef( > {children} - ); + ) }, -); +) // IP信息卡片组件 export const IpInfoCard = () => { - const { t } = useTranslation(); - const [showIp, setShowIp] = useState(false); - const appWindow = useMemo(() => getCurrentWebviewWindow(), []); + const { t } = useTranslation() + const [showIp, setShowIp] = useState(false) + const appWindow = useMemo(() => getCurrentWebviewWindow(), []) // track ip info card has been in viewport or not // hasIntersected default to false, and will be true once the card is in viewport // and will never be false again afterwards (unless resetIntersected is called or // the component is unmounted) const [containerRef, hasIntersected, _resetIntersected] = useIntersection({ - rootMargin: "0px", - }); + rootMargin: '0px', + }) const [countdown, setCountdown] = useState({ - type: "countdown", + type: 'countdown', remainingSeconds: IP_REFRESH_SECONDS, - }); + }) - const { data: ipInfo, error, isLoading, mutate } = useIPInfo(); + const { data: ipInfo, error, isLoading, mutate } = useIPInfo() // function useEffectEvent const onCountdownTick = useEffectEvent(async () => { - const now = Date.now(); - const ts = ipInfo?.lastFetchTs; + const now = Date.now() + const ts = ipInfo?.lastFetchTs if (!ts) { - return; + return } - const elapsed = Math.floor((now - ts) / 1000); - const remaining = IP_REFRESH_SECONDS - elapsed; + const elapsed = Math.floor((now - ts) / 1000) + const remaining = IP_REFRESH_SECONDS - elapsed if (remaining <= 0) { if ( @@ -139,21 +139,21 @@ export const IpInfoCard = () => { // is online navigator.onLine && // there is no ongoing revalidation already scheduled - countdown.type !== "revalidating" && + countdown.type !== 'revalidating' && // window is visible (await appWindow.isVisible()) ) { - setCountdown({ type: "revalidating" }); + setCountdown({ type: 'revalidating' }) // we do not care about the result of mutate here. after mutate is done, // simply wait for next interval tick with `setCountdown({ type: "countdown", ... })` try { - await mutate(); + await mutate() } finally { // in case mutate throws error, we still need to reset the countdown state setCountdown({ - type: "countdown", + type: 'countdown', remainingSeconds: IP_REFRESH_SECONDS, - }); + }) } } else { // do nothing. we even skip "setCountdown" to reduce re-renders @@ -164,15 +164,15 @@ export const IpInfoCard = () => { } } else { setCountdown({ - type: "countdown", + type: 'countdown', remainingSeconds: remaining, - }); + }) } - }); + }) // Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired useEffect(() => { - let timer: number | null = null; + let timer: number | null = null // Do not add document.hidden check here as it is not reliable in Tauri. // @@ -180,17 +180,17 @@ export const IpInfoCard = () => { // instead of Tauri, which is reliable enough. if (hasIntersected) { console.debug( - "IP info card has entered the viewport, starting the countdown interval.", - ); - timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL); + 'IP info card has entered the viewport, starting the countdown interval.', + ) + timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL) } else { console.debug( - "IP info card has not yet entered the viewport, no counting down.", - ); + 'IP info card has not yet entered the viewport, no counting down.', + ) } // This will fire when the window is minimized or restored - document.addEventListener("visibilitychange", onVisibilityChange); + document.addEventListener('visibilitychange', onVisibilityChange) // Tauri's visibility change detection is actually broken on some platforms: // https://github.com/tauri-apps/tauri/issues/10592 // @@ -199,100 +199,100 @@ export const IpInfoCard = () => { // reduce power consumption. function onVisibilityChange() { if (document.hidden) { - console.debug("Document hidden, pause the interval"); + console.debug('Document hidden, pause the interval') // Pause the timer if (timer != null) { - clearInterval(timer); - timer = null; + clearInterval(timer) + timer = null } } else if (hasIntersected) { - console.debug("Document visible, resume the interval"); + console.debug('Document visible, resume the interval') // Resume the timer only when previous one is cleared if (timer == null) { - timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL); + timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL) } } else { console.debug( - "Document visible, but IP info card has never entered the viewport, not even once, not starting the interval.", - ); + 'Document visible, but IP info card has never entered the viewport, not even once, not starting the interval.', + ) } } return () => { - if (timer != null) clearInterval(timer); - document.removeEventListener("visibilitychange", onVisibilityChange); - }; - }, [hasIntersected]); + if (timer != null) clearInterval(timer) + document.removeEventListener('visibilitychange', onVisibilityChange) + } + }, [hasIntersected]) const toggleShowIp = useCallback(() => { - setShowIp((prev) => !prev); - }, []); + setShowIp((prev) => !prev) + }, []) - let mainElement: React.ReactElement; + let mainElement: React.ReactElement switch (true) { case isLoading: mainElement = ( - + - ); - break; + ) + break case !!error: mainElement = ( {error instanceof Error ? error.message - : t("home.components.ipInfo.errors.load")} + : t('home.components.ipInfo.errors.load')} - ); - break; + ) + break default: // Normal render mainElement = ( - + {/* 左侧:国家和IP地址 */} - + { {ipInfo?.country || - t("home.components.ipInfo.labels.unknown")} + t('home.components.ipInfo.labels.unknown')} - + - {t("home.components.ipInfo.labels.ip")}: + {t('home.components.ipInfo.labels.ip')}: - {showIp ? ipInfo?.ip : "••••••••••"} + {showIp ? ipInfo?.ip : '••••••••••'} {showIp ? ( @@ -354,29 +354,29 @@ export const IpInfoCard = () => { {/* 右侧:组织、ISP和位置信息 */} - + @@ -384,43 +384,43 @@ export const IpInfoCard = () => { - {t("home.components.ipInfo.labels.autoRefresh")} - {countdown.type === "countdown" + {t('home.components.ipInfo.labels.autoRefresh')} + {countdown.type === 'countdown' ? `: ${countdown.remainingSeconds}s` - : "..."} + : '...'} - {`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`} + {`${ipInfo?.country_code ?? 'N/A'}, ${ipInfo?.longitude?.toFixed(2) ?? 'N/A'}, ${ipInfo?.latitude?.toFixed(2) ?? 'N/A'}`} - ); + ) } return ( {mainElement} - ); -}; + ) +} function useIPInfo() { - return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, SWR_EXTERNAL_API); + return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, SWR_EXTERNAL_API) } diff --git a/src/components/home/proxy-tun-card.tsx b/src/components/home/proxy-tun-card.tsx index de597a953..c4be87a81 100644 --- a/src/components/home/proxy-tun-card.tsx +++ b/src/components/home/proxy-tun-card.tsx @@ -3,7 +3,7 @@ import { TroubleshootRounded, HelpOutlineRounded, SvgIconComponent, -} from "@mui/icons-material"; +} from '@mui/icons-material' import { Box, Typography, @@ -13,24 +13,24 @@ import { alpha, useTheme, Fade, -} from "@mui/material"; -import { useState, useMemo, memo, FC } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { useState, useMemo, memo, FC } from 'react' +import { useTranslation } from 'react-i18next' -import ProxyControlSwitches from "@/components/shared/proxy-control-switches"; -import { useSystemProxyState } from "@/hooks/use-system-proxy-state"; -import { useSystemState } from "@/hooks/use-system-state"; -import { useVerge } from "@/hooks/use-verge"; -import { showNotice } from "@/services/notice-service"; +import ProxyControlSwitches from '@/components/shared/proxy-control-switches' +import { useSystemProxyState } from '@/hooks/use-system-proxy-state' +import { useSystemState } from '@/hooks/use-system-state' +import { useVerge } from '@/hooks/use-verge' +import { showNotice } from '@/services/notice-service' -const LOCAL_STORAGE_TAB_KEY = "clash-verge-proxy-active-tab"; +const LOCAL_STORAGE_TAB_KEY = 'clash-verge-proxy-active-tab' interface TabButtonProps { - isActive: boolean; - onClick: () => void; - icon: SvgIconComponent; - label: string; - hasIndicator?: boolean; + isActive: boolean + onClick: () => void + icon: SvgIconComponent + label: string + hasIndicator?: boolean } // Tab组件 @@ -40,34 +40,34 @@ const TabButton: FC = memo( elevation={isActive ? 2 : 0} onClick={onClick} sx={{ - cursor: "pointer", + cursor: 'pointer', px: 2, py: 1, - display: "flex", - alignItems: "center", - justifyContent: "center", + display: 'flex', + alignItems: 'center', + justifyContent: 'center', gap: 1, - bgcolor: isActive ? "primary.main" : "background.paper", - color: isActive ? "primary.contrastText" : "text.primary", + bgcolor: isActive ? 'primary.main' : 'background.paper', + color: isActive ? 'primary.contrastText' : 'text.primary', borderRadius: 1.5, flex: 1, maxWidth: 160, - transition: "all 0.2s ease-in-out", - position: "relative", - "&:hover": { - transform: "translateY(-1px)", + transition: 'all 0.2s ease-in-out', + position: 'relative', + '&:hover': { + transform: 'translateY(-1px)', boxShadow: 1, }, - "&:after": isActive + '&:after': isActive ? { content: '""', - position: "absolute", + position: 'absolute', bottom: -9, - left: "50%", + left: '50%', width: 2, height: 9, - bgcolor: "primary.main", - transform: "translateX(-50%)", + bgcolor: 'primary.main', + transform: 'translateX(-50%)', } : {}, }} @@ -81,9 +81,9 @@ const TabButton: FC = memo( sx={{ width: 8, height: 8, - borderRadius: "50%", - bgcolor: isActive ? "#fff" : "success.main", - position: "absolute", + borderRadius: '50%', + bgcolor: isActive ? '#fff' : 'success.main', + position: 'absolute', top: 8, right: 8, }} @@ -91,11 +91,11 @@ const TabButton: FC = memo( )} ), -); +) interface TabDescriptionProps { - description: string; - tooltipTitle: string; + description: string + tooltipTitle: string } // 描述文本组件 @@ -106,21 +106,21 @@ const TabDescription: FC = memo( variant="caption" component="div" sx={{ - width: "95%", - textAlign: "center", - color: "text.secondary", + width: '95%', + textAlign: 'center', + color: 'text.secondary', p: 0.8, borderRadius: 1, - borderColor: "primary.main", + borderColor: 'primary.main', borderWidth: 1, - borderStyle: "solid", - backgroundColor: "background.paper", - display: "flex", - alignItems: "center", - justifyContent: "center", + borderStyle: 'solid', + backgroundColor: 'background.paper', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', gap: 0.5, - wordBreak: "break-word", - hyphens: "auto", + wordBreak: 'break-word', + hyphens: 'auto', }} > {description} @@ -132,47 +132,47 @@ const TabDescription: FC = memo( ), -); +) export const ProxyTunCard: FC = () => { - const { t } = useTranslation(); - const theme = useTheme(); + const { t } = useTranslation() + const theme = useTheme() const [activeTab, setActiveTab] = useState( - () => localStorage.getItem(LOCAL_STORAGE_TAB_KEY) || "system", - ); + () => localStorage.getItem(LOCAL_STORAGE_TAB_KEY) || 'system', + ) - const { verge } = useVerge(); - const { isTunModeAvailable } = useSystemState(); - const { configState: systemProxyConfigState } = useSystemProxyState(); + const { verge } = useVerge() + const { isTunModeAvailable } = useSystemState() + const { configState: systemProxyConfigState } = useSystemProxyState() - const { enable_tun_mode } = verge ?? {}; + const { enable_tun_mode } = verge ?? {} const handleError = (err: unknown) => { - showNotice.error(err); - }; + showNotice.error(err) + } const handleTabChange = (tab: string) => { - setActiveTab(tab); - localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab); - }; + setActiveTab(tab) + localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab) + } const tabDescription = useMemo(() => { - if (activeTab === "system") { + if (activeTab === 'system') { return { text: systemProxyConfigState - ? t("home.components.proxyTun.status.systemProxyEnabled") - : t("home.components.proxyTun.status.systemProxyDisabled"), - tooltip: t("home.components.proxyTun.tooltips.systemProxy"), - }; + ? t('home.components.proxyTun.status.systemProxyEnabled') + : t('home.components.proxyTun.status.systemProxyDisabled'), + tooltip: t('home.components.proxyTun.tooltips.systemProxy'), + } } else { return { text: !isTunModeAvailable - ? t("home.components.proxyTun.status.tunModeServiceRequired") + ? t('home.components.proxyTun.status.tunModeServiceRequired') : enable_tun_mode - ? t("home.components.proxyTun.status.tunModeEnabled") - : t("home.components.proxyTun.status.tunModeDisabled"), - tooltip: t("home.components.proxyTun.tooltips.tunMode"), - }; + ? t('home.components.proxyTun.status.tunModeEnabled') + : t('home.components.proxyTun.status.tunModeDisabled'), + tooltip: t('home.components.proxyTun.tooltips.tunMode'), + } } }, [ activeTab, @@ -180,44 +180,44 @@ export const ProxyTunCard: FC = () => { enable_tun_mode, isTunModeAvailable, t, - ]); + ]) return ( - + handleTabChange("system")} + isActive={activeTab === 'system'} + onClick={() => handleTabChange('system')} icon={ComputerRounded} - label={t("settings.sections.system.toggles.systemProxy")} + label={t('settings.sections.system.toggles.systemProxy')} hasIndicator={systemProxyConfigState} /> handleTabChange("tun")} + isActive={activeTab === 'tun'} + onClick={() => handleTabChange('tun')} icon={TroubleshootRounded} - label={t("settings.sections.system.toggles.tunMode")} + label={t('settings.sections.system.toggles.tunMode')} hasIndicator={enable_tun_mode && isTunModeAvailable} /> { - ); -}; + ) +} diff --git a/src/components/home/system-info-card.tsx b/src/components/home/system-info-card.tsx index 11fcdf913..bca595b0b 100644 --- a/src/components/home/system-info-card.tsx +++ b/src/components/home/system-info-card.tsx @@ -4,195 +4,195 @@ import { AdminPanelSettingsOutlined, DnsOutlined, ExtensionOutlined, -} from "@mui/icons-material"; -import { Typography, Stack, Divider, Chip, IconButton } from "@mui/material"; -import { useLockFn } from "ahooks"; -import { useCallback, useEffect, useMemo, useReducer } from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; +} from '@mui/icons-material' +import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material' +import { useLockFn } from 'ahooks' +import { useCallback, useEffect, useMemo, useReducer } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' -import { useServiceInstaller } from "@/hooks/use-service-installer"; -import { useSystemState } from "@/hooks/use-system-state"; -import { useUpdate } from "@/hooks/use-update"; -import { useVerge } from "@/hooks/use-verge"; -import { getSystemInfo } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; -import { version as appVersion } from "@root/package.json"; +import { useServiceInstaller } from '@/hooks/use-service-installer' +import { useSystemState } from '@/hooks/use-system-state' +import { useUpdate } from '@/hooks/use-update' +import { useVerge } from '@/hooks/use-verge' +import { getSystemInfo } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import { version as appVersion } from '@root/package.json' -import { EnhancedCard } from "./enhanced-card"; +import { EnhancedCard } from './enhanced-card' interface SystemState { - osInfo: string; - lastCheckUpdate: string; + osInfo: string + lastCheckUpdate: string } type SystemStateAction = - | { type: "set-os-info"; payload: string } - | { type: "set-last-check-update"; payload: string }; + | { type: 'set-os-info'; payload: string } + | { type: 'set-last-check-update'; payload: string } const systemStateReducer = ( state: SystemState, action: SystemStateAction, ): SystemState => { switch (action.type) { - case "set-os-info": - return { ...state, osInfo: action.payload }; - case "set-last-check-update": - return { ...state, lastCheckUpdate: action.payload }; + case 'set-os-info': + return { ...state, osInfo: action.payload } + case 'set-last-check-update': + return { ...state, lastCheckUpdate: action.payload } default: - return state; + return state } -}; +} export const SystemInfoCard = () => { - const { t } = useTranslation(); - const { verge, patchVerge } = useVerge(); - const navigate = useNavigate(); - const { isAdminMode, isSidecarMode } = useSystemState(); - const { installServiceAndRestartCore } = useServiceInstaller(); + const { t } = useTranslation() + const { verge, patchVerge } = useVerge() + const navigate = useNavigate() + const { isAdminMode, isSidecarMode } = useSystemState() + const { installServiceAndRestartCore } = useServiceInstaller() // 自动检查更新逻辑 const { checkUpdate: triggerCheckUpdate } = useUpdate(true, { onSuccess: () => { - const now = Date.now(); - localStorage.setItem("last_check_update", now.toString()); + const now = Date.now() + localStorage.setItem('last_check_update', now.toString()) dispatchSystemState({ - type: "set-last-check-update", + type: 'set-last-check-update', payload: new Date(now).toLocaleString(), - }); + }) }, - }); + }) // 系统信息状态 const [systemState, dispatchSystemState] = useReducer(systemStateReducer, { - osInfo: "", - lastCheckUpdate: "-", - }); + osInfo: '', + lastCheckUpdate: '-', + }) // 初始化系统信息 useEffect(() => { - let timeoutId: number | undefined; + let timeoutId: number | undefined getSystemInfo() .then((info) => { - const lines = info.split("\n"); + const lines = info.split('\n') if (lines.length > 0) { - const sysName = lines[0].split(": ")[1] || ""; - let sysVersion = lines[1].split(": ")[1] || ""; + const sysName = lines[0].split(': ')[1] || '' + let sysVersion = lines[1].split(': ')[1] || '' if ( sysName && sysVersion.toLowerCase().startsWith(sysName.toLowerCase()) ) { - sysVersion = sysVersion.substring(sysName.length).trim(); + sysVersion = sysVersion.substring(sysName.length).trim() } dispatchSystemState({ - type: "set-os-info", + type: 'set-os-info', payload: `${sysName} ${sysVersion}`, - }); + }) } }) - .catch(console.error); + .catch(console.error) // 获取最后检查更新时间 - const lastCheck = localStorage.getItem("last_check_update"); + const lastCheck = localStorage.getItem('last_check_update') if (lastCheck) { try { - const timestamp = parseInt(lastCheck, 10); + const timestamp = parseInt(lastCheck, 10) if (!isNaN(timestamp)) { dispatchSystemState({ - type: "set-last-check-update", + type: 'set-last-check-update', payload: new Date(timestamp).toLocaleString(), - }); + }) } } catch (e) { - console.error("Error parsing last check update time", e); + console.error('Error parsing last check update time', e) } } else if (verge?.auto_check_update) { // 如果启用了自动检查更新但没有记录,设置当前时间并延迟检查 - const now = Date.now(); - localStorage.setItem("last_check_update", now.toString()); + const now = Date.now() + localStorage.setItem('last_check_update', now.toString()) dispatchSystemState({ - type: "set-last-check-update", + type: 'set-last-check-update', payload: new Date(now).toLocaleString(), - }); + }) timeoutId = window.setTimeout(() => { if (verge?.auto_check_update) { - triggerCheckUpdate().catch(console.error); + triggerCheckUpdate().catch(console.error) } - }, 5000); + }, 5000) } return () => { if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); + window.clearTimeout(timeoutId) } - }; - }, [verge?.auto_check_update, dispatchSystemState, triggerCheckUpdate]); + } + }, [verge?.auto_check_update, dispatchSystemState, triggerCheckUpdate]) // 导航到设置页面 const goToSettings = useCallback(() => { - navigate("/settings"); - }, [navigate]); + navigate('/settings') + }, [navigate]) // 切换自启动状态 const toggleAutoLaunch = useCallback(async () => { - if (!verge) return; + if (!verge) return try { - await patchVerge({ enable_auto_launch: !verge.enable_auto_launch }); + await patchVerge({ enable_auto_launch: !verge.enable_auto_launch }) } catch (err) { - console.error("切换开机自启动状态失败:", err); + console.error('切换开机自启动状态失败:', err) } - }, [verge, patchVerge]); + }, [verge, patchVerge]) // 点击运行模式处理,Sidecar或纯管理员模式允许安装服务 const handleRunningModeClick = useCallback(() => { if (isSidecarMode || (isAdminMode && isSidecarMode)) { - installServiceAndRestartCore(); + installServiceAndRestartCore() } - }, [isSidecarMode, isAdminMode, installServiceAndRestartCore]); + }, [isSidecarMode, isAdminMode, installServiceAndRestartCore]) // 检查更新 const onCheckUpdate = useLockFn(async () => { try { - const info = await triggerCheckUpdate(); + const info = await triggerCheckUpdate() if (!info?.available) { showNotice.success( - "settings.components.verge.advanced.notifications.latestVersion", - ); + 'settings.components.verge.advanced.notifications.latestVersion', + ) } else { - showNotice.info("shared.feedback.notifications.updateAvailable", 2000); - goToSettings(); + showNotice.info('shared.feedback.notifications.updateAvailable', 2000) + goToSettings() } } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) // 是否启用自启动 const autoLaunchEnabled = useMemo( () => verge?.enable_auto_launch || false, [verge], - ); + ) // 运行模式样式 const runningModeStyle = useMemo( () => ({ // Sidecar或纯管理员模式允许安装服务 cursor: - isSidecarMode || (isAdminMode && isSidecarMode) ? "pointer" : "default", + isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default', textDecoration: - isSidecarMode || (isAdminMode && isSidecarMode) ? "underline" : "none", - display: "flex", - alignItems: "center", + isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none', + display: 'flex', + alignItems: 'center', gap: 0.5, - "&:hover": { + '&:hover': { opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1, }, }), [isSidecarMode, isAdminMode], - ); + ) // 获取模式图标和文本 const getModeIcon = () => { @@ -202,67 +202,67 @@ export const SystemInfoCard = () => { return ( <> - ); + ) } return ( - ); + ) } else if (isSidecarMode) { return ( - ); + ) } else { return ( - ); + ) } - }; + } // 获取模式文本 const getModeText = () => { if (isAdminMode) { // 判断是否同时处于服务模式 if (!isSidecarMode) { - return t("home.components.systemInfo.badges.adminServiceMode"); + return t('home.components.systemInfo.badges.adminServiceMode') } - return t("home.components.systemInfo.badges.adminMode"); + return t('home.components.systemInfo.badges.adminMode') } else if (isSidecarMode) { - return t("home.components.systemInfo.badges.sidecarMode"); + return t('home.components.systemInfo.badges.sidecarMode') } else { - return t("home.components.systemInfo.badges.serviceMode"); + return t('home.components.systemInfo.badges.serviceMode') } - }; + } // 只有当verge存在时才渲染内容 - if (!verge) return null; + if (!verge) return null return ( } iconColor="error" action={ @@ -271,7 +271,7 @@ export const SystemInfoCard = () => { - {t("home.components.systemInfo.fields.osInfo")} + {t('home.components.systemInfo.fields.osInfo')} {systemState.osInfo} @@ -284,20 +284,20 @@ export const SystemInfoCard = () => { alignItems="center" > - {t("home.components.systemInfo.fields.autoLaunch")} + {t('home.components.systemInfo.fields.autoLaunch')} @@ -308,7 +308,7 @@ export const SystemInfoCard = () => { alignItems="center" > - {t("home.components.systemInfo.fields.runningMode")} + {t('home.components.systemInfo.fields.runningMode')} { - {t("home.components.systemInfo.fields.lastCheckUpdate")} + {t('home.components.systemInfo.fields.lastCheckUpdate')} {systemState.lastCheckUpdate} @@ -341,7 +341,7 @@ export const SystemInfoCard = () => { - {t("home.components.systemInfo.fields.vergeVersion")} + {t('home.components.systemInfo.fields.vergeVersion')} v{appVersion} @@ -349,5 +349,5 @@ export const SystemInfoCard = () => { - ); -}; + ) +} diff --git a/src/components/home/test-card.tsx b/src/components/home/test-card.tsx index 8eddc2863..f9fc6c45b 100644 --- a/src/components/home/test-card.tsx +++ b/src/components/home/test-card.tsx @@ -6,145 +6,145 @@ import { useSensors, DragEndEvent, DragOverlay, -} from "@dnd-kit/core"; -import { SortableContext } from "@dnd-kit/sortable"; -import { Add, NetworkCheck } from "@mui/icons-material"; -import { Box, IconButton, Tooltip, alpha, styled, Grid } from "@mui/material"; -import { emit } from "@tauri-apps/api/event"; -import { nanoid } from "nanoid"; -import { useEffect, useRef, useMemo, useCallback } from "react"; -import { useTranslation } from "react-i18next"; +} from '@dnd-kit/core' +import { SortableContext } from '@dnd-kit/sortable' +import { Add, NetworkCheck } from '@mui/icons-material' +import { Box, IconButton, Tooltip, alpha, styled, Grid } from '@mui/material' +import { emit } from '@tauri-apps/api/event' +import { nanoid } from 'nanoid' +import { useEffect, useRef, useMemo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' // test icons -import apple from "@/assets/image/test/apple.svg?raw"; -import github from "@/assets/image/test/github.svg?raw"; -import google from "@/assets/image/test/google.svg?raw"; -import youtube from "@/assets/image/test/youtube.svg?raw"; -import { TestItem } from "@/components/test/test-item"; -import { TestViewer, TestViewerRef } from "@/components/test/test-viewer"; -import { useVerge } from "@/hooks/use-verge"; +import apple from '@/assets/image/test/apple.svg?raw' +import github from '@/assets/image/test/github.svg?raw' +import google from '@/assets/image/test/google.svg?raw' +import youtube from '@/assets/image/test/youtube.svg?raw' +import { TestItem } from '@/components/test/test-item' +import { TestViewer, TestViewerRef } from '@/components/test/test-viewer' +import { useVerge } from '@/hooks/use-verge' -import { EnhancedCard } from "./enhanced-card"; +import { EnhancedCard } from './enhanced-card' // 自定义滚动条样式 const ScrollBox = styled(Box)(({ theme }) => ({ - maxHeight: "180px", - overflowY: "auto", - overflowX: "hidden", - "&::-webkit-scrollbar": { - width: "6px", + maxHeight: '180px', + overflowY: 'auto', + overflowX: 'hidden', + '&::-webkit-scrollbar': { + width: '6px', }, - "&::-webkit-scrollbar-thumb": { + '&::-webkit-scrollbar-thumb': { backgroundColor: alpha(theme.palette.text.primary, 0.2), - borderRadius: "3px", + borderRadius: '3px', }, -})); +})) // 默认测试列表,移到组件外部避免重复创建 const DEFAULT_TEST_LIST = [ { uid: nanoid(), - name: "Apple", - url: "https://www.apple.com", + name: 'Apple', + url: 'https://www.apple.com', icon: apple, }, { uid: nanoid(), - name: "GitHub", - url: "https://www.github.com", + name: 'GitHub', + url: 'https://www.github.com', icon: github, }, { uid: nanoid(), - name: "Google", - url: "https://www.google.com", + name: 'Google', + url: 'https://www.google.com', icon: google, }, { uid: nanoid(), - name: "YouTube", - url: "https://www.youtube.com", + name: 'YouTube', + url: 'https://www.youtube.com', icon: youtube, }, -]; +] export const TestCard = () => { - const { t } = useTranslation(); + const { t } = useTranslation() const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }), - ); - const { verge, mutateVerge, patchVerge } = useVerge(); - const viewerRef = useRef(null); + ) + const { verge, mutateVerge, patchVerge } = useVerge() + const viewerRef = useRef(null) // 使用useMemo优化测试列表,避免每次渲染重新计算 const testList = useMemo(() => { - return verge?.test_list ?? DEFAULT_TEST_LIST; - }, [verge?.test_list]); + return verge?.test_list ?? DEFAULT_TEST_LIST + }, [verge?.test_list]) // 使用useCallback优化函数引用,避免不必要的重新渲染 const onTestListItemChange = useCallback( (uid: string, patch?: Partial) => { if (!patch) { - mutateVerge(); - return; + mutateVerge() + return } const newList = testList.map((x) => x.uid === uid ? { ...x, ...patch } : x, - ); + ) - mutateVerge({ ...verge, test_list: newList }, false); + mutateVerge({ ...verge, test_list: newList }, false) }, [testList, verge, mutateVerge], - ); + ) const onDeleteTestListItem = useCallback( (uid: string) => { - const newList = testList.filter((x) => x.uid !== uid); - patchVerge({ test_list: newList }); - mutateVerge({ ...verge, test_list: newList }, false); + const newList = testList.filter((x) => x.uid !== uid) + patchVerge({ test_list: newList }) + mutateVerge({ ...verge, test_list: newList }, false) }, [testList, verge, patchVerge, mutateVerge], - ); + ) const onDragEnd = useCallback( async (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; + const { active, over } = event + if (!over || active.id === over.id) return - const old_index = testList.findIndex((x) => x.uid === active.id); - const new_index = testList.findIndex((x) => x.uid === over.id); + const old_index = testList.findIndex((x) => x.uid === active.id) + const new_index = testList.findIndex((x) => x.uid === over.id) if (old_index >= 0 && new_index >= 0) { - const newList = [...testList]; - const [removed] = newList.splice(old_index, 1); - newList.splice(new_index, 0, removed); + const newList = [...testList] + const [removed] = newList.splice(old_index, 1) + newList.splice(new_index, 0, removed) // 优化:先本地更新,再异步 patch,避免UI卡死 - mutateVerge({ ...verge, test_list: newList }, false); + mutateVerge({ ...verge, test_list: newList }, false) const patchFn = () => { try { - patchVerge({ test_list: newList }); + patchVerge({ test_list: newList }) } catch {} - }; + } if (window.requestIdleCallback) { - window.requestIdleCallback(patchFn); + window.requestIdleCallback(patchFn) } else { - setTimeout(patchFn, 0); + setTimeout(patchFn, 0) } } }, [testList, verge, mutateVerge, patchVerge], - ); + ) // 仅在verge首次加载时初始化测试列表 useEffect(() => { if (verge && !verge.test_list) { - patchVerge({ test_list: DEFAULT_TEST_LIST }); + patchVerge({ test_list: DEFAULT_TEST_LIST }) } - }, [verge, patchVerge]); + }, [verge, patchVerge]) // 使用useMemo优化UI内容,减少渲染计算 const renderTestItems = useMemo( @@ -165,28 +165,28 @@ export const TestCard = () => { ), [testList, onDeleteTestListItem], - ); + ) const handleTestAll = useCallback(() => { - emit("verge://test-all"); - }, []); + emit('verge://test-all') + }, []) const handleCreateTest = useCallback(() => { - viewerRef.current?.create(); - }, []); + viewerRef.current?.create() + }, []) return ( } action={ - - + + - + @@ -207,5 +207,5 @@ export const TestCard = () => { - ); -}; + ) +} diff --git a/src/components/layout/layout-item.tsx b/src/components/layout/layout-item.tsx index 23dc8e70a..532d22fa3 100644 --- a/src/components/layout/layout-item.tsx +++ b/src/components/layout/layout-item.tsx @@ -1,60 +1,60 @@ import type { DraggableAttributes, DraggableSyntheticListeners, -} from "@dnd-kit/core"; +} from '@dnd-kit/core' import { alpha, ListItem, ListItemButton, ListItemIcon, ListItemText, -} from "@mui/material"; -import type { CSSProperties, ReactNode } from "react"; -import { useMatch, useNavigate, useResolvedPath } from "react-router"; +} from '@mui/material' +import type { CSSProperties, ReactNode } from 'react' +import { useMatch, useNavigate, useResolvedPath } from 'react-router' -import { useVerge } from "@/hooks/use-verge"; +import { useVerge } from '@/hooks/use-verge' interface SortableProps { - setNodeRef?: (element: HTMLElement | null) => void; - attributes?: DraggableAttributes; - listeners?: DraggableSyntheticListeners; - style?: CSSProperties; - isDragging?: boolean; - disabled?: boolean; + setNodeRef?: (element: HTMLElement | null) => void + attributes?: DraggableAttributes + listeners?: DraggableSyntheticListeners + style?: CSSProperties + isDragging?: boolean + disabled?: boolean } interface Props { - to: string; - children: string; - icon: ReactNode[]; - sortable?: SortableProps; + to: string + children: string + icon: ReactNode[] + sortable?: SortableProps } export const LayoutItem = (props: Props) => { - const { to, children, icon, sortable } = props; - const { verge } = useVerge(); - const { menu_icon } = verge ?? {}; - const navCollapsed = verge?.collapse_navbar ?? false; - const resolved = useResolvedPath(to); - const match = useMatch({ path: resolved.pathname, end: true }); - const navigate = useNavigate(); + const { to, children, icon, sortable } = props + const { verge } = useVerge() + const { menu_icon } = verge ?? {} + const navCollapsed = verge?.collapse_navbar ?? false + const resolved = useResolvedPath(to) + const match = useMatch({ path: resolved.pathname, end: true }) + const navigate = useNavigate() const effectiveMenuIcon = - navCollapsed && menu_icon === "disable" ? "monochrome" : menu_icon; + navCollapsed && menu_icon === 'disable' ? 'monochrome' : menu_icon const { setNodeRef, attributes, listeners, style, isDragging, disabled } = - sortable ?? {}; + sortable ?? {} - const draggable = Boolean(sortable) && !disabled; + const draggable = Boolean(sortable) && !disabled const dragHandleProps = draggable ? { ...(attributes ?? {}), ...(listeners ?? {}) } - : undefined; + : undefined return ( @@ -68,54 +68,54 @@ export const LayoutItem = (props: Props) => { paddingLeft: 1, paddingRight: 1, marginRight: 1.25, - cursor: draggable ? "grab" : "pointer", - "&:active": draggable ? { cursor: "grabbing" } : {}, - "& .MuiListItemText-primary": { - color: "text.primary", - fontWeight: "700", + cursor: draggable ? 'grab' : 'pointer', + '&:active': draggable ? { cursor: 'grabbing' } : {}, + '& .MuiListItemText-primary': { + color: 'text.primary', + fontWeight: '700', }, }, ({ palette: { mode, primary } }) => { const bgcolor = - mode === "light" + mode === 'light' ? alpha(primary.main, 0.15) - : alpha(primary.main, 0.35); - const color = mode === "light" ? "#1f1f1f" : "#ffffff"; + : alpha(primary.main, 0.35) + const color = mode === 'light' ? '#1f1f1f' : '#ffffff' return { - "&.Mui-selected": { bgcolor }, - "&.Mui-selected:hover": { bgcolor }, - "&.Mui-selected .MuiListItemText-primary": { color }, - }; + '&.Mui-selected': { bgcolor }, + '&.Mui-selected:hover': { bgcolor }, + '&.Mui-selected .MuiListItemText-primary': { color }, + } }, ]} title={navCollapsed ? children : undefined} aria-label={navCollapsed ? children : undefined} onClick={() => navigate(to)} > - {(effectiveMenuIcon === "monochrome" || !effectiveMenuIcon) && ( + {(effectiveMenuIcon === 'monochrome' || !effectiveMenuIcon) && ( {icon[0]} )} - {effectiveMenuIcon === "colorful" && ( - + {effectiveMenuIcon === 'colorful' && ( + {icon[1]} )} - ); -}; + ) +} diff --git a/src/components/layout/layout-traffic.tsx b/src/components/layout/layout-traffic.tsx index f95604715..d4304aab1 100644 --- a/src/components/layout/layout-traffic.tsx +++ b/src/components/layout/layout-traffic.tsx @@ -2,37 +2,37 @@ import { ArrowDownwardRounded, ArrowUpwardRounded, MemoryRounded, -} from "@mui/icons-material"; -import { Box, Typography } from "@mui/material"; -import { useEffect, useRef } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/icons-material' +import { Box, Typography } from '@mui/material' +import { useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' -import { LightweightTrafficErrorBoundary } from "@/components/shared/traffic-error-boundary"; -import { useMemoryData } from "@/hooks/use-memory-data"; -import { useTrafficData } from "@/hooks/use-traffic-data"; -import { useVerge } from "@/hooks/use-verge"; -import { useVisibility } from "@/hooks/use-visibility"; -import parseTraffic from "@/utils/parse-traffic"; +import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary' +import { useMemoryData } from '@/hooks/use-memory-data' +import { useTrafficData } from '@/hooks/use-traffic-data' +import { useVerge } from '@/hooks/use-verge' +import { useVisibility } from '@/hooks/use-visibility' +import parseTraffic from '@/utils/parse-traffic' -import { TrafficGraph, type TrafficRef } from "./traffic-graph"; +import { TrafficGraph, type TrafficRef } from './traffic-graph' // setup the traffic export const LayoutTraffic = () => { - const { t } = useTranslation(); - const { verge } = useVerge(); + const { t } = useTranslation() + const { verge } = useVerge() // whether hide traffic graph - const trafficGraph = verge?.traffic_graph ?? true; + const trafficGraph = verge?.traffic_graph ?? true - const trafficRef = useRef(null); - const pageVisible = useVisibility(); + const trafficRef = useRef(null) + const pageVisible = useVisibility() const { response: { data: traffic }, - } = useTrafficData({ enabled: trafficGraph && pageVisible }); + } = useTrafficData({ enabled: trafficGraph && pageVisible }) const { response: { data: memory }, - } = useMemoryData(); + } = useMemoryData() // 监听数据变化,为图表添加数据点 useEffect(() => { @@ -40,45 +40,45 @@ export const LayoutTraffic = () => { trafficRef.current.appendData({ up: traffic?.up || 0, down: traffic?.down || 0, - }); + }) } - }, [traffic]); + }, [traffic]) // 显示内存使用情况的设置 - const displayMemory = verge?.enable_memory_usage ?? true; + const displayMemory = verge?.enable_memory_usage ?? true // 使用parseTraffic统一处理转换,保持与首页一致的显示格式 - const [up, upUnit] = parseTraffic(traffic?.up || 0); - const [down, downUnit] = parseTraffic(traffic?.down || 0); - const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0); + const [up, upUnit] = parseTraffic(traffic?.up || 0) + const [down, downUnit] = parseTraffic(traffic?.down || 0) + const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0) const boxStyle: any = { - display: "flex", - alignItems: "center", - whiteSpace: "nowrap", - }; + display: 'flex', + alignItems: 'center', + whiteSpace: 'nowrap', + } const iconStyle: any = { - sx: { mr: "8px", fontSize: 16 }, - }; + sx: { mr: '8px', fontSize: 16 }, + } const valStyle: any = { - component: "span", - textAlign: "center", - sx: { flex: "1 1 56px", userSelect: "none" }, - }; + component: 'span', + textAlign: 'center', + sx: { flex: '1 1 56px', userSelect: 'none' }, + } const unitStyle: any = { - component: "span", - color: "grey.500", - fontSize: "12px", - textAlign: "right", - sx: { flex: "0 1 27px", userSelect: "none" }, - }; + component: 'span', + color: 'grey.500', + fontSize: '12px', + textAlign: 'right', + sx: { flex: '0 1 27px', userSelect: 'none' }, + } return ( {trafficGraph && pageVisible && (
@@ -87,7 +87,7 @@ export const LayoutTraffic = () => { { > 0 ? "secondary" : "disabled"} + color={(traffic?.up || 0) > 0 ? 'secondary' : 'disabled'} /> {up} @@ -105,7 +105,7 @@ export const LayoutTraffic = () => { { > 0 ? "primary" : "disabled"} + color={(traffic?.down || 0) > 0 ? 'primary' : 'disabled'} /> {down} @@ -124,13 +124,13 @@ export const LayoutTraffic = () => { {displayMemory && ( { // isDebug && (await gc()); }} @@ -143,5 +143,5 @@ export const LayoutTraffic = () => { - ); -}; + ) +} diff --git a/src/components/layout/notice-manager.tsx b/src/components/layout/notice-manager.tsx index 57487a16d..059fae1d4 100644 --- a/src/components/layout/notice-manager.tsx +++ b/src/components/layout/notice-manager.tsx @@ -1,180 +1,180 @@ -import { CloseRounded } from "@mui/icons-material"; +import { CloseRounded } from '@mui/icons-material' import { Snackbar, Alert, IconButton, Box, type SnackbarOrigin, -} from "@mui/material"; -import React, { useCallback, useMemo, useSyncExternalStore } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import React, { useCallback, useMemo, useSyncExternalStore } from 'react' +import { useTranslation } from 'react-i18next' import { subscribeNotices, hideNotice, getSnapshotNotices, showNotice, -} from "@/services/notice-service"; -import type { TranslationKey } from "@/types/generated/i18n-keys"; +} from '@/services/notice-service' +import type { TranslationKey } from '@/types/generated/i18n-keys' -type NoticePosition = NonNullable; -type NoticeItem = ReturnType[number]; -type TranslationFn = ReturnType["t"]; +type NoticePosition = NonNullable +type NoticeItem = ReturnType[number] +type TranslationFn = ReturnType['t'] const VALID_POSITIONS: NoticePosition[] = [ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]; + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right', +] const resolvePosition = (position?: NoticePosition | null): NoticePosition => { if (position && VALID_POSITIONS.includes(position)) { - return position; + return position } - return "top-right"; -}; + return 'top-right' +} const getAnchorOrigin = (position: NoticePosition): SnackbarOrigin => { - const [vertical, horizontal] = position.split("-") as [ - SnackbarOrigin["vertical"], - SnackbarOrigin["horizontal"], - ]; - return { vertical, horizontal }; -}; + const [vertical, horizontal] = position.split('-') as [ + SnackbarOrigin['vertical'], + SnackbarOrigin['horizontal'], + ] + return { vertical, horizontal } +} const resolveNoticeMessage = ( notice: NoticeItem, t: TranslationFn, ): React.ReactNode => { - const i18n = notice.i18n; - if (!i18n) return notice.message; + const i18n = notice.i18n + if (!i18n) return notice.message - const params = (i18n.params ?? {}) as Record; - const { prefixKey, prefixParams, prefix, message, ...restParams } = params; + const params = (i18n.params ?? {}) as Record + const { prefixKey, prefixParams, prefix, message, ...restParams } = params const prefixKeyParams = - prefixParams && typeof prefixParams === "object" + prefixParams && typeof prefixParams === 'object' ? (prefixParams as Record) - : undefined; + : undefined const resolvedPrefix = - typeof prefixKey === "string" + typeof prefixKey === 'string' ? t(prefixKey as TranslationKey, { defaultValue: prefixKey, ...(prefixKeyParams ?? {}), ...restParams, }) - : typeof prefix === "string" + : typeof prefix === 'string' ? prefix - : undefined; + : undefined - const messageStr = typeof message === "string" ? message : undefined; + const messageStr = typeof message === 'string' ? message : undefined const defaultValue = messageStr === undefined ? undefined : resolvedPrefix ? `${resolvedPrefix} ${messageStr}` - : messageStr; + : messageStr return t(i18n.key as TranslationKey, { defaultValue, ...restParams, ...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}), ...(messageStr !== undefined ? { message: messageStr } : {}), - }); -}; + }) +} const extractNoticeCopyText = (input: unknown): string | undefined => { - if (input === null || input === undefined) return undefined; - if (typeof input === "string") return input; - if (typeof input === "number" || typeof input === "boolean") { - return String(input); + if (input === null || input === undefined) return undefined + if (typeof input === 'string') return input + if (typeof input === 'number' || typeof input === 'boolean') { + return String(input) } if (input instanceof Error) { - return input.message || input.name; + return input.message || input.name } - if (React.isValidElement(input)) return undefined; - if (typeof input === "object") { - const maybeMessage = (input as { message?: unknown }).message; - if (typeof maybeMessage === "string") return maybeMessage; + if (React.isValidElement(input)) return undefined + if (typeof input === 'object') { + const maybeMessage = (input as { message?: unknown }).message + if (typeof maybeMessage === 'string') return maybeMessage } try { - return JSON.stringify(input); + return JSON.stringify(input) } catch { - return String(input); + return String(input) } -}; +} const resolveNoticeCopyText = ( notice: NoticeItem, t: TranslationFn, ): string | undefined => { if ( - notice.i18n?.key === "shared.feedback.notices.prefixedRaw" || - notice.i18n?.key === "shared.feedback.notices.raw" + notice.i18n?.key === 'shared.feedback.notices.prefixedRaw' || + notice.i18n?.key === 'shared.feedback.notices.raw' ) { - const rawText = extractNoticeCopyText(notice.i18n?.params?.message); - if (rawText) return rawText; + const rawText = extractNoticeCopyText(notice.i18n?.params?.message) + if (rawText) return rawText } return ( extractNoticeCopyText(resolveNoticeMessage(notice, t)) ?? extractNoticeCopyText(notice.message) - ); -}; + ) +} interface NoticeManagerProps { - position?: NoticePosition | null; + position?: NoticePosition | null } export const NoticeManager: React.FC = ({ position }) => { - const { t } = useTranslation(); - const resolvedPosition = useMemo(() => resolvePosition(position), [position]); + const { t } = useTranslation() + const resolvedPosition = useMemo(() => resolvePosition(position), [position]) const anchorOrigin = useMemo( () => getAnchorOrigin(resolvedPosition), [resolvedPosition], - ); + ) const currentNotices = useSyncExternalStore( subscribeNotices, getSnapshotNotices, - ); + ) const handleClose = (id: number) => { - hideNotice(id); - }; + hideNotice(id) + } const handleNoticeCopy = useCallback( async (notice: NoticeItem) => { - const text = resolveNoticeCopyText(notice, t); - if (!text) return; + const text = resolveNoticeCopyText(notice, t) + if (!text) return try { - await navigator.clipboard.writeText(text); + await navigator.clipboard.writeText(text) showNotice.success( - "shared.feedback.notifications.common.copySuccess", + 'shared.feedback.notifications.common.copySuccess', 1000, - ); + ) } catch (error) { - console.warn("[NoticeManager] copy to clipboard failed:", error); + console.warn('[NoticeManager] copy to clipboard failed:', error) } }, [t], - ); + ) return ( {currentNotices.map((notice) => ( @@ -183,23 +183,23 @@ export const NoticeManager: React.FC = ({ position }) => { open={true} anchorOrigin={anchorOrigin} sx={{ - position: "relative", - transform: "none", - top: "auto", - right: "auto", - bottom: "auto", - left: "auto", - width: "100%", + position: 'relative', + transform: 'none', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + width: '100%', }} > { - event.preventDefault(); - event.stopPropagation(); - void handleNoticeCopy(notice); + event.preventDefault() + event.stopPropagation() + void handleNoticeCopy(notice) }} action={ = ({ position }) => { ))} - ); -}; + ) +} diff --git a/src/components/layout/scroll-top-button.tsx b/src/components/layout/scroll-top-button.tsx index 66fe80c17..f59091e2a 100644 --- a/src/components/layout/scroll-top-button.tsx +++ b/src/components/layout/scroll-top-button.tsx @@ -1,10 +1,10 @@ -import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; -import { IconButton, Fade, SxProps, Theme } from "@mui/material"; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' +import { IconButton, Fade, SxProps, Theme } from '@mui/material' interface Props { - onClick: () => void; - show: boolean; - sx?: SxProps; + onClick: () => void + show: boolean + sx?: SxProps } export const ScrollTopButton = ({ onClick, show, sx }: Props) => { @@ -13,25 +13,25 @@ export const ScrollTopButton = ({ onClick, show, sx }: Props) => { - theme.palette.mode === "dark" - ? "rgba(255,255,255,0.1)" - : "rgba(0,0,0,0.1)", - "&:hover": { + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.1)' + : 'rgba(0,0,0,0.1)', + '&:hover': { backgroundColor: (theme) => - theme.palette.mode === "dark" - ? "rgba(255,255,255,0.2)" - : "rgba(0,0,0,0.2)", + theme.palette.mode === 'dark' + ? 'rgba(255,255,255,0.2)' + : 'rgba(0,0,0,0.2)', }, - visibility: show ? "visible" : "hidden", + visibility: show ? 'visible' : 'hidden', ...sx, }} > - ); -}; + ) +} diff --git a/src/components/layout/traffic-graph.tsx b/src/components/layout/traffic-graph.tsx index 1266309a8..d8a1a0b46 100644 --- a/src/components/layout/traffic-graph.tsx +++ b/src/components/layout/traffic-graph.tsx @@ -1,201 +1,201 @@ -import { useTheme } from "@mui/material"; -import { useEffect, useImperativeHandle, useRef, type Ref } from "react"; -import { Traffic } from "tauri-plugin-mihomo-api"; +import { useTheme } from '@mui/material' +import { useEffect, useImperativeHandle, useRef, type Ref } from 'react' +import { Traffic } from 'tauri-plugin-mihomo-api' -const maxPoint = 30; +const maxPoint = 30 -const refLineAlpha = 1; -const refLineWidth = 2; +const refLineAlpha = 1 +const refLineWidth = 2 -const upLineAlpha = 0.6; -const upLineWidth = 4; +const upLineAlpha = 0.6 +const upLineWidth = 4 -const downLineAlpha = 1; -const downLineWidth = 4; +const downLineAlpha = 1 +const downLineWidth = 4 -const defaultList = Array(maxPoint + 2).fill({ up: 0, down: 0 }); +const defaultList = Array(maxPoint + 2).fill({ up: 0, down: 0 }) export interface TrafficRef { - appendData: (data: Traffic) => void; - toggleStyle: () => void; + appendData: (data: Traffic) => void + toggleStyle: () => void } /** * draw the traffic graph */ export function TrafficGraph({ ref }: { ref?: Ref }) { - const countRef = useRef(0); - const styleRef = useRef(true); - const listRef = useRef(defaultList); - const canvasRef = useRef(null!); + const countRef = useRef(0) + const styleRef = useRef(true) + const listRef = useRef(defaultList) + const canvasRef = useRef(null!) - const cacheRef = useRef(null); + const cacheRef = useRef(null) - const { palette } = useTheme(); + const { palette } = useTheme() useImperativeHandle(ref, () => ({ appendData: (data: Traffic) => { - cacheRef.current = data; + cacheRef.current = data }, toggleStyle: () => { - styleRef.current = !styleRef.current; + styleRef.current = !styleRef.current }, - })); + })) useEffect(() => { - let timer: any; - const zero = { up: 0, down: 0 }; + let timer: any + const zero = { up: 0, down: 0 } const handleData = () => { - const data = cacheRef.current ? cacheRef.current : zero; - cacheRef.current = null; + const data = cacheRef.current ? cacheRef.current : zero + cacheRef.current = null - const list = listRef.current; - if (list.length > maxPoint + 2) list.shift(); - list.push(data); - countRef.current = 0; + const list = listRef.current + if (list.length > maxPoint + 2) list.shift() + list.push(data) + countRef.current = 0 - timer = setTimeout(handleData, 1000); - }; + timer = setTimeout(handleData, 1000) + } - handleData(); + handleData() return () => { - if (timer) clearTimeout(timer); - }; - }, []); + if (timer) clearTimeout(timer) + } + }, []) useEffect(() => { - let raf = 0; - const canvas = canvasRef.current!; + let raf = 0 + const canvas = canvasRef.current! - if (!canvas) return; + if (!canvas) return - const context = canvas.getContext("2d")!; + const context = canvas.getContext('2d')! - if (!context) return; + if (!context) return - const { primary, secondary, divider } = palette; - const refLineColor = divider || "rgba(0, 0, 0, 0.12)"; - const upLineColor = secondary.main || "#9c27b0"; - const downLineColor = primary.main || "#5b5c9d"; + const { primary, secondary, divider } = palette + const refLineColor = divider || 'rgba(0, 0, 0, 0.12)' + const upLineColor = secondary.main || '#9c27b0' + const downLineColor = primary.main || '#5b5c9d' - const width = canvas.width; - const height = canvas.height; - const dx = width / maxPoint; - const dy = height / 7; - const l1 = dy; - const l2 = dy * 4; + const width = canvas.width + const height = canvas.height + const dx = width / maxPoint + const dy = height / 7 + const l1 = dy + const l2 = dy * 4 const countY = (v: number) => { - const h = height; + const h = height - if (v == 0) return h - 1; - if (v <= 10) return h - (v / 10) * dy; - if (v <= 100) return h - (v / 100 + 1) * dy; - if (v <= 1024) return h - (v / 1024 + 2) * dy; - if (v <= 10240) return h - (v / 10240 + 3) * dy; - if (v <= 102400) return h - (v / 102400 + 4) * dy; - if (v <= 1048576) return h - (v / 1048576 + 5) * dy; - if (v <= 10485760) return h - (v / 10485760 + 6) * dy; - return 1; - }; + if (v == 0) return h - 1 + if (v <= 10) return h - (v / 10) * dy + if (v <= 100) return h - (v / 100 + 1) * dy + if (v <= 1024) return h - (v / 1024 + 2) * dy + if (v <= 10240) return h - (v / 10240 + 3) * dy + if (v <= 102400) return h - (v / 102400 + 4) * dy + if (v <= 1048576) return h - (v / 1048576 + 5) * dy + if (v <= 10485760) return h - (v / 10485760 + 6) * dy + return 1 + } const drawBezier = (list: number[], offset: number) => { const points = list.map((y, i) => [ (dx * (i - 1) - offset + 3) | 0, countY(y), - ]); + ]) - context.moveTo(points[0][0], points[0][1]); + context.moveTo(points[0][0], points[0][1]) for (let i = 1; i < points.length; i++) { - const p1 = points[i]; - const p2 = points[i + 1] || p1; + const p1 = points[i] + const p2 = points[i + 1] || p1 - const x1 = (p1[0] + p2[0]) / 2; - const y1 = (p1[1] + p2[1]) / 2; + const x1 = (p1[0] + p2[0]) / 2 + const y1 = (p1[1] + p2[1]) / 2 - context.quadraticCurveTo(p1[0], p1[1], x1, y1); + context.quadraticCurveTo(p1[0], p1[1], x1, y1) } - }; + } const drawLine = (list: number[], offset: number) => { const points = list.map((y, i) => [ (dx * (i - 1) - offset) | 0, countY(y), - ]); + ]) - context.moveTo(points[0][0], points[0][1]); + context.moveTo(points[0][0], points[0][1]) for (let i = 1; i < points.length; i++) { - const p = points[i]; - context.lineTo(p[0], p[1]); + const p = points[i] + context.lineTo(p[0], p[1]) } - }; + } const drawGraph = (lastTime: number) => { - const listUp = listRef.current.map((v) => v.up); - const listDown = listRef.current.map((v) => v.down); - const lineStyle = styleRef.current; + const listUp = listRef.current.map((v) => v.up) + const listDown = listRef.current.map((v) => v.down) + const lineStyle = styleRef.current - const now = Date.now(); - const diff = now - lastTime; + const now = Date.now() + const diff = now - lastTime if (diff < 33) { - raf = requestAnimationFrame(() => drawGraph(lastTime)); - return; + raf = requestAnimationFrame(() => drawGraph(lastTime)) + return } - const temp = Math.min((diff / 1000) * dx + countRef.current, dx); - const offset = countRef.current === 0 ? 0 : temp; - countRef.current = temp; + const temp = Math.min((diff / 1000) * dx + countRef.current, dx) + const offset = countRef.current === 0 ? 0 : temp + countRef.current = temp - context.clearRect(0, 0, width, height); + context.clearRect(0, 0, width, height) // Reference lines - context.beginPath(); - context.globalAlpha = refLineAlpha; - context.lineWidth = refLineWidth; - context.strokeStyle = refLineColor; - context.moveTo(0, l1); - context.lineTo(width, l1); - context.moveTo(0, l2); - context.lineTo(width, l2); - context.stroke(); - context.closePath(); + context.beginPath() + context.globalAlpha = refLineAlpha + context.lineWidth = refLineWidth + context.strokeStyle = refLineColor + context.moveTo(0, l1) + context.lineTo(width, l1) + context.moveTo(0, l2) + context.lineTo(width, l2) + context.stroke() + context.closePath() - context.beginPath(); - context.globalAlpha = upLineAlpha; - context.lineWidth = upLineWidth; - context.strokeStyle = upLineColor; + context.beginPath() + context.globalAlpha = upLineAlpha + context.lineWidth = upLineWidth + context.strokeStyle = upLineColor if (lineStyle) { - drawBezier(listUp, offset); + drawBezier(listUp, offset) } else { - drawLine(listUp, offset); + drawLine(listUp, offset) } - context.stroke(); - context.closePath(); + context.stroke() + context.closePath() - context.beginPath(); - context.globalAlpha = downLineAlpha; - context.lineWidth = downLineWidth; - context.strokeStyle = downLineColor; + context.beginPath() + context.globalAlpha = downLineAlpha + context.lineWidth = downLineWidth + context.strokeStyle = downLineColor if (lineStyle) { - drawBezier(listDown, offset); + drawBezier(listDown, offset) } else { - drawLine(listDown, offset); + drawLine(listDown, offset) } - context.stroke(); - context.closePath(); + context.stroke() + context.closePath() - raf = requestAnimationFrame(() => drawGraph(now)); - }; + raf = requestAnimationFrame(() => drawGraph(now)) + } - drawGraph(Date.now()); + drawGraph(Date.now()) return () => { - cancelAnimationFrame(raf); - }; - }, [palette]); + cancelAnimationFrame(raf) + } + }, [palette]) - return ; + return } diff --git a/src/components/layout/update-button.tsx b/src/components/layout/update-button.tsx index 53dba06f9..67e4c1100 100644 --- a/src/components/layout/update-button.tsx +++ b/src/components/layout/update-button.tsx @@ -1,22 +1,22 @@ -import { Button } from "@mui/material"; -import { useRef } from "react"; +import { Button } from '@mui/material' +import { useRef } from 'react' -import { DialogRef } from "@/components/base"; -import { useUpdate } from "@/hooks/use-update"; +import { DialogRef } from '@/components/base' +import { useUpdate } from '@/hooks/use-update' -import { UpdateViewer } from "../setting/mods/update-viewer"; +import { UpdateViewer } from '../setting/mods/update-viewer' interface Props { - className?: string; + className?: string } export const UpdateButton = (props: Props) => { - const { className } = props; - const viewerRef = useRef(null); + const { className } = props + const viewerRef = useRef(null) - const { updateInfo } = useUpdate(); + const { updateInfo } = useUpdate() - if (!updateInfo?.available) return null; + if (!updateInfo?.available) return null return ( <> @@ -32,5 +32,5 @@ export const UpdateButton = (props: Props) => { New - ); -}; + ) +} diff --git a/src/components/layout/window-controller.tsx b/src/components/layout/window-controller.tsx index f8ad6dcc1..2f8b1f6b7 100644 --- a/src/components/layout/window-controller.tsx +++ b/src/components/layout/window-controller.tsx @@ -1,12 +1,12 @@ -import { Close, CropSquare, FilterNone, Minimize } from "@mui/icons-material"; -import { Box, IconButton } from "@mui/material"; -import { forwardRef, useImperativeHandle } from "react"; +import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material' +import { Box, IconButton } from '@mui/material' +import { forwardRef, useImperativeHandle } from 'react' -import { useWindowControls } from "@/hooks/use-window"; -import getSystem from "@/utils/get-system"; +import { useWindowControls } from '@/hooks/use-window' +import getSystem from '@/utils/get-system' export const WindowControls = forwardRef(function WindowControls(props, ref) { - const OS = getSystem(); + const OS = getSystem() const { currentWindow, maximized, @@ -14,7 +14,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { close, toggleFullscreen, toggleMaximize, - } = useWindowControls(); + } = useWindowControls() useImperativeHandle( ref, @@ -34,7 +34,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { toggleFullscreen, toggleMaximize, ], - ); + ) // 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。 // 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。 @@ -42,15 +42,15 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { return ( button": { - cursor: "default", + alignItems: 'center', + '> button': { + cursor: 'default', }, }} > - {OS === "macos" && ( + {OS === 'macos' && ( <> {/* macOS 风格:关闭 → 最小化 → 全屏 */} @@ -73,7 +73,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { )} - {OS === "windows" && ( + {OS === 'windows' && ( <> {/* Windows 风格:最小化 → 最大化 → 关闭 */} @@ -92,7 +92,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { @@ -100,7 +100,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { )} - {OS === "linux" && ( + {OS === 'linux' && ( <> {/* Linux 桌面常见布局(GNOME/KDE 多为:最小化 → 最大化 → 关闭) */} @@ -119,7 +119,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { @@ -127,5 +127,5 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { )} - ); -}); + ) +}) diff --git a/src/components/log/log-item.tsx b/src/components/log/log-item.tsx index 778a174d3..d370e6ee5 100644 --- a/src/components/log/log-item.tsx +++ b/src/components/log/log-item.tsx @@ -1,26 +1,26 @@ -import { styled, Box } from "@mui/material"; -import type { ReactNode } from "react"; +import { styled, Box } from '@mui/material' +import type { ReactNode } from 'react' -import type { SearchState } from "@/components/base"; +import type { SearchState } from '@/components/base' const Item = styled(Box)(({ theme: { palette, typography } }) => ({ - padding: "8px 0", - margin: "0 12px", + padding: '8px 0', + margin: '0 12px', lineHeight: 1.35, borderBottom: `1px solid ${palette.divider}`, - fontSize: "0.875rem", + fontSize: '0.875rem', fontFamily: typography.fontFamily, - userSelect: "text", - "& .time": { + userSelect: 'text', + '& .time': { color: palette.text.secondary, }, - "& .type": { - display: "inline-block", + '& .type': { + display: 'inline-block', marginLeft: 8, - textAlign: "center", + textAlign: 'center', borderRadius: 2, - textTransform: "uppercase", - fontWeight: "600", + textTransform: 'uppercase', + fontWeight: '600', }, '& .type[data-type="error"], & .type[data-type="err"]': { color: palette.error.main, @@ -31,84 +31,84 @@ const Item = styled(Box)(({ theme: { palette, typography } }) => ({ '& .type[data-type="info"], & .type[data-type="inf"]': { color: palette.info.main, }, - "& .data": { + '& .data': { color: palette.text.primary, - overflowWrap: "anywhere", + overflowWrap: 'anywhere', }, - "& .highlight": { - backgroundColor: palette.mode === "dark" ? "#ffeb3b40" : "#ffeb3b90", + '& .highlight': { + backgroundColor: palette.mode === 'dark' ? '#ffeb3b40' : '#ffeb3b90', borderRadius: 2, - padding: "0 2px", + padding: '0 2px', }, -})); +})) interface Props { - value: ILogItem; - searchState?: SearchState; + value: ILogItem + searchState?: SearchState } const LogItem = ({ value, searchState }: Props) => { const renderHighlightText = (text: string) => { - if (!searchState?.text.trim()) return text; + if (!searchState?.text.trim()) return text try { - const searchText = searchState.text; - let pattern: string; + const searchText = searchState.text + let pattern: string if (searchState.useRegularExpression) { try { - new RegExp(searchText); - pattern = searchText; + new RegExp(searchText) + pattern = searchText } catch { - pattern = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + pattern = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } } else { - const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - pattern = searchState.matchWholeWord ? `\\b${escaped}\\b` : escaped; + const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + pattern = searchState.matchWholeWord ? `\\b${escaped}\\b` : escaped } - const flags = searchState.matchCase ? "g" : "gi"; - const regex = new RegExp(pattern, flags); - const elements: ReactNode[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; + const flags = searchState.matchCase ? 'g' : 'gi' + const regex = new RegExp(pattern, flags) + const elements: ReactNode[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null while ((match = regex.exec(text)) !== null) { - const start = match.index; - const matchText = match[0]; + const start = match.index + const matchText = match[0] - if (matchText === "") { - regex.lastIndex += 1; - continue; + if (matchText === '') { + regex.lastIndex += 1 + continue } if (start > lastIndex) { - elements.push(text.slice(lastIndex, start)); + elements.push(text.slice(lastIndex, start)) } elements.push( {matchText} , - ); + ) - lastIndex = start + matchText.length; + lastIndex = start + matchText.length } if (lastIndex < text.length) { - elements.push(text.slice(lastIndex)); + elements.push(text.slice(lastIndex)) } - return elements.length ? elements : text; + return elements.length ? elements : text } catch { - return text; + return text } - }; + } return (
- {renderHighlightText(value.time || "")} + {renderHighlightText(value.time || '')} {renderHighlightText(value.type)} @@ -117,7 +117,7 @@ const LogItem = ({ value, searchState }: Props) => { {renderHighlightText(value.payload)}
- ); -}; + ) +} -export default LogItem; +export default LogItem diff --git a/src/components/profile/confirm-viewer.tsx b/src/components/profile/confirm-viewer.tsx index 001fc013e..98e80b97a 100644 --- a/src/components/profile/confirm-viewer.tsx +++ b/src/components/profile/confirm-viewer.tsx @@ -4,43 +4,43 @@ import { DialogActions, DialogContent, DialogTitle, -} from "@mui/material"; -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' interface Props { - open: boolean; - title: string; - message: string; - onClose: () => void; - onConfirm: () => void; + open: boolean + title: string + message: string + onClose: () => void + onConfirm: () => void } export const ConfirmViewer = (props: Props) => { - const { open, title, message, onClose, onConfirm } = props; + const { open, title, message, onClose, onConfirm } = props - const { t } = useTranslation(); + const { t } = useTranslation() useEffect(() => { - if (!open) return; - }, [open]); + if (!open) return + }, [open]) return ( {title} - + {message} - ); -}; + ) +} diff --git a/src/components/profile/editor-viewer.tsx b/src/components/profile/editor-viewer.tsx index 05c7eb6eb..43d3dec63 100644 --- a/src/components/profile/editor-viewer.tsx +++ b/src/components/profile/editor-viewer.tsx @@ -1,10 +1,10 @@ -import MonacoEditor from "@monaco-editor/react"; +import MonacoEditor from '@monaco-editor/react' import { CloseFullscreenRounded, ContentPasteRounded, FormatPaintRounded, OpenInFullRounded, -} from "@mui/icons-material"; +} from '@mui/icons-material' import { Button, ButtonGroup, @@ -13,44 +13,38 @@ import { DialogContent, DialogTitle, IconButton, -} from "@mui/material"; -import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; -import { useLockFn } from "ahooks"; -import type { editor } from "monaco-editor"; -import { - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' +import { useLockFn } from 'ahooks' +import type { editor } from 'monaco-editor' +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { BaseLoadingOverlay } from "@/components/base"; -import { beforeEditorMount } from "@/services/monaco"; -import { showNotice } from "@/services/notice-service"; -import { useThemeMode } from "@/services/states"; -import debounce from "@/utils/debounce"; -import getSystem from "@/utils/get-system"; +import { BaseLoadingOverlay } from '@/components/base' +import { beforeEditorMount } from '@/services/monaco' +import { showNotice } from '@/services/notice-service' +import { useThemeMode } from '@/services/states' +import debounce from '@/utils/debounce' +import getSystem from '@/utils/get-system' -const appWindow = getCurrentWebviewWindow(); +const appWindow = getCurrentWebviewWindow() -export type EditorLanguage = "yaml" | "javascript" | "css"; +export type EditorLanguage = 'yaml' | 'javascript' | 'css' export interface EditorViewerProps { - open: boolean; - title?: string | ReactNode; - value: string; - language: EditorLanguage; - path: string; - readOnly?: boolean; - loading?: boolean; - dirty?: boolean; - saveDisabled?: boolean; - onChange?: (value: string) => void; - onSave?: () => void | Promise; - onClose: () => void; - onValidate?: (markers: editor.IMarker[]) => void; + open: boolean + title?: string | ReactNode + value: string + language: EditorLanguage + path: string + readOnly?: boolean + loading?: boolean + dirty?: boolean + saveDisabled?: boolean + onChange?: (value: string) => void + onSave?: () => void | Promise + onClose: () => void + onValidate?: (markers: editor.IMarker[]) => void } export const EditorViewer = ({ @@ -68,111 +62,111 @@ export const EditorViewer = ({ onClose, onValidate, }: EditorViewerProps) => { - const { t } = useTranslation(); - const themeMode = useThemeMode(); - const [isMaximized, setIsMaximized] = useState(false); - const editorRef = useRef(null); + const { t } = useTranslation() + const themeMode = useThemeMode() + const [isMaximized, setIsMaximized] = useState(false) + const editorRef = useRef(null) - const resolvedTitle = title ?? t("profiles.components.menu.editFile"); - const disableSave = loading || saveDisabled || dirty === false; + const resolvedTitle = title ?? t('profiles.components.menu.editFile') + const disableSave = loading || saveDisabled || dirty === false const syncMaximizedState = useCallback(async () => { try { - setIsMaximized(await appWindow.isMaximized()); + setIsMaximized(await appWindow.isMaximized()) } catch { - setIsMaximized(false); + setIsMaximized(false) } - }, []); + }, []) const handleSave = useLockFn(async () => { try { if (!readOnly) { - await onSave?.(); + await onSave?.() } - onClose(); + onClose() } catch (error) { - showNotice.error(error); + showNotice.error(error) } - }); + }) const handleClose = () => { try { - onClose(); + onClose() } catch (error) { - showNotice.error(error); + showNotice.error(error) } - }; + } const handlePaste = useLockFn(async () => { try { - if (readOnly || loading || !editorRef.current) return; + if (readOnly || loading || !editorRef.current) return - const text = await navigator.clipboard.readText(); - if (!text) return; + const text = await navigator.clipboard.readText() + if (!text) return - const editorInstance = editorRef.current; - const model = editorInstance.getModel(); - const selections = editorInstance.getSelections(); - if (!model || !selections || selections.length === 0) return; + const editorInstance = editorRef.current + const model = editorInstance.getModel() + const selections = editorInstance.getSelections() + if (!model || !selections || selections.length === 0) return - editorInstance.pushUndoStop(); + editorInstance.pushUndoStop() editorInstance.executeEdits( - "explicit-paste", + 'explicit-paste', selections.map((selection) => ({ range: selection, text, forceMoveMarkers: true, })), - ); - editorInstance.pushUndoStop(); - editorInstance.focus(); + ) + editorInstance.pushUndoStop() + editorInstance.focus() } catch (error) { - showNotice.error(error); + showNotice.error(error) } - }); + }) const handleFormat = useLockFn(async () => { try { - if (loading) return; - await editorRef.current?.getAction("editor.action.formatDocument")?.run(); + if (loading) return + await editorRef.current?.getAction('editor.action.formatDocument')?.run() } catch (error) { - showNotice.error(error); + showNotice.error(error) } - }); + }) const handleToggleMaximize = useLockFn(async () => { try { - await appWindow.toggleMaximize(); - await syncMaximizedState(); - editorRef.current?.layout(); + await appWindow.toggleMaximize() + await syncMaximizedState() + editorRef.current?.layout() } catch (error) { - showNotice.error(error); + showNotice.error(error) } - }); + }) useEffect(() => { - if (!open) return; - void syncMaximizedState(); - }, [open, syncMaximizedState]); + if (!open) return + void syncMaximizedState() + }, [open, syncMaximizedState]) useEffect(() => { - if (!open) return; + if (!open) return const onResized = debounce(() => { - void syncMaximizedState(); + void syncMaximizedState() try { - editorRef.current?.layout(); + editorRef.current?.layout() } catch { // Ignore transient layout errors during window transitions. } - }, 100); + }, 100) - const unlistenResized = appWindow.onResized(onResized); + const unlistenResized = appWindow.onResized(onResized) return () => { - unlistenResized.then((unlisten) => unlisten()); - }; - }, [open, syncMaximizedState]); + unlistenResized.then((unlisten) => unlisten()) + } + }, [open, syncMaximizedState]) return ( -
+
{!loading && ( { - editorRef.current = editorInstance; + editorRef.current = editorInstance }} - onChange={(nextValue) => onChange?.(nextValue ?? "")} + onChange={(nextValue) => onChange?.(nextValue ?? '')} onValidate={onValidate} options={{ automaticLayout: true, tabSize: 2, minimap: { enabled: - typeof document !== "undefined" && + typeof document !== 'undefined' && document.documentElement.clientWidth >= 1500, }, mouseWheelZoom: true, readOnly, readOnlyMessage: { - value: t("profiles.modals.editor.messages.readOnly"), + value: t('profiles.modals.editor.messages.readOnly'), }, - renderValidationDecorations: "on", + renderValidationDecorations: 'on', quickSuggestions: { strings: true, comments: true, @@ -234,7 +228,7 @@ export const EditorViewer = ({ top: 33, }, fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${ - getSystem() === "windows" ? ", twemoji mozilla" : "" + getSystem() === 'windows' ? ', twemoji mozilla' : '' }`, fontLigatures: false, smoothScrolling: true, @@ -245,16 +239,16 @@ export const EditorViewer = ({ { - void handlePaste(); + void handlePaste() }} > @@ -262,11 +256,11 @@ export const EditorViewer = ({ { - void handleFormat(); + void handleFormat() }} > @@ -275,10 +269,10 @@ export const EditorViewer = ({ size="medium" color="inherit" title={t( - isMaximized ? "shared.window.minimize" : "shared.window.maximize", + isMaximized ? 'shared.window.minimize' : 'shared.window.maximize', )} onClick={() => { - void handleToggleMaximize(); + void handleToggleMaximize() }} > {isMaximized ? : } @@ -288,20 +282,20 @@ export const EditorViewer = ({ {!readOnly && ( )}
- ); -}; + ) +} diff --git a/src/components/profile/file-input.tsx b/src/components/profile/file-input.tsx index 20b4ad20c..4215a031e 100644 --- a/src/components/profile/file-input.tsx +++ b/src/components/profile/file-input.tsx @@ -1,61 +1,61 @@ -import { Box, Button, Typography } from "@mui/material"; -import { useLockFn } from "ahooks"; -import { useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { Box, Button, Typography } from '@mui/material' +import { useLockFn } from 'ahooks' +import { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' interface Props { - onChange: (file: File, value: string) => void; + onChange: (file: File, value: string) => void } export const FileInput = (props: Props) => { - const { onChange } = props; + const { onChange } = props - const { t } = useTranslation(); + const { t } = useTranslation() // file input - const inputRef = useRef(undefined); - const [loading, setLoading] = useState(false); - const [fileName, setFileName] = useState(""); + const inputRef = useRef(undefined) + const [loading, setLoading] = useState(false) + const [fileName, setFileName] = useState('') const onFileInput = useLockFn(async (e: any) => { - const file = e.target.files?.[0] as File; + const file = e.target.files?.[0] as File - if (!file) return; + if (!file) return - setFileName(file.name); - setLoading(true); + setFileName(file.name) + setLoading(true) return new Promise((resolve, reject) => { - const reader = new FileReader(); + const reader = new FileReader() reader.onload = (event) => { - resolve(null); - onChange(file, event.target?.result as string); - }; - reader.onerror = reject; - reader.readAsText(file); - }).finally(() => setLoading(false)); - }); + resolve(null) + onChange(file, event.target?.result as string) + } + reader.onerror = reject + reader.readAsText(file) + }).finally(() => setLoading(false)) + }) return ( - + - {loading ? "Loading..." : fileName} + {loading ? 'Loading...' : fileName} - ); -}; + ) +} diff --git a/src/components/profile/group-item.tsx b/src/components/profile/group-item.tsx index 0377dde01..10a30b0b5 100644 --- a/src/components/profile/group-item.tsx +++ b/src/components/profile/group-item.tsx @@ -1,6 +1,6 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material"; +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { DeleteForeverRounded, UndoRounded } from '@mui/icons-material' import { Box, IconButton, @@ -8,18 +8,18 @@ import { ListItemText, alpha, styled, -} from "@mui/material"; +} from '@mui/material' -import { useIconCache } from "@/hooks/use-icon-cache"; +import { useIconCache } from '@/hooks/use-icon-cache' interface Props { - type: "prepend" | "original" | "delete" | "append"; - group: IProxyGroupConfig; - onDelete: () => void; + type: 'prepend' | 'original' | 'delete' | 'append' + group: IProxyGroupConfig + onDelete: () => void } export const GroupItem = (props: Props) => { - const { type, group, onDelete } = props; - const sortable = type === "prepend" || type === "append"; + const { type, group, onDelete } = props + const sortable = type === 'prepend' || type === 'append' const { attributes: sortableAttributes, @@ -31,64 +31,64 @@ export const GroupItem = (props: Props) => { } = useSortable({ id: group.name, disabled: !sortable, - }); - const dragAttributes = sortable ? sortableAttributes : undefined; - const dragListeners = sortable ? sortableListeners : undefined; - const dragNodeRef = sortable ? sortableSetNodeRef : undefined; + }) + const dragAttributes = sortable ? sortableAttributes : undefined + const dragListeners = sortable ? sortableListeners : undefined + const dragNodeRef = sortable ? sortableSetNodeRef : undefined const iconCachePath = useIconCache({ icon: group.icon, - cacheKey: group.name.replaceAll(" ", ""), - }); + cacheKey: group.name.replaceAll(' ', ''), + }) return ( ({ - position: "relative", + position: 'relative', background: - type === "original" - ? palette.mode === "dark" + type === 'original' + ? palette.mode === 'dark' ? alpha(palette.background.paper, 0.3) : alpha(palette.grey[400], 0.3) - : type === "delete" + : type === 'delete' ? alpha(palette.error.main, 0.3) : alpha(palette.success.main, 0.3), - height: "100%", - margin: "8px 0", - borderRadius: "8px", + height: '100%', + margin: '8px 0', + borderRadius: '8px', transform: CSS.Transform.toString(transform), transition, - zIndex: isDragging ? "calc(infinity)" : undefined, + zIndex: isDragging ? 'calc(infinity)' : undefined, })} > - {group.icon && group.icon?.trim().startsWith("http") && ( + {group.icon && group.icon?.trim().startsWith('http') && ( )} - {group.icon && group.icon?.trim().startsWith("data") && ( + {group.icon && group.icon?.trim().startsWith('data') && ( )} - {group.icon && group.icon?.trim().startsWith(" )} @@ -96,10 +96,10 @@ export const GroupItem = (props: Props) => { {...(dragAttributes ?? {})} {...(dragListeners ?? {})} ref={dragNodeRef} - sx={{ cursor: sortable ? "move" : "" }} + sx={{ cursor: sortable ? 'move' : '' }} primary={ {group.name} @@ -107,13 +107,13 @@ export const GroupItem = (props: Props) => { secondary={ - + {group.type} @@ -121,41 +121,41 @@ export const GroupItem = (props: Props) => { slotProps={{ secondary: { sx: { - display: "flex", - alignItems: "center", - color: "#ccc", + display: 'flex', + alignItems: 'center', + color: '#ccc', }, }, }} /> - {type === "delete" ? : } + {type === 'delete' ? : } - ); -}; + ) +} -const StyledPrimary = styled("div")` +const StyledPrimary = styled('div')` font-size: 15px; font-weight: 700; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -`; +` -const ListItemTextChild = styled("span")` +const ListItemTextChild = styled('span')` display: block; -`; +` const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({ - display: "inline-block", - border: "1px solid #ccc", + display: 'inline-block', + border: '1px solid #ccc', borderColor: alpha(theme.palette.primary.main, 0.5), color: alpha(theme.palette.primary.main, 0.8), borderRadius: 4, fontSize: 10, - padding: "0 4px", + padding: '0 4px', lineHeight: 1.5, - marginRight: "8px", -})); + marginRight: '8px', +})) diff --git a/src/components/profile/groups-editor-viewer.tsx b/src/components/profile/groups-editor-viewer.tsx index 5553117f4..2606da8eb 100644 --- a/src/components/profile/groups-editor-viewer.tsx +++ b/src/components/profile/groups-editor-viewer.tsx @@ -6,16 +6,13 @@ import { closestCenter, useSensor, useSensors, -} from "@dnd-kit/core"; -import { - SortableContext, - sortableKeyboardCoordinates, -} from "@dnd-kit/sortable"; -import MonacoEditor from "@monaco-editor/react"; +} from '@dnd-kit/core' +import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable' +import MonacoEditor from '@monaco-editor/react' import { VerticalAlignBottomRounded, VerticalAlignTopRounded, -} from "@mui/icons-material"; +} from '@mui/icons-material' import { Autocomplete, Box, @@ -30,94 +27,94 @@ import { ListItemText, TextField, styled, -} from "@mui/material"; -import { useLockFn } from "ahooks"; +} from '@mui/material' +import { useLockFn } from 'ahooks' import { cancelIdleCallback, requestIdleCallback, -} from "foxact/request-idle-callback"; -import yaml from "js-yaml"; +} from 'foxact/request-idle-callback' +import yaml from 'js-yaml' import { startTransition, useCallback, useEffect, useMemo, useState, -} from "react"; -import { Controller, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { Virtuoso } from "react-virtuoso"; +} from 'react' +import { Controller, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { Virtuoso } from 'react-virtuoso' -import { BaseSearchBox, Switch } from "@/components/base"; -import { GroupItem } from "@/components/profile/group-item"; +import { BaseSearchBox, Switch } from '@/components/base' +import { GroupItem } from '@/components/profile/group-item' import { getNetworkInterfaces, readProfileFile, saveProfileFile, -} from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; -import { useThemeMode } from "@/services/states"; -import type { TranslationKey } from "@/types/generated/i18n-keys"; -import getSystem from "@/utils/get-system"; +} from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import { useThemeMode } from '@/services/states' +import type { TranslationKey } from '@/types/generated/i18n-keys' +import getSystem from '@/utils/get-system' interface Props { - proxiesUid: string; - mergeUid: string; - profileUid: string; - property: string; - open: boolean; - onClose: () => void; - onSave?: (prev?: string, curr?: string) => void; + proxiesUid: string + mergeUid: string + profileUid: string + property: string + open: boolean + onClose: () => void + onSave?: (prev?: string, curr?: string) => void } -const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"]; +const builtinProxyPolicies = ['DIRECT', 'REJECT', 'REJECT-DROP', 'PASS'] const PROXY_STRATEGY_LABEL_KEYS: Record = { - select: "proxies.components.enums.strategies.select", - "url-test": "proxies.components.enums.strategies.url-test", - fallback: "proxies.components.enums.strategies.fallback", - "load-balance": "proxies.components.enums.strategies.load-balance", - relay: "proxies.components.enums.strategies.relay", -}; + select: 'proxies.components.enums.strategies.select', + 'url-test': 'proxies.components.enums.strategies.url-test', + fallback: 'proxies.components.enums.strategies.fallback', + 'load-balance': 'proxies.components.enums.strategies.load-balance', + relay: 'proxies.components.enums.strategies.relay', +} const PROXY_POLICY_LABEL_KEYS: Record = builtinProxyPolicies.reduce( (acc, policy) => { acc[policy] = - `proxies.components.enums.policies.${policy}` as TranslationKey; - return acc; + `proxies.components.enums.policies.${policy}` as TranslationKey + return acc }, {} as Record, - ); + ) const normalizeDeleteSeq = (input?: unknown): string[] => { if (!Array.isArray(input)) { - return []; + return [] } const names = input .map((item) => { - if (typeof item === "string") { - return item; + if (typeof item === 'string') { + return item } if ( item && - typeof item === "object" && - "name" in item && - typeof (item as { name: unknown }).name === "string" + typeof item === 'object' && + 'name' in item && + typeof (item as { name: unknown }).name === 'string' ) { - return (item as { name: string }).name; + return (item as { name: string }).name } - return undefined; + return undefined }) .filter( - (name): name is string => typeof name === "string" && name.length > 0, - ); + (name): name is string => typeof name === 'string' && name.length > 0, + ) - return Array.from(new Set(names)); -}; + return Array.from(new Set(names)) +} const buildGroupsYaml = ( prepend: IProxyGroupConfig[], @@ -131,62 +128,62 @@ const buildGroupsYaml = ( delete: deleteList, }, { forceQuotes: true }, - ); -}; + ) +} export const GroupsEditorViewer = (props: Props) => { const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } = - props; - const { t } = useTranslation(); + props + const { t } = useTranslation() const translateStrategy = useCallback( (value: string) => PROXY_STRATEGY_LABEL_KEYS[value] ? t(PROXY_STRATEGY_LABEL_KEYS[value]) : value, [t], - ); + ) const translatePolicy = useCallback( (value: string) => PROXY_POLICY_LABEL_KEYS[value] ? t(PROXY_POLICY_LABEL_KEYS[value]) : value, [t], - ); - const themeMode = useThemeMode(); - const [prevData, setPrevData] = useState(""); - const [currData, setCurrData] = useState(""); - const [visualization, setVisualization] = useState(true); - const [match, setMatch] = useState(() => (_: string) => true); - const [interfaceNameList, setInterfaceNameList] = useState([]); + ) + const themeMode = useThemeMode() + const [prevData, setPrevData] = useState('') + const [currData, setCurrData] = useState('') + const [visualization, setVisualization] = useState(true) + const [match, setMatch] = useState(() => (_: string) => true) + const [interfaceNameList, setInterfaceNameList] = useState([]) const { control, ...formIns } = useForm({ defaultValues: { - type: "select", - name: "", + type: 'select', + name: '', interval: 300, timeout: 5000, - "max-failed-times": 5, + 'max-failed-times': 5, lazy: true, }, - }); - const [groupList, setGroupList] = useState([]); - const [proxyPolicyList, setProxyPolicyList] = useState([]); - const [proxyProviderList, setProxyProviderList] = useState([]); - const [prependSeq, setPrependSeq] = useState([]); - const [appendSeq, setAppendSeq] = useState([]); - const [deleteSeq, setDeleteSeq] = useState([]); + }) + const [groupList, setGroupList] = useState([]) + const [proxyPolicyList, setProxyPolicyList] = useState([]) + const [proxyProviderList, setProxyProviderList] = useState([]) + const [prependSeq, setPrependSeq] = useState([]) + const [appendSeq, setAppendSeq] = useState([]) + const [deleteSeq, setDeleteSeq] = useState([]) const filteredPrependSeq = useMemo( () => prependSeq.filter((group) => match(group.name)), [prependSeq, match], - ); + ) const filteredGroupList = useMemo( () => groupList.filter((group) => match(group.name)), [groupList, match], - ); + ) const filteredAppendSeq = useMemo( () => appendSeq.filter((group) => match(group.name)), [appendSeq, match], - ); + ) const sensors = useSensors( useSensor(PointerSensor, { @@ -195,236 +192,236 @@ export const GroupsEditorViewer = (props: Props) => { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), - ); + ) const reorder = ( list: IProxyGroupConfig[], startIndex: number, endIndex: number, ) => { - const result = Array.from(list); - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - return result; - }; + const result = Array.from(list) + const [removed] = result.splice(startIndex, 1) + result.splice(endIndex, 0, removed) + return result + } const onPrependDragEnd = async (event: DragEndEvent) => { - const { active, over } = event; + const { active, over } = event if (over) { if (active.id !== over.id) { - let activeIndex = 0; - let overIndex = 0; + let activeIndex = 0 + let overIndex = 0 prependSeq.forEach((item, index) => { if (item.name === active.id) { - activeIndex = index; + activeIndex = index } if (item.name === over.id) { - overIndex = index; + overIndex = index } - }); + }) - setPrependSeq(reorder(prependSeq, activeIndex, overIndex)); + setPrependSeq(reorder(prependSeq, activeIndex, overIndex)) } } - }; + } const onAppendDragEnd = async (event: DragEndEvent) => { - const { active, over } = event; + const { active, over } = event if (over) { if (active.id !== over.id) { - let activeIndex = 0; - let overIndex = 0; + let activeIndex = 0 + let overIndex = 0 appendSeq.forEach((item, index) => { if (item.name === active.id) { - activeIndex = index; + activeIndex = index } if (item.name === over.id) { - overIndex = index; + overIndex = index } - }); - setAppendSeq(reorder(appendSeq, activeIndex, overIndex)); + }) + setAppendSeq(reorder(appendSeq, activeIndex, overIndex)) } } - }; + } const fetchContent = useCallback(async () => { - const data = await readProfileFile(property); - const obj = yaml.load(data) as ISeqProfileConfig | null; + const data = await readProfileFile(property) + const obj = yaml.load(data) as ISeqProfileConfig | null - setPrependSeq(obj?.prepend || []); - setAppendSeq(obj?.append || []); + setPrependSeq(obj?.prepend || []) + setAppendSeq(obj?.append || []) setDeleteSeq((prev) => { - const normalized = normalizeDeleteSeq(obj?.delete); + const normalized = normalizeDeleteSeq(obj?.delete) if ( normalized.length === prev.length && normalized.every((item, index) => item === prev[index]) ) { - return prev; + return prev } - return normalized; - }); + return normalized + }) - setPrevData(data); - setCurrData(data); - }, [property]); + setPrevData(data) + setCurrData(data) + }, [property]) useEffect(() => { - if (currData === "" || visualization !== true) { - return; + if (currData === '' || visualization !== true) { + return } - const obj = yaml.load(currData) as ISeqProfileConfig | null; + const obj = yaml.load(currData) as ISeqProfileConfig | null startTransition(() => { - setPrependSeq(obj?.prepend ?? []); - setAppendSeq(obj?.append ?? []); + setPrependSeq(obj?.prepend ?? []) + setAppendSeq(obj?.append ?? []) setDeleteSeq((prev) => { - const normalized = normalizeDeleteSeq(obj?.delete); + const normalized = normalizeDeleteSeq(obj?.delete) if ( normalized.length === prev.length && normalized.every((item, index) => item === prev[index]) ) { - return prev; + return prev } - return normalized; - }); - }); - }, [currData, visualization]); + return normalized + }) + }) + }, [currData, visualization]) // 优化:异步处理大数据yaml.dump,避免UI卡死 useEffect(() => { if (prependSeq && appendSeq && deleteSeq) { const serialize = () => { try { - setCurrData(buildGroupsYaml(prependSeq, appendSeq, deleteSeq)); + setCurrData(buildGroupsYaml(prependSeq, appendSeq, deleteSeq)) } catch (e) { - console.warn("[GroupsEditorViewer] yaml.dump failed:", e); + console.warn('[GroupsEditorViewer] yaml.dump failed:', e) // 防止异常导致UI卡死 } - }; + } - const handle = requestIdleCallback(serialize); + const handle = requestIdleCallback(serialize) return () => { - cancelIdleCallback(handle); - }; + cancelIdleCallback(handle) + } } - }, [prependSeq, appendSeq, deleteSeq]); + }, [prependSeq, appendSeq, deleteSeq]) const fetchProxyPolicy = useCallback(async () => { - const data = await readProfileFile(profileUid); - const proxiesData = await readProfileFile(proxiesUid); + const data = await readProfileFile(profileUid) + const proxiesData = await readProfileFile(proxiesUid) const originGroupsObj = yaml.load(data) as { - "proxy-groups": IProxyGroupConfig[]; - } | null; + 'proxy-groups': IProxyGroupConfig[] + } | null - const originProxiesObj = yaml.load(data) as { proxies: [] } | null; - const originProxies = originProxiesObj?.proxies || []; - const moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null; - const morePrependProxies = moreProxiesObj?.prepend || []; - const moreAppendProxies = moreProxiesObj?.append || []; - const moreDeleteProxies = normalizeDeleteSeq(moreProxiesObj?.delete); + const originProxiesObj = yaml.load(data) as { proxies: [] } | null + const originProxies = originProxiesObj?.proxies || [] + const moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null + const morePrependProxies = moreProxiesObj?.prepend || [] + const moreAppendProxies = moreProxiesObj?.append || [] + const moreDeleteProxies = normalizeDeleteSeq(moreProxiesObj?.delete) const proxies = morePrependProxies.concat( originProxies.filter((proxy: any) => { const proxyName = - typeof proxy === "string" + typeof proxy === 'string' ? proxy - : (proxy?.name as string | undefined); - return proxyName ? !moreDeleteProxies.includes(proxyName) : true; + : (proxy?.name as string | undefined) + return proxyName ? !moreDeleteProxies.includes(proxyName) : true }), moreAppendProxies, - ); + ) const proxyNames = proxies .map((proxy: any) => - typeof proxy === "string" ? proxy : (proxy?.name as string | undefined), + typeof proxy === 'string' ? proxy : (proxy?.name as string | undefined), ) .filter( - (name): name is string => typeof name === "string" && name.length > 0, - ); + (name): name is string => typeof name === 'string' && name.length > 0, + ) const computedPolicyList = builtinProxyPolicies.concat( prependSeq.map((group: IProxyGroupConfig) => group.name), - (originGroupsObj?.["proxy-groups"] || []) + (originGroupsObj?.['proxy-groups'] || []) .map((group: IProxyGroupConfig) => group.name) .filter((name) => !deleteSeq.includes(name)), appendSeq.map((group: IProxyGroupConfig) => group.name), proxyNames, - ); + ) - setProxyPolicyList(Array.from(new Set(computedPolicyList))); - }, [appendSeq, deleteSeq, prependSeq, profileUid, proxiesUid]); + setProxyPolicyList(Array.from(new Set(computedPolicyList))) + }, [appendSeq, deleteSeq, prependSeq, profileUid, proxiesUid]) const fetchProfile = useCallback(async () => { - const data = await readProfileFile(profileUid); - const mergeData = await readProfileFile(mergeUid); - const globalMergeData = await readProfileFile("Merge"); + const data = await readProfileFile(profileUid) + const mergeData = await readProfileFile(mergeUid) + const globalMergeData = await readProfileFile('Merge') const originGroupsObj = yaml.load(data) as { - "proxy-groups": IProxyGroupConfig[]; - } | null; + 'proxy-groups': IProxyGroupConfig[] + } | null const originProviderObj = yaml.load(data) as { - "proxy-providers": Record; - } | null; - const originProvider = originProviderObj?.["proxy-providers"] || {}; + 'proxy-providers': Record + } | null + const originProvider = originProviderObj?.['proxy-providers'] || {} const moreProviderObj = yaml.load(mergeData) as { - "proxy-providers": Record; - } | null; - const moreProvider = moreProviderObj?.["proxy-providers"] || {}; + 'proxy-providers': Record + } | null + const moreProvider = moreProviderObj?.['proxy-providers'] || {} const globalProviderObj = yaml.load(globalMergeData) as { - "proxy-providers": Record; - } | null; - const globalProvider = globalProviderObj?.["proxy-providers"] || {}; + 'proxy-providers': Record + } | null + const globalProvider = globalProviderObj?.['proxy-providers'] || {} const provider = Object.assign( {}, originProvider, moreProvider, globalProvider, - ); + ) - setProxyProviderList(Object.keys(provider)); - setGroupList(originGroupsObj?.["proxy-groups"] || []); - }, [mergeUid, profileUid]); + setProxyProviderList(Object.keys(provider)) + setGroupList(originGroupsObj?.['proxy-groups'] || []) + }, [mergeUid, profileUid]) const getInterfaceNameList = useCallback(async () => { - const list = await getNetworkInterfaces(); - setInterfaceNameList(list); - }, []); + const list = await getNetworkInterfaces() + setInterfaceNameList(list) + }, []) useEffect(() => { - if (!open) return; - fetchProxyPolicy(); - }, [fetchProxyPolicy, open]); + if (!open) return + fetchProxyPolicy() + }, [fetchProxyPolicy, open]) useEffect(() => { - if (!open) return; - fetchContent(); - fetchProfile(); - getInterfaceNameList(); - }, [fetchContent, fetchProfile, getInterfaceNameList, open]); + if (!open) return + fetchContent() + fetchProfile() + getInterfaceNameList() + }, [fetchContent, fetchProfile, getInterfaceNameList, open]) const validateGroup = () => { - const group = formIns.getValues(); - if (group.name === "") { - throw new Error(t("profiles.modals.groupsEditor.errors.nameRequired")); + const group = formIns.getValues() + if (group.name === '') { + throw new Error(t('profiles.modals.groupsEditor.errors.nameRequired')) } - }; + } const handleSave = useLockFn(async () => { try { const nextData = visualization ? buildGroupsYaml(prependSeq, appendSeq, deleteSeq) - : currData; + : currData if (visualization) { - setCurrData(nextData); + setCurrData(nextData) } - await saveProfileFile(property, nextData); - showNotice.success("shared.feedback.notifications.saved"); - setPrevData(nextData); - onSave?.(prevData, nextData); - onClose(); + await saveProfileFile(property, nextData) + showNotice.success('shared.feedback.notifications.saved') + setPrevData(nextData) + onSave?.(prevData, nextData) + onClose() } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) return ( { { - {t("profiles.modals.groupsEditor.title")} + {t('profiles.modals.groupsEditor.title')} @@ -456,20 +453,20 @@ export const GroupsEditorViewer = (props: Props) => { {visualization ? ( <> { render={({ field }) => ( { - const { key, ...optionProps } = props; + const { key, ...optionProps } = props return (
  • { > {translateStrategy(option)}
  • - ); + ) }} onChange={(_, value) => value && field.onChange(value)} renderInput={(params) => } @@ -516,14 +513,14 @@ export const GroupsEditorViewer = (props: Props) => { render={({ field }) => ( @@ -535,12 +532,12 @@ export const GroupsEditorViewer = (props: Props) => { render={({ field }) => ( @@ -553,13 +550,13 @@ export const GroupsEditorViewer = (props: Props) => { { onChange={(_, value) => value && field.onChange(value)} renderInput={(params) => } renderOption={(props, option) => { - const { key, ...optionProps } = props; + const { key, ...optionProps } = props return (
  • { > {translatePolicy(option)}
  • - ); + ) }} getOptionLabel={translatePolicy} /> @@ -590,12 +587,12 @@ export const GroupsEditorViewer = (props: Props) => { { @@ -632,16 +629,16 @@ export const GroupsEditorViewer = (props: Props) => { { - field.onChange(parseInt(e.target.value)); + field.onChange(parseInt(e.target.value)) }} /> @@ -654,7 +651,7 @@ export const GroupsEditorViewer = (props: Props) => { { placeholder="300" type="number" size="small" - sx={{ width: "calc(100% - 150px)" }} + sx={{ width: 'calc(100% - 150px)' }} onChange={(e) => { - field.onChange(parseInt(e.target.value)); + field.onChange(parseInt(e.target.value)) }} slotProps={{ input: { endAdornment: ( - {t("shared.units.seconds")} + {t('shared.units.seconds')} ), }, @@ -684,21 +681,21 @@ export const GroupsEditorViewer = (props: Props) => { control={control} render={({ field }) => ( - + { - field.onChange(parseInt(e.target.value)); + field.onChange(parseInt(e.target.value)) }} slotProps={{ input: { endAdornment: ( - {t("shared.units.milliseconds")} + {t('shared.units.milliseconds')} ), }, @@ -714,7 +711,7 @@ export const GroupsEditorViewer = (props: Props) => { { placeholder="5" type="number" size="small" - sx={{ width: "calc(100% - 150px)" }} + sx={{ width: 'calc(100% - 150px)' }} onChange={(e) => { - field.onChange(parseInt(e.target.value)); + field.onChange(parseInt(e.target.value)) }} /> @@ -737,12 +734,12 @@ export const GroupsEditorViewer = (props: Props) => { value && field.onChange(value)} @@ -758,16 +755,16 @@ export const GroupsEditorViewer = (props: Props) => { { - field.onChange(parseInt(e.target.value)); + field.onChange(parseInt(e.target.value)) }} /> @@ -780,13 +777,13 @@ export const GroupsEditorViewer = (props: Props) => { @@ -799,13 +796,13 @@ export const GroupsEditorViewer = (props: Props) => { @@ -818,47 +815,47 @@ export const GroupsEditorViewer = (props: Props) => { { - field.onChange(value.join("|")); + field.onChange(value.join('|')) }} renderInput={(params) => } /> @@ -872,7 +869,7 @@ export const GroupsEditorViewer = (props: Props) => { @@ -886,7 +883,7 @@ export const GroupsEditorViewer = (props: Props) => { @@ -900,7 +897,7 @@ export const GroupsEditorViewer = (props: Props) => { @@ -913,7 +910,7 @@ export const GroupsEditorViewer = (props: Props) => { render={({ field }) => ( @@ -926,7 +923,7 @@ export const GroupsEditorViewer = (props: Props) => { @@ -940,7 +937,7 @@ export const GroupsEditorViewer = (props: Props) => { @@ -955,21 +952,21 @@ export const GroupsEditorViewer = (props: Props) => { startIcon={} onClick={() => { try { - validateGroup(); + validateGroup() for (const item of [...prependSeq, ...groupList]) { if (item.name === formIns.getValues().name) { throw new Error( - t("profiles.modals.groupsEditor.errors.nameExists"), - ); + t('profiles.modals.groupsEditor.errors.nameExists'), + ) } } - setPrependSeq([formIns.getValues(), ...prependSeq]); + setPrependSeq([formIns.getValues(), ...prependSeq]) } catch (err) { - showNotice.error(err); + showNotice.error(err) } }} > - {t("profiles.modals.groupsEditor.actions.prepend")} + {t('profiles.modals.groupsEditor.actions.prepend')} @@ -979,34 +976,34 @@ export const GroupsEditorViewer = (props: Props) => { startIcon={} onClick={() => { try { - validateGroup(); + validateGroup() for (const item of [...appendSeq, ...groupList]) { if (item.name === formIns.getValues().name) { throw new Error( - t("profiles.modals.groupsEditor.errors.nameExists"), - ); + t('profiles.modals.groupsEditor.errors.nameExists'), + ) } } - setAppendSeq([...appendSeq, formIns.getValues()]); + setAppendSeq([...appendSeq, formIns.getValues()]) } catch (err) { - showNotice.error(err); + showNotice.error(err) } }} > - {t("profiles.modals.groupsEditor.actions.append")} + {t('profiles.modals.groupsEditor.actions.append')}
    setMatch(() => match)} /> 0 ? 1 : 0) + @@ -1014,7 +1011,7 @@ export const GroupsEditorViewer = (props: Props) => { } increaseViewportBy={256} itemContent={(index) => { - const shift = filteredPrependSeq.length > 0 ? 1 : 0; + const shift = filteredPrependSeq.length > 0 ? 1 : 0 if (filteredPrependSeq.length > 0 && index === 0) { return ( { > { - return x.name; + return x.name })} > {filteredPrependSeq.map((item) => { @@ -1038,23 +1035,23 @@ export const GroupsEditorViewer = (props: Props) => { prependSeq.filter( (v) => v.name !== item.name, ), - ); + ) }} /> - ); + ) })} - ); + ) } else if (index < filteredGroupList.length + shift) { - const newIndex = index - shift; + const newIndex = index - shift return ( { @@ -1065,16 +1062,16 @@ export const GroupsEditorViewer = (props: Props) => { deleteSeq.filter( (v) => v !== filteredGroupList[newIndex].name, ), - ); + ) } else { setDeleteSeq((prev) => [ ...prev, filteredGroupList[newIndex].name, - ]); + ]) } }} /> - ); + ) } else { return ( { > { - return x.name; + return x.name })} > {filteredAppendSeq.map((item) => { @@ -1098,14 +1095,14 @@ export const GroupsEditorViewer = (props: Props) => { appendSeq.filter( (v) => v.name !== item.name, ), - ); + ) }} /> - ); + ) })} - ); + ) } }} /> @@ -1116,7 +1113,7 @@ export const GroupsEditorViewer = (props: Props) => { height="100%" language="yaml" value={currData} - theme={themeMode === "light" ? "light" : "vs-dark"} + theme={themeMode === 'light' ? 'light' : 'vs-dark'} options={{ tabSize: 2, // 根据语言类型设置缩进大小 minimap: { @@ -1132,29 +1129,29 @@ export const GroupsEditorViewer = (props: Props) => { top: 33, // 顶部padding防止遮挡snippets }, fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${ - getSystem() === "windows" ? ", twemoji mozilla" : "" + getSystem() === 'windows' ? ', twemoji mozilla' : '' }`, fontLigatures: false, // 连字符 smoothScrolling: true, // 平滑滚动 }} - onChange={(value) => setCurrData(value ?? "")} + onChange={(value) => setCurrData(value ?? '')} /> )}
    - ); -}; + ) +} const Item = styled(ListItem)(() => ({ - padding: "5px 2px", -})); + padding: '5px 2px', +})) diff --git a/src/components/profile/log-viewer.tsx b/src/components/profile/log-viewer.tsx index 90349ccca..70f78d2ec 100644 --- a/src/components/profile/log-viewer.tsx +++ b/src/components/profile/log-viewer.tsx @@ -7,33 +7,33 @@ import { DialogTitle, Divider, Typography, -} from "@mui/material"; -import { Fragment } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { Fragment } from 'react' +import { useTranslation } from 'react-i18next' -import { BaseEmpty } from "@/components/base"; +import { BaseEmpty } from '@/components/base' interface Props { - open: boolean; - logInfo: [string, string][]; - onClose: () => void; + open: boolean + logInfo: [string, string][] + onClose: () => void } export const LogViewer = (props: Props) => { - const { open, logInfo, onClose } = props; + const { open, logInfo, onClose } = props - const { t } = useTranslation(); + const { t } = useTranslation() return ( - {t("profiles.modals.logViewer.title")} + {t('profiles.modals.logViewer.title')} @@ -45,9 +45,9 @@ export const LogViewer = (props: Props) => { size="small" variant="outlined" color={ - level === "error" || level === "exception" - ? "error" - : "default" + level === 'error' || level === 'exception' + ? 'error' + : 'default' } sx={{ mr: 1 }} /> @@ -62,9 +62,9 @@ export const LogViewer = (props: Props) => { - ); -}; + ) +} diff --git a/src/components/profile/profile-box.tsx b/src/components/profile/profile-box.tsx index da2edc6c4..e89376819 100644 --- a/src/components/profile/profile-box.tsx +++ b/src/components/profile/profile-box.tsx @@ -1,58 +1,58 @@ -import { alpha, Box, styled } from "@mui/material"; +import { alpha, Box, styled } from '@mui/material' export const ProfileBox = styled(Box)(({ theme, - "aria-selected": selected, + 'aria-selected': selected, }) => { - const { mode, primary, text } = theme.palette; - const key = `${mode}-${!!selected}`; + const { mode, primary, text } = theme.palette + const key = `${mode}-${!!selected}` - const backgroundColor = mode === "light" ? "#ffffff" : "#282A36"; + const backgroundColor = mode === 'light' ? '#ffffff' : '#282A36' const color = { - "light-true": text.secondary, - "light-false": text.secondary, - "dark-true": alpha(text.secondary, 0.65), - "dark-false": alpha(text.secondary, 0.65), - }[key]!; + 'light-true': text.secondary, + 'light-false': text.secondary, + 'dark-true': alpha(text.secondary, 0.65), + 'dark-false': alpha(text.secondary, 0.65), + }[key]! const h2color = { - "light-true": primary.main, - "light-false": text.primary, - "dark-true": primary.main, - "dark-false": text.primary, - }[key]!; + 'light-true': primary.main, + 'light-false': text.primary, + 'dark-true': primary.main, + 'dark-false': text.primary, + }[key]! const borderSelect = { - "light-true": { + 'light-true': { borderLeft: `3px solid ${primary.main}`, width: `calc(100% + 3px)`, marginLeft: `-3px`, }, - "light-false": { - width: "100%", + 'light-false': { + width: '100%', }, - "dark-true": { + 'dark-true': { borderLeft: `3px solid ${primary.main}`, width: `calc(100% + 3px)`, marginLeft: `-3px`, }, - "dark-false": { - width: "100%", + 'dark-false': { + width: '100%', }, - }[key]; + }[key] return { - position: "relative", - display: "block", - cursor: "pointer", - textAlign: "left", - padding: "8px 16px", - boxSizing: "border-box", + position: 'relative', + display: 'block', + cursor: 'pointer', + textAlign: 'left', + padding: '8px 16px', + boxSizing: 'border-box', backgroundColor, ...borderSelect, - borderRadius: "8px", + borderRadius: '8px', color, - "& h2": { color: h2color }, - }; -}); + '& h2': { color: h2color }, + } +}) diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index e5767d10e..b8a8faab4 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -1,11 +1,11 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { RefreshRounded, DragIndicatorRounded, CheckBoxRounded, CheckBoxOutlineBlankRounded, -} from "@mui/icons-material"; +} from '@mui/icons-material' import { Box, Typography, @@ -15,51 +15,51 @@ import { MenuItem, Menu, CircularProgress, -} from "@mui/material"; -import { open } from "@tauri-apps/plugin-shell"; -import { useLockFn } from "ahooks"; -import dayjs from "dayjs"; -import { useCallback, useEffect, useReducer, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { mutate } from "swr"; +} from '@mui/material' +import { open } from '@tauri-apps/plugin-shell' +import { useLockFn } from 'ahooks' +import dayjs from 'dayjs' +import { useCallback, useEffect, useReducer, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { mutate } from 'swr' -import { ConfirmViewer } from "@/components/profile/confirm-viewer"; -import { EditorViewer } from "@/components/profile/editor-viewer"; -import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer"; -import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer"; -import { useEditorDocument } from "@/hooks/use-editor-document"; +import { ConfirmViewer } from '@/components/profile/confirm-viewer' +import { EditorViewer } from '@/components/profile/editor-viewer' +import { GroupsEditorViewer } from '@/components/profile/groups-editor-viewer' +import { RulesEditorViewer } from '@/components/profile/rules-editor-viewer' +import { useEditorDocument } from '@/hooks/use-editor-document' import { viewProfile, readProfileFile, updateProfile, saveProfileFile, getNextUpdateTime, -} from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; -import { useLoadingCache, useSetLoadingCache } from "@/services/states"; -import type { TranslationKey } from "@/types/generated/i18n-keys"; -import { debugLog } from "@/utils/debug"; -import parseTraffic from "@/utils/parse-traffic"; +} from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import { useLoadingCache, useSetLoadingCache } from '@/services/states' +import type { TranslationKey } from '@/types/generated/i18n-keys' +import { debugLog } from '@/utils/debug' +import parseTraffic from '@/utils/parse-traffic' -import { ProfileBox } from "./profile-box"; -import { ProxiesEditorViewer } from "./proxies-editor-viewer"; +import { ProfileBox } from './profile-box' +import { ProxiesEditorViewer } from './proxies-editor-viewer' const round = keyframes` from { transform: rotate(0deg); } to { transform: rotate(360deg); } -`; +` interface Props { - id: string; - selected: boolean; - activating: boolean; - itemData: IProfileItem; - onSelect: (force: boolean) => void; - onEdit: () => void; - onSave?: (prev?: string, curr?: string) => void; - onDelete: () => void; - batchMode?: boolean; - isSelected?: boolean; - onSelectionChange?: () => void; + id: string + selected: boolean + activating: boolean + itemData: IProfileItem + onSelect: (force: boolean) => void + onEdit: () => void + onSave?: (prev?: string, curr?: string) => void + onDelete: () => void + batchMode?: boolean + isSelected?: boolean + onSelectionChange?: () => void } export const ProfileItem = (props: Props) => { @@ -75,7 +75,7 @@ export const ProfileItem = (props: Props) => { batchMode, isSelected, onSelectionChange, - } = props; + } = props const { attributes, listeners, @@ -85,19 +85,19 @@ export const ProfileItem = (props: Props) => { isDragging, } = useSortable({ id, - }); + }) - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const [position, setPosition] = useState({ left: 0, top: 0 }); - const loadingCache = useLoadingCache(); - const setLoadingCache = useSetLoadingCache(); + const { t } = useTranslation() + const [anchorEl, setAnchorEl] = useState(null) + const [position, setPosition] = useState({ left: 0, top: 0 }) + const loadingCache = useLoadingCache() + const setLoadingCache = useSetLoadingCache() // 新增状态:是否显示下次更新时间 - const [showNextUpdate, setShowNextUpdate] = useState(false); - const [nextUpdateTime, setNextUpdateTime] = useState(""); + const [showNextUpdate, setShowNextUpdate] = useState(false) + const [nextUpdateTime, setNextUpdateTime] = useState('') - const { uid, name = "Profile", extra, updated = 0, option } = itemData; + const { uid, name = 'Profile', extra, updated = 0, option } = itemData // 获取下次更新时间的函数 const fetchNextUpdateTime = useLockFn(async (forceRefresh = false) => { @@ -106,313 +106,313 @@ export const ProfileItem = (props: Props) => { itemData.option.update_interval > 0 ) { try { - debugLog(`尝试获取配置 ${itemData.uid} 的下次更新时间`); + debugLog(`尝试获取配置 ${itemData.uid} 的下次更新时间`) // 如果需要强制刷新,先触发Timer.refresh() if (forceRefresh) { // 这里可以通过一个新的API来触发刷新,但目前我们依赖patch_profile中的刷新 - debugLog(`强制刷新定时器任务`); + debugLog(`强制刷新定时器任务`) } - const nextUpdate = await getNextUpdateTime(itemData.uid); - debugLog(`获取到下次更新时间结果:`, nextUpdate); + const nextUpdate = await getNextUpdateTime(itemData.uid) + debugLog(`获取到下次更新时间结果:`, nextUpdate) if (nextUpdate) { - const nextUpdateDate = dayjs(nextUpdate * 1000); - const now = dayjs(); + const nextUpdateDate = dayjs(nextUpdate * 1000) + const now = dayjs() // 如果已经过期,显示"更新失败" if (nextUpdateDate.isBefore(now)) { setNextUpdateTime( - t("profiles.components.profileItem.status.lastUpdateFailed"), - ); + t('profiles.components.profileItem.status.lastUpdateFailed'), + ) } else { // 否则显示剩余时间 - const diffMinutes = nextUpdateDate.diff(now, "minute"); + const diffMinutes = nextUpdateDate.diff(now, 'minute') if (diffMinutes < 60) { if (diffMinutes <= 0) { setNextUpdateTime( - `${t("profiles.components.profileItem.status.nextUp")} <1m`, - ); + `${t('profiles.components.profileItem.status.nextUp')} <1m`, + ) } else { setNextUpdateTime( - `${t("profiles.components.profileItem.status.nextUp")} ${diffMinutes}m`, - ); + `${t('profiles.components.profileItem.status.nextUp')} ${diffMinutes}m`, + ) } } else { - const hours = Math.floor(diffMinutes / 60); - const mins = diffMinutes % 60; + const hours = Math.floor(diffMinutes / 60) + const mins = diffMinutes % 60 setNextUpdateTime( - `${t("profiles.components.profileItem.status.nextUp")} ${hours}h ${mins}m`, - ); + `${t('profiles.components.profileItem.status.nextUp')} ${hours}h ${mins}m`, + ) } } } else { - debugLog(`返回的下次更新时间为空`); + debugLog(`返回的下次更新时间为空`) setNextUpdateTime( - t("profiles.components.profileItem.status.noSchedule"), - ); + t('profiles.components.profileItem.status.noSchedule'), + ) } } catch (err) { - console.error(`获取下次更新时间出错:`, err); - setNextUpdateTime(t("profiles.components.profileItem.status.unknown")); + console.error(`获取下次更新时间出错:`, err) + setNextUpdateTime(t('profiles.components.profileItem.status.unknown')) } } else { - debugLog(`该配置未设置更新间隔或间隔为0`); + debugLog(`该配置未设置更新间隔或间隔为0`) setNextUpdateTime( - t("profiles.components.profileItem.status.autoUpdateDisabled"), - ); + t('profiles.components.profileItem.status.autoUpdateDisabled'), + ) } - }); + }) // 切换显示模式的函数 const toggleUpdateTimeDisplay = (e: React.MouseEvent) => { - e.stopPropagation(); + e.stopPropagation() if (!showNextUpdate) { - fetchNextUpdateTime(); + fetchNextUpdateTime() } - setShowNextUpdate(!showNextUpdate); - }; + setShowNextUpdate(!showNextUpdate) + } // 当组件加载或更新间隔变化时更新下次更新时间 useEffect(() => { if (showNextUpdate) { - fetchNextUpdateTime(); + fetchNextUpdateTime() } }, [ fetchNextUpdateTime, showNextUpdate, itemData.option?.update_interval, updated, - ]); + ]) // 订阅定时器更新事件 useEffect(() => { - let refreshTimeout: number | undefined; + let refreshTimeout: number | undefined // 处理定时器更新事件 - 这个事件专门用于通知定时器变更 const handleTimerUpdate = (event: Event) => { - const source = event as CustomEvent & { payload?: string }; - const updatedUid = source.detail ?? source.payload; + const source = event as CustomEvent & { payload?: string } + const updatedUid = source.detail ?? source.payload // 只有当更新的是当前配置时才刷新显示 if (updatedUid === itemData.uid && showNextUpdate) { - debugLog(`收到定时器更新事件: uid=${updatedUid}`); + debugLog(`收到定时器更新事件: uid=${updatedUid}`) if (refreshTimeout !== undefined) { - clearTimeout(refreshTimeout); + clearTimeout(refreshTimeout) } refreshTimeout = window.setTimeout(() => { - fetchNextUpdateTime(true); - }, 1000); + fetchNextUpdateTime(true) + }, 1000) } - }; + } // 只注册定时器更新事件监听 - window.addEventListener("verge://timer-updated", handleTimerUpdate); + window.addEventListener('verge://timer-updated', handleTimerUpdate) return () => { if (refreshTimeout !== undefined) { - clearTimeout(refreshTimeout); + clearTimeout(refreshTimeout) } // 清理事件监听 - window.removeEventListener("verge://timer-updated", handleTimerUpdate); - }; - }, [fetchNextUpdateTime, itemData.uid, showNextUpdate]); + window.removeEventListener('verge://timer-updated', handleTimerUpdate) + } + }, [fetchNextUpdateTime, itemData.uid, showNextUpdate]) // local file mode // remote file mode // remote file mode - const hasUrl = !!itemData.url; - const hasExtra = !!extra; // only subscription url has extra info - const hasHome = !!itemData.home; // only subscription url has home page + const hasUrl = !!itemData.url + const hasExtra = !!extra // only subscription url has extra info + const hasHome = !!itemData.home // only subscription url has home page - const { upload = 0, download = 0, total = 0 } = extra ?? {}; - const from = parseUrl(itemData.url); - const description = itemData.desc; - const expire = parseExpire(extra?.expire); + const { upload = 0, download = 0, total = 0 } = extra ?? {} + const from = parseUrl(itemData.url) + const description = itemData.desc + const expire = parseExpire(extra?.expire) const progress = Math.min( Math.round(((download + upload) * 100) / (total + 0.01)) + 1, 100, - ); + ) - const loading = loadingCache[itemData.uid] ?? false; + const loading = loadingCache[itemData.uid] ?? false // interval update fromNow field - const [, forceRefresh] = useReducer((value: number) => value + 1, 0); + const [, forceRefresh] = useReducer((value: number) => value + 1, 0) useEffect(() => { - if (!hasUrl) return; + if (!hasUrl) return - let timer: ReturnType | undefined; + let timer: ReturnType | undefined const handler = () => { - const now = Date.now(); - const lastUpdate = updated * 1000; + const now = Date.now() + const lastUpdate = updated * 1000 // 大于一天的不管 - if (now - lastUpdate >= 24 * 36e5) return; + if (now - lastUpdate >= 24 * 36e5) return - const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4; + const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4 timer = setTimeout(() => { - forceRefresh(); - handler(); - }, wait); - }; + forceRefresh() + handler() + }, wait) + } - handler(); + handler() return () => { if (timer) { - clearTimeout(timer); - timer = undefined; + clearTimeout(timer) + timer = undefined } - }; - }, [forceRefresh, hasUrl, updated]); + } + }, [forceRefresh, hasUrl, updated]) - const [fileOpen, setFileOpen] = useState(false); - const [rulesOpen, setRulesOpen] = useState(false); - const [proxiesOpen, setProxiesOpen] = useState(false); - const [groupsOpen, setGroupsOpen] = useState(false); - const [mergeOpen, setMergeOpen] = useState(false); - const [scriptOpen, setScriptOpen] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); + const [fileOpen, setFileOpen] = useState(false) + const [rulesOpen, setRulesOpen] = useState(false) + const [proxiesOpen, setProxiesOpen] = useState(false) + const [groupsOpen, setGroupsOpen] = useState(false) + const [mergeOpen, setMergeOpen] = useState(false) + const [scriptOpen, setScriptOpen] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) - const loadProfileDocument = useCallback(() => readProfileFile(uid), [uid]); + const loadProfileDocument = useCallback(() => readProfileFile(uid), [uid]) const loadMergeDocument = useCallback( - () => readProfileFile(option?.merge ?? ""), + () => readProfileFile(option?.merge ?? ''), [option?.merge], - ); + ) const loadScriptDocument = useCallback( - () => readProfileFile(option?.script ?? ""), + () => readProfileFile(option?.script ?? ''), [option?.script], - ); + ) const profileDocument = useEditorDocument({ open: fileOpen, load: loadProfileDocument, - }); + }) const mergeDocument = useEditorDocument({ open: mergeOpen, load: loadMergeDocument, - }); + }) const scriptDocument = useEditorDocument({ open: scriptOpen, load: loadScriptDocument, - }); + }) const onOpenHome = () => { - setAnchorEl(null); - open(itemData.home ?? ""); - }; + setAnchorEl(null) + open(itemData.home ?? '') + } const onEditInfo = () => { - setAnchorEl(null); - onEdit(); - }; + setAnchorEl(null) + onEdit() + } const onEditFile = () => { - setAnchorEl(null); - setFileOpen(true); - }; + setAnchorEl(null) + setFileOpen(true) + } const onEditRules = () => { - setAnchorEl(null); - setRulesOpen(true); - }; + setAnchorEl(null) + setRulesOpen(true) + } const onEditProxies = () => { - setAnchorEl(null); - setProxiesOpen(true); - }; + setAnchorEl(null) + setProxiesOpen(true) + } const onEditGroups = () => { - setAnchorEl(null); - setGroupsOpen(true); - }; + setAnchorEl(null) + setGroupsOpen(true) + } const onEditMerge = () => { - setAnchorEl(null); - setMergeOpen(true); - }; + setAnchorEl(null) + setMergeOpen(true) + } const onEditScript = () => { - setAnchorEl(null); - setScriptOpen(true); - }; + setAnchorEl(null) + setScriptOpen(true) + } const onForceSelect = () => { - setAnchorEl(null); - onSelect(true); - }; + setAnchorEl(null) + onSelect(true) + } const onOpenFile = useLockFn(async () => { - setAnchorEl(null); + setAnchorEl(null) try { - await viewProfile(itemData.uid); + await viewProfile(itemData.uid) } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) /// 0 不使用任何代理 /// 1 使用订阅好的代理 /// 2 至少使用一个代理,根据订阅,如果没订阅,默认使用系统代理 const onUpdate = useLockFn(async (type: 0 | 1 | 2): Promise => { - setAnchorEl(null); - setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true })); + setAnchorEl(null) + setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true })) // 根据类型设置初始更新选项 - const option: Partial = {}; + const option: Partial = {} if (type === 0) { - option.with_proxy = false; - option.self_proxy = false; + option.with_proxy = false + option.self_proxy = false } else if (type === 2) { if (itemData.option?.self_proxy) { - option.with_proxy = false; - option.self_proxy = true; + option.with_proxy = false + option.self_proxy = true } else { - option.with_proxy = true; - option.self_proxy = false; + option.with_proxy = true + option.self_proxy = false } } try { // 调用后端更新(后端会自动处理回退逻辑) - const payload = Object.keys(option).length > 0 ? option : undefined; - await updateProfile(itemData.uid, payload); + const payload = Object.keys(option).length > 0 ? option : undefined + await updateProfile(itemData.uid, payload) // 更新成功,刷新列表 - mutate("getProfiles"); + mutate('getProfiles') } catch { // 更新完全失败(包括后端的回退尝试) // 不需要做处理,后端会通过事件通知系统发送错误 } finally { - setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })); + setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })) } - }); + }) type ContextMenuItem = { - label: string; - handler: () => void; - disabled: boolean; - }; + label: string + handler: () => void + disabled: boolean + } const menuLabels: Record = { - home: "profiles.components.menu.home", - select: "profiles.components.menu.select", - editInfo: "profiles.components.menu.editInfo", - editFile: "profiles.components.menu.editFile", - editRules: "profiles.components.menu.editRules", - editProxies: "profiles.components.menu.editProxies", - editGroups: "profiles.components.menu.editGroups", - extendConfig: "profiles.components.menu.extendConfig", - extendScript: "profiles.components.menu.extendScript", - openFile: "profiles.components.menu.openFile", - update: "profiles.components.menu.update", - updateViaProxy: "profiles.components.menu.updateViaProxy", - delete: "shared.actions.delete", - } as const; + home: 'profiles.components.menu.home', + select: 'profiles.components.menu.select', + editInfo: 'profiles.components.menu.editInfo', + editFile: 'profiles.components.menu.editFile', + editRules: 'profiles.components.menu.editRules', + editProxies: 'profiles.components.menu.editProxies', + editGroups: 'profiles.components.menu.editGroups', + extendConfig: 'profiles.components.menu.extendConfig', + extendScript: 'profiles.components.menu.extendScript', + openFile: 'profiles.components.menu.openFile', + update: 'profiles.components.menu.update', + updateViaProxy: 'profiles.components.menu.updateViaProxy', + delete: 'shared.actions.delete', + } as const const urlModeMenu: ContextMenuItem[] = [ ...(hasHome @@ -482,19 +482,19 @@ export const ProfileItem = (props: Props) => { { label: menuLabels.delete, handler: () => { - setAnchorEl(null); + setAnchorEl(null) if (batchMode) { // If in batch mode, just toggle selection instead of showing delete confirmation if (onSelectionChange) { - onSelectionChange(); + onSelectionChange() } } else { - setConfirmOpen(true); + setConfirmOpen(true) } }, disabled: false, }, - ]; + ] const fileModeMenu: ContextMenuItem[] = [ { label: menuLabels.select, @@ -544,91 +544,91 @@ export const ProfileItem = (props: Props) => { { label: menuLabels.delete, handler: () => { - setAnchorEl(null); + setAnchorEl(null) if (batchMode) { // If in batch mode, just toggle selection instead of showing delete confirmation if (onSelectionChange) { - onSelectionChange(); + onSelectionChange() } } else { - setConfirmOpen(true); + setConfirmOpen(true) } }, disabled: false, }, - ]; + ] const boxStyle = { height: 26, - display: "flex", - alignItems: "center", - justifyContent: "space-between", - }; + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + } // 监听自动更新事件 useEffect(() => { const handleUpdateStarted = (event: Event) => { - const customEvent = event as CustomEvent<{ uid?: string }>; + const customEvent = event as CustomEvent<{ uid?: string }> if (customEvent.detail?.uid === itemData.uid) { - setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true })); + setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true })) } - }; + } const handleUpdateCompleted = (event: Event) => { - const customEvent = event as CustomEvent<{ uid?: string }>; + const customEvent = event as CustomEvent<{ uid?: string }> if (customEvent.detail?.uid === itemData.uid) { - setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })); + setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })) // 更新完成后刷新显示 if (showNextUpdate) { - fetchNextUpdateTime(); + fetchNextUpdateTime() } } - }; + } // 注册事件监听 - window.addEventListener("profile-update-started", handleUpdateStarted); - window.addEventListener("profile-update-completed", handleUpdateCompleted); + window.addEventListener('profile-update-started', handleUpdateStarted) + window.addEventListener('profile-update-completed', handleUpdateCompleted) return () => { // 清理事件监听 - window.removeEventListener("profile-update-started", handleUpdateStarted); + window.removeEventListener('profile-update-started', handleUpdateStarted) window.removeEventListener( - "profile-update-completed", + 'profile-update-completed', handleUpdateCompleted, - ); - }; - }, [fetchNextUpdateTime, itemData.uid, setLoadingCache, showNextUpdate]); + ) + } + }, [fetchNextUpdateTime, itemData.uid, setLoadingCache, showNextUpdate]) const handleSaveProfileDocument = useLockFn(async () => { - const currentValue = profileDocument.value; - await saveProfileFile(uid, currentValue); - onSave?.(profileDocument.savedValue, currentValue); - profileDocument.markSaved(currentValue); - }); + const currentValue = profileDocument.value + await saveProfileFile(uid, currentValue) + onSave?.(profileDocument.savedValue, currentValue) + profileDocument.markSaved(currentValue) + }) const handleSaveMergeDocument = useLockFn(async () => { - const mergeUid = option?.merge ?? ""; - const currentValue = mergeDocument.value; - await saveProfileFile(mergeUid, currentValue); - onSave?.(mergeDocument.savedValue, currentValue); - mergeDocument.markSaved(currentValue); - }); + const mergeUid = option?.merge ?? '' + const currentValue = mergeDocument.value + await saveProfileFile(mergeUid, currentValue) + onSave?.(mergeDocument.savedValue, currentValue) + mergeDocument.markSaved(currentValue) + }) const handleSaveScriptDocument = useLockFn(async () => { - const scriptUid = option?.script ?? ""; - const currentValue = scriptDocument.value; - await saveProfileFile(scriptUid, currentValue); - onSave?.(scriptDocument.savedValue, currentValue); - scriptDocument.markSaved(currentValue); - }); + const scriptUid = option?.script ?? '' + const currentValue = scriptDocument.value + await saveProfileFile(scriptUid, currentValue) + onSave?.(scriptDocument.savedValue, currentValue) + scriptDocument.markSaved(currentValue) + }) return ( { onClick={(e) => { // 如果正在激活中,阻止重复点击 if (activating) { - e.preventDefault(); - e.stopPropagation(); - return; + e.preventDefault() + e.stopPropagation() + return } - onSelect(false); + onSelect(false) }} onContextMenu={(event) => { - const { clientX, clientY } = event; - setPosition({ top: clientY, left: clientX }); - setAnchorEl(event.currentTarget as HTMLElement); - event.preventDefault(); + const { clientX, clientY } = event + setPosition({ top: clientY, left: clientX }) + setAnchorEl(event.currentTarget as HTMLElement) + event.preventDefault() }} > {activating && ( )} - + {batchMode && ( { - e.stopPropagation(); + e.stopPropagation() if (onSelectionChange) { - onSelectionChange(); + onSelectionChange() } }} > @@ -697,26 +697,26 @@ export const ProfileItem = (props: Props) => { { - return { color: text.primary }; + return { color: text.primary } }, ]} /> { {/* only if has url can it be updated */} {hasUrl && ( { - e.stopPropagation(); + e.stopPropagation() // 如果正在激活或加载中,阻止更新操作 if (activating || loading) { - return; + return } - onUpdate(1); + onUpdate(1) }} > @@ -761,7 +761,7 @@ export const ProfileItem = (props: Props) => { {description} @@ -769,7 +769,7 @@ export const ProfileItem = (props: Props) => { hasUrl && ( {from} @@ -778,9 +778,9 @@ export const ProfileItem = (props: Props) => { {hasUrl && ( { textAlign="right" title={ showNextUpdate - ? t("profiles.components.profileItem.tooltips.showLast") - : `${t("shared.labels.updateTime")}: ${parseExpire(updated)}\n${t("profiles.components.profileItem.tooltips.showNext")}` + ? t('profiles.components.profileItem.tooltips.showLast') + : `${t('shared.labels.updateTime')}: ${parseExpire(updated)}\n${t('profiles.components.profileItem.tooltips.showNext')}` } sx={{ - cursor: "pointer", - display: "inline-block", - borderBottom: "1px dashed transparent", - transition: "all 0.2s", - "&:hover": { - borderBottomColor: "primary.main", - color: "primary.main", + cursor: 'pointer', + display: 'inline-block', + borderBottom: '1px dashed transparent', + transition: 'all 0.2s', + '&:hover': { + borderBottomColor: 'primary.main', + color: 'primary.main', }, }} onClick={toggleUpdateTimeDisplay} @@ -809,7 +809,7 @@ export const ProfileItem = (props: Props) => { ? nextUpdateTime : updated > 0 ? dayjs(updated * 1000).fromNow() - : ""} + : ''} )} @@ -819,14 +819,14 @@ export const ProfileItem = (props: Props) => { {/* the third line show extra info or last updated time */} {hasExtra ? ( - + {parseTraffic(upload + download)} / {parseTraffic(total)} - {expire} + {expire} ) : ( - - + + {parseExpire(updated)} @@ -847,8 +847,8 @@ export const ProfileItem = (props: Props) => { transitionDuration={225} MenuListProps={{ sx: { py: 0.5 } }} onContextMenu={(e) => { - setAnchorEl(null); - e.preventDefault(); + setAnchorEl(null) + e.preventDefault() }} > {(hasUrl ? urlModeMenu : fileModeMenu).map((item) => ( @@ -866,7 +866,7 @@ export const ProfileItem = (props: Props) => { item.label === menuLabels.delete ? theme.palette.error.main : undefined, - }; + } }, ]} dense @@ -890,10 +890,10 @@ export const ProfileItem = (props: Props) => { )} {rulesOpen && ( setRulesOpen(false)} @@ -902,7 +902,7 @@ export const ProfileItem = (props: Props) => { {proxiesOpen && ( setProxiesOpen(false)} @@ -910,14 +910,14 @@ export const ProfileItem = (props: Props) => { )} {groupsOpen && ( { - setGroupsOpen(false); + setGroupsOpen(false) }} /> )} @@ -926,7 +926,7 @@ export const ProfileItem = (props: Props) => { open={true} value={mergeDocument.value} language="yaml" - path={`merge:${option?.merge ?? ""}.yaml`} + path={`merge:${option?.merge ?? ''}.yaml`} loading={mergeDocument.loading} dirty={mergeDocument.dirty} onChange={mergeDocument.setValue} @@ -939,7 +939,7 @@ export const ProfileItem = (props: Props) => { open={true} value={scriptDocument.value} language="javascript" - path={`script:${option?.script ?? ""}.js`} + path={`script:${option?.script ?? ''}.js`} loading={scriptDocument.loading} dirty={scriptDocument.dirty} onChange={scriptDocument.setValue} @@ -949,27 +949,27 @@ export const ProfileItem = (props: Props) => { )} setConfirmOpen(false)} onConfirm={() => { - onDelete(); - setConfirmOpen(false); + onDelete() + setConfirmOpen(false) }} /> - ); -}; + ) +} function parseUrl(url?: string) { - if (!url) return ""; - const regex = /https?:\/\/(.+?)\//; - const result = url.match(regex); - return result ? result[1] : "local file"; + if (!url) return '' + const regex = /https?:\/\/(.+?)\// + const result = url.match(regex) + return result ? result[1] : 'local file' } function parseExpire(expire?: number) { - if (!expire) return "-"; - return dayjs(expire * 1000).format("YYYY-MM-DD"); + if (!expire) return '-' + return dayjs(expire * 1000).format('YYYY-MM-DD') } diff --git a/src/components/profile/profile-more.tsx b/src/components/profile/profile-more.tsx index d296e53b8..362ff8d2f 100644 --- a/src/components/profile/profile-more.tsx +++ b/src/components/profile/profile-more.tsx @@ -1,4 +1,4 @@ -import { FeaturedPlayListRounded } from "@mui/icons-material"; +import { FeaturedPlayListRounded } from '@mui/icons-material' import { Box, Badge, @@ -7,99 +7,99 @@ import { Menu, MenuItem, Typography, -} from "@mui/material"; -import { useLockFn } from "ahooks"; -import { useCallback, useState } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { useLockFn } from 'ahooks' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { EditorViewer } from "@/components/profile/editor-viewer"; -import { useEditorDocument } from "@/hooks/use-editor-document"; -import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; +import { EditorViewer } from '@/components/profile/editor-viewer' +import { useEditorDocument } from '@/hooks/use-editor-document' +import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' -import { LogViewer } from "./log-viewer"; -import { ProfileBox } from "./profile-box"; +import { LogViewer } from './log-viewer' +import { ProfileBox } from './profile-box' interface Props { - logInfo?: [string, string][]; - id: "Merge" | "Script"; - onSave?: (prev?: string, curr?: string) => void; + logInfo?: [string, string][] + id: 'Merge' | 'Script' + onSave?: (prev?: string, curr?: string) => void } -const EMPTY_LOG_INFO: [string, string][] = []; +const EMPTY_LOG_INFO: [string, string][] = [] // profile enhanced item export const ProfileMore = (props: Props) => { - const { id, logInfo, onSave } = props; + const { id, logInfo, onSave } = props - const entries = logInfo ?? EMPTY_LOG_INFO; - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const [position, setPosition] = useState({ left: 0, top: 0 }); - const [fileOpen, setFileOpen] = useState(false); - const [logOpen, setLogOpen] = useState(false); + const entries = logInfo ?? EMPTY_LOG_INFO + const { t } = useTranslation() + const [anchorEl, setAnchorEl] = useState(null) + const [position, setPosition] = useState({ left: 0, top: 0 }) + const [fileOpen, setFileOpen] = useState(false) + const [logOpen, setLogOpen] = useState(false) - const loadDocument = useCallback(() => readProfileFile(id), [id]); + const loadDocument = useCallback(() => readProfileFile(id), [id]) const document = useEditorDocument({ open: fileOpen, load: loadDocument, - }); + }) const onEditFile = () => { - setAnchorEl(null); - setFileOpen(true); - }; + setAnchorEl(null) + setFileOpen(true) + } const onOpenFile = useLockFn(async () => { - setAnchorEl(null); + setAnchorEl(null) try { - await viewProfile(id); + await viewProfile(id) } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) - const hasError = entries.some(([level]) => level === "exception"); + const hasError = entries.some(([level]) => level === 'exception') - const globalTitles: Record = { - Merge: "profiles.components.more.global.merge", - Script: "profiles.components.more.global.script", - }; + const globalTitles: Record = { + Merge: 'profiles.components.more.global.merge', + Script: 'profiles.components.more.global.script', + } - const chipLabels: Record = { - Merge: "profiles.components.more.chips.merge", - Script: "profiles.components.more.chips.script", - }; + const chipLabels: Record = { + Merge: 'profiles.components.more.chips.merge', + Script: 'profiles.components.more.chips.script', + } const itemMenu = [ - { label: "profiles.components.menu.editFile", handler: onEditFile }, - { label: "profiles.components.menu.openFile", handler: onOpenFile }, - ]; + { label: 'profiles.components.menu.editFile', handler: onEditFile }, + { label: 'profiles.components.menu.openFile', handler: onOpenFile }, + ] const boxStyle = { height: 26, - display: "flex", - alignItems: "center", - justifyContent: "space-between", + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', lineHeight: 1, - }; + } const handleSave = useLockFn(async () => { - const currentValue = document.value; - await saveProfileFile(id, currentValue); - onSave?.(document.savedValue, currentValue); - document.markSaved(currentValue); - }); + const currentValue = document.value + await saveProfileFile(id, currentValue) + onSave?.(document.savedValue, currentValue) + document.markSaved(currentValue) + }) return ( <> { - const { clientX, clientY } = event; - setPosition({ top: clientY, left: clientX }); - setAnchorEl(event.currentTarget as HTMLElement); - event.preventDefault(); + const { clientX, clientY } = event + setPosition({ top: clientY, left: clientX }) + setAnchorEl(event.currentTarget as HTMLElement) + event.preventDefault() }} > { color="primary" size="small" variant="outlined" - sx={{ height: 20, textTransform: "capitalize" }} + sx={{ height: 20, textTransform: 'capitalize' }} /> - {id === "Script" && + {id === 'Script' && (hasError ? ( setLogOpen(true)} > @@ -146,7 +146,7 @@ export const ProfileMore = (props: Props) => { size="small" edge="start" color="inherit" - title={t("profiles.modals.logViewer.title")} + title={t('profiles.modals.logViewer.title')} onClick={() => setLogOpen(true)} > @@ -164,8 +164,8 @@ export const ProfileMore = (props: Props) => { transitionDuration={225} MenuListProps={{ sx: { py: 0.5 } }} onContextMenu={(e) => { - setAnchorEl(null); - e.preventDefault(); + setAnchorEl(null) + e.preventDefault() }} > {itemMenu @@ -179,10 +179,10 @@ export const ProfileMore = (props: Props) => { (theme) => { return { color: - item.label === "Delete" + item.label === 'Delete' ? theme.palette.error.main : undefined, - }; + } }, ]} dense @@ -196,8 +196,8 @@ export const ProfileMore = (props: Props) => { open={true} title={t(globalTitles[id])} value={document.value} - language={id === "Merge" ? "yaml" : "javascript"} - path={`profile-more:${id}.${id === "Merge" ? "yaml" : "js"}`} + language={id === 'Merge' ? 'yaml' : 'javascript'} + path={`profile-more:${id}.${id === 'Merge' ? 'yaml' : 'js'}`} loading={document.loading} dirty={document.dirty} onChange={document.setValue} @@ -213,5 +213,5 @@ export const ProfileMore = (props: Props) => { /> )} - ); -}; + ) +} diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 6324bf330..1de50cd89 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -7,146 +7,146 @@ import { Select, styled, TextField, -} from "@mui/material"; -import { useLockFn } from "ahooks"; -import type { Ref } from "react"; -import { useEffect, useImperativeHandle, useRef, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { useLockFn } from 'ahooks' +import type { Ref } from 'react' +import { useEffect, useImperativeHandle, useRef, useState } from 'react' +import { Controller, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' -import { BaseDialog, Switch } from "@/components/base"; -import { useProfiles } from "@/hooks/use-profiles"; -import { createProfile, patchProfile } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; -import { version } from "@root/package.json"; +import { BaseDialog, Switch } from '@/components/base' +import { useProfiles } from '@/hooks/use-profiles' +import { createProfile, patchProfile } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import { version } from '@root/package.json' -import { FileInput } from "./file-input"; +import { FileInput } from './file-input' interface Props { - onChange: (isActivating?: boolean) => void; + onChange: (isActivating?: boolean) => void } export interface ProfileViewerRef { - create: () => void; - edit: (item: IProfileItem) => void; + create: () => void + edit: (item: IProfileItem) => void } // create or edit the profile // remote / local -type ProfileViewerProps = Props & { ref?: Ref }; +type ProfileViewerProps = Props & { ref?: Ref } export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [openType, setOpenType] = useState<"new" | "edit">("new"); - const [loading, setLoading] = useState(false); - const { profiles } = useProfiles(); + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [openType, setOpenType] = useState<'new' | 'edit'>('new') + const [loading, setLoading] = useState(false) + const { profiles } = useProfiles() // file input - const fileDataRef = useRef(null); + const fileDataRef = useRef(null) const { control, watch, setValue, reset, handleSubmit, getValues } = useForm({ defaultValues: { - type: "remote", - name: "", - desc: "", - url: "", + type: 'remote', + name: '', + desc: '', + url: '', option: { with_proxy: false, self_proxy: false, }, }, - }); + }) useImperativeHandle(ref, () => ({ create: () => { - setOpenType("new"); - setOpen(true); + setOpenType('new') + setOpen(true) }, edit: (item: IProfileItem) => { if (item) { Object.entries(item).forEach(([key, value]) => { - setValue(key as any, value); - }); + setValue(key as any, value) + }) } - setOpenType("edit"); - setOpen(true); + setOpenType('edit') + setOpen(true) }, - })); + })) - const selfProxy = watch("option.self_proxy"); - const withProxy = watch("option.with_proxy"); + const selfProxy = watch('option.self_proxy') + const withProxy = watch('option.with_proxy') useEffect(() => { - if (selfProxy) setValue("option.with_proxy", false); - }, [selfProxy, setValue]); + if (selfProxy) setValue('option.with_proxy', false) + }, [selfProxy, setValue]) useEffect(() => { - if (withProxy) setValue("option.self_proxy", false); - }, [setValue, withProxy]); + if (withProxy) setValue('option.self_proxy', false) + }, [setValue, withProxy]) const handleOk = useLockFn( handleSubmit(async (form) => { - setLoading(true); + setLoading(true) try { // 基本验证 - if (!form.type) throw new Error("`Type` should not be null"); - if (form.type === "remote" && !form.url) { - throw new Error("The URL should not be null"); + if (!form.type) throw new Error('`Type` should not be null') + if (form.type === 'remote' && !form.url) { + throw new Error('The URL should not be null') } // 处理表单数据 - const option = form.option ? { ...form.option } : undefined; + const option = form.option ? { ...form.option } : undefined if (option?.timeout_seconds) { - option.timeout_seconds = +option.timeout_seconds; + option.timeout_seconds = +option.timeout_seconds } if (option?.update_interval) { - option.update_interval = +option.update_interval; + option.update_interval = +option.update_interval } else if (option) { - option.update_interval = undefined; + option.update_interval = undefined } - if (option?.user_agent === "") { - option.user_agent = undefined; + if (option?.user_agent === '') { + option.user_agent = undefined } - const name = form.name || `${form.type} file`; - const item = { ...form, name, option }; - const isRemote = form.type === "remote"; - const isUpdate = openType === "edit"; + const name = form.name || `${form.type} file` + const item = { ...form, name, option } + const isRemote = form.type === 'remote' + const isUpdate = openType === 'edit' // 判断是否是当前激活的配置 - const isActivating = isUpdate && form.uid === (profiles?.current ?? ""); + const isActivating = isUpdate && form.uid === (profiles?.current ?? '') // 保存原始代理设置以便回退成功后恢复 const originalOptions = { with_proxy: form.option?.with_proxy, self_proxy: form.option?.self_proxy, - }; + } // 执行创建或更新操作,本地配置不需要回退机制 if (!isRemote) { - if (openType === "new") { - await createProfile(item, fileDataRef.current); + if (openType === 'new') { + await createProfile(item, fileDataRef.current) } else { - if (!form.uid) throw new Error("UID not found"); - await patchProfile(form.uid, item); + if (!form.uid) throw new Error('UID not found') + await patchProfile(form.uid, item) } } else { // 远程配置使用回退机制 try { // 尝试正常操作 - if (openType === "new") { - await createProfile(item, fileDataRef.current); + if (openType === 'new') { + await createProfile(item, fileDataRef.current) } else { - if (!form.uid) throw new Error("UID not found"); - await patchProfile(form.uid, item); + if (!form.uid) throw new Error('UID not found') + await patchProfile(form.uid, item) } } catch { // 首次创建/更新失败,尝试使用自身代理 showNotice.info( - "profiles.modals.profileForm.feedback.notifications.creationRetry", - ); + 'profiles.modals.profileForm.feedback.notifications.creationRetry', + ) // 使用自身代理的配置 const retryItem = { @@ -156,76 +156,76 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { with_proxy: false, self_proxy: true, }, - }; + } // 使用自身代理再次尝试 - if (openType === "new") { - await createProfile(retryItem, fileDataRef.current); + if (openType === 'new') { + await createProfile(retryItem, fileDataRef.current) } else { - if (!form.uid) throw new Error("UID not found"); - await patchProfile(form.uid, retryItem); + if (!form.uid) throw new Error('UID not found') + await patchProfile(form.uid, retryItem) // 编辑模式下恢复原始代理设置 - await patchProfile(form.uid, { option: originalOptions }); + await patchProfile(form.uid, { option: originalOptions }) } showNotice.success( - "profiles.modals.profileForm.feedback.notifications.creationSuccess", - ); + 'profiles.modals.profileForm.feedback.notifications.creationSuccess', + ) } } // 成功后的操作 - setOpen(false); - setTimeout(() => reset(), 500); - fileDataRef.current = null; + setOpen(false) + setTimeout(() => reset(), 500) + fileDataRef.current = null // 优化:UI先关闭,异步通知父组件 setTimeout(() => { - onChange(isActivating); - }, 0); + onChange(isActivating) + }, 0) } catch (err) { - showNotice.error(err); + showNotice.error(err) } finally { - setLoading(false); + setLoading(false) } }), - ); + ) const handleClose = () => { try { - setOpen(false); - fileDataRef.current = null; - setTimeout(() => reset(), 500); + setOpen(false) + fileDataRef.current = null + setTimeout(() => reset(), 500) } catch (e) { - console.warn("[ProfileViewer] handleClose error:", e); + console.warn('[ProfileViewer] handleClose error:', e) } - }; + } const text = { fullWidth: true, - size: "small", - margin: "normal", - variant: "outlined", - autoComplete: "off", - autoCorrect: "off", - } as const; + size: 'small', + margin: 'normal', + variant: 'outlined', + autoComplete: 'off', + autoCorrect: 'off', + } as const - const formType = watch("type"); - const isRemote = formType === "remote"; - const isLocal = formType === "local"; + const formType = watch('type') + const isRemote = formType === 'remote' + const isLocal = formType === 'local' return ( ( - {t("profiles.modals.profileForm.fields.type")} + {t('profiles.modals.profileForm.fields.type')} fake-ip redir-host @@ -710,14 +710,14 @@ export function DnsViewer({ ref }: { ref?: Ref }) { @@ -725,12 +725,12 @@ export function DnsViewer({ ref }: { ref?: Ref }) { { - const ret = changeRef.current.slice(); + const ret = changeRef.current.slice() if (ret.length) { - onChange(ret); - changeRef.current = []; + onChange(ret) + changeRef.current = [] } }} onKeyDown={(e) => { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault() + e.stopPropagation() - const key = parseHotkey(e); - if (key === "UNIDENTIFIED") return; + const key = parseHotkey(e) + if (key === 'UNIDENTIFIED') return - changeRef.current = [...new Set([...changeRef.current, key])]; - setKeys(changeRef.current); + changeRef.current = [...new Set([...changeRef.current, key])] + setKeys(changeRef.current) }} /> @@ -102,15 +102,15 @@ export const HotkeyInput = (props: Props) => { { - onChange([]); - setKeys([]); + onChange([]) + setKeys([]) }} > - ); -}; + ) +} diff --git a/src/components/setting/mods/hotkey-viewer.tsx b/src/components/setting/mods/hotkey-viewer.tsx index 870332388..e079c68a1 100644 --- a/src/components/setting/mods/hotkey-viewer.tsx +++ b/src/components/setting/mods/hotkey-viewer.tsx @@ -1,119 +1,119 @@ -import { styled, Typography } from "@mui/material"; -import { useLockFn } from "ahooks"; -import { forwardRef, useImperativeHandle, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { styled, Typography } from '@mui/material' +import { useLockFn } from 'ahooks' +import { forwardRef, useImperativeHandle, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { BaseDialog, DialogRef, Switch } from "@/components/base"; -import { useVerge } from "@/hooks/use-verge"; -import { showNotice } from "@/services/notice-service"; +import { BaseDialog, DialogRef, Switch } from '@/components/base' +import { useVerge } from '@/hooks/use-verge' +import { showNotice } from '@/services/notice-service' -import { HotkeyInput } from "./hotkey-input"; +import { HotkeyInput } from './hotkey-input' -const ItemWrapper = styled("div")` +const ItemWrapper = styled('div')` display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; -`; +` const HOTKEY_FUNC = [ - "open_or_close_dashboard", - "clash_mode_rule", - "clash_mode_global", - "clash_mode_direct", - "toggle_system_proxy", - "toggle_tun_mode", - "entry_lightweight_mode", - "reactivate_profiles", -] as const; + 'open_or_close_dashboard', + 'clash_mode_rule', + 'clash_mode_global', + 'clash_mode_direct', + 'toggle_system_proxy', + 'toggle_tun_mode', + 'entry_lightweight_mode', + 'reactivate_profiles', +] as const const HOTKEY_FUNC_LABELS: Record<(typeof HOTKEY_FUNC)[number], string> = { open_or_close_dashboard: - "settings.modals.hotkey.functions.openOrCloseDashboard", - clash_mode_rule: "settings.modals.hotkey.functions.rule", - clash_mode_global: "settings.modals.hotkey.functions.global", - clash_mode_direct: "settings.modals.hotkey.functions.direct", - toggle_system_proxy: "settings.modals.hotkey.functions.toggleSystemProxy", - toggle_tun_mode: "settings.modals.hotkey.functions.toggleTunMode", + 'settings.modals.hotkey.functions.openOrCloseDashboard', + clash_mode_rule: 'settings.modals.hotkey.functions.rule', + clash_mode_global: 'settings.modals.hotkey.functions.global', + clash_mode_direct: 'settings.modals.hotkey.functions.direct', + toggle_system_proxy: 'settings.modals.hotkey.functions.toggleSystemProxy', + toggle_tun_mode: 'settings.modals.hotkey.functions.toggleTunMode', entry_lightweight_mode: - "settings.modals.hotkey.functions.entryLightweightMode", - reactivate_profiles: "settings.modals.hotkey.functions.reactivateProfiles", -}; + 'settings.modals.hotkey.functions.entryLightweightMode', + reactivate_profiles: 'settings.modals.hotkey.functions.reactivateProfiles', +} export const HotkeyViewer = forwardRef((props, ref) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); + const { t } = useTranslation() + const [open, setOpen] = useState(false) - const { verge, patchVerge } = useVerge(); + const { verge, patchVerge } = useVerge() - const [hotkeyMap, setHotkeyMap] = useState>({}); + const [hotkeyMap, setHotkeyMap] = useState>({}) const [enableGlobalHotkey, setEnableGlobalHotkey] = useState( verge?.enable_global_hotkey ?? true, - ); + ) useImperativeHandle(ref, () => ({ open: () => { - setOpen(true); + setOpen(true) - const map = {} as typeof hotkeyMap; + const map = {} as typeof hotkeyMap verge?.hotkeys?.forEach((text) => { - const [func, key] = text.split(",").map((e) => e.trim()); + const [func, key] = text.split(',').map((e) => e.trim()) - if (!func || !key) return; + if (!func || !key) return map[func] = key - .split("+") + .split('+') .map((e) => e.trim()) - .map((k) => (k === "PLUS" ? "+" : k)); - }); + .map((k) => (k === 'PLUS' ? '+' : k)) + }) - setHotkeyMap(map); + setHotkeyMap(map) }, close: () => setOpen(false), - })); + })) const onSave = useLockFn(async () => { const hotkeys = Object.entries(hotkeyMap) .map(([func, keys]) => { - if (!func || !keys?.length) return ""; + if (!func || !keys?.length) return '' const key = keys .map((k) => k.trim()) .filter(Boolean) - .map((k) => (k === "+" ? "PLUS" : k)) - .join("+"); + .map((k) => (k === '+' ? 'PLUS' : k)) + .join('+') - if (!key) return ""; - return `${func},${key}`; + if (!key) return '' + return `${func},${key}` }) - .filter(Boolean); + .filter(Boolean) try { await patchVerge({ hotkeys, enable_global_hotkey: enableGlobalHotkey, - }); - setOpen(false); + }) + setOpen(false) } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) return ( setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} > - {t("settings.modals.hotkey.toggles.enableGlobal")} + {t('settings.modals.hotkey.toggles.enableGlobal')} ((props, ref) => { ))} - ); -}); + ) +}) diff --git a/src/components/setting/mods/layout-viewer.tsx b/src/components/setting/mods/layout-viewer.tsx index 945f191f2..cc8f91a1b 100644 --- a/src/components/setting/mods/layout-viewer.tsx +++ b/src/components/setting/mods/layout-viewer.tsx @@ -9,113 +9,113 @@ import { Select, TextField, styled, -} from "@mui/material"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { join } from "@tauri-apps/api/path"; -import { open as openDialog } from "@tauri-apps/plugin-dialog"; -import { exists } from "@tauri-apps/plugin-fs"; -import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { convertFileSrc } from '@tauri-apps/api/core' +import { join } from '@tauri-apps/api/path' +import { open as openDialog } from '@tauri-apps/plugin-dialog' +import { exists } from '@tauri-apps/plugin-fs' +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { BaseDialog, DialogRef, Switch, TooltipIcon } from "@/components/base"; -import { DEFAULT_HOVER_DELAY } from "@/components/proxy/proxy-group-navigator"; -import { useVerge } from "@/hooks/use-verge"; -import { useWindowDecorations } from "@/hooks/use-window"; -import { copyIconFile, getAppDir } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; -import getSystem from "@/utils/get-system"; +import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base' +import { DEFAULT_HOVER_DELAY } from '@/components/proxy/proxy-group-navigator' +import { useVerge } from '@/hooks/use-verge' +import { useWindowDecorations } from '@/hooks/use-window' +import { copyIconFile, getAppDir } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import getSystem from '@/utils/get-system' -import { GuardState } from "./guard-state"; +import { GuardState } from './guard-state' -const OS = getSystem(); +const OS = getSystem() const clampHoverDelay = (value: number) => { if (!Number.isFinite(value)) { - return DEFAULT_HOVER_DELAY; + return DEFAULT_HOVER_DELAY } - return Math.min(5000, Math.max(0, Math.round(value))); -}; + return Math.min(5000, Math.max(0, Math.round(value))) +} const getIcons = async (icon_dir: string, name: string) => { - const updateTime = localStorage.getItem(`icon_${name}_update_time`) || ""; + const updateTime = localStorage.getItem(`icon_${name}_update_time`) || '' - const icon_png = await join(icon_dir, `${name}-${updateTime}.png`); - const icon_ico = await join(icon_dir, `${name}-${updateTime}.ico`); + const icon_png = await join(icon_dir, `${name}-${updateTime}.png`) + const icon_ico = await join(icon_dir, `${name}-${updateTime}.ico`) return { icon_png, icon_ico, - }; -}; + } +} export const LayoutViewer = forwardRef((_, ref) => { - const { t } = useTranslation(); - const { verge, patchVerge, mutateVerge } = useVerge(); + const { t } = useTranslation() + const { verge, patchVerge, mutateVerge } = useVerge() - const [open, setOpen] = useState(false); - const [commonIcon, setCommonIcon] = useState(""); - const [sysproxyIcon, setSysproxyIcon] = useState(""); - const [tunIcon, setTunIcon] = useState(""); + const [open, setOpen] = useState(false) + const [commonIcon, setCommonIcon] = useState('') + const [sysproxyIcon, setSysproxyIcon] = useState('') + const [tunIcon, setTunIcon] = useState('') - const { decorated, toggleDecorations } = useWindowDecorations(); + const { decorated, toggleDecorations } = useWindowDecorations() useEffect(() => { - initIconPath(); - }, []); + initIconPath() + }, []) async function initIconPath() { - const appDir = await getAppDir(); + const appDir = await getAppDir() - const icon_dir = await join(appDir, "icons"); + const icon_dir = await join(appDir, 'icons') const { icon_png: common_icon_png, icon_ico: common_icon_ico } = - await getIcons(icon_dir, "common"); + await getIcons(icon_dir, 'common') const { icon_png: sysproxy_icon_png, icon_ico: sysproxy_icon_ico } = - await getIcons(icon_dir, "sysproxy"); + await getIcons(icon_dir, 'sysproxy') const { icon_png: tun_icon_png, icon_ico: tun_icon_ico } = await getIcons( icon_dir, - "tun", - ); + 'tun', + ) if (await exists(common_icon_ico)) { - setCommonIcon(common_icon_ico); + setCommonIcon(common_icon_ico) } else { - setCommonIcon(common_icon_png); + setCommonIcon(common_icon_png) } if (await exists(sysproxy_icon_ico)) { - setSysproxyIcon(sysproxy_icon_ico); + setSysproxyIcon(sysproxy_icon_ico) } else { - setSysproxyIcon(sysproxy_icon_png); + setSysproxyIcon(sysproxy_icon_png) } if (await exists(tun_icon_ico)) { - setTunIcon(tun_icon_ico); + setTunIcon(tun_icon_ico) } else { - setTunIcon(tun_icon_png); + setTunIcon(tun_icon_png) } } useImperativeHandle(ref, () => ({ open: () => setOpen(true), close: () => setOpen(false), - })); + })) - const onSwitchFormat = (_e: any, value: boolean) => value; + const onSwitchFormat = (_e: any, value: boolean) => value const onError = (err: any) => { - showNotice.error(err); - }; + showNotice.error(err) + } const onChangeData = (patch: Partial) => { - mutateVerge({ ...verge, ...patch }, false); - }; + mutateVerge({ ...verge, ...patch }, false) + } return ( setOpen(false)} onCancel={() => setOpen(false)} > @@ -123,7 +123,7 @@ export const LayoutViewer = forwardRef((_, ref) => { ((_, ref) => { onCatch={onError} onFormat={onSwitchFormat} onChange={async () => { - await toggleDecorations(); + await toggleDecorations() }} > @@ -141,7 +141,7 @@ export const LayoutViewer = forwardRef((_, ref) => { ((_, ref) => { ((_, ref) => { ((_, ref) => { e.target.value} onChange={(value) => onChangeData({ notice_position: value })} onGuard={(value) => patchVerge({ notice_position: value })} > - div': { py: '7.5px' } }}> {t( - "settings.components.verge.layout.options.toastPosition.topRight", + 'settings.components.verge.layout.options.toastPosition.topRight', )} {t( - "settings.components.verge.layout.options.toastPosition.topLeft", + 'settings.components.verge.layout.options.toastPosition.topLeft', )} {t( - "settings.components.verge.layout.options.toastPosition.bottomRight", + 'settings.components.verge.layout.options.toastPosition.bottomRight', )} {t( - "settings.components.verge.layout.options.toastPosition.bottomLeft", + 'settings.components.verge.layout.options.toastPosition.bottomLeft', )} @@ -228,15 +228,15 @@ export const LayoutViewer = forwardRef((_, ref) => { + - {t("settings.components.verge.layout.fields.hoverNavigator")} + {t('settings.components.verge.layout.fields.hoverNavigator')} } @@ -256,17 +256,17 @@ export const LayoutViewer = forwardRef((_, ref) => { + {t( - "settings.components.verge.layout.fields.hoverNavigatorDelay", + 'settings.components.verge.layout.fields.hoverNavigatorDelay', )} } @@ -298,7 +298,7 @@ export const LayoutViewer = forwardRef((_, ref) => { input: { endAdornment: ( - {t("shared.units.milliseconds")} + {t('shared.units.milliseconds')} ), }, @@ -314,24 +314,24 @@ export const LayoutViewer = forwardRef((_, ref) => { e.target.value} onChange={(value) => onChangeData({ menu_icon: value })} onGuard={(value) => patchVerge({ menu_icon: value })} > - div': { py: '7.5px' } }}> - {t("settings.components.verge.layout.options.icon.monochrome")} + {t('settings.components.verge.layout.options.icon.monochrome')} - {t("settings.components.verge.layout.options.icon.colorful")} + {t('settings.components.verge.layout.options.icon.colorful')} - {t("settings.components.verge.layout.options.icon.disable")} + {t('settings.components.verge.layout.options.icon.disable')} @@ -340,7 +340,7 @@ export const LayoutViewer = forwardRef((_, ref) => { ((_, ref) => { - {OS === "macos" && ( + {OS === 'macos' && ( e.target.value} onChange={(e) => onChangeData({ tray_icon: e })} @@ -369,15 +369,15 @@ export const LayoutViewer = forwardRef((_, ref) => { > @@ -421,11 +421,11 @@ export const LayoutViewer = forwardRef((_, ref) => { e.target.value} onChange={(value) => @@ -435,20 +435,20 @@ export const LayoutViewer = forwardRef((_, ref) => { patchVerge({ tray_proxy_groups_display_mode: value }) } > - div': { py: '7.5px' } }}> {t( - "settings.components.verge.layout.options.proxyGroupsDisplayMode.default", + 'settings.components.verge.layout.options.proxyGroupsDisplayMode.default', )} {t( - "settings.components.verge.layout.options.proxyGroupsDisplayMode.inline", + 'settings.components.verge.layout.options.proxyGroupsDisplayMode.inline', )} {t( - "settings.components.verge.layout.options.proxyGroupsDisplayMode.disable", + 'settings.components.verge.layout.options.proxyGroupsDisplayMode.disable', )} @@ -457,7 +457,7 @@ export const LayoutViewer = forwardRef((_, ref) => { ((_, ref) => { ((_, ref) => { } onClick={async () => { if (verge?.common_tray_icon) { - onChangeData({ common_tray_icon: false }); - patchVerge({ common_tray_icon: false }); + onChangeData({ common_tray_icon: false }) + patchVerge({ common_tray_icon: false }) } else { const selected = await openDialog({ directory: false, multiple: false, filters: [ { - name: "Tray Icon Image", - extensions: ["png", "ico"], + name: 'Tray Icon Image', + extensions: ['png', 'ico'], }, ], - }); + }) if (selected) { - await copyIconFile(`${selected}`, "common"); - await initIconPath(); - onChangeData({ common_tray_icon: true }); - patchVerge({ common_tray_icon: true }); + await copyIconFile(`${selected}`, 'common') + await initIconPath() + onChangeData({ common_tray_icon: true }) + patchVerge({ common_tray_icon: true }) } } }} > {verge?.common_tray_icon - ? t("shared.actions.clear") - : t("settings.components.verge.basic.actions.browse")} + ? t('shared.actions.clear') + : t('settings.components.verge.basic.actions.browse')} @@ -528,7 +528,7 @@ export const LayoutViewer = forwardRef((_, ref) => { ((_, ref) => { } onClick={async () => { if (verge?.sysproxy_tray_icon) { - onChangeData({ sysproxy_tray_icon: false }); - patchVerge({ sysproxy_tray_icon: false }); + onChangeData({ sysproxy_tray_icon: false }) + patchVerge({ sysproxy_tray_icon: false }) } else { const selected = await openDialog({ directory: false, multiple: false, filters: [ { - name: "Tray Icon Image", - extensions: ["png", "ico"], + name: 'Tray Icon Image', + extensions: ['png', 'ico'], }, ], - }); + }) if (selected) { - await copyIconFile(`${selected}`, "sysproxy"); - await initIconPath(); - onChangeData({ sysproxy_tray_icon: true }); - patchVerge({ sysproxy_tray_icon: true }); + await copyIconFile(`${selected}`, 'sysproxy') + await initIconPath() + onChangeData({ sysproxy_tray_icon: true }) + patchVerge({ sysproxy_tray_icon: true }) } } }} > {verge?.sysproxy_tray_icon - ? t("shared.actions.clear") - : t("settings.components.verge.basic.actions.browse")} + ? t('shared.actions.clear') + : t('settings.components.verge.basic.actions.browse')} ((_, ref) => { } onClick={async () => { if (verge?.tun_tray_icon) { - onChangeData({ tun_tray_icon: false }); - patchVerge({ tun_tray_icon: false }); + onChangeData({ tun_tray_icon: false }) + patchVerge({ tun_tray_icon: false }) } else { const selected = await openDialog({ directory: false, multiple: false, filters: [ { - name: "Tun Icon Image", - extensions: ["png", "ico"], + name: 'Tun Icon Image', + extensions: ['png', 'ico'], }, ], - }); + }) if (selected) { - await copyIconFile(`${selected}`, "tun"); - await initIconPath(); - onChangeData({ tun_tray_icon: true }); - patchVerge({ tun_tray_icon: true }); + await copyIconFile(`${selected}`, 'tun') + await initIconPath() + onChangeData({ tun_tray_icon: true }) + patchVerge({ tun_tray_icon: true }) } } }} > {verge?.tun_tray_icon - ? t("shared.actions.clear") - : t("settings.components.verge.basic.actions.browse")} + ? t('shared.actions.clear') + : t('settings.components.verge.basic.actions.browse')} - ); -}); + ) +}) const Item = styled(ListItem)(() => ({ - padding: "5px 2px", -})); + padding: '5px 2px', +})) diff --git a/src/components/setting/mods/lite-mode-viewer.tsx b/src/components/setting/mods/lite-mode-viewer.tsx index c6e2cd2af..02b38660c 100644 --- a/src/components/setting/mods/lite-mode-viewer.tsx +++ b/src/components/setting/mods/lite-mode-viewer.tsx @@ -5,87 +5,87 @@ import { ListItemText, TextField, Typography, -} from "@mui/material"; -import { useLockFn } from "ahooks"; -import type { Ref } from "react"; -import { useImperativeHandle, useState } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { useLockFn } from 'ahooks' +import type { Ref } from 'react' +import { useImperativeHandle, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { BaseDialog, DialogRef, Switch, TooltipIcon } from "@/components/base"; -import { useVerge } from "@/hooks/use-verge"; -import { entry_lightweight_mode } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; +import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base' +import { useVerge } from '@/hooks/use-verge' +import { entry_lightweight_mode } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' export function LiteModeViewer({ ref }: { ref?: Ref }) { - const { t } = useTranslation(); - const { verge, patchVerge } = useVerge(); + const { t } = useTranslation() + const { verge, patchVerge } = useVerge() - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false) const [values, setValues] = useState({ autoEnterLiteMode: false, autoEnterLiteModeDelay: 10, // 默认10分钟 - }); + }) useImperativeHandle(ref, () => ({ open: () => { - setOpen(true); + setOpen(true) setValues({ autoEnterLiteMode: verge?.enable_auto_light_weight_mode ?? false, autoEnterLiteModeDelay: verge?.auto_light_weight_minutes ?? 10, - }); + }) }, close: () => setOpen(false), - })); + })) const onSave = useLockFn(async () => { try { await patchVerge({ enable_auto_light_weight_mode: values.autoEnterLiteMode, auto_light_weight_minutes: values.autoEnterLiteModeDelay, - }); - setOpen(false); + }) + setOpen(false) } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) return ( setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} > - + await entry_lightweight_mode()} > - {t("shared.actions.enable")} + {t('shared.actions.enable')} - + }) { onChange={(_, c) => setValues((v) => ({ ...v, autoEnterLiteMode: c })) } - sx={{ marginLeft: "auto" }} + sx={{ marginLeft: 'auto' }} /> {values.autoEnterLiteMode && ( <> - + }) { input: { endAdornment: ( - {t("shared.units.minutes")} + {t('shared.units.minutes')} ), }, @@ -130,13 +130,13 @@ export function LiteModeViewer({ ref }: { ref?: Ref }) { /> - + - {t("settings.modals.liteMode.messages.autoEnterHint", { + {t('settings.modals.liteMode.messages.autoEnterHint', { n: values.autoEnterLiteModeDelay, })} @@ -145,5 +145,5 @@ export function LiteModeViewer({ ref }: { ref?: Ref }) { )} - ); + ) } diff --git a/src/components/setting/mods/misc-viewer.tsx b/src/components/setting/mods/misc-viewer.tsx index e87ada919..03f5ed5e9 100644 --- a/src/components/setting/mods/misc-viewer.tsx +++ b/src/components/setting/mods/misc-viewer.tsx @@ -6,22 +6,22 @@ import { MenuItem, Select, TextField, -} from "@mui/material"; -import { useLockFn } from "ahooks"; -import { forwardRef, useImperativeHandle, useState } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { useLockFn } from 'ahooks' +import { forwardRef, useImperativeHandle, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { BaseDialog, DialogRef, Switch, TooltipIcon } from "@/components/base"; -import { useVerge } from "@/hooks/use-verge"; -import { showNotice } from "@/services/notice-service"; +import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base' +import { useVerge } from '@/hooks/use-verge' +import { showNotice } from '@/services/notice-service' export const MiscViewer = forwardRef((props, ref) => { - const { t } = useTranslation(); - const { verge, patchVerge } = useVerge(); + const { t } = useTranslation() + const { verge, patchVerge } = useVerge() - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false) const [values, setValues] = useState({ - appLogLevel: "warn", + appLogLevel: 'warn', appLogMaxSize: 8, appLogMaxCount: 12, autoCloseConnection: true, @@ -30,16 +30,16 @@ export const MiscViewer = forwardRef((props, ref) => { proxyLayoutColumn: 6, enableAutoDelayDetection: false, autoDelayDetectionIntervalMinutes: 5, - defaultLatencyTest: "", + defaultLatencyTest: '', autoLogClean: 2, defaultLatencyTimeout: 10000, - }); + }) useImperativeHandle(ref, () => ({ open: () => { - setOpen(true); + setOpen(true) setValues({ - appLogLevel: verge?.app_log_level ?? "warn", + appLogLevel: verge?.app_log_level ?? 'warn', appLogMaxSize: verge?.app_log_max_size ?? 128, appLogMaxCount: verge?.app_log_max_count ?? 8, autoCloseConnection: verge?.auto_close_connection ?? true, @@ -49,13 +49,13 @@ export const MiscViewer = forwardRef((props, ref) => { enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false, autoDelayDetectionIntervalMinutes: verge?.auto_delay_detection_interval_minutes ?? 5, - defaultLatencyTest: verge?.default_latency_test || "", + defaultLatencyTest: verge?.default_latency_test || '', autoLogClean: verge?.auto_log_clean || 0, defaultLatencyTimeout: verge?.default_latency_timeout || 10000, - }); + }) }, close: () => setOpen(false), - })); + })) const onSave = useLockFn(async () => { try { @@ -73,32 +73,32 @@ export const MiscViewer = forwardRef((props, ref) => { default_latency_test: values.defaultLatencyTest, default_latency_timeout: values.defaultLatencyTimeout, auto_log_clean: values.autoLogClean as any, - }); - setOpen(false); + }) + setOpen(false) } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) return ( setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} > - + - + ((props, ref) => { autoCorrect="off" autoCapitalize="off" spellCheck="false" - sx={{ width: 140, marginLeft: "auto" }} + sx={{ width: 140, marginLeft: 'auto' }} value={values.appLogMaxSize} onChange={(e) => setValues((v) => ({ @@ -139,7 +139,7 @@ export const MiscViewer = forwardRef((props, ref) => { input: { endAdornment: ( - {t("shared.units.kilobytes")} + {t('shared.units.kilobytes')} ), }, @@ -147,10 +147,10 @@ export const MiscViewer = forwardRef((props, ref) => { /> - + ((props, ref) => { autoCorrect="off" autoCapitalize="off" spellCheck="false" - sx={{ width: 140, marginLeft: "auto" }} + sx={{ width: 140, marginLeft: 'auto' }} value={values.appLogMaxCount} onChange={(e) => setValues((v) => ({ @@ -171,7 +171,7 @@ export const MiscViewer = forwardRef((props, ref) => { input: { endAdornment: ( - {t("shared.units.files")} + {t('shared.units.files')} ), }, @@ -179,14 +179,14 @@ export const MiscViewer = forwardRef((props, ref) => { /> - + ((props, ref) => { onChange={(_, c) => setValues((v) => ({ ...v, autoCloseConnection: c })) } - sx={{ marginLeft: "auto" }} + sx={{ marginLeft: 'auto' }} /> - + ((props, ref) => { /> - + ((props, ref) => { onChange={(_, c) => setValues((v) => ({ ...v, enableBuiltinEnhanced: c })) } - sx={{ marginLeft: "auto" }} + sx={{ marginLeft: 'auto' }} /> - + - + - + ((props, ref) => { onChange={(_, c) => setValues((v) => ({ ...v, enableAutoDelayDetection: c })) } - sx={{ marginLeft: "auto" }} + sx={{ marginLeft: 'auto' }} /> - + ((props, ref) => { autoCorrect="off" autoCapitalize="off" spellCheck="false" - sx={{ width: 160, marginLeft: "auto" }} + sx={{ width: 160, marginLeft: 'auto' }} value={values.autoDelayDetectionIntervalMinutes} disabled={!values.enableAutoDelayDetection} onChange={(e) => { - const parsed = parseInt(e.target.value, 10); + const parsed = parseInt(e.target.value, 10) const intervalMinutes = - Number.isFinite(parsed) && parsed > 0 ? parsed : 1; + Number.isFinite(parsed) && parsed > 0 ? parsed : 1 setValues((v) => ({ ...v, autoDelayDetectionIntervalMinutes: intervalMinutes, - })); + })) }} slotProps={{ input: { endAdornment: ( - {t("shared.units.minutes")} + {t('shared.units.minutes')} ), }, @@ -366,14 +366,14 @@ export const MiscViewer = forwardRef((props, ref) => { /> - + ((props, ref) => { autoCorrect="off" autoCapitalize="off" spellCheck="false" - sx={{ width: 250, marginLeft: "auto" }} + sx={{ width: 250, marginLeft: 'auto' }} value={values.defaultLatencyTest} placeholder="http://cp.cloudflare.com" onChange={(e) => @@ -390,9 +390,9 @@ export const MiscViewer = forwardRef((props, ref) => { /> - + ((props, ref) => { input: { endAdornment: ( - {t("shared.units.milliseconds")} + {t('shared.units.milliseconds')} ), }, @@ -423,5 +423,5 @@ export const MiscViewer = forwardRef((props, ref) => { - ); -}); + ) +}) diff --git a/src/components/setting/mods/network-interface-viewer.tsx b/src/components/setting/mods/network-interface-viewer.tsx index 7defbdd5d..4e7934a0a 100644 --- a/src/components/setting/mods/network-interface-viewer.tsx +++ b/src/components/setting/mods/network-interface-viewer.tsx @@ -1,50 +1,50 @@ -import { ContentCopyRounded } from "@mui/icons-material"; -import { alpha, Box, Button, IconButton } from "@mui/material"; -import { writeText } from "@tauri-apps/plugin-clipboard-manager"; -import type { Ref } from "react"; -import { useImperativeHandle, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { ContentCopyRounded } from '@mui/icons-material' +import { alpha, Box, Button, IconButton } from '@mui/material' +import { writeText } from '@tauri-apps/plugin-clipboard-manager' +import type { Ref } from 'react' +import { useImperativeHandle, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { BaseDialog, DialogRef } from "@/components/base"; -import { useNetworkInterfaces } from "@/hooks/use-network"; -import { showNotice } from "@/services/notice-service"; +import { BaseDialog, DialogRef } from '@/components/base' +import { useNetworkInterfaces } from '@/hooks/use-network' +import { showNotice } from '@/services/notice-service' export function NetworkInterfaceViewer({ ref }: { ref?: Ref }) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [isV4, setIsV4] = useState(true); + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [isV4, setIsV4] = useState(true) useImperativeHandle(ref, () => ({ open: () => { - setOpen(true); + setOpen(true) }, close: () => setOpen(false), - })); + })) - const { networkInterfaces } = useNetworkInterfaces(); + const { networkInterfaces } = useNetworkInterfaces() return ( - {t("settings.modals.networkInterface.title")} + {t('settings.modals.networkInterface.title')} } contentSx={{ width: 450 }} disableOk - cancelBtn={t("shared.actions.close")} + cancelBtn={t('shared.actions.close')} onClose={() => setOpen(false)} onCancel={() => setOpen(false)} > @@ -60,7 +60,7 @@ export function NetworkInterfaceViewer({ ref }: { ref?: Ref }) { @@ -68,9 +68,9 @@ export function NetworkInterfaceViewer({ ref }: { ref?: Ref }) { )} )} @@ -82,7 +82,7 @@ export function NetworkInterfaceViewer({ ref }: { ref?: Ref }) { @@ -90,9 +90,9 @@ export function NetworkInterfaceViewer({ ref }: { ref?: Ref }) { )} )} @@ -100,48 +100,48 @@ export function NetworkInterfaceViewer({ ref }: { ref?: Ref }) { ))} - ); + ) } const AddressDisplay = ({ label, content, }: { - label: string; - content: string; + label: string + content: string }) => { return ( {label} ({ - borderRadius: "8px", - padding: "2px 2px 2px 8px", + borderRadius: '8px', + padding: '2px 2px 2px 8px', background: - palette.mode === "dark" + palette.mode === 'dark' ? alpha(palette.background.paper, 0.3) : alpha(palette.grey[400], 0.3), })} > - {content} + {content} { - await writeText(content); + await writeText(content) showNotice.success( - "shared.feedback.notifications.common.copySuccess", - ); + 'shared.feedback.notifications.common.copySuccess', + ) }} > - + - ); -}; + ) +} diff --git a/src/components/setting/mods/password-input.tsx b/src/components/setting/mods/password-input.tsx index c58e310e1..32a66cfaa 100644 --- a/src/components/setting/mods/password-input.tsx +++ b/src/components/setting/mods/password-input.tsx @@ -5,36 +5,36 @@ import { DialogContent, DialogTitle, TextField, -} from "@mui/material"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' interface Props { - onConfirm: (passwd: string) => Promise; + onConfirm: (passwd: string) => Promise } export const PasswordInput = (props: Props) => { - const { onConfirm } = props; + const { onConfirm } = props - const { t } = useTranslation(); - const [passwd, setPasswd] = useState(""); + const { t } = useTranslation() + const [passwd, setPasswd] = useState('') return ( - {t("settings.modals.password.prompts.enterRoot")} + {t('settings.modals.password.prompts.enterRoot')} e.key === "Enter" && onConfirm(passwd)} + onKeyDown={(e) => e.key === 'Enter' && onConfirm(passwd)} onChange={(e) => setPasswd(e.target.value)} > @@ -44,9 +44,9 @@ export const PasswordInput = (props: Props) => { onClick={async () => await onConfirm(passwd)} variant="contained" > - {t("shared.actions.confirm")} + {t('shared.actions.confirm')} - ); -}; + ) +} diff --git a/src/components/setting/mods/setting-comp.tsx b/src/components/setting/mods/setting-comp.tsx index de3f2f55b..c76428a8f 100644 --- a/src/components/setting/mods/setting-comp.tsx +++ b/src/components/setting/mods/setting-comp.tsx @@ -1,4 +1,4 @@ -import { ChevronRightRounded } from "@mui/icons-material"; +import { ChevronRightRounded } from '@mui/icons-material' import { Box, List, @@ -6,18 +6,18 @@ import { ListItemButton, ListItemText, ListSubheader, -} from "@mui/material"; -import CircularProgress from "@mui/material/CircularProgress"; -import React, { ReactNode, useState } from "react"; +} from '@mui/material' +import CircularProgress from '@mui/material/CircularProgress' +import React, { ReactNode, useState } from 'react' -import isAsyncFunction from "@/utils/is-async-function"; +import isAsyncFunction from '@/utils/is-async-function' interface ItemProps { - label: ReactNode; - extra?: ReactNode; - children?: ReactNode; - secondary?: ReactNode; - onClick?: () => void | Promise; + label: ReactNode + extra?: ReactNode + children?: ReactNode + secondary?: ReactNode + onClick?: () => void | Promise } export const SettingItem: React.FC = ({ @@ -27,26 +27,26 @@ export const SettingItem: React.FC = ({ secondary, onClick, }) => { - const clickable = !!onClick; + const clickable = !!onClick const primary = ( - + {label} {extra ? extra : null} - ); + ) - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false) const handleClick = () => { if (onClick) { if (isAsyncFunction(onClick)) { - setIsLoading(true); - onClick()!.finally(() => setIsLoading(false)); + setIsLoading(true) + onClick()!.finally(() => setIsLoading(false)) } else { - onClick(); + onClick() } } - }; + } return clickable ? ( @@ -60,25 +60,25 @@ export const SettingItem: React.FC = ({ ) : ( - + {children} - ); -}; + ) +} export const SettingList: React.FC<{ - title: string; - children: ReactNode; + title: string + children: ReactNode }> = ({ title, children }) => ( { return { color: palette.text.primary, - }; + } }, ]} disableSticky @@ -88,4 +88,4 @@ export const SettingList: React.FC<{ {children} -); +) diff --git a/src/components/setting/mods/stack-mode-switch.tsx b/src/components/setting/mods/stack-mode-switch.tsx index 90bcf07eb..a129e6376 100644 --- a/src/components/setting/mods/stack-mode-switch.tsx +++ b/src/components/setting/mods/stack-mode-switch.tsx @@ -1,36 +1,36 @@ -import { Button, ButtonGroup } from "@mui/material"; +import { Button, ButtonGroup } from '@mui/material' interface Props { - value?: string; - onChange?: (value: string) => void; + value?: string + onChange?: (value: string) => void } export const StackModeSwitch = (props: Props) => { - const { value, onChange } = props; + const { value, onChange } = props return ( - + - ); -}; + ) +} diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 34cc90ab3..9fb7f56b0 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -1,4 +1,4 @@ -import { EditRounded } from "@mui/icons-material"; +import { EditRounded } from '@mui/icons-material' import { Autocomplete, Box, @@ -11,8 +11,8 @@ import { styled, TextField, Typography, -} from "@mui/material"; -import { useLockFn } from "ahooks"; +} from '@mui/material' +import { useLockFn } from 'ahooks' import { forwardRef, useEffect, @@ -20,9 +20,9 @@ import { useMemo, useRef, useState, -} from "react"; -import { useTranslation } from "react-i18next"; -import { mutate } from "swr"; +} from 'react' +import { useTranslation } from 'react-i18next' +import { mutate } from 'swr' import { BaseDialog, @@ -31,90 +31,90 @@ import { DialogRef, Switch, TooltipIcon, -} from "@/components/base"; -import { EditorViewer } from "@/components/profile/editor-viewer"; -import { useVerge } from "@/hooks/use-verge"; -import { useAppData } from "@/providers/app-data-context"; +} from '@/components/base' +import { EditorViewer } from '@/components/profile/editor-viewer' +import { useVerge } from '@/hooks/use-verge' +import { useAppData } from '@/providers/app-data-context' import { getAutotemProxy, getNetworkInterfacesInfo, getSystemHostname, getSystemProxy, patchVergeConfig, -} from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; -import { debugLog } from "@/utils/debug"; -import getSystem from "@/utils/get-system"; +} from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import { debugLog } from '@/utils/debug' +import getSystem from '@/utils/get-system' const sleep = (ms: number) => new Promise((resolve) => { - setTimeout(resolve, ms); - }); + setTimeout(resolve, ms) + }) const DEFAULT_PAC = `function FindProxyForURL(url, host) { return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;"; -}`; +}` /** NO_PROXY validation */ // *., cdn*., *, etc. -const domain_subdomain_part = String.raw`(?:[a-z0-9\-\*]+\.|\*)*`; +const domain_subdomain_part = String.raw`(?:[a-z0-9\-\*]+\.|\*)*` // .*, .cn, .moe, .co*, * -const domain_tld_part = String.raw`(?:\w{2,64}\*?|\*)`; +const domain_tld_part = String.raw`(?:\w{2,64}\*?|\*)` // *epicgames*, *skk.moe, *.skk.moe, skk.*, sponsor.cdn.skk.moe, *.*, etc. // also matches 192.168.*, 10.*, 127.0.0.*, etc. (partial ipv4) -const rDomainSimple = domain_subdomain_part + domain_tld_part; +const rDomainSimple = domain_subdomain_part + domain_tld_part -const ipv4_part = String.raw`\d{1,3}`; +const ipv4_part = String.raw`\d{1,3}` -const ipv6_part = "(?:[a-fA-F0-9:])+"; +const ipv6_part = '(?:[a-fA-F0-9:])+' -const rLocal = `localhost||localdomain`; +const rLocal = `localhost||localdomain` const getValidReg = (isWindows: boolean) => { // 127.0.0.1 (full ipv4) - const rIPv4Unix = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}(?:\/\d{1,2})?`; - const rIPv4Windows = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}`; + const rIPv4Unix = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}(?:\/\d{1,2})?` + const rIPv4Windows = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}` - const rIPv6Unix = String.raw`(?:${ipv6_part}:+)+${ipv6_part}(?:\/\d{1,3})?`; - const rIPv6Windows = String.raw`(?:${ipv6_part}:+)+${ipv6_part}`; + const rIPv6Unix = String.raw`(?:${ipv6_part}:+)+${ipv6_part}(?:\/\d{1,3})?` + const rIPv6Windows = String.raw`(?:${ipv6_part}:+)+${ipv6_part}` const rValidPart = `${rDomainSimple}|${ isWindows ? rIPv4Windows : rIPv4Unix - }|${isWindows ? rIPv6Windows : rIPv6Unix}|${rLocal}`; - const separator = isWindows ? ";" : ","; - const rValid = String.raw`^(${rValidPart})(?:${separator}\s?(${rValidPart}))*${separator}?$`; + }|${isWindows ? rIPv6Windows : rIPv6Unix}|${rLocal}` + const separator = isWindows ? ';' : ',' + const rValid = String.raw`^(${rValidPart})(?:${separator}\s?(${rValidPart}))*${separator}?$` - return new RegExp(rValid); -}; + return new RegExp(rValid) +} const splitBypass = (value?: string) => - (value ?? "") + (value ?? '') .split(/[,\n;\r]+/) .map((item) => item.trim()) - .filter(Boolean); + .filter(Boolean) export const SysproxyViewer = forwardRef((props, ref) => { - const { t } = useTranslation(); - const systemName = getSystem(); - const isWindows = systemName === "windows"; - const validReg = useMemo(() => getValidReg(isWindows), [isWindows]); + const { t } = useTranslation() + const systemName = getSystem() + const isWindows = systemName === 'windows' + const validReg = useMemo(() => getValidReg(isWindows), [isWindows]) - const [open, setOpen] = useState(false); - const [editorOpen, setEditorOpen] = useState(false); - const [pacEditorValue, setPacEditorValue] = useState(DEFAULT_PAC); - const [pacEditorSavedValue, setPacEditorSavedValue] = useState(DEFAULT_PAC); - const [saving, setSaving] = useState(false); - const { verge, patchVerge, mutateVerge } = useVerge(); - const [hostOptions, setHostOptions] = useState([]); + const [open, setOpen] = useState(false) + const [editorOpen, setEditorOpen] = useState(false) + const [pacEditorValue, setPacEditorValue] = useState(DEFAULT_PAC) + const [pacEditorSavedValue, setPacEditorSavedValue] = useState(DEFAULT_PAC) + const [saving, setSaving] = useState(false) + const { verge, patchVerge, mutateVerge } = useVerge() + const [hostOptions, setHostOptions] = useState([]) - type SysProxy = Awaited>; - const [sysproxy, setSysproxy] = useState(); + type SysProxy = Awaited> + const [sysproxy, setSysproxy] = useState() - type AutoProxy = Awaited>; - const [autoproxy, setAutoproxy] = useState(); + type AutoProxy = Awaited> + const [autoproxy, setAutoproxy] = useState() - const { clashConfig } = useAppData(); + const { clashConfig } = useAppData() const { enable_system_proxy: enabled, @@ -126,7 +126,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { system_proxy_bypass, proxy_guard_duration, proxy_host, - } = verge ?? {}; + } = verge ?? {} const [value, setValue] = useState({ guard: enable_proxy_guard, @@ -136,67 +136,67 @@ export const SysproxyViewer = forwardRef((props, ref) => { use_default: use_default_bypass ?? true, pac: proxy_auto_config, pac_content: pac_file_content ?? DEFAULT_PAC, - proxy_host: proxy_host ?? "127.0.0.1", - }); + proxy_host: proxy_host ?? '127.0.0.1', + }) - const separator = useMemo(() => (isWindows ? ";" : ","), [isWindows]); + const separator = useMemo(() => (isWindows ? ';' : ','), [isWindows]) const defaultBypass = () => { if (isWindows) { - return "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;"; + return 'localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;' } - if (systemName === "linux") { - return "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1"; + if (systemName === 'linux') { + return 'localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,::1' } - return "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,"; - }; + return '127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,localhost,*.local,*.crashlytics.com,' + } - const prevMixedPortRef = useRef(clashConfig?.mixedPort); + const prevMixedPortRef = useRef(clashConfig?.mixedPort) useEffect(() => { - const mixedPort = clashConfig?.mixedPort; + const mixedPort = clashConfig?.mixedPort if (!mixedPort || mixedPort === prevMixedPortRef.current) { - return; + return } - prevMixedPortRef.current = mixedPort; + prevMixedPortRef.current = mixedPort const updateProxy = async () => { try { - const currentSysProxy = await getSystemProxy(); - const currentAutoProxy = await getAutotemProxy(); + const currentSysProxy = await getSystemProxy() + const currentAutoProxy = await getAutotemProxy() if (value.pac ? currentAutoProxy?.enable : currentSysProxy?.enable) { - await patchVergeConfig({ enable_system_proxy: false }); - await sleep(200); - await patchVergeConfig({ enable_system_proxy: true }); + await patchVergeConfig({ enable_system_proxy: false }) + await sleep(200) + await patchVergeConfig({ enable_system_proxy: true }) await Promise.all([ - mutate("getSystemProxy"), - mutate("getAutotemProxy"), - ]); + mutate('getSystemProxy'), + mutate('getAutotemProxy'), + ]) } } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }; + } - updateProxy(); - }, [clashConfig?.mixedPort, value.pac]); + updateProxy() + }, [clashConfig?.mixedPort, value.pac]) - const { systemProxyAddress } = useAppData(); + const { systemProxyAddress } = useAppData() // 为当前状态计算系统代理地址 const getSystemProxyAddress = useMemo(() => { - if (!clashConfig) return "-"; + if (!clashConfig) return '-' - const isPacMode = value.pac ?? false; + const isPacMode = value.pac ?? false if (isPacMode) { - const host = value.proxy_host || "127.0.0.1"; - const port = verge?.verge_mixed_port || clashConfig.mixedPort || 7897; - return `${host}:${port}`; + const host = value.proxy_host || '127.0.0.1' + const port = verge?.verge_mixed_port || clashConfig.mixedPort || 7897 + return `${host}:${port}` } else { - return systemProxyAddress; + return systemProxyAddress } }, [ value.pac, @@ -204,13 +204,13 @@ export const SysproxyViewer = forwardRef((props, ref) => { verge?.verge_mixed_port, clashConfig, systemProxyAddress, - ]); + ]) const getCurrentPacUrl = useMemo(() => { - const host = value.proxy_host || "127.0.0.1"; + const host = value.proxy_host || '127.0.0.1' // 根据环境判断PAC端口 - const port = import.meta.env.DEV ? 11233 : 33331; - return `http://${host}:${port}/commands/pac`; - }, [value.proxy_host]); + const port = import.meta.env.DEV ? 11233 : 33331 + return `http://${host}:${port}/commands/pac` + }, [value.proxy_host]) const bypassError = value.enable_bypass_check && @@ -218,26 +218,26 @@ export const SysproxyViewer = forwardRef((props, ref) => { !value.use_default && value.bypass ? !validReg.test(value.bypass) - : false; + : false const openPacEditor = () => { - const nextPac = value.pac_content ?? DEFAULT_PAC; - setPacEditorValue(nextPac); - setPacEditorSavedValue(nextPac); - setEditorOpen(true); - }; + const nextPac = value.pac_content ?? DEFAULT_PAC + setPacEditorValue(nextPac) + setPacEditorSavedValue(nextPac) + setEditorOpen(true) + } const handleSavePac = useLockFn(async () => { const nextPac = - pacEditorValue.trim().length > 0 ? pacEditorValue : DEFAULT_PAC; + pacEditorValue.trim().length > 0 ? pacEditorValue : DEFAULT_PAC - setValue((current) => ({ ...current, pac_content: nextPac })); - setPacEditorSavedValue(nextPac); - }); + setValue((current) => ({ ...current, pac_content: nextPac })) + setPacEditorSavedValue(nextPac) + }) useImperativeHandle(ref, () => ({ open: () => { - setOpen(true); + setOpen(true) setValue({ guard: enable_proxy_guard, enable_bypass_check: enable_bypass_check ?? true, @@ -246,78 +246,78 @@ export const SysproxyViewer = forwardRef((props, ref) => { use_default: use_default_bypass ?? true, pac: proxy_auto_config, pac_content: pac_file_content ?? DEFAULT_PAC, - proxy_host: proxy_host ?? "127.0.0.1", - }); - getSystemProxy().then((p) => setSysproxy(p)); - getAutotemProxy().then((p) => setAutoproxy(p)); - fetchNetworkInterfaces(); + proxy_host: proxy_host ?? '127.0.0.1', + }) + getSystemProxy().then((p) => setSysproxy(p)) + getAutotemProxy().then((p) => setAutoproxy(p)) + fetchNetworkInterfaces() }, close: () => setOpen(false), - })); + })) // 获取网络接口和主机名 const fetchNetworkInterfaces = async () => { try { // 获取系统网络接口信息 - const interfaces = await getNetworkInterfacesInfo(); - const ipAddresses: string[] = []; + const interfaces = await getNetworkInterfacesInfo() + const ipAddresses: string[] = [] // 从interfaces中提取IPv4和IPv6地址 interfaces.forEach((iface) => { iface.addr.forEach((address) => { if (address.V4 && address.V4.ip) { - ipAddresses.push(address.V4.ip); + ipAddresses.push(address.V4.ip) } if (address.V6 && address.V6.ip) { - ipAddresses.push(address.V6.ip); + ipAddresses.push(address.V6.ip) } - }); - }); + }) + }) // 获取当前系统的主机名 - let hostname = ""; + let hostname = '' try { - hostname = await getSystemHostname(); - debugLog("获取到主机名:", hostname); + hostname = await getSystemHostname() + debugLog('获取到主机名:', hostname) } catch (err) { - console.error("获取主机名失败:", err); + console.error('获取主机名失败:', err) } // 构建选项列表 - const options = ["127.0.0.1", "localhost"]; + const options = ['127.0.0.1', 'localhost'] // 确保主机名添加到列表,即使它是空字符串也记录下来 if (hostname) { // 如果主机名不是localhost或127.0.0.1,则添加它 - if (hostname !== "localhost" && hostname !== "127.0.0.1") { - hostname = hostname + ".local"; - options.push(hostname); - debugLog("主机名已添加到选项中:", hostname); + if (hostname !== 'localhost' && hostname !== '127.0.0.1') { + hostname = hostname + '.local' + options.push(hostname) + debugLog('主机名已添加到选项中:', hostname) } else { - debugLog("主机名与已有选项重复:", hostname); + debugLog('主机名与已有选项重复:', hostname) } } else { - debugLog("主机名为空"); + debugLog('主机名为空') } // 添加IP地址 - options.push(...ipAddresses); + options.push(...ipAddresses) // 去重 - const uniqueOptions = Array.from(new Set(options)); - debugLog("最终选项列表:", uniqueOptions); - setHostOptions(uniqueOptions); + const uniqueOptions = Array.from(new Set(options)) + debugLog('最终选项列表:', uniqueOptions) + setHostOptions(uniqueOptions) } catch (error) { - console.error("获取网络接口失败:", error); + console.error('获取网络接口失败:', error) // 失败时至少提供基本选项 - setHostOptions(["127.0.0.1", "localhost"]); + setHostOptions(['127.0.0.1', 'localhost']) } - }; + } const onSave = useLockFn(async () => { if (value.duration < 1) { - showNotice.error("settings.modals.sysproxy.messages.durationTooShort"); - return; + showNotice.error('settings.modals.sysproxy.messages.durationTooShort') + return } if ( value.enable_bypass_check && @@ -326,75 +326,75 @@ export const SysproxyViewer = forwardRef((props, ref) => { value.bypass && !validReg.test(value.bypass) ) { - showNotice.error("settings.modals.sysproxy.messages.invalidBypass"); - return; + showNotice.error('settings.modals.sysproxy.messages.invalidBypass') + return } // 修改验证规则,允许IP和主机名 const ipv4Regex = - /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/ const ipv6Regex = - /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; + /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/ const hostnameRegex = - /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/ if ( !ipv4Regex.test(value.proxy_host) && !ipv6Regex.test(value.proxy_host) && !hostnameRegex.test(value.proxy_host) ) { - showNotice.error("settings.modals.sysproxy.messages.invalidProxyHost"); - return; + showNotice.error('settings.modals.sysproxy.messages.invalidProxyHost') + return } - setSaving(true); - setOpen(false); - setSaving(false); - const patch: Partial = {}; + setSaving(true) + setOpen(false) + setSaving(false) + const patch: Partial = {} if (value.guard !== enable_proxy_guard) { - patch.enable_proxy_guard = value.guard; + patch.enable_proxy_guard = value.guard } if (value.enable_bypass_check !== enable_bypass_check) { - patch.enable_bypass_check = value.enable_bypass_check; + patch.enable_bypass_check = value.enable_bypass_check } if (value.duration !== proxy_guard_duration) { - patch.proxy_guard_duration = value.duration; + patch.proxy_guard_duration = value.duration } if (value.bypass !== system_proxy_bypass) { - patch.system_proxy_bypass = value.bypass; + patch.system_proxy_bypass = value.bypass } if (value.pac !== proxy_auto_config) { - patch.proxy_auto_config = value.pac; + patch.proxy_auto_config = value.pac } if (value.use_default !== use_default_bypass) { - patch.use_default_bypass = value.use_default; + patch.use_default_bypass = value.use_default } - let pacContent = value.pac_content; + let pacContent = value.pac_content if (pacContent) { - pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host); + pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host) // 将 mixed-port 转换为字符串 - const mixedPortStr = (clashConfig?.mixedPort || "").toString(); - pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr); + const mixedPortStr = (clashConfig?.mixedPort || '').toString() + pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr) } if (pacContent !== pac_file_content) { - patch.pac_file_content = pacContent; + patch.pac_file_content = pacContent } // 处理IPv6地址,如果是IPv6地址但没有被方括号包围,则添加方括号 - let proxyHost = value.proxy_host; + let proxyHost = value.proxy_host if ( ipv6Regex.test(proxyHost) && - !proxyHost.startsWith("[") && - !proxyHost.endsWith("]") + !proxyHost.startsWith('[') && + !proxyHost.endsWith(']') ) { - proxyHost = `[${proxyHost}]`; + proxyHost = `[${proxyHost}]` } if (proxyHost !== proxy_host) { - patch.proxy_host = proxyHost; + patch.proxy_host = proxyHost } // 判断是否需要重置系统代理 @@ -403,65 +403,65 @@ export const SysproxyViewer = forwardRef((props, ref) => { proxyHost !== proxy_host || pacContent !== pac_file_content || value.bypass !== system_proxy_bypass || - value.use_default !== use_default_bypass; + value.use_default !== use_default_bypass Promise.resolve().then(async () => { try { // 乐观更新本地状态 if (Object.keys(patch).length > 0) { - mutateVerge({ ...verge, ...patch }, false); + mutateVerge({ ...verge, ...patch }, false) } if (Object.keys(patch).length > 0) { - await patchVerge(patch); + await patchVerge(patch) } setTimeout(async () => { try { await Promise.all([ - mutate("getSystemProxy"), - mutate("getAutotemProxy"), - ]); + mutate('getSystemProxy'), + mutate('getAutotemProxy'), + ]) // 如果需要重置代理且代理当前启用 if (needResetProxy && enabled) { const [currentSysProxy, currentAutoProxy] = await Promise.all([ getSystemProxy(), getAutotemProxy(), - ]); + ]) const isProxyActive = value.pac ? currentAutoProxy?.enable - : currentSysProxy?.enable; + : currentSysProxy?.enable if (isProxyActive) { - await patchVergeConfig({ enable_system_proxy: false }); - await new Promise((resolve) => setTimeout(resolve, 50)); - await patchVergeConfig({ enable_system_proxy: true }); + await patchVergeConfig({ enable_system_proxy: false }) + await new Promise((resolve) => setTimeout(resolve, 50)) + await patchVergeConfig({ enable_system_proxy: true }) await Promise.all([ - mutate("getSystemProxy"), - mutate("getAutotemProxy"), - ]); + mutate('getSystemProxy'), + mutate('getAutotemProxy'), + ]) } } } catch (err) { - console.warn("代理状态更新失败:", err); + console.warn('代理状态更新失败:', err) } - }, 50); + }, 50) } catch (err) { - console.error("配置保存失败:", err); - mutateVerge(); - showNotice.error(err); + console.error('配置保存失败:', err) + mutateVerge() + showNotice.error(err) // setOpen(true); } - }); - }); + }) + }) return ( setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} @@ -470,27 +470,27 @@ export const SysproxyViewer = forwardRef((props, ref) => { > - {t("settings.modals.sysproxy.fields.enableStatus")} + {t('settings.modals.sysproxy.fields.enableStatus')} {value.pac ? autoproxy?.enable - ? t("shared.statuses.enabled") - : t("shared.statuses.disabled") + ? t('shared.statuses.enabled') + : t('shared.statuses.disabled') : sysproxy?.enable - ? t("shared.statuses.enabled") - : t("shared.statuses.disabled")} + ? t('shared.statuses.enabled') + : t('shared.statuses.disabled')} {!value.pac && ( - {t("settings.modals.sysproxy.fields.serverAddr")} + {t('settings.modals.sysproxy.fields.serverAddr')} {getSystemProxyAddress} @@ -498,17 +498,17 @@ export const SysproxyViewer = forwardRef((props, ref) => { {value.pac && ( - {t("settings.modals.sysproxy.fields.pacUrl")} + {t('settings.modals.sysproxy.fields.pacUrl')} - {getCurrentPacUrl || "-"} + {getCurrentPacUrl || '-'} )} - + ((props, ref) => { onChange={(_, newValue) => { setValue((v) => ({ ...v, - proxy_host: newValue || "127.0.0.1", - })); + proxy_host: newValue || '127.0.0.1', + })) }} onInputChange={(_, newInputValue) => { setValue((v) => ({ ...v, - proxy_host: newInputValue || "127.0.0.1", - })); + proxy_host: newInputValue || '127.0.0.1', + })) }} /> - + ((props, ref) => { /> - + setValue((v) => ({ ...v, guard: e }))} - sx={{ marginLeft: "auto" }} + sx={{ marginLeft: 'auto' }} /> - + ((props, ref) => { onChange={(e) => { setValue((v) => ({ ...v, - duration: +e.target.value.replace(/\D/, ""), - })); + duration: +e.target.value.replace(/\D/, ''), + })) }} /> {!value.pac && ( - + ((props, ref) => { checked={value.use_default} onChange={(_, e) => { if (!e && !value.bypass) { - const nextBypass = defaultBypass(); + const nextBypass = defaultBypass() setValue((v) => ({ ...v, use_default: e, // 当取消选择use_default且当前bypass为空时,填充默认值 bypass: nextBypass, - })); - return; + })) + return } - setValue((v) => ({ ...v, use_default: e })); + setValue((v) => ({ ...v, use_default: e })) }} /> )} {!value.pac && ( - + ((props, ref) => { {!value.pac && !value.use_default && ( { - setValue((v) => ({ ...v, bypass: nextValue })); + setValue((v) => ({ ...v, bypass: nextValue })) }} renderHeader={(modeToggle) => ( - + {modeToggle ? ( - {modeToggle} + {modeToggle} ) : null} )} @@ -661,10 +661,10 @@ export const SysproxyViewer = forwardRef((props, ref) => { {!value.pac && value.use_default && ( <> - - + + {splitBypass(defaultBypass()).map((item) => ( ))} @@ -674,22 +674,22 @@ export const SysproxyViewer = forwardRef((props, ref) => { )} {value.pac && ( - + {editorOpen && ( ((props, ref) => { )} - ); -}); + ) +}) -const FlexBox = styled("div")` +const FlexBox = styled('div')` display: flex; margin-top: 4px; @@ -714,4 +714,4 @@ const FlexBox = styled("div")` flex: none; //width: 85px; } -`; +` diff --git a/src/components/setting/mods/theme-mode-switch.tsx b/src/components/setting/mods/theme-mode-switch.tsx index c232d80ee..988ce753a 100644 --- a/src/components/setting/mods/theme-mode-switch.tsx +++ b/src/components/setting/mods/theme-mode-switch.tsx @@ -1,31 +1,31 @@ -import { Button, ButtonGroup } from "@mui/material"; -import { useTranslation } from "react-i18next"; +import { Button, ButtonGroup } from '@mui/material' +import { useTranslation } from 'react-i18next' -type ThemeValue = IVergeConfig["theme_mode"]; +type ThemeValue = IVergeConfig['theme_mode'] interface Props { - value?: ThemeValue; - onChange?: (value: ThemeValue) => void; + value?: ThemeValue + onChange?: (value: ThemeValue) => void } export const ThemeModeSwitch = (props: Props) => { - const { value, onChange } = props; - const { t } = useTranslation(); + const { value, onChange } = props + const { t } = useTranslation() - const modes = ["light", "dark", "system"] as const; + const modes = ['light', 'dark', 'system'] as const return ( - + {modes.map((mode) => ( ))} - ); -}; + ) +} diff --git a/src/components/setting/mods/theme-viewer.tsx b/src/components/setting/mods/theme-viewer.tsx index be1f17183..f49bcd081 100644 --- a/src/components/setting/mods/theme-viewer.tsx +++ b/src/components/setting/mods/theme-viewer.tsx @@ -1,4 +1,4 @@ -import { EditRounded } from "@mui/icons-material"; +import { EditRounded } from '@mui/icons-material' import { Button, List, @@ -7,148 +7,148 @@ import { styled, TextField, useTheme, -} from "@mui/material"; -import { useLockFn } from "ahooks"; +} from '@mui/material' +import { useLockFn } from 'ahooks' import { useEffect, useImperativeHandle, useMemo, useRef, useState, -} from "react"; -import { useTranslation } from "react-i18next"; +} from 'react' +import { useTranslation } from 'react-i18next' -import { BaseDialog, DialogRef } from "@/components/base"; -import { EditorViewer } from "@/components/profile/editor-viewer"; -import { useVerge } from "@/hooks/use-verge"; -import { defaultDarkTheme, defaultTheme } from "@/pages/_theme"; -import { showNotice } from "@/services/notice-service"; +import { BaseDialog, DialogRef } from '@/components/base' +import { EditorViewer } from '@/components/profile/editor-viewer' +import { useVerge } from '@/hooks/use-verge' +import { defaultDarkTheme, defaultTheme } from '@/pages/_theme' +import { showNotice } from '@/services/notice-service' export function ThemeViewer(props: { ref?: React.Ref }) { - const { ref } = props; - const { t } = useTranslation(); + const { ref } = props + const { t } = useTranslation() - const [open, setOpen] = useState(false); - const [editorOpen, setEditorOpen] = useState(false); - const [cssEditorValue, setCssEditorValue] = useState(""); - const [cssEditorSavedValue, setCssEditorSavedValue] = useState(""); - const { verge, patchVerge } = useVerge(); - const { theme_setting } = verge ?? {}; - const [theme, setTheme] = useState(theme_setting || {}); + const [open, setOpen] = useState(false) + const [editorOpen, setEditorOpen] = useState(false) + const [cssEditorValue, setCssEditorValue] = useState('') + const [cssEditorSavedValue, setCssEditorSavedValue] = useState('') + const { verge, patchVerge } = useVerge() + const { theme_setting } = verge ?? {} + const [theme, setTheme] = useState(theme_setting || {}) // Latest theme ref to avoid stale closures when saving CSS - const themeRef = useRef(theme); + const themeRef = useRef(theme) useEffect(() => { - themeRef.current = theme; - }, [theme]); + themeRef.current = theme + }, [theme]) useImperativeHandle(ref, () => ({ open: () => { - setOpen(true); - setTheme({ ...theme_setting }); + setOpen(true) + setTheme({ ...theme_setting }) }, close: () => setOpen(false), - })); + })) const textProps = { - size: "small", - autoComplete: "off", + size: 'small', + autoComplete: 'off', sx: { width: 135 }, - } as const; + } as const const handleChange = (field: keyof typeof theme) => (e: any) => { - setTheme((t) => ({ ...t, [field]: e.target.value })); - }; + setTheme((t) => ({ ...t, [field]: e.target.value })) + } const onSave = useLockFn(async () => { try { - await patchVerge({ theme_setting: theme }); - setOpen(false); + await patchVerge({ theme_setting: theme }) + setOpen(false) } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) - const { palette } = useTheme(); + const { palette } = useTheme() - const dt = palette.mode === "light" ? defaultTheme : defaultDarkTheme; + const dt = palette.mode === 'light' ? defaultTheme : defaultDarkTheme - type ThemeKey = keyof typeof theme & keyof typeof defaultTheme; + type ThemeKey = keyof typeof theme & keyof typeof defaultTheme const fieldDefinitions: Array<{ labelKey: string; key: ThemeKey }> = useMemo( () => [ { - labelKey: "settings.components.verge.theme.fields.primaryColor", - key: "primary_color", + labelKey: 'settings.components.verge.theme.fields.primaryColor', + key: 'primary_color', }, { - labelKey: "settings.components.verge.theme.fields.secondaryColor", - key: "secondary_color", + labelKey: 'settings.components.verge.theme.fields.secondaryColor', + key: 'secondary_color', }, { - labelKey: "settings.components.verge.theme.fields.primaryText", - key: "primary_text", + labelKey: 'settings.components.verge.theme.fields.primaryText', + key: 'primary_text', }, { - labelKey: "settings.components.verge.theme.fields.secondaryText", - key: "secondary_text", + labelKey: 'settings.components.verge.theme.fields.secondaryText', + key: 'secondary_text', }, { - labelKey: "settings.components.verge.theme.fields.infoColor", - key: "info_color", + labelKey: 'settings.components.verge.theme.fields.infoColor', + key: 'info_color', }, { - labelKey: "settings.components.verge.theme.fields.warningColor", - key: "warning_color", + labelKey: 'settings.components.verge.theme.fields.warningColor', + key: 'warning_color', }, { - labelKey: "settings.components.verge.theme.fields.errorColor", - key: "error_color", + labelKey: 'settings.components.verge.theme.fields.errorColor', + key: 'error_color', }, { - labelKey: "settings.components.verge.theme.fields.successColor", - key: "success_color", + labelKey: 'settings.components.verge.theme.fields.successColor', + key: 'success_color', }, ], [], - ); + ) const openCssEditor = () => { - const nextCss = themeRef.current?.css_injection ?? ""; - setCssEditorValue(nextCss); - setCssEditorSavedValue(nextCss); - setEditorOpen(true); - }; + const nextCss = themeRef.current?.css_injection ?? '' + setCssEditorValue(nextCss) + setCssEditorSavedValue(nextCss) + setEditorOpen(true) + } const handleSaveCss = useLockFn(async () => { - const prevTheme = themeRef.current || {}; - setTheme({ ...prevTheme, css_injection: cssEditorValue }); - setCssEditorSavedValue(cssEditorValue); - }); + const prevTheme = themeRef.current || {} + setTheme({ ...prevTheme, css_injection: cssEditorValue }) + setCssEditorSavedValue(cssEditorValue) + }) const renderItem = (labelKey: string, key: ThemeKey) => { - const label = t(labelKey); + const label = t(labelKey) return ( e.key === "Enter" && onSave()} + onKeyDown={(e) => e.key === 'Enter' && onSave()} /> - ); - }; + ) + } return ( setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} @@ -158,30 +158,30 @@ export function ThemeViewer(props: { ref?: React.Ref }) { e.key === "Enter" && onSave()} + value={theme.font_family ?? ''} + onChange={handleChange('font_family')} + onKeyDown={(e) => e.key === 'Enter' && onSave()} /> {editorOpen && ( }) { onChange={setCssEditorValue} onSave={handleSaveCss} onClose={() => { - setEditorOpen(false); + setEditorOpen(false) }} /> )} - ); + ) } const Item = styled(ListItem)(() => ({ - padding: "5px 2px", -})); + padding: '5px 2px', +})) -const Round = styled("div")(() => ({ - width: "24px", - height: "24px", - borderRadius: "18px", - display: "inline-block", - marginRight: "8px", -})); +const Round = styled('div')(() => ({ + width: '24px', + height: '24px', + borderRadius: '18px', + display: 'inline-block', + marginRight: '8px', +})) diff --git a/src/components/setting/mods/tun-viewer.tsx b/src/components/setting/mods/tun-viewer.tsx index 639f4df7a..5c381caa6 100644 --- a/src/components/setting/mods/tun-viewer.tsx +++ b/src/components/setting/mods/tun-viewer.tsx @@ -6,11 +6,11 @@ import { ListItemText, TextField, Typography, -} from "@mui/material"; -import { useLockFn } from "ahooks"; -import type { Ref } from "react"; -import { useImperativeHandle, useState } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { useLockFn } from 'ahooks' +import type { Ref } from 'react' +import { useImperativeHandle, useState } from 'react' +import { useTranslation } from 'react-i18next' import { BaseDialog, @@ -18,198 +18,198 @@ import { TooltipIcon, DialogRef, Switch, -} from "@/components/base"; -import { useClash } from "@/hooks/use-clash"; -import { enhanceProfiles } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; -import getSystem from "@/utils/get-system"; -import { areValidIpCidrs } from "@/utils/network"; +} from '@/components/base' +import { useClash } from '@/hooks/use-clash' +import { enhanceProfiles } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import getSystem from '@/utils/get-system' +import { areValidIpCidrs } from '@/utils/network' -import { StackModeSwitch } from "./stack-mode-switch"; +import { StackModeSwitch } from './stack-mode-switch' -const OS = getSystem(); +const OS = getSystem() const splitRouteExcludeAddress = (value: string) => value .split(/[,\n;\r]+/) .map((item) => item.trim()) - .filter(Boolean); + .filter(Boolean) export function TunViewer({ ref }: { ref?: Ref }) { - const { t } = useTranslation(); + const { t } = useTranslation() - const { clash, mutateClash, patchClash } = useClash(); + const { clash, mutateClash, patchClash } = useClash() - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false) const [values, setValues] = useState({ - stack: "mixed", - device: OS === "macos" ? "utun1024" : "Mihomo", + stack: 'mixed', + device: OS === 'macos' ? 'utun1024' : 'Mihomo', autoRoute: true, - routeExcludeAddress: "", + routeExcludeAddress: '', autoRedirect: false, autoDetectInterface: true, - dnsHijack: ["any:53"], + dnsHijack: ['any:53'], strictRoute: false, mtu: 1500, - }); + }) const routeExcludeAddressItems = splitRouteExcludeAddress( values.routeExcludeAddress, - ); + ) const routeExcludeAddressError = values.autoRoute && routeExcludeAddressItems.length > 0 && - !areValidIpCidrs(routeExcludeAddressItems); + !areValidIpCidrs(routeExcludeAddressItems) const routeExcludeAddressHelperText = routeExcludeAddressError - ? t("settings.modals.tun.messages.invalidRouteExcludeAddress") - : t("settings.modals.tun.messages.routeExcludeAddressHint"); + ? t('settings.modals.tun.messages.invalidRouteExcludeAddress') + : t('settings.modals.tun.messages.routeExcludeAddressHint') useImperativeHandle(ref, () => ({ open: () => { - setOpen(true); - const nextAutoRoute = clash?.tun["auto-route"] ?? true; - const rawAutoRedirect = clash?.tun["auto-redirect"] ?? false; + setOpen(true) + const nextAutoRoute = clash?.tun['auto-route'] ?? true + const rawAutoRedirect = clash?.tun['auto-redirect'] ?? false const computedAutoRedirect = - OS === "linux" ? (nextAutoRoute ? rawAutoRedirect : false) : false; + OS === 'linux' ? (nextAutoRoute ? rawAutoRedirect : false) : false setValues({ - stack: clash?.tun.stack ?? "gvisor", - device: clash?.tun.device ?? (OS === "macos" ? "utun1024" : "Mihomo"), + stack: clash?.tun.stack ?? 'gvisor', + device: clash?.tun.device ?? (OS === 'macos' ? 'utun1024' : 'Mihomo'), autoRoute: nextAutoRoute, - routeExcludeAddress: (clash?.tun["route-exclude-address"] ?? []).join( - ",", + routeExcludeAddress: (clash?.tun['route-exclude-address'] ?? []).join( + ',', ), autoRedirect: computedAutoRedirect, - autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true, - dnsHijack: clash?.tun["dns-hijack"] ?? ["any:53"], - strictRoute: clash?.tun["strict-route"] ?? false, + autoDetectInterface: clash?.tun['auto-detect-interface'] ?? true, + dnsHijack: clash?.tun['dns-hijack'] ?? ['any:53'], + strictRoute: clash?.tun['strict-route'] ?? false, mtu: clash?.tun.mtu ?? 1500, - }); + }) }, close: () => setOpen(false), - })); + })) const onSave = useLockFn(async () => { try { - const routeExcludeAddress = routeExcludeAddressItems; + const routeExcludeAddress = routeExcludeAddressItems if (routeExcludeAddressError) { showNotice.error( - "settings.modals.tun.messages.invalidRouteExcludeAddress", - ); - return; + 'settings.modals.tun.messages.invalidRouteExcludeAddress', + ) + return } - const tun: IConfigData["tun"] = { + const tun: IConfigData['tun'] = { stack: values.stack, device: - values.device === "" - ? OS === "macos" - ? "utun1024" - : "Mihomo" + values.device === '' + ? OS === 'macos' + ? 'utun1024' + : 'Mihomo' : values.device, - "auto-route": values.autoRoute, - "route-exclude-address": routeExcludeAddress, - ...(OS === "linux" + 'auto-route': values.autoRoute, + 'route-exclude-address': routeExcludeAddress, + ...(OS === 'linux' ? { - "auto-redirect": values.autoRedirect, + 'auto-redirect': values.autoRedirect, } : {}), - "auto-detect-interface": values.autoDetectInterface, - "dns-hijack": values.dnsHijack[0] === "" ? [] : values.dnsHijack, - "strict-route": values.strictRoute, + 'auto-detect-interface': values.autoDetectInterface, + 'dns-hijack': values.dnsHijack[0] === '' ? [] : values.dnsHijack, + 'strict-route': values.strictRoute, mtu: values.mtu ?? 1500, - }; - await patchClash({ tun }); + } + await patchClash({ tun }) await mutateClash( (old) => ({ ...old!, tun, }), false, - ); - setOpen(false); - showNotice.success("settings.modals.tun.messages.applied"); + ) + setOpen(false) + showNotice.success('settings.modals.tun.messages.applied') void enhanceProfiles().catch((err: any) => { - showNotice.error(err); - }); + showNotice.error(err) + }) } catch (err: any) { - showNotice.error(err); + showNotice.error(err) } - }); + }) return ( - {t("settings.modals.tun.title")} + {t('settings.modals.tun.title')} } contentSx={{ width: 450 }} - okBtn={t("shared.actions.save")} - cancelBtn={t("shared.actions.cancel")} + okBtn={t('shared.actions.save')} + cancelBtn={t('shared.actions.cancel')} onClose={() => setOpen(false)} onCancel={() => setOpen(false)} onOk={onSave} > - - + + { setValues((v) => ({ ...v, stack: value, - })); + })) }} /> - - + + }) { /> - - + + }) { /> - {OS === "linux" && ( - + {OS === 'linux' && ( + }) { })) } disabled={!values.autoRoute} - sx={{ marginLeft: "auto" }} + sx={{ marginLeft: 'auto' }} /> )} - - + + }) { /> - + }) { /> - - + + }) { autoCapitalize="off" spellCheck="false" sx={{ width: 250 }} - value={values.dnsHijack.join(",")} - placeholder={t("settings.modals.tun.tooltips.dnsHijack")} + value={values.dnsHijack.join(',')} + placeholder={t('settings.modals.tun.tooltips.dnsHijack')} onChange={(e) => - setValues((v) => ({ ...v, dnsHijack: e.target.value.split(",") })) + setValues((v) => ({ ...v, dnsHijack: e.target.value.split(',') })) } /> - - + + }) { }) { setValues((v) => ({ ...v, routeExcludeAddress: nextValue })) } renderHeader={(modeToggle) => ( - + {modeToggle ? ( - {modeToggle} + {modeToggle} ) : null} )} /> - ); + ) } diff --git a/src/components/setting/mods/tunnels-viewer.tsx b/src/components/setting/mods/tunnels-viewer.tsx index aa1848fa7..9e0b2499d 100644 --- a/src/components/setting/mods/tunnels-viewer.tsx +++ b/src/components/setting/mods/tunnels-viewer.tsx @@ -1,4 +1,4 @@ -import { Delete, ExpandLess, ExpandMore } from "@mui/icons-material"; +import { Delete, ExpandLess, ExpandMore } from '@mui/icons-material' import { Button, Divider, @@ -10,77 +10,77 @@ import { TextField, Select, MenuItem, -} from "@mui/material"; -import { forwardRef, useImperativeHandle, useState, useMemo } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/material' +import { forwardRef, useImperativeHandle, useState, useMemo } from 'react' +import { useTranslation } from 'react-i18next' -import { BaseDialog } from "@/components/base"; -import { useClash } from "@/hooks/use-clash"; -import { useAppData } from "@/providers/app-data-context"; -import { isPortInUse } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; +import { BaseDialog } from '@/components/base' +import { useClash } from '@/hooks/use-clash' +import { useAppData } from '@/providers/app-data-context' +import { isPortInUse } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' import { formatHostPort, isValidPort, normalizeHost, normalizeListenHost, -} from "@/utils/network"; +} from '@/utils/network' interface TunnelsViewerRef { - open: () => void; - close: () => void; + open: () => void + close: () => void } interface TunnelEntry { - network: string[]; - address: string; - target: string; - proxy?: string; + network: string[] + address: string + target: string + proxy?: string } export const TunnelsViewer = forwardRef((_, ref) => { - const { t } = useTranslation(); - const { clash, mutateClash, patchClash } = useClash(); + const { t } = useTranslation() + const { clash, mutateClash, patchClash } = useClash() - const [open, setOpen] = useState(false); - const [expanded, setExpanded] = useState(false); + const [open, setOpen] = useState(false) + const [expanded, setExpanded] = useState(false) const [values, setValues] = useState({ - localAddr: "", - localPort: "", - targetAddr: "", - targetPort: "", - network: "tcp+udp", - group: "", - proxy: "", - }); - const [draftTunnels, setDraftTunnels] = useState([]); + localAddr: '', + localPort: '', + targetAddr: '', + targetPort: '', + network: 'tcp+udp', + group: '', + proxy: '', + }) + const [draftTunnels, setDraftTunnels] = useState([]) useImperativeHandle(ref, () => ({ open: () => { setValues(() => ({ - localAddr: "", - localPort: "", - targetAddr: "", - targetPort: "", - network: "tcp+udp", - group: "", - proxy: "", - })); - setDraftTunnels(() => clash?.tunnels ?? []); - setOpen(true); + localAddr: '', + localPort: '', + targetAddr: '', + targetPort: '', + network: 'tcp+udp', + group: '', + proxy: '', + })) + setDraftTunnels(() => clash?.tunnels ?? []) + setOpen(true) // 如果没有隧道,则自动展开 - setExpanded((clash?.tunnels ?? []).length === 0); + setExpanded((clash?.tunnels ?? []).length === 0) }, close: () => { - setOpen(false); + setOpen(false) }, - })); + })) const tunnelEntries = useMemo(() => { - const counts: Record = {}; + const counts: Record = {} return draftTunnels.map((tunnel, index) => { - const base = `${tunnel.address}_${tunnel.target}_${tunnel.network.join("+")}`; - const occurrence = (counts[base] = (counts[base] ?? 0) + 1); + const base = `${tunnel.address}_${tunnel.target}_${tunnel.network.join('+')}` + const occurrence = (counts[base] = (counts[base] ?? 0) + 1) return { index, key: `${base}_${occurrence}`, @@ -88,137 +88,137 @@ export const TunnelsViewer = forwardRef((_, ref) => { target: tunnel.target, network: tunnel.network, proxy: tunnel.proxy, - }; - }); - }, [draftTunnels]); + } + }) + }, [draftTunnels]) - const { proxies } = useAppData(); + const { proxies } = useAppData() const proxyGroups = useMemo(() => { - return proxies?.groups ?? []; - }, [proxies]); + return proxies?.groups ?? [] + }, [proxies]) const groupNames = useMemo( () => proxyGroups.map((group) => group.name), [proxyGroups], - ); + ) const proxyOptions = useMemo(() => { - const group = proxyGroups.find((item) => item.name === values.group); - return group?.all ?? []; - }, [proxyGroups, values.group]); + const group = proxyGroups.find((item) => item.name === values.group) + return group?.all ?? [] + }, [proxyGroups, values.group]) const handleSave = async () => { try { - await patchClash({ tunnels: draftTunnels }); - await mutateClash(); - showNotice.success("shared.feedback.notifications.common.saveSuccess"); - setOpen(false); + await patchClash({ tunnels: draftTunnels }) + await mutateClash() + showNotice.success('shared.feedback.notifications.common.saveSuccess') + setOpen(false) } catch (err: any) { - showNotice.error(err); + showNotice.error(err) } - }; + } const handleAdd = async () => { const { localAddr, localPort, targetAddr, targetPort, network, proxy } = - values; + values // 基础非空校验 if (!localAddr || !localPort || !targetAddr || !targetPort) { showNotice.error( - "settings.sections.clash.form.fields.tunnels.messages.incomplete", - ); - return; + 'settings.sections.clash.form.fields.tunnels.messages.incomplete', + ) + return } // 本地地址校验(host) - const localHost = normalizeListenHost(localAddr); + const localHost = normalizeListenHost(localAddr) if (!localHost) { showNotice.error( - "settings.sections.clash.form.fields.tunnels.messages.invalidLocalAddr", - ); - return; + 'settings.sections.clash.form.fields.tunnels.messages.invalidLocalAddr', + ) + return } // 本地端口校验 (port) if (!isValidPort(localPort)) { showNotice.error( - "settings.sections.clash.form.fields.tunnels.messages.invalidLocalPort", - ); - return; + 'settings.sections.clash.form.fields.tunnels.messages.invalidLocalPort', + ) + return } - const inUse = await isPortInUse(Number(localPort)); + const inUse = await isPortInUse(Number(localPort)) if (inUse) { - showNotice.error("settings.modals.clashPort.messages.portInUse", { + showNotice.error('settings.modals.clashPort.messages.portInUse', { port: localPort, - }); - return; + }) + return } // 目标地址校验 (host) - const targetHost = normalizeHost(targetAddr); + const targetHost = normalizeHost(targetAddr) if (!targetHost) { showNotice.error( - "settings.sections.clash.form.fields.tunnels.messages.invalidTargetAddr", - ); - return; + 'settings.sections.clash.form.fields.tunnels.messages.invalidTargetAddr', + ) + return } // 目标端口校验 (port) if (!isValidPort(targetPort)) { showNotice.error( - "settings.sections.clash.form.fields.tunnels.messages.invalidTargetPort", - ); - return; + 'settings.sections.clash.form.fields.tunnels.messages.invalidTargetPort', + ) + return } // 构造新 entry const entry: TunnelEntry = { - network: network === "tcp+udp" ? ["tcp", "udp"] : [network], + network: network === 'tcp+udp' ? ['tcp', 'udp'] : [network], address: formatHostPort(localHost, localPort), target: formatHostPort(targetHost, targetPort), ...(proxy ? { proxy } : {}), - }; + } // 写入配置 + 清空输入 - setDraftTunnels((prev) => [...prev, entry]); + setDraftTunnels((prev) => [...prev, entry]) setValues((v) => ({ ...v, - localAddr: "", - localPort: "", - targetAddr: "", - targetPort: "", - network: "tcp+udp", - })); - }; + localAddr: '', + localPort: '', + targetAddr: '', + targetPort: '', + network: 'tcp+udp', + })) + } const handleDelete = (index: number) => { - setDraftTunnels((prev) => prev.filter((_, i) => i !== index)); - }; + setDraftTunnels((prev) => prev.filter((_, i) => i !== index)) + } return ( { - setOpen(false); + setOpen(false) }} onCancel={() => { - setOpen(false); + setOpen(false) }} onOk={handleSave} > {draftTunnels.length > 0 && ( <> - + @@ -226,7 +226,7 @@ export const TunnelsViewer = forwardRef((_, ref) => { {tunnelEntries.map((item) => ( ((_, ref) => { > @@ -252,30 +252,30 @@ export const TunnelsViewer = forwardRef((_, ref) => { )} setExpanded((v) => !v)} > {expanded ? : } {expanded && ( - -
    + +
    {/* 输入框区域 */} {/* 协议 */} - + div": { py: "7.5px" } }} + sx={{ width: 200, '> div': { py: '7.5px' } }} value={values.group} displayEmpty onChange={(e) => { - const nextGroup = e.target.value as string; - const group = proxyGroups.find((g) => g.name === nextGroup); - const firstProxy = group?.all?.[0].name ?? ""; + const nextGroup = e.target.value as string + const group = proxyGroups.find((g) => g.name === nextGroup) + const firstProxy = group?.all?.[0].name ?? '' setValues((v) => ({ ...v, group: nextGroup, proxy: firstProxy, // 组切换时自动选第一条节点 - })); + })) }} > - {t("settings.sections.clash.form.fields.tunnels.default")} + {t('settings.sections.clash.form.fields.tunnels.default')} {groupNames.map((name) => ( @@ -416,18 +416,18 @@ export const TunnelsViewer = forwardRef((_, ref) => { {/* 代理节点 */} - + {t( - "settings.sections.clash.form.fields.tunnels.proxyNode", + 'settings.sections.clash.form.fields.tunnels.proxyNode', )} - - {" "} + + {' '} ( {t( - "settings.sections.clash.form.fields.tunnels.optional", + 'settings.sections.clash.form.fields.tunnels.optional', )} ) @@ -436,7 +436,7 @@ export const TunnelsViewer = forwardRef((_, ref) => { /> div": { py: "7.5px" } }}> + - + { - portRef.current?.open(); - (e.target as any).blur(); + portRef.current?.open() + ;(e.target as any).blur() }} /> { - e.stopPropagation(); - corsRef.current?.open(); + e.stopPropagation() + corsRef.current?.open() }} /> } onClick={() => { - ctrlRef.current?.open(); + ctrlRef.current?.open() }} /> webRef.current?.open()} - label={t("settings.sections.clash.form.fields.webUI")} + label={t('settings.sections.clash.form.fields.webUI')} /> { /> } > - {version} + {version} {isWIN && ( } /> @@ -282,15 +280,15 @@ const SettingClash = ({ onError }: Props) => { tunnelRef.current?.open()} /> - ); -}; + ) +} -export default SettingClash; +export default SettingClash diff --git a/src/components/setting/setting-system.tsx b/src/components/setting/setting-system.tsx index 5088c5d9f..d7ca00fa3 100644 --- a/src/components/setting/setting-system.tsx +++ b/src/components/setting/setting-system.tsx @@ -1,73 +1,73 @@ -import React, { useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { mutate } from "swr"; +import React, { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { mutate } from 'swr' -import { DialogRef, Switch, TooltipIcon } from "@/components/base"; -import ProxyControlSwitches from "@/components/shared/proxy-control-switches"; -import { useVerge } from "@/hooks/use-verge"; +import { DialogRef, Switch, TooltipIcon } from '@/components/base' +import ProxyControlSwitches from '@/components/shared/proxy-control-switches' +import { useVerge } from '@/hooks/use-verge' -import { GuardState } from "./mods/guard-state"; -import { SettingList, SettingItem } from "./mods/setting-comp"; -import { SysproxyViewer } from "./mods/sysproxy-viewer"; -import { TunViewer } from "./mods/tun-viewer"; +import { GuardState } from './mods/guard-state' +import { SettingList, SettingItem } from './mods/setting-comp' +import { SysproxyViewer } from './mods/sysproxy-viewer' +import { TunViewer } from './mods/tun-viewer' interface Props { - onError?: (err: Error) => void; + onError?: (err: Error) => void } const SettingSystem = ({ onError }: Props) => { - const { t } = useTranslation(); + const { t } = useTranslation() - const { verge, mutateVerge, patchVerge } = useVerge(); + const { verge, mutateVerge, patchVerge } = useVerge() - const { enable_auto_launch, enable_silent_start } = verge ?? {}; + const { enable_auto_launch, enable_silent_start } = verge ?? {} - const sysproxyRef = useRef(null); - const tunRef = useRef(null); + const sysproxyRef = useRef(null) + const tunRef = useRef(null) const onSwitchFormat = ( _e: React.ChangeEvent, value: boolean, - ) => value; + ) => value const onChangeData = (patch: Partial) => { - mutateVerge({ ...verge, ...patch }, false); - }; + mutateVerge({ ...verge, ...patch }, false) + } return ( - + - + { - onChangeData({ enable_auto_launch: e }); + onChangeData({ enable_auto_launch: e }) }} onGuard={async (e) => { try { // 先触发UI更新立即看到反馈 - onChangeData({ enable_auto_launch: e }); - await patchVerge({ enable_auto_launch: e }); - await mutate("getAutoLaunchStatus"); - return Promise.resolve(); + onChangeData({ enable_auto_launch: e }) + await patchVerge({ enable_auto_launch: e }) + await mutate('getAutoLaunchStatus') + return Promise.resolve() } catch (error) { // 如果出错,恢复原始状态 - onChangeData({ enable_auto_launch: !e }); - return Promise.reject(error); + onChangeData({ enable_auto_launch: !e }) + return Promise.reject(error) } }} > @@ -76,11 +76,11 @@ const SettingSystem = ({ onError }: Props) => { } > @@ -96,7 +96,7 @@ const SettingSystem = ({ onError }: Props) => { - ); -}; + ) +} -export default SettingSystem; +export default SettingSystem diff --git a/src/components/setting/setting-verge-advanced.tsx b/src/components/setting/setting-verge-advanced.tsx index 43f95e324..625bf3bca 100644 --- a/src/components/setting/setting-verge-advanced.tsx +++ b/src/components/setting/setting-verge-advanced.tsx @@ -1,9 +1,9 @@ -import { ContentCopyRounded } from "@mui/icons-material"; -import { Typography } from "@mui/material"; -import { useCallback, useRef } from "react"; -import { useTranslation } from "react-i18next"; +import { ContentCopyRounded } from '@mui/icons-material' +import { Typography } from '@mui/material' +import { useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' -import { DialogRef, TooltipIcon } from "@/components/base"; +import { DialogRef, TooltipIcon } from '@/components/base' import { exitApp, exportDiagnosticInfo, @@ -11,71 +11,68 @@ import { openCoreDir, openDevTools, openLogsDir, -} from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; -import { checkUpdateSafe as checkUpdate } from "@/services/update"; -import { version } from "@root/package.json"; +} from '@/services/cmds' +import { showNotice } from '@/services/notice-service' +import { checkUpdateSafe as checkUpdate } from '@/services/update' +import { version } from '@root/package.json' -import { BackupViewer } from "./mods/backup-viewer"; -import { ConfigViewer } from "./mods/config-viewer"; -import { HotkeyViewer } from "./mods/hotkey-viewer"; -import { LayoutViewer } from "./mods/layout-viewer"; -import { LiteModeViewer } from "./mods/lite-mode-viewer"; -import { MiscViewer } from "./mods/misc-viewer"; -import { SettingItem, SettingList } from "./mods/setting-comp"; -import { ThemeViewer } from "./mods/theme-viewer"; -import { UpdateViewer } from "./mods/update-viewer"; +import { BackupViewer } from './mods/backup-viewer' +import { ConfigViewer } from './mods/config-viewer' +import { HotkeyViewer } from './mods/hotkey-viewer' +import { LayoutViewer } from './mods/layout-viewer' +import { LiteModeViewer } from './mods/lite-mode-viewer' +import { MiscViewer } from './mods/misc-viewer' +import { SettingItem, SettingList } from './mods/setting-comp' +import { ThemeViewer } from './mods/theme-viewer' +import { UpdateViewer } from './mods/update-viewer' interface Props { - onError?: (err: Error) => void; + onError?: (err: Error) => void } const SettingVergeAdvanced = ({ onError: _ }: Props) => { - const { t } = useTranslation(); + const { t } = useTranslation() - const configRef = useRef(null); - const hotkeyRef = useRef(null); - const miscRef = useRef(null); - const themeRef = useRef(null); - const layoutRef = useRef(null); - const updateRef = useRef(null); - const backupRef = useRef(null); - const liteModeRef = useRef(null); + const configRef = useRef(null) + const hotkeyRef = useRef(null) + const miscRef = useRef(null) + const themeRef = useRef(null) + const layoutRef = useRef(null) + const updateRef = useRef(null) + const backupRef = useRef(null) + const liteModeRef = useRef(null) const onCheckUpdate = async () => { try { - const info = await checkUpdate(); + const info = await checkUpdate() if (!info?.available) { showNotice.success( - "settings.components.verge.advanced.notifications.latestVersion", - ); + 'settings.components.verge.advanced.notifications.latestVersion', + ) } else { - updateRef.current?.open(); + updateRef.current?.open() } } catch (err: any) { - showNotice.error(err); + showNotice.error(err) } - }; + } const onExportDiagnosticInfo = useCallback(async () => { - await exportDiagnosticInfo(); - showNotice.success( - "shared.feedback.notifications.common.copySuccess", - 1000, - ); - }, []); + await exportDiagnosticInfo() + showNotice.success('shared.feedback.notifications.common.copySuccess', 1000) + }, []) const copyVersion = useCallback(() => { navigator.clipboard.writeText(`v${version}`).then(() => { showNotice.success( - "settings.components.verge.advanced.notifications.versionCopied", + 'settings.components.verge.advanced.notifications.versionCopied', 1000, - ); - }); - }, []); + ) + }) + }, []) return ( - + @@ -87,57 +84,57 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => { backupRef.current?.open()} - label={t("settings.components.verge.advanced.fields.backupSetting")} + label={t('settings.components.verge.advanced.fields.backupSetting')} extra={ } /> configRef.current?.open()} - label={t("settings.components.verge.advanced.fields.runtimeConfig")} + label={t('settings.components.verge.advanced.fields.runtimeConfig')} /> } /> } onClick={() => liteModeRef.current?.open()} @@ -145,13 +142,13 @@ const SettingVergeAdvanced = ({ onError: _ }: Props) => { { - exitApp(); + exitApp() }} - label={t("settings.components.verge.advanced.fields.exit")} + label={t('settings.components.verge.advanced.fields.exit')} /> { > } > - v{version} + v{version} - ); -}; + ) +} -export default SettingVergeAdvanced; +export default SettingVergeAdvanced diff --git a/src/components/setting/setting-verge-basic.tsx b/src/components/setting/setting-verge-basic.tsx index ab516d319..4d4512461 100644 --- a/src/components/setting/setting-verge-basic.tsx +++ b/src/components/setting/setting-verge-basic.tsx @@ -1,58 +1,58 @@ -import { ContentCopyRounded } from "@mui/icons-material"; -import { Button, Input, MenuItem, Select } from "@mui/material"; -import { open } from "@tauri-apps/plugin-dialog"; -import { useCallback, useRef } from "react"; -import { useTranslation } from "react-i18next"; +import { ContentCopyRounded } from '@mui/icons-material' +import { Button, Input, MenuItem, Select } from '@mui/material' +import { open } from '@tauri-apps/plugin-dialog' +import { useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' -import { DialogRef, TooltipIcon } from "@/components/base"; -import { useVerge } from "@/hooks/use-verge"; -import { navItems } from "@/pages/_routers"; -import { copyClashEnv } from "@/services/cmds"; -import { supportedLanguages } from "@/services/i18n"; -import { showNotice } from "@/services/notice-service"; -import getSystem from "@/utils/get-system"; +import { DialogRef, TooltipIcon } from '@/components/base' +import { useVerge } from '@/hooks/use-verge' +import { navItems } from '@/pages/_routers' +import { copyClashEnv } from '@/services/cmds' +import { supportedLanguages } from '@/services/i18n' +import { showNotice } from '@/services/notice-service' +import getSystem from '@/utils/get-system' -import { BackupViewer } from "./mods/backup-viewer"; -import { ConfigViewer } from "./mods/config-viewer"; -import { GuardState } from "./mods/guard-state"; -import { HotkeyViewer } from "./mods/hotkey-viewer"; -import { LayoutViewer } from "./mods/layout-viewer"; -import { MiscViewer } from "./mods/misc-viewer"; -import { SettingItem, SettingList } from "./mods/setting-comp"; -import { ThemeModeSwitch } from "./mods/theme-mode-switch"; -import { ThemeViewer } from "./mods/theme-viewer"; -import { UpdateViewer } from "./mods/update-viewer"; +import { BackupViewer } from './mods/backup-viewer' +import { ConfigViewer } from './mods/config-viewer' +import { GuardState } from './mods/guard-state' +import { HotkeyViewer } from './mods/hotkey-viewer' +import { LayoutViewer } from './mods/layout-viewer' +import { MiscViewer } from './mods/misc-viewer' +import { SettingItem, SettingList } from './mods/setting-comp' +import { ThemeModeSwitch } from './mods/theme-mode-switch' +import { ThemeViewer } from './mods/theme-viewer' +import { UpdateViewer } from './mods/update-viewer' interface Props { - onError?: (err: Error) => void; + onError?: (err: Error) => void } -const OS = getSystem(); +const OS = getSystem() const languageOptions = supportedLanguages.map((code) => { const labels: { [key: string]: string } = { - en: "English", - ru: "Русский", - zh: "中文", - fa: "فارسی", - tt: "Татар", - id: "Bahasa Indonesia", - ar: "العربية", - ko: "한국어", - tr: "Türkçe", - de: "Deutsch", - es: "Español", - jp: "日本語", - zhtw: "繁體中文", - }; - const label = labels[code] || code; - return { code, label }; -}); + en: 'English', + ru: 'Русский', + zh: '中文', + fa: 'فارسی', + tt: 'Татар', + id: 'Bahasa Indonesia', + ar: 'العربية', + ko: '한국어', + tr: 'Türkçe', + de: 'Deutsch', + es: 'Español', + jp: '日本語', + zhtw: '繁體中文', + } + const label = labels[code] || code + return { code, label } +}) const SettingVergeBasic = ({ onError }: Props) => { - const { t } = useTranslation(); + const { t } = useTranslation() - const { verge, patchVerge, mutateVerge } = useVerge(); + const { verge, patchVerge, mutateVerge } = useVerge() const { theme_mode, language, @@ -60,29 +60,26 @@ const SettingVergeBasic = ({ onError }: Props) => { env_type, startup_script, start_page, - } = verge ?? {}; - const configRef = useRef(null); - const hotkeyRef = useRef(null); - const miscRef = useRef(null); - const themeRef = useRef(null); - const layoutRef = useRef(null); - const updateRef = useRef(null); - const backupRef = useRef(null); + } = verge ?? {} + const configRef = useRef(null) + const hotkeyRef = useRef(null) + const miscRef = useRef(null) + const themeRef = useRef(null) + const layoutRef = useRef(null) + const updateRef = useRef(null) + const backupRef = useRef(null) const onChangeData = (patch: any) => { - mutateVerge({ ...verge, ...patch }, false); - }; + mutateVerge({ ...verge, ...patch }, false) + } const onCopyClashEnv = useCallback(async () => { - await copyClashEnv(); - showNotice.success( - "shared.feedback.notifications.common.copySuccess", - 1000, - ); - }, []); + await copyClashEnv() + showNotice.success('shared.feedback.notifications.common.copySuccess', 1000) + }, []) return ( - + @@ -91,15 +88,15 @@ const SettingVergeBasic = ({ onError }: Props) => { - + e.target.value} onChange={(e) => onChangeData({ language: e })} onGuard={(e) => patchVerge({ language: e })} > - div': { py: '7.5px' } }}> {languageOptions.map(({ code, label }) => ( {label} @@ -110,7 +107,7 @@ const SettingVergeBasic = ({ onError }: Props) => { { - {OS !== "linux" && ( + {OS !== 'linux' && ( e.target.value} onChange={(e) => onChangeData({ tray_event: e })} onGuard={(e) => patchVerge({ tray_event: e })} > - div': { py: '7.5px' } }}> {t( - "settings.components.verge.basic.trayOptions.showMainWindow", + 'settings.components.verge.basic.trayOptions.showMainWindow', )} - {t("settings.components.verge.basic.trayOptions.showTrayMenu")} + {t('settings.components.verge.basic.trayOptions.showTrayMenu')} - {t("settings.sections.system.toggles.systemProxy")} + {t('settings.sections.system.toggles.systemProxy')} - {t("settings.sections.system.toggles.tunMode")} + {t('settings.sections.system.toggles.tunMode')} - {t("settings.components.verge.basic.trayOptions.disable")} + {t('settings.components.verge.basic.trayOptions.disable')} @@ -157,19 +154,19 @@ const SettingVergeBasic = ({ onError }: Props) => { )} } > e.target.value} onChange={(e) => onChangeData({ env_type: e })} onGuard={(e) => patchVerge({ env_type: e })} > - div': { py: '7.5px' } }}> Bash Fish Nushell @@ -180,32 +177,32 @@ const SettingVergeBasic = ({ onError }: Props) => { e.target.value} onChange={(e) => onChangeData({ start_page: e })} onGuard={(e) => patchVerge({ start_page: e })} > - div': { py: '7.5px' } }}> {navItems.map((page: { label: string; path: string }) => { return ( {t(page.label)} - ); + ) })} e.target.value} onChange={(e) => onChangeData({ startup_script: e })} @@ -225,27 +222,27 @@ const SettingVergeBasic = ({ onError }: Props) => { multiple: false, filters: [ { - name: "Shell Script", - extensions: ["sh", "bat", "ps1"], + name: 'Shell Script', + extensions: ['sh', 'bat', 'ps1'], }, ], - }); + }) if (selected) { - onChangeData({ startup_script: `${selected}` }); - patchVerge({ startup_script: `${selected}` }); + onChangeData({ startup_script: `${selected}` }) + patchVerge({ startup_script: `${selected}` }) } }} > - {t("settings.components.verge.basic.actions.browse")} + {t('settings.components.verge.basic.actions.browse')} {startup_script && ( )} @@ -256,25 +253,25 @@ const SettingVergeBasic = ({ onError }: Props) => { themeRef.current?.open()} - label={t("settings.components.verge.basic.fields.themeSetting")} + label={t('settings.components.verge.basic.fields.themeSetting')} /> layoutRef.current?.open()} - label={t("settings.components.verge.basic.fields.layoutSetting")} + label={t('settings.components.verge.basic.fields.layoutSetting')} /> miscRef.current?.open()} - label={t("settings.components.verge.basic.fields.misc")} + label={t('settings.components.verge.basic.fields.misc')} /> hotkeyRef.current?.open()} - label={t("settings.components.verge.basic.fields.hotkeySetting")} + label={t('settings.components.verge.basic.fields.hotkeySetting')} /> - ); -}; + ) +} -export default SettingVergeBasic; +export default SettingVergeBasic diff --git a/src/components/shared/proxy-control-switches.tsx b/src/components/shared/proxy-control-switches.tsx index 40597e967..3320998b9 100644 --- a/src/components/shared/proxy-control-switches.tsx +++ b/src/components/shared/proxy-control-switches.tsx @@ -5,39 +5,39 @@ import { PlayCircleOutlineRounded, SettingsRounded, WarningRounded, -} from "@mui/icons-material"; -import { Box, Typography, alpha, useTheme } from "@mui/material"; -import { useLockFn } from "ahooks"; -import React, { useCallback, useRef } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/icons-material' +import { Box, Typography, alpha, useTheme } from '@mui/material' +import { useLockFn } from 'ahooks' +import React, { useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' -import { DialogRef, Switch, TooltipIcon } from "@/components/base"; -import { GuardState } from "@/components/setting/mods/guard-state"; -import { SysproxyViewer } from "@/components/setting/mods/sysproxy-viewer"; -import { TunViewer } from "@/components/setting/mods/tun-viewer"; -import { useServiceInstaller } from "@/hooks/use-service-installer"; -import { useServiceUninstaller } from "@/hooks/use-service-uninstaller"; -import { useSystemProxyState } from "@/hooks/use-system-proxy-state"; -import { useSystemState } from "@/hooks/use-system-state"; -import { useVerge } from "@/hooks/use-verge"; -import { showNotice } from "@/services/notice-service"; +import { DialogRef, Switch, TooltipIcon } from '@/components/base' +import { GuardState } from '@/components/setting/mods/guard-state' +import { SysproxyViewer } from '@/components/setting/mods/sysproxy-viewer' +import { TunViewer } from '@/components/setting/mods/tun-viewer' +import { useServiceInstaller } from '@/hooks/use-service-installer' +import { useServiceUninstaller } from '@/hooks/use-service-uninstaller' +import { useSystemProxyState } from '@/hooks/use-system-proxy-state' +import { useSystemState } from '@/hooks/use-system-state' +import { useVerge } from '@/hooks/use-verge' +import { showNotice } from '@/services/notice-service' interface ProxySwitchProps { - label?: string; - onError?: (err: Error) => void; - noRightPadding?: boolean; + label?: string + onError?: (err: Error) => void + noRightPadding?: boolean } interface SwitchRowProps { - label: string; - active: boolean; - disabled?: boolean; - infoTitle: string; - onInfoClick?: () => void; - extraIcons?: React.ReactNode; - onToggle: (value: boolean) => Promise; - onError?: (err: Error) => void; - highlight?: boolean; + label: string + active: boolean + disabled?: boolean + infoTitle: string + onInfoClick?: () => void + extraIcons?: React.ReactNode + onToggle: (value: boolean) => Promise + onError?: (err: Error) => void + highlight?: boolean } /** @@ -54,32 +54,32 @@ const SwitchRow = ({ onError, highlight, }: SwitchRowProps) => { - const theme = useTheme(); + const theme = useTheme() return ( - + {active ? ( - + ) : ( - + )} {label} @@ -102,75 +102,75 @@ const SwitchRow = ({ - ); -}; + ) +} const ProxyControlSwitches = ({ label, onError, noRightPadding = false, }: ProxySwitchProps) => { - const { t } = useTranslation(); - const { verge, mutateVerge, patchVerge } = useVerge(); - const { installServiceAndRestartCore } = useServiceInstaller(); - const { uninstallServiceAndRestartCore } = useServiceUninstaller(); + const { t } = useTranslation() + const { verge, mutateVerge, patchVerge } = useVerge() + const { installServiceAndRestartCore } = useServiceInstaller() + const { uninstallServiceAndRestartCore } = useServiceUninstaller() const { configState: systemProxyConfigState, toggleSystemProxy } = - useSystemProxyState(); + useSystemProxyState() const { isServiceOk, isTunModeAvailable, mutateSystemState } = - useSystemState(); + useSystemState() - const sysproxyRef = useRef(null); - const tunRef = useRef(null); + const sysproxyRef = useRef(null) + const tunRef = useRef(null) - const { enable_tun_mode } = verge ?? {}; + const { enable_tun_mode } = verge ?? {} const showErrorNotice = useCallback( (msg: string) => showNotice.error(msg), [], - ); + ) const handleTunToggle = async (value: boolean) => { if (!isTunModeAvailable) { - const msgKey = "settings.sections.proxyControl.tooltips.tunUnavailable"; - showErrorNotice(msgKey); - throw new Error(t(msgKey)); + const msgKey = 'settings.sections.proxyControl.tooltips.tunUnavailable' + showErrorNotice(msgKey) + throw new Error(t(msgKey)) } - mutateVerge({ ...verge, enable_tun_mode: value }, false); - await patchVerge({ enable_tun_mode: value }); - }; + mutateVerge({ ...verge, enable_tun_mode: value }, false) + await patchVerge({ enable_tun_mode: value }) + } const onInstallService = useLockFn(async () => { try { - await installServiceAndRestartCore(); - await mutateSystemState(); + await installServiceAndRestartCore() + await mutateSystemState() } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) const onUninstallService = useLockFn(async () => { try { if (verge?.enable_tun_mode) { - await handleTunToggle(false); + await handleTunToggle(false) } - await uninstallServiceAndRestartCore(); - await mutateSystemState(); + await uninstallServiceAndRestartCore() + await mutateSystemState() } catch (err) { - showNotice.error(err); + showNotice.error(err) } - }); + }) const isSystemProxyMode = - label === t("settings.sections.system.toggles.systemProxy") || !label; - const isTunMode = label === t("settings.sections.system.toggles.tunMode"); + label === t('settings.sections.system.toggles.systemProxy') || !label + const isTunMode = label === t('settings.sections.system.toggles.tunMode') return ( - + {isSystemProxyMode && ( sysproxyRef.current?.open()} onToggle={(value) => toggleSystemProxy(value)} onError={onError} @@ -180,9 +180,9 @@ const ProxyControlSwitches = ({ {isTunMode && ( tunRef.current?.open()} onToggle={handleTunToggle} onError={onError} @@ -194,14 +194,14 @@ const ProxyControlSwitches = ({ <> - ); -}; + ) +} -export default ProxyControlSwitches; +export default ProxyControlSwitches diff --git a/src/components/shared/traffic-error-boundary.tsx b/src/components/shared/traffic-error-boundary.tsx index c43d3d579..112ae1268 100644 --- a/src/components/shared/traffic-error-boundary.tsx +++ b/src/components/shared/traffic-error-boundary.tsx @@ -2,22 +2,22 @@ import { ErrorOutlineRounded, RefreshRounded, BugReportRounded, -} from "@mui/icons-material"; -import { Box, Typography, Button, Alert, Collapse } from "@mui/material"; -import React, { Component, ErrorInfo, ReactNode } from "react"; -import { useTranslation } from "react-i18next"; +} from '@mui/icons-material' +import { Box, Typography, Button, Alert, Collapse } from '@mui/material' +import React, { Component, ErrorInfo, ReactNode } from 'react' +import { useTranslation } from 'react-i18next' interface Props { - children: ReactNode; - fallbackComponent?: ReactNode; - onError?: (error: Error, errorInfo: ErrorInfo) => void; + children: ReactNode + fallbackComponent?: ReactNode + onError?: (error: Error, errorInfo: ErrorInfo) => void } interface State { - hasError: boolean; - error: Error | null; - errorInfo: ErrorInfo | null; - showDetails: boolean; + hasError: boolean + error: Error | null + errorInfo: ErrorInfo | null + showDetails: boolean } /** @@ -25,39 +25,39 @@ interface State { * 处理图表和流量统计组件的错误,提供优雅的降级体验 */ export class TrafficErrorBoundary extends Component { - private retryCount = 0; - private maxRetries = 3; + private retryCount = 0 + private maxRetries = 3 constructor(props: Props) { - super(props); + super(props) this.state = { hasError: false, error: null, errorInfo: null, showDetails: false, - }; + } } static getDerivedStateFromError(error: Error): Partial { // 更新状态以显示降级UI - return { hasError: true, error }; + return { hasError: true, error } } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error("[TrafficErrorBoundary] 捕获到组件错误:", error, errorInfo); + console.error('[TrafficErrorBoundary] 捕获到组件错误:', error, errorInfo) this.setState({ error, errorInfo, - }); + }) // 调用错误回调 if (this.props.onError) { - this.props.onError(error, errorInfo); + this.props.onError(error, errorInfo) } // 发送错误到监控系统(如果有的话) - this.reportError(error, errorInfo); + this.reportError(error, errorInfo) } private reportError = (error: Error, errorInfo: ErrorInfo) => { @@ -69,44 +69,44 @@ export class TrafficErrorBoundary extends Component { timestamp: new Date().toISOString(), userAgent: navigator.userAgent, url: window.location.href, - }; + } - console.error("[TrafficErrorBoundary] 错误报告:", errorReport); + console.error('[TrafficErrorBoundary] 错误报告:', errorReport) // TODO: 发送到错误监控服务 // sendErrorReport(errorReport); - }; + } private handleRetry = () => { if (this.retryCount < this.maxRetries) { - this.retryCount++; + this.retryCount++ console.log( `[TrafficErrorBoundary] 尝试重试 (${this.retryCount}/${this.maxRetries})`, - ); + ) this.setState({ hasError: false, error: null, errorInfo: null, showDetails: false, - }); + }) } else { - console.warn("[TrafficErrorBoundary] 已达到最大重试次数"); + console.warn('[TrafficErrorBoundary] 已达到最大重试次数') } - }; + } private handleRefresh = () => { - window.location.reload(); - }; + window.location.reload() + } private toggleDetails = () => { - this.setState((prev) => ({ showDetails: !prev.showDetails })); - }; + this.setState((prev) => ({ showDetails: !prev.showDetails })) + } render() { if (this.state.hasError) { // 如果提供了自定义降级组件,使用它 if (this.props.fallbackComponent) { - return this.props.fallbackComponent; + return this.props.fallbackComponent } // 默认错误UI @@ -122,10 +122,10 @@ export class TrafficErrorBoundary extends Component { onRefresh={this.handleRefresh} onToggleDetails={this.toggleDetails} /> - ); + ) } - return this.props.children; + return this.props.children } } @@ -133,15 +133,15 @@ export class TrafficErrorBoundary extends Component { * 错误降级UI组件 */ interface TrafficErrorFallbackProps { - error: Error | null; - errorInfo: ErrorInfo | null; - showDetails: boolean; - canRetry: boolean; - retryCount: number; - maxRetries: number; - onRetry: () => void; - onRefresh: () => void; - onToggleDetails: () => void; + error: Error | null + errorInfo: ErrorInfo | null + showDetails: boolean + canRetry: boolean + retryCount: number + maxRetries: number + onRetry: () => void + onRefresh: () => void + onToggleDetails: () => void } const TrafficErrorFallback: React.FC = ({ @@ -155,28 +155,28 @@ const TrafficErrorFallback: React.FC = ({ onRefresh, onToggleDetails, }) => { - const { t } = useTranslation(); + const { t } = useTranslation() return ( - + - {t("shared.feedback.errors.trafficStats")} + {t('shared.feedback.errors.trafficStats')} = ({ textAlign="center" sx={{ mb: 2 }} > - {t("shared.feedback.errors.trafficStatsDescription")} + {t('shared.feedback.errors.trafficStatsDescription')} - Error:{" "} - {error instanceof Error ? error.message : "Unknown error"} + Error:{' '} + {error instanceof Error ? error.message : 'Unknown error'} {retryCount > 0 && ( - {t("shared.labels.retryAttempts")}: {retryCount}/{maxRetries} + {t('shared.labels.retryAttempts')}: {retryCount}/{maxRetries} )} - + {canRetry && ( )} - + @@ -246,11 +246,11 @@ const TrafficErrorFallback: React.FC = ({ variant="caption" component="pre" sx={{ - whiteSpace: "pre-wrap", - wordBreak: "break-word", - fontFamily: "monospace", - fontSize: "0.75rem", - color: "text.secondary", + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + fontFamily: 'monospace', + fontSize: '0.75rem', + color: 'text.secondary', }} > {error?.stack} @@ -265,11 +265,11 @@ const TrafficErrorFallback: React.FC = ({ variant="caption" component="pre" sx={{ - whiteSpace: "pre-wrap", - wordBreak: "break-word", - fontFamily: "monospace", - fontSize: "0.75rem", - color: "text.secondary", + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + fontFamily: 'monospace', + fontSize: '0.75rem', + color: 'text.secondary', }} > {errorInfo.componentStack} @@ -279,15 +279,15 @@ const TrafficErrorFallback: React.FC = ({ - ); -}; + ) +} /** * 轻量级流量统计错误边界 * 用于小型流量显示组件,提供最小化的错误UI */ export const LightweightTrafficErrorBoundary: React.FC<{ - children: ReactNode; + children: ReactNode }> = ({ children }) => { return ( @@ -311,5 +311,5 @@ export const LightweightTrafficErrorBoundary: React.FC<{ > {children} - ); -}; + ) +} diff --git a/src/components/shared/with-traffic-error-boundary.tsx b/src/components/shared/with-traffic-error-boundary.tsx index 65b6b927a..d69a96494 100644 --- a/src/components/shared/with-traffic-error-boundary.tsx +++ b/src/components/shared/with-traffic-error-boundary.tsx @@ -1,14 +1,14 @@ -import React from "react"; -import type { ErrorInfo } from "react"; +import React from 'react' +import type { ErrorInfo } from 'react' import { TrafficErrorBoundary, LightweightTrafficErrorBoundary, -} from "./traffic-error-boundary"; +} from './traffic-error-boundary' interface WithTrafficErrorBoundaryOptions { - lightweight?: boolean; - onError?: (error: Error, errorInfo: ErrorInfo) => void; + lightweight?: boolean + onError?: (error: Error, errorInfo: ErrorInfo) => void } /** @@ -21,16 +21,16 @@ export function withTrafficErrorBoundary

    ( const WithErrorBoundaryComponent = (props: P) => { const ErrorBoundaryComponent = options?.lightweight ? LightweightTrafficErrorBoundary - : TrafficErrorBoundary; + : TrafficErrorBoundary return ( - ); - }; + ) + } - WithErrorBoundaryComponent.displayName = `withTrafficErrorBoundary(${WrappedComponent.displayName || WrappedComponent.name})`; + WithErrorBoundaryComponent.displayName = `withTrafficErrorBoundary(${WrappedComponent.displayName || WrappedComponent.name})` - return WithErrorBoundaryComponent; + return WithErrorBoundaryComponent } diff --git a/src/components/test/test-box.tsx b/src/components/test/test-box.tsx index 43ea8242d..2fff18102 100644 --- a/src/components/test/test-box.tsx +++ b/src/components/test/test-box.tsx @@ -1,44 +1,44 @@ -import { alpha, Box, styled } from "@mui/material"; +import { alpha, Box, styled } from '@mui/material' -export const TestBox = styled(Box)(({ theme, "aria-selected": selected }) => { - const { mode, primary, text } = theme.palette; - const key = `${mode}-${!!selected}`; +export const TestBox = styled(Box)(({ theme, 'aria-selected': selected }) => { + const { mode, primary, text } = theme.palette + const key = `${mode}-${!!selected}` const backgroundColor = - mode === "light" ? alpha(primary.main, 0.05) : alpha(primary.main, 0.08); + mode === 'light' ? alpha(primary.main, 0.05) : alpha(primary.main, 0.08) const color = { - "light-true": text.secondary, - "light-false": text.secondary, - "dark-true": alpha(text.secondary, 0.65), - "dark-false": alpha(text.secondary, 0.65), - }[key]!; + 'light-true': text.secondary, + 'light-false': text.secondary, + 'dark-true': alpha(text.secondary, 0.65), + 'dark-false': alpha(text.secondary, 0.65), + }[key]! const h2color = { - "light-true": primary.main, - "light-false": text.primary, - "dark-true": primary.main, - "dark-false": text.primary, - }[key]!; + 'light-true': primary.main, + 'light-false': text.primary, + 'dark-true': primary.main, + 'dark-false': text.primary, + }[key]! return { - position: "relative", - width: "100%", - display: "block", - cursor: "pointer", - textAlign: "left", + position: 'relative', + width: '100%', + display: 'block', + cursor: 'pointer', + textAlign: 'left', borderRadius: 8, boxShadow: theme.shadows[1], - padding: "8px 16px", - boxSizing: "border-box", + padding: '8px 16px', + boxSizing: 'border-box', backgroundColor, color, - "& h2": { color: h2color }, - transition: "background-color 0.3s, box-shadow 0.3s", - "&:hover": { + '& h2': { color: h2color }, + transition: 'background-color 0.3s, box-shadow 0.3s', + '&:hover': { backgroundColor: - mode === "light" ? alpha(primary.main, 0.1) : alpha(primary.main, 0.15), + mode === 'light' ? alpha(primary.main, 0.1) : alpha(primary.main, 0.15), boxShadow: theme.shadows[2], }, - }; -}); + } +}) diff --git a/src/components/test/test-item.tsx b/src/components/test/test-item.tsx index a36ae29b5..c87d636ec 100644 --- a/src/components/test/test-item.tsx +++ b/src/components/test/test-item.tsx @@ -1,27 +1,27 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { LanguageRounded } from "@mui/icons-material"; -import { Box, Divider, MenuItem, Menu, styled, alpha } from "@mui/material"; -import { UnlistenFn } from "@tauri-apps/api/event"; -import { useLockFn } from "ahooks"; -import { useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { LanguageRounded } from '@mui/icons-material' +import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material' +import { UnlistenFn } from '@tauri-apps/api/event' +import { useLockFn } from 'ahooks' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { BaseLoading } from "@/components/base"; -import { useIconCache } from "@/hooks/use-icon-cache"; -import { useListen } from "@/hooks/use-listen"; -import { cmdTestDelay } from "@/services/cmds"; -import delayManager from "@/services/delay"; -import { showNotice } from "@/services/notice-service"; -import { debugLog } from "@/utils/debug"; +import { BaseLoading } from '@/components/base' +import { useIconCache } from '@/hooks/use-icon-cache' +import { useListen } from '@/hooks/use-listen' +import { cmdTestDelay } from '@/services/cmds' +import delayManager from '@/services/delay' +import { showNotice } from '@/services/notice-service' +import { debugLog } from '@/utils/debug' -import { TestBox } from "./test-box"; +import { TestBox } from './test-box' interface Props { - id: string; - itemData: IVergeTestItem; - onEdit: () => void; - onDelete: (uid: string) => void; + id: string + itemData: IVergeTestItem + onEdit: () => void + onDelete: (uid: string) => void } export const TestItem = ({ @@ -39,101 +39,101 @@ export const TestItem = ({ isDragging, } = useSortable({ id, - }); + }) - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const [position, setPosition] = useState({ left: 0, top: 0 }); - const [delay, setDelay] = useState(-1); - const { uid, name, icon, url } = itemData; - const iconCachePath = useIconCache({ icon, cacheKey: uid }); - const { addListener } = useListen(); + const { t } = useTranslation() + const [anchorEl, setAnchorEl] = useState(null) + const [position, setPosition] = useState({ left: 0, top: 0 }) + const [delay, setDelay] = useState(-1) + const { uid, name, icon, url } = itemData + const iconCachePath = useIconCache({ icon, cacheKey: uid }) + const { addListener } = useListen() const onDelay = useCallback(async () => { - setDelay(-2); - const result = await cmdTestDelay(url); - setDelay(result); - }, [url]); + setDelay(-2) + const result = await cmdTestDelay(url) + setDelay(result) + }, [url]) const onEditTest = () => { - setAnchorEl(null); - onEdit(); - }; + setAnchorEl(null) + onEdit() + } const onDelete = useLockFn(async () => { - setAnchorEl(null); + setAnchorEl(null) try { - removeTest(uid); + removeTest(uid) } catch (err: any) { - showNotice.error(err); + showNotice.error(err) } - }); + }) const menu = [ - { label: "Edit", handler: onEditTest }, - { label: "Delete", handler: onDelete }, - ]; + { label: 'Edit', handler: onEditTest }, + { label: 'Delete', handler: onDelete }, + ] useEffect(() => { - let unlistenFn: UnlistenFn | null = null; + let unlistenFn: UnlistenFn | null = null const setupListener = async () => { if (unlistenFn) { - unlistenFn(); + unlistenFn() } - unlistenFn = await addListener("verge://test-all", () => { - onDelay(); - }); - }; + unlistenFn = await addListener('verge://test-all', () => { + onDelay() + }) + } - setupListener(); + setupListener() return () => { if (unlistenFn) { debugLog( `TestItem for ${id} unmounting or url changed, cleaning up test-all listener.`, - ); - unlistenFn(); + ) + unlistenFn() } - }; - }, [url, addListener, onDelay, id]); + } + }, [url, addListener, onDelay, id]) return ( { - const { clientX, clientY } = event; - setPosition({ top: clientY, left: clientX }); - setAnchorEl(event.currentTarget); - event.preventDefault(); + const { clientX, clientY } = event + setPosition({ top: clientY, left: clientX }) + setAnchorEl(event.currentTarget) + event.preventDefault() }} > - {icon && icon.trim() !== "" ? ( - - {icon.trim().startsWith("http") && ( + {icon && icon.trim() !== '' ? ( + + {icon.trim().startsWith('http') && ( )} - {icon.trim().startsWith("data") && ( + {icon.trim().startsWith('data') && ( )} - {icon.trim().startsWith(" ) : ( - - + + )} - {name} + {name} - + {delay === -2 && ( @@ -167,15 +167,15 @@ export const TestItem = ({ { - e.preventDefault(); - e.stopPropagation(); - onDelay(); + e.preventDefault() + e.stopPropagation() + onDelay() }} sx={({ palette }) => ({ - ":hover": { bgcolor: alpha(palette.primary.main, 0.15) }, + ':hover': { bgcolor: alpha(palette.primary.main, 0.15) }, })} > - {t("tests.components.item.actions.test")} + {t('tests.components.item.actions.test')} )} @@ -184,13 +184,13 @@ export const TestItem = ({ { - e.preventDefault(); - e.stopPropagation(); - onDelay(); + e.preventDefault() + e.stopPropagation() + onDelay() }} color={delayManager.formatDelayColor(delay)} sx={({ palette }) => ({ - ":hover": { + ':hover': { bgcolor: alpha(palette.primary.main, 0.15), }, })} @@ -210,8 +210,8 @@ export const TestItem = ({ transitionDuration={225} MenuListProps={{ sx: { py: 0.5 } }} onContextMenu={(e) => { - setAnchorEl(null); - e.preventDefault(); + setAnchorEl(null) + e.preventDefault() }} > {menu.map((item) => ( @@ -226,11 +226,11 @@ export const TestItem = ({ ))} - ); -}; + ) +} const Widget = styled(Box)(({ theme: { typography } }) => ({ - padding: "3px 6px", + padding: '3px 6px', fontSize: 14, fontFamily: typography.fontFamily, - borderRadius: "4px", -})); + borderRadius: '4px', +})) diff --git a/src/components/test/test-viewer.tsx b/src/components/test/test-viewer.tsx index 6cf71ad3b..236b3fbbc 100644 --- a/src/components/test/test-viewer.tsx +++ b/src/components/test/test-viewer.tsx @@ -1,39 +1,39 @@ -import { TextField } from "@mui/material"; -import { useLockFn } from "ahooks"; -import { nanoid } from "nanoid"; -import { forwardRef, useImperativeHandle, useState } from "react"; -import { useForm, Controller } from "react-hook-form"; -import { useTranslation } from "react-i18next"; +import { TextField } from '@mui/material' +import { useLockFn } from 'ahooks' +import { nanoid } from 'nanoid' +import { forwardRef, useImperativeHandle, useState } from 'react' +import { useForm, Controller } from 'react-hook-form' +import { useTranslation } from 'react-i18next' -import { BaseDialog } from "@/components/base"; -import { useVerge } from "@/hooks/use-verge"; -import { showNotice } from "@/services/notice-service"; +import { BaseDialog } from '@/components/base' +import { useVerge } from '@/hooks/use-verge' +import { showNotice } from '@/services/notice-service' interface Props { - onChange: (uid: string, patch?: Partial) => void; + onChange: (uid: string, patch?: Partial) => void } export interface TestViewerRef { - create: () => void; - edit: (item: IVergeTestItem) => void; + create: () => void + edit: (item: IVergeTestItem) => void } // create or edit the test item export const TestViewer = forwardRef( ({ onChange }, ref) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [openType, setOpenType] = useState<"new" | "edit">("new"); - const [loading, setLoading] = useState(false); - const { verge, patchVerge } = useVerge(); - const testList = verge?.test_list ?? []; + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [openType, setOpenType] = useState<'new' | 'edit'>('new') + const [loading, setLoading] = useState(false) + const { verge, patchVerge } = useVerge() + const testList = verge?.test_list ?? [] const { control, ...formIns } = useForm({ defaultValues: { - name: "", - icon: "", - url: "", + name: '', + icon: '', + url: '', }, - }); + }) const patchTestList = async ( uid: string, @@ -41,101 +41,101 @@ export const TestViewer = forwardRef( ) => { const newList = testList.map((x) => { if (x.uid === uid) { - return { ...x, ...patch }; + return { ...x, ...patch } } - return x; - }); - await patchVerge({ test_list: newList }); - }; + return x + }) + await patchVerge({ test_list: newList }) + } useImperativeHandle(ref, () => ({ create: () => { - setOpenType("new"); - setOpen(true); + setOpenType('new') + setOpen(true) }, edit: (item) => { if (item) { Object.entries(item).forEach(([key, value]) => { - formIns.setValue(key as any, value); - }); + formIns.setValue(key as any, value) + }) } - setOpenType("edit"); - setOpen(true); + setOpenType('edit') + setOpen(true) }, - })); + })) const handleOk = useLockFn( formIns.handleSubmit(async (form) => { - setLoading(true); + setLoading(true) try { - if (!form.name) throw new Error("`Name` should not be null"); - if (!form.url) throw new Error("`Url` should not be null"); + if (!form.name) throw new Error('`Name` should not be null') + if (!form.url) throw new Error('`Url` should not be null') - let newList; - let uid; + let newList + let uid - if (form.icon && form.icon.startsWith("/g, ""); + form.icon = form.icon.replace(//g, '') } const doc = new DOMParser().parseFromString( form.icon, - "image/svg+xml", - ); - if (doc.querySelector("parsererror")) { - throw new Error("`Icon`svg format error"); + 'image/svg+xml', + ) + if (doc.querySelector('parsererror')) { + throw new Error('`Icon`svg format error') } } - if (openType === "new") { - uid = nanoid(); - const item = { ...form, uid }; - newList = [...testList, item]; - await patchVerge({ test_list: newList }); - onChange(uid); + if (openType === 'new') { + uid = nanoid() + const item = { ...form, uid } + newList = [...testList, item] + await patchVerge({ test_list: newList }) + onChange(uid) } else { - if (!form.uid) throw new Error("UID not found"); - uid = form.uid; + if (!form.uid) throw new Error('UID not found') + uid = form.uid - await patchTestList(uid, form); - onChange(uid, form); + await patchTestList(uid, form) + onChange(uid, form) } - setOpen(false); - setLoading(false); - setTimeout(() => formIns.reset(), 500); + setOpen(false) + setLoading(false) + setTimeout(() => formIns.reset(), 500) } catch (err: any) { - showNotice.error(err); - setLoading(false); + showNotice.error(err) + setLoading(false) } }), - ); + ) const handleClose = () => { - setOpen(false); - setTimeout(() => formIns.reset(), 500); - }; + setOpen(false) + setTimeout(() => formIns.reset(), 500) + } const text = { fullWidth: true, - size: "small", - margin: "normal", - variant: "outlined", - autoComplete: "off", - autoCorrect: "off", - } as const; + size: 'small', + margin: 'normal', + variant: 'outlined', + autoComplete: 'off', + autoCorrect: 'off', + } as const return ( ( name="name" control={control} render={({ field }) => ( - + )} /> ( {...field} multiline maxRows={5} - label={t("shared.labels.icon")} + label={t('shared.labels.icon')} /> )} /> @@ -170,11 +170,11 @@ export const TestViewer = forwardRef( {...field} multiline maxRows={3} - label={t("tests.modals.test.fields.url")} + label={t('tests.modals.test.fields.url')} /> )} /> - ); + ) }, -); +) diff --git a/src/hooks/use-clash-log.ts b/src/hooks/use-clash-log.ts index 66579f798..fb7ad3a95 100644 --- a/src/hooks/use-clash-log.ts +++ b/src/hooks/use-clash-log.ts @@ -1,14 +1,14 @@ -import { useLocalStorage } from "foxact/use-local-storage"; +import { useLocalStorage } from 'foxact/use-local-storage' const defaultClashLog: IClashLog = { enable: true, - logLevel: "info", - logFilter: "all", - logOrder: "asc", -}; + logLevel: 'info', + logFilter: 'all', + logOrder: 'asc', +} export const useClashLog = () => - useLocalStorage("clash-log", defaultClashLog, { + useLocalStorage('clash-log', defaultClashLog, { serializer: JSON.stringify, deserializer: JSON.parse, - }); + }) diff --git a/src/hooks/use-clash.ts b/src/hooks/use-clash.ts index 4053ad985..e60c49546 100644 --- a/src/hooks/use-clash.ts +++ b/src/hooks/use-clash.ts @@ -1,76 +1,76 @@ -import { useLockFn } from "ahooks"; -import useSWR, { mutate } from "swr"; -import { getVersion } from "tauri-plugin-mihomo-api"; +import { useLockFn } from 'ahooks' +import useSWR, { mutate } from 'swr' +import { getVersion } from 'tauri-plugin-mihomo-api' import { getClashInfo, getRuntimeConfig, patchClashConfig, -} from "@/services/cmds"; +} from '@/services/cmds' const PORT_KEYS = [ - "port", - "socks-port", - "mixed-port", - "redir-port", - "tproxy-port", -] as const; + 'port', + 'socks-port', + 'mixed-port', + 'redir-port', + 'tproxy-port', +] as const type ClashInfoPatch = Partial< Pick< IConfigData, - | "port" - | "socks-port" - | "mixed-port" - | "redir-port" - | "tproxy-port" - | "external-controller" - | "secret" + | 'port' + | 'socks-port' + | 'mixed-port' + | 'redir-port' + | 'tproxy-port' + | 'external-controller' + | 'secret' > ->; +> const hasClashInfoPayload = (patch: ClashInfoPatch) => PORT_KEYS.some((key) => patch[key] != null) || - patch["external-controller"] != null || - patch.secret != null; + patch['external-controller'] != null || + patch.secret != null const validatePortRange = (port: number) => { if (port < 1000) { - throw new Error("The port should not < 1000"); + throw new Error('The port should not < 1000') } if (port > 65536) { - throw new Error("The port should not > 65536"); + throw new Error('The port should not > 65536') } -}; +} const validatePorts = (patch: ClashInfoPatch) => { PORT_KEYS.forEach((key) => { - const port = patch[key]; - if (!port) return; - validatePortRange(port); - }); -}; + const port = patch[key] + if (!port) return + validatePortRange(port) + }) +} export const useRuntimeConfig = (shouldFetch: boolean = true) => { - return useSWR(shouldFetch ? "getRuntimeConfig" : null, getRuntimeConfig); -}; + return useSWR(shouldFetch ? 'getRuntimeConfig' : null, getRuntimeConfig) +} export const useClash = () => { - const { data: clash, mutate: mutateClash } = useRuntimeConfig(); + const { data: clash, mutate: mutateClash } = useRuntimeConfig() const { data: versionData, mutate: mutateVersion } = useSWR( - "getVersion", + 'getVersion', getVersion, - ); + ) const patchClash = useLockFn(async (patch: Partial) => { - await patchClashConfig(patch); - mutateClash(); - }); + await patchClashConfig(patch) + mutateClash() + }) const version = versionData?.meta ? `${versionData.version} Mihomo` - : versionData?.version || "-"; + : versionData?.version || '-' return { clash, @@ -78,28 +78,28 @@ export const useClash = () => { mutateClash, mutateVersion, patchClash, - }; -}; + } +} export const useClashInfo = () => { const { data: clashInfo, mutate: mutateInfo } = useSWR( - "getClashInfo", + 'getClashInfo', getClashInfo, - ); + ) const patchInfo = async (patch: ClashInfoPatch) => { - if (!hasClashInfoPayload(patch)) return; + if (!hasClashInfoPayload(patch)) return - validatePorts(patch); + validatePorts(patch) - await patchClashConfig(patch); - mutateInfo(); - mutate("getClashConfig"); - }; + await patchClashConfig(patch) + mutateInfo() + mutate('getClashConfig') + } return { clashInfo, mutateInfo, patchInfo, - }; -}; + } +} diff --git a/src/hooks/use-connection-data.ts b/src/hooks/use-connection-data.ts index 15b41d25f..b7e7e818b 100644 --- a/src/hooks/use-connection-data.ts +++ b/src/hooks/use-connection-data.ts @@ -1,22 +1,22 @@ -import { mutate } from "swr"; -import { MihomoWebSocket } from "tauri-plugin-mihomo-api"; +import { mutate } from 'swr' +import { MihomoWebSocket } from 'tauri-plugin-mihomo-api' -import { useMihomoWsSubscription } from "./use-mihomo-ws-subscription"; +import { useMihomoWsSubscription } from './use-mihomo-ws-subscription' -const MAX_CLOSED_CONNS_NUM = 500; +const MAX_CLOSED_CONNS_NUM = 500 export const initConnData: ConnectionMonitorData = { uploadTotal: 0, downloadTotal: 0, activeConnections: [], closedConnections: [], -}; +} export interface ConnectionMonitorData { - uploadTotal: number; - downloadTotal: number; - activeConnections: IConnectionsItem[]; - closedConnections: IConnectionsItem[]; + uploadTotal: number + downloadTotal: number + activeConnections: IConnectionsItem[] + closedConnections: IConnectionsItem[] } const trimClosedConnections = ( @@ -24,32 +24,32 @@ const trimClosedConnections = ( ): IConnectionsItem[] => closedConnections.length > MAX_CLOSED_CONNS_NUM ? closedConnections.slice(-MAX_CLOSED_CONNS_NUM) - : closedConnections; + : closedConnections const mergeConnectionSnapshot = ( payload: IConnections, previous: ConnectionMonitorData = initConnData, ): ConnectionMonitorData => { - const nextConnections = payload.connections ?? []; - const previousActive = previous.activeConnections ?? []; - const nextById = new Map(nextConnections.map((conn) => [conn.id, conn])); - const newIds = new Set(nextConnections.map((conn) => conn.id)); + const nextConnections = payload.connections ?? [] + const previousActive = previous.activeConnections ?? [] + const nextById = new Map(nextConnections.map((conn) => [conn.id, conn])) + const newIds = new Set(nextConnections.map((conn) => conn.id)) // Keep surviving connections in their previous relative order to reduce row reshuffle, // but constrain the array to the incoming snapshot length. const carried = previousActive .map((prev) => { - const next = nextById.get(prev.id); - if (!next) return null; + const next = nextById.get(prev.id) + if (!next) return null - nextById.delete(prev.id); + nextById.delete(prev.id) return { ...next, curUpload: next.upload - prev.upload, curDownload: next.download - prev.download, - } as IConnectionsItem; + } as IConnectionsItem }) - .filter(Boolean) as IConnectionsItem[]; + .filter(Boolean) as IConnectionsItem[] const newcomers = nextConnections .filter((conn) => nextById.has(conn.id)) @@ -57,63 +57,63 @@ const mergeConnectionSnapshot = ( ...conn, curUpload: 0, curDownload: 0, - })); + })) - const activeConnections = [...carried, ...newcomers]; + const activeConnections = [...carried, ...newcomers] const closedConnections = trimClosedConnections([ ...(previous.closedConnections ?? []), ...previousActive.filter((conn) => !newIds.has(conn.id)), - ]); + ]) return { uploadTotal: payload.uploadTotal ?? 0, downloadTotal: payload.downloadTotal ?? 0, activeConnections, closedConnections, - }; -}; + } +} export const useConnectionData = () => { const { response, refresh, subscriptionCacheKey } = useMihomoWsSubscription({ - storageKey: "mihomo_connection_date", + storageKey: 'mihomo_connection_date', buildSubscriptKey: (date) => `getClashConnection-${date}`, fallbackData: initConnData, connect: () => MihomoWebSocket.connect_connections(), setupHandlers: ({ next, scheduleReconnect }) => ({ handleMessage: (data) => { - if (data.startsWith("Websocket error")) { - next(data); - void scheduleReconnect(); - return; + if (data.startsWith('Websocket error')) { + next(data) + void scheduleReconnect() + return } try { - const parsed = JSON.parse(data) as IConnections; + const parsed = JSON.parse(data) as IConnections next(null, (old = initConnData) => mergeConnectionSnapshot(parsed, old), - ); + ) } catch (error) { - next(error); + next(error) } }, }), - }); + }) const clearClosedConnections = () => { - if (!subscriptionCacheKey) return; + if (!subscriptionCacheKey) return mutate(subscriptionCacheKey, { uploadTotal: response.data?.uploadTotal ?? 0, downloadTotal: response.data?.downloadTotal ?? 0, activeConnections: response.data?.activeConnections ?? [], closedConnections: [], - }); - }; + }) + } return { response, refreshGetClashConnection: refresh, clearClosedConnections, - }; -}; + } +} diff --git a/src/hooks/use-connection-setting.ts b/src/hooks/use-connection-setting.ts index 4b88ccab2..032f9c4a8 100644 --- a/src/hooks/use-connection-setting.ts +++ b/src/hooks/use-connection-setting.ts @@ -1,13 +1,13 @@ -import { useLocalStorage } from "foxact/use-local-storage"; +import { useLocalStorage } from 'foxact/use-local-storage' -const defaultConnectionSetting: IConnectionSetting = { layout: "table" }; +const defaultConnectionSetting: IConnectionSetting = { layout: 'table' } export const useConnectionSetting = () => useLocalStorage( - "connections-setting", + 'connections-setting', defaultConnectionSetting, { serializer: JSON.stringify, deserializer: JSON.parse, }, - ); + ) diff --git a/src/hooks/use-current-proxy.ts b/src/hooks/use-current-proxy.ts index 7d3523269..c5d7d0996 100644 --- a/src/hooks/use-current-proxy.ts +++ b/src/hooks/use-current-proxy.ts @@ -1,76 +1,76 @@ -import { useMemo } from "react"; +import { useMemo } from 'react' -import { useAppData } from "@/providers/app-data-context"; +import { useAppData } from '@/providers/app-data-context' // 定义代理组类型 interface ProxyGroup { - name: string; - now: string; + name: string + now: string } // 获取当前代理节点信息的自定义Hook export const useCurrentProxy = () => { // 从AppDataProvider获取数据 - const { proxies, clashConfig, refreshProxy } = useAppData(); + const { proxies, clashConfig, refreshProxy } = useAppData() // 获取当前模式 - const currentMode = clashConfig?.mode?.toLowerCase() || "rule"; + const currentMode = clashConfig?.mode?.toLowerCase() || 'rule' // 获取当前代理节点信息 const currentProxyInfo = useMemo(() => { - if (!proxies) return { currentProxy: null, primaryGroupName: null }; + if (!proxies) return { currentProxy: null, primaryGroupName: null } - const { global, groups, records } = proxies; + const { global, groups, records } = proxies // 默认信息 - let primaryGroupName = "GLOBAL"; - let currentName = global?.now; + let primaryGroupName = 'GLOBAL' + let currentName = global?.now // 在规则模式下,寻找主要代理组(通常是第一个或者名字包含特定关键词的组) - if (currentMode === "rule" && groups.length > 0) { + if (currentMode === 'rule' && groups.length > 0) { // 查找主要的代理组(优先级:包含关键词 > 第一个非GLOBAL组) const primaryKeywords = [ - "auto", - "select", - "proxy", - "节点选择", - "自动选择", - ]; + 'auto', + 'select', + 'proxy', + '节点选择', + '自动选择', + ] const primaryGroup = groups.find((group: ProxyGroup) => primaryKeywords.some((keyword) => group.name.toLowerCase().includes(keyword.toLowerCase()), ), - ) || groups.filter((g: ProxyGroup) => g.name !== "GLOBAL")[0]; + ) || groups.filter((g: ProxyGroup) => g.name !== 'GLOBAL')[0] if (primaryGroup) { - primaryGroupName = primaryGroup.name; - currentName = primaryGroup.now; + primaryGroupName = primaryGroup.name + currentName = primaryGroup.now } } // 如果找不到当前节点,返回null - if (!currentName) return { currentProxy: null, primaryGroupName }; + if (!currentName) return { currentProxy: null, primaryGroupName } // 获取完整的节点信息 const currentProxy = records[currentName] || { name: currentName, - type: "Unknown", + type: 'Unknown', udp: false, xudp: false, tfo: false, mptcp: false, smux: false, history: [], - }; + } - return { currentProxy, primaryGroupName }; - }, [proxies, currentMode]); + return { currentProxy, primaryGroupName } + }, [proxies, currentMode]) return { currentProxy: currentProxyInfo.currentProxy, primaryGroupName: currentProxyInfo.primaryGroupName, mode: currentMode, refreshProxy, - }; -}; + } +} diff --git a/src/hooks/use-editor-document.ts b/src/hooks/use-editor-document.ts index 1d3f5afaa..524475001 100644 --- a/src/hooks/use-editor-document.ts +++ b/src/hooks/use-editor-document.ts @@ -1,54 +1,54 @@ /* eslint-disable @eslint-react/hooks-extra/no-direct-set-state-in-use-effect */ -import { useEffect } from "foxact/use-abortable-effect"; -import { useCallback, useState } from "react"; +import { useEffect } from 'foxact/use-abortable-effect' +import { useCallback, useState } from 'react' -import { showNotice } from "@/services/notice-service"; +import { showNotice } from '@/services/notice-service' interface UseEditorDocumentOptions { - open: boolean; - load: () => Promise; + open: boolean + load: () => Promise } export const useEditorDocument = ({ open, load }: UseEditorDocumentOptions) => { - const [value, setValue] = useState(""); - const [savedValue, setSavedValue] = useState(""); - const [loading, setLoading] = useState(true); + const [value, setValue] = useState('') + const [savedValue, setSavedValue] = useState('') + const [loading, setLoading] = useState(true) const resetDocumentState = useCallback(() => { - setValue(""); - setSavedValue(""); - setLoading(true); - }, []); + setValue('') + setSavedValue('') + setLoading(true) + }, []) useEffect( (signal) => { - resetDocumentState(); + resetDocumentState() - if (!open) return; + if (!open) return load() .then((nextValue) => { - if (signal.aborted) return; + if (signal.aborted) return - const normalized = nextValue ?? ""; - setValue(normalized); - setSavedValue(normalized); + const normalized = nextValue ?? '' + setValue(normalized) + setSavedValue(normalized) }) .catch((error) => { - if (!signal.aborted) showNotice.error(error); + if (!signal.aborted) showNotice.error(error) }) .finally(() => { - if (!signal.aborted) setLoading(false); - }); + if (!signal.aborted) setLoading(false) + }) }, [load, open, resetDocumentState], - ); + ) const markSaved = useCallback((nextValue: string) => { - setSavedValue(nextValue); - }, []); + setSavedValue(nextValue) + }, []) - const dirty = value !== savedValue; + const dirty = value !== savedValue return { value, @@ -57,5 +57,5 @@ export const useEditorDocument = ({ open, load }: UseEditorDocumentOptions) => { loading, dirty, markSaved, - }; -}; + } +} diff --git a/src/hooks/use-i18n.ts b/src/hooks/use-i18n.ts index ca04ab170..7fb6932d3 100644 --- a/src/hooks/use-i18n.ts +++ b/src/hooks/use-i18n.ts @@ -1,47 +1,47 @@ -import { useState, useCallback } from "react"; -import { useTranslation } from "react-i18next"; +import { useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' import { changeLanguage, resolveLanguage, supportedLanguages, -} from "@/services/i18n"; +} from '@/services/i18n' -import { useVerge } from "./use-verge"; +import { useVerge } from './use-verge' export const useI18n = () => { - const { i18n, t } = useTranslation(); - const { patchVerge } = useVerge(); - const [isLoading, setIsLoading] = useState(false); + const { i18n, t } = useTranslation() + const { patchVerge } = useVerge() + const [isLoading, setIsLoading] = useState(false) const switchLanguage = useCallback( async (language: string) => { - const targetLanguage = resolveLanguage(language); + const targetLanguage = resolveLanguage(language) if (!supportedLanguages.includes(targetLanguage)) { - console.warn(`Unsupported language: ${language}`); - return; + console.warn(`Unsupported language: ${language}`) + return } if (i18n.language === targetLanguage) { - return; + return } - setIsLoading(true); + setIsLoading(true) try { - await changeLanguage(targetLanguage); + await changeLanguage(targetLanguage) if (patchVerge) { - await patchVerge({ language: targetLanguage }); + await patchVerge({ language: targetLanguage }) } } catch (error) { - console.error("Failed to change language:", error); + console.error('Failed to change language:', error) } finally { - setIsLoading(false); + setIsLoading(false) } }, [i18n.language, patchVerge], - ); + ) return { currentLanguage: i18n.language, @@ -49,5 +49,5 @@ export const useI18n = () => { switchLanguage, isLoading, t, - }; -}; + } +} diff --git a/src/hooks/use-icon-cache.ts b/src/hooks/use-icon-cache.ts index 7585be8b7..a606123f5 100644 --- a/src/hooks/use-icon-cache.ts +++ b/src/hooks/use-icon-cache.ts @@ -1,54 +1,54 @@ -import { convertFileSrc } from "@tauri-apps/api/core"; -import { useMemo } from "react"; -import useSWR from "swr"; +import { convertFileSrc } from '@tauri-apps/api/core' +import { useMemo } from 'react' +import useSWR from 'swr' -import { downloadIconCache } from "@/services/cmds"; -import { SWR_DEFAULTS } from "@/services/config"; +import { downloadIconCache } from '@/services/cmds' +import { SWR_DEFAULTS } from '@/services/config' export interface UseIconCacheOptions { - icon?: string | null; - cacheKey?: string; - enabled?: boolean; + icon?: string | null + cacheKey?: string + enabled?: boolean } const getFileNameFromUrl = (url: string) => { - const lastSlashIndex = url.lastIndexOf("/"); - return lastSlashIndex >= 0 ? url.slice(lastSlashIndex + 1) : url; -}; + const lastSlashIndex = url.lastIndexOf('/') + return lastSlashIndex >= 0 ? url.slice(lastSlashIndex + 1) : url +} export const useIconCache = ({ icon, cacheKey, enabled = true, }: UseIconCacheOptions) => { - const iconValue = icon?.trim() ?? ""; - const cacheKeyValue = cacheKey?.trim() ?? ""; + const iconValue = icon?.trim() ?? '' + const cacheKeyValue = cacheKey?.trim() ?? '' const swrKey = useMemo(() => { - if (!enabled || !iconValue.startsWith("http") || cacheKeyValue === "") { - return null; + if (!enabled || !iconValue.startsWith('http') || cacheKeyValue === '') { + return null } - return ["icon-cache", iconValue, cacheKeyValue] as const; - }, [enabled, iconValue, cacheKeyValue]); + return ['icon-cache', iconValue, cacheKeyValue] as const + }, [enabled, iconValue, cacheKeyValue]) const { data } = useSWR( swrKey, async () => { try { - const fileName = `${cacheKeyValue}-${getFileNameFromUrl(iconValue)}`; - const iconPath = await downloadIconCache(iconValue, fileName); - return convertFileSrc(iconPath); + const fileName = `${cacheKeyValue}-${getFileNameFromUrl(iconValue)}` + const iconPath = await downloadIconCache(iconValue, fileName) + return convertFileSrc(iconPath) } catch { - return ""; + return '' } }, SWR_DEFAULTS, - ); + ) if (!swrKey) { - return ""; + return '' } - return data ?? ""; -}; + return data ?? '' +} diff --git a/src/hooks/use-listen.ts b/src/hooks/use-listen.ts index 315a014fa..53902b828 100644 --- a/src/hooks/use-listen.ts +++ b/src/hooks/use-listen.ts @@ -1,49 +1,49 @@ -import { event } from "@tauri-apps/api"; -import { listen, UnlistenFn, EventCallback } from "@tauri-apps/api/event"; -import { useCallback, useRef } from "react"; +import { event } from '@tauri-apps/api' +import { listen, UnlistenFn, EventCallback } from '@tauri-apps/api/event' +import { useCallback, useRef } from 'react' export const useListen = () => { - const unlistenFnsRef = useRef([]); + const unlistenFnsRef = useRef([]) const addListener = useCallback( async (eventName: string, handler: EventCallback) => { - const unlisten = await listen(eventName, handler); - unlistenFnsRef.current.push(unlisten); - return unlisten; + const unlisten = await listen(eventName, handler) + unlistenFnsRef.current.push(unlisten) + return unlisten }, [], - ); + ) const removeAllListeners = useCallback(() => { - const errors: Error[] = []; + const errors: Error[] = [] unlistenFnsRef.current.forEach((unlisten) => { try { - unlisten(); + unlisten() } catch (error) { - errors.push(error instanceof Error ? error : new Error(String(error))); + errors.push(error instanceof Error ? error : new Error(String(error))) } - }); + }) if (errors.length > 0) { console.warn( `[useListen] 清理监听器时发生 ${errors.length} 个错误`, errors, - ); + ) } - unlistenFnsRef.current.length = 0; - }, []); + unlistenFnsRef.current.length = 0 + }, []) const setupCloseListener = useCallback(async () => { - await event.once("tauri://close-requested", async () => { - removeAllListeners(); - }); - }, [removeAllListeners]); + await event.once('tauri://close-requested', async () => { + removeAllListeners() + }) + }, [removeAllListeners]) return { addListener, removeAllListeners, setupCloseListener, - }; -}; + } +} diff --git a/src/hooks/use-log-data.ts b/src/hooks/use-log-data.ts index 7cf31a5c3..9889812ad 100644 --- a/src/hooks/use-log-data.ts +++ b/src/hooks/use-log-data.ts @@ -1,139 +1,139 @@ -import dayjs from "dayjs"; -import { useEffect, useRef } from "react"; -import { mutate } from "swr"; -import { MihomoWebSocket, type LogLevel } from "tauri-plugin-mihomo-api"; +import dayjs from 'dayjs' +import { useEffect, useRef } from 'react' +import { mutate } from 'swr' +import { MihomoWebSocket, type LogLevel } from 'tauri-plugin-mihomo-api' -import { getClashLogs } from "@/services/cmds"; +import { getClashLogs } from '@/services/cmds' -import { useClashLog } from "./use-clash-log"; -import { useMihomoWsSubscription } from "./use-mihomo-ws-subscription"; +import { useClashLog } from './use-clash-log' +import { useMihomoWsSubscription } from './use-mihomo-ws-subscription' -const MAX_LOG_NUM = 1000; -const FLUSH_DELAY_MS = 50; -type LogType = ILogItem["type"]; +const MAX_LOG_NUM = 1000 +const FLUSH_DELAY_MS = 50 +type LogType = ILogItem['type'] -const DEFAULT_LOG_TYPES: LogType[] = ["debug", "info", "warning", "error"]; +const DEFAULT_LOG_TYPES: LogType[] = ['debug', 'info', 'warning', 'error'] const LOG_LEVEL_FILTERS: Record = { debug: DEFAULT_LOG_TYPES, - info: ["info", "warning", "error"], - warning: ["warning", "error"], - error: ["error"], + info: ['info', 'warning', 'error'], + warning: ['warning', 'error'], + error: ['error'], silent: [], -}; +} const clampLogs = (logs: ILogItem[]): ILogItem[] => - logs.length > MAX_LOG_NUM ? logs.slice(-MAX_LOG_NUM) : logs; + logs.length > MAX_LOG_NUM ? logs.slice(-MAX_LOG_NUM) : logs const filterLogsByLevel = ( logs: ILogItem[], allowedTypes: LogType[], ): ILogItem[] => { - if (allowedTypes.length === 0) return []; - if (allowedTypes.length === DEFAULT_LOG_TYPES.length) return logs; - return logs.filter((log) => allowedTypes.includes(log.type)); -}; + if (allowedTypes.length === 0) return [] + if (allowedTypes.length === DEFAULT_LOG_TYPES.length) return logs + return logs.filter((log) => allowedTypes.includes(log.type)) +} const appendLogs = ( current: ILogItem[] | undefined, incoming: ILogItem[], -): ILogItem[] => clampLogs([...(current ?? []), ...incoming]); +): ILogItem[] => clampLogs([...(current ?? []), ...incoming]) export const useLogData = () => { - const [clashLog] = useClashLog(); - const enableLog = clashLog.enable; - const logLevel = clashLog.logLevel; - const allowedTypes = LOG_LEVEL_FILTERS[logLevel] ?? DEFAULT_LOG_TYPES; + const [clashLog] = useClashLog() + const enableLog = clashLog.enable + const logLevel = clashLog.logLevel + const allowedTypes = LOG_LEVEL_FILTERS[logLevel] ?? DEFAULT_LOG_TYPES const { response, refresh, subscriptionCacheKey } = useMihomoWsSubscription< ILogItem[] >({ - storageKey: "mihomo_logs_date", + storageKey: 'mihomo_logs_date', buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null), fallbackData: [], keepPreviousData: true, connect: () => MihomoWebSocket.connect_logs(logLevel), setupHandlers: ({ next, scheduleReconnect, isMounted }) => { - let flushTimer: ReturnType | null = null; - const buffer: ILogItem[] = []; + let flushTimer: ReturnType | null = null + const buffer: ILogItem[] = [] const clearFlushTimer = () => { if (flushTimer) { - clearTimeout(flushTimer); - flushTimer = null; + clearTimeout(flushTimer) + flushTimer = null } - }; + } const flush = () => { if (!buffer.length || !isMounted()) { - flushTimer = null; - return; + flushTimer = null + return } - const pendingLogs = buffer.splice(0, buffer.length); - next(null, (current) => appendLogs(current, pendingLogs)); - flushTimer = null; - }; + const pendingLogs = buffer.splice(0, buffer.length) + next(null, (current) => appendLogs(current, pendingLogs)) + flushTimer = null + } return { handleMessage: (data) => { - if (data.startsWith("Websocket error")) { - next(data); - void scheduleReconnect(); - return; + if (data.startsWith('Websocket error')) { + next(data) + void scheduleReconnect() + return } try { - const parsed = JSON.parse(data) as ILogItem; + const parsed = JSON.parse(data) as ILogItem if ( allowedTypes.length > 0 && !allowedTypes.includes(parsed.type) ) { - return; + return } - parsed.time = dayjs().format("MM-DD HH:mm:ss"); - buffer.push(parsed); + parsed.time = dayjs().format('MM-DD HH:mm:ss') + buffer.push(parsed) if (!flushTimer) { - flushTimer = setTimeout(flush, FLUSH_DELAY_MS); + flushTimer = setTimeout(flush, FLUSH_DELAY_MS) } } catch (error) { - next(error); + next(error) } }, async onConnected() { - const logs = await getClashLogs(); + const logs = await getClashLogs() if (isMounted()) { - next(null, clampLogs(filterLogsByLevel(logs, allowedTypes))); + next(null, clampLogs(filterLogsByLevel(logs, allowedTypes))) } }, cleanup: clearFlushTimer, - }; + } }, - }); + }) - const previousLogLevelRef = useRef(undefined); + const previousLogLevelRef = useRef(undefined) useEffect(() => { if (!logLevel) { - previousLogLevelRef.current = logLevel ?? undefined; - return; + previousLogLevelRef.current = logLevel ?? undefined + return } if (previousLogLevelRef.current === logLevel) { - return; + return } - previousLogLevelRef.current = logLevel; - refresh(); - }, [logLevel, refresh]); + previousLogLevelRef.current = logLevel + refresh() + }, [logLevel, refresh]) const refreshGetClashLog = (clear = false) => { if (clear) { if (subscriptionCacheKey) { - mutate(subscriptionCacheKey, []); + mutate(subscriptionCacheKey, []) } } else { - refresh(); + refresh() } - }; + } - return { response, refreshGetClashLog }; -}; + return { response, refreshGetClashLog } +} diff --git a/src/hooks/use-memory-data.ts b/src/hooks/use-memory-data.ts index 2a7d78da3..1ca527c99 100644 --- a/src/hooks/use-memory-data.ts +++ b/src/hooks/use-memory-data.ts @@ -1,37 +1,37 @@ -import { MihomoWebSocket } from "tauri-plugin-mihomo-api"; +import { MihomoWebSocket } from 'tauri-plugin-mihomo-api' -import { useMihomoWsSubscription } from "./use-mihomo-ws-subscription"; +import { useMihomoWsSubscription } from './use-mihomo-ws-subscription' export interface IMemoryUsageItem { - inuse: number; - oslimit?: number; + inuse: number + oslimit?: number } -const FALLBACK_MEMORY_USAGE: IMemoryUsageItem = { inuse: 0 }; +const FALLBACK_MEMORY_USAGE: IMemoryUsageItem = { inuse: 0 } export const useMemoryData = () => { const { response, refresh } = useMihomoWsSubscription({ - storageKey: "mihomo_memory_date", + storageKey: 'mihomo_memory_date', buildSubscriptKey: (date) => `getClashMemory-${date}`, fallbackData: FALLBACK_MEMORY_USAGE, connect: () => MihomoWebSocket.connect_memory(), setupHandlers: ({ next, scheduleReconnect }) => ({ handleMessage: (data) => { - if (data.startsWith("Websocket error")) { - next(data, FALLBACK_MEMORY_USAGE); - void scheduleReconnect(); - return; + if (data.startsWith('Websocket error')) { + next(data, FALLBACK_MEMORY_USAGE) + void scheduleReconnect() + return } try { - const parsed = JSON.parse(data) as IMemoryUsageItem; - next(null, parsed); + const parsed = JSON.parse(data) as IMemoryUsageItem + next(null, parsed) } catch (error) { - next(error, FALLBACK_MEMORY_USAGE); + next(error, FALLBACK_MEMORY_USAGE) } }, }), - }); + }) - return { response, refreshGetClashMemory: refresh }; -}; + return { response, refreshGetClashMemory: refresh } +} diff --git a/src/hooks/use-mihomo-ws-subscription.ts b/src/hooks/use-mihomo-ws-subscription.ts index b97b76b86..43258c686 100644 --- a/src/hooks/use-mihomo-ws-subscription.ts +++ b/src/hooks/use-mihomo-ws-subscription.ts @@ -1,32 +1,32 @@ -import { useLocalStorage } from "foxact/use-local-storage"; -import { useCallback, useEffect, useRef } from "react"; -import { mutate, type MutatorCallback } from "swr"; -import useSWRSubscription from "swr/subscription"; -import { type Message, type MihomoWebSocket } from "tauri-plugin-mihomo-api"; +import { useLocalStorage } from 'foxact/use-local-storage' +import { useCallback, useEffect, useRef } from 'react' +import { mutate, type MutatorCallback } from 'swr' +import useSWRSubscription from 'swr/subscription' +import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api' -export const RECONNECT_DELAY_MS = 100; +export const RECONNECT_DELAY_MS = 100 -type NextFn = (error?: any, data?: T | MutatorCallback) => void; +type NextFn = (error?: any, data?: T | MutatorCallback) => void interface HandlerContext { - next: NextFn; - scheduleReconnect: () => Promise; - isMounted: () => boolean; + next: NextFn + scheduleReconnect: () => Promise + isMounted: () => boolean } interface HandlerResult { - handleMessage: (data: string) => void; - onConnected?: (ws: MihomoWebSocket) => Promise | void; - cleanup?: () => void; + handleMessage: (data: string) => void + onConnected?: (ws: MihomoWebSocket) => Promise | void + cleanup?: () => void } interface UseMihomoWsSubscriptionOptions { - storageKey: string; - buildSubscriptKey: (date: number) => string | null; - fallbackData: T; - connect: () => Promise; - keepPreviousData?: boolean; - setupHandlers: (ctx: HandlerContext) => HandlerResult; + storageKey: string + buildSubscriptKey: (date: number) => string | null + fallbackData: T + connect: () => Promise + keepPreviousData?: boolean + setupHandlers: (ctx: HandlerContext) => HandlerResult } export const useMihomoWsSubscription = ( @@ -39,42 +39,42 @@ export const useMihomoWsSubscription = ( connect, keepPreviousData = true, setupHandlers, - } = options; + } = options - const [date, setDate] = useLocalStorage(storageKey, Date.now()); - const subscriptKey = buildSubscriptKey(date); - const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null; + const [date, setDate] = useLocalStorage(storageKey, Date.now()) + const subscriptKey = buildSubscriptKey(date) + const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null - const wsRef = useRef(null); - const wsFirstConnectionRef = useRef(true); - const timeoutRef = useRef | null>(null); + const wsRef = useRef(null) + const wsFirstConnectionRef = useRef(true) + const timeoutRef = useRef | null>(null) const response = useSWRSubscription( subscriptKey, (_key, { next }) => { - let isMounted = true; + let isMounted = true const clearReconnectTimer = () => { if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; + clearTimeout(timeoutRef.current) + timeoutRef.current = null } - }; + } const closeSocket = async () => { if (wsRef.current) { - await wsRef.current.close(); - wsRef.current = null; + await wsRef.current.close() + wsRef.current = null } - }; + } const scheduleReconnect = async () => { - if (!isMounted) return; - clearReconnectTimer(); - await closeSocket(); - if (!isMounted) return; - timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS); - }; + if (!isMounted) return + clearReconnectTimer() + await closeSocket() + if (!isMounted) return + timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS) + } const { handleMessage: handleTextMessage, @@ -84,73 +84,73 @@ export const useMihomoWsSubscription = ( next, scheduleReconnect, isMounted: () => isMounted, - }); + }) const cleanupAll = () => { - clearReconnectTimer(); - cleanup?.(); - void closeSocket(); - }; + clearReconnectTimer() + cleanup?.() + void closeSocket() + } const handleMessage = (msg: Message) => { - if (msg.type !== "Text") return; - handleTextMessage(msg.data); - }; + if (msg.type !== 'Text') return + handleTextMessage(msg.data) + } async function connectWs() { try { - const ws_ = await connect(); + const ws_ = await connect() if (!isMounted) { - await ws_.close(); - return; + await ws_.close() + return } - wsRef.current = ws_; - clearReconnectTimer(); + wsRef.current = ws_ + clearReconnectTimer() if (onConnected) { - await onConnected(ws_); + await onConnected(ws_) if (!isMounted) { - await ws_.close(); - return; + await ws_.close() + return } } - ws_.addListener(handleMessage); + ws_.addListener(handleMessage) } catch (ignoreError) { if (!wsRef.current && isMounted) { - timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS); + timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS) } } } if (wsFirstConnectionRef.current || !wsRef.current) { - wsFirstConnectionRef.current = false; - cleanupAll(); - void connectWs(); + wsFirstConnectionRef.current = false + cleanupAll() + void connectWs() } return () => { - isMounted = false; - wsFirstConnectionRef.current = true; - cleanupAll(); - }; + isMounted = false + wsFirstConnectionRef.current = true + cleanupAll() + } }, { fallbackData, keepPreviousData, }, - ); + ) useEffect(() => { if (subscriptionCacheKey) { - mutate(subscriptionCacheKey); + mutate(subscriptionCacheKey) } - }, [subscriptionCacheKey]); + }, [subscriptionCacheKey]) const refresh = useCallback(() => { - setDate(Date.now()); - }, [setDate]); + setDate(Date.now()) + }, [setDate]) - return { response, refresh, subscriptionCacheKey, wsRef }; -}; + return { response, refresh, subscriptionCacheKey, wsRef } +} diff --git a/src/hooks/use-network.ts b/src/hooks/use-network.ts index 567dfe465..f88f4e600 100644 --- a/src/hooks/use-network.ts +++ b/src/hooks/use-network.ts @@ -1,22 +1,22 @@ -import useSWR from "swr"; +import useSWR from 'swr' -import { getNetworkInterfacesInfo } from "@/services/cmds"; +import { getNetworkInterfacesInfo } from '@/services/cmds' export const useNetworkInterfaces = () => { const { data, error, isLoading, mutate } = useSWR( - "getNetworkInterfacesInfo", + 'getNetworkInterfacesInfo', getNetworkInterfacesInfo, { revalidateOnFocus: false, revalidateOnReconnect: false, fallbackData: [], }, - ); + ) return { networkInterfaces: data || [], loading: isLoading, error, mutate, - }; -}; + } +} diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 6d84ecc8a..101f9db3f 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,13 +1,13 @@ -import useSWR, { mutate } from "swr"; -import { selectNodeForGroup } from "tauri-plugin-mihomo-api"; +import useSWR, { mutate } from 'swr' +import { selectNodeForGroup } from 'tauri-plugin-mihomo-api' import { calcuProxies, getProfiles, patchProfile, patchProfilesConfig, -} from "@/services/cmds"; -import { debugLog } from "@/utils/debug"; +} from '@/services/cmds' +import { debugLog } from '@/utils/debug' export const useProfiles = () => { const { @@ -15,7 +15,7 @@ export const useProfiles = () => { mutate: mutateProfiles, error, isValidating, - } = useSWR("getProfiles", getProfiles, { + } = useSWR('getProfiles', getProfiles, { revalidateOnFocus: false, revalidateOnReconnect: false, dedupingInterval: 500, // 减少去重时间,提高响应性 @@ -23,15 +23,15 @@ export const useProfiles = () => { errorRetryInterval: 1000, refreshInterval: 0, // 完全由手动控制 onError: (error) => { - console.error("[useProfiles] SWR错误:", error); + console.error('[useProfiles] SWR错误:', error) }, onSuccess: (data) => { debugLog( - "[useProfiles] 配置数据更新成功,配置数量:", + '[useProfiles] 配置数据更新成功,配置数量:', data?.items?.length || 0, - ); + ) }, - }); + }) const patchProfiles = async ( value: Partial, @@ -40,162 +40,159 @@ export const useProfiles = () => { ) => { try { if (signal?.aborted) { - throw new DOMException("Operation was aborted", "AbortError"); + throw new DOMException('Operation was aborted', 'AbortError') } - const success = await patchProfilesConfig(value); + const success = await patchProfilesConfig(value) if (signal?.aborted) { - throw new DOMException("Operation was aborted", "AbortError"); + throw new DOMException('Operation was aborted', 'AbortError') } if (!options?.deferRefreshOnSuccess || !success) { - await mutateProfiles(); + await mutateProfiles() } - return success; + return success } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - throw error; + if (error instanceof DOMException && error.name === 'AbortError') { + throw error } - await mutateProfiles(); - throw error; + await mutateProfiles() + throw error } - }; + } const patchCurrent = async (value: Partial) => { if (profiles?.current) { - await patchProfile(profiles.current, value); + await patchProfile(profiles.current, value) if (!value.selected) { - mutateProfiles(); + mutateProfiles() } } - }; + } // 根据selected的节点选择 const activateSelected = async (profileOverride?: IProfilesConfig) => { try { - debugLog("[ActivateSelected] 开始处理代理选择"); + debugLog('[ActivateSelected] 开始处理代理选择') - const proxiesData = await calcuProxies(); - const profileData = profileOverride ?? profiles; + const proxiesData = await calcuProxies() + const profileData = profileOverride ?? profiles if (!profileData || !proxiesData || !profileData.items) { - debugLog("[ActivateSelected] 代理或配置数据不可用,跳过处理"); - return; + debugLog('[ActivateSelected] 代理或配置数据不可用,跳过处理') + return } const current = profileData.items?.find( (e) => e && e.uid === profileData.current, - ); + ) if (!current) { - debugLog("[ActivateSelected] 未找到当前profile配置"); - return; + debugLog('[ActivateSelected] 未找到当前profile配置') + return } // 检查是否有saved的代理选择 - const { selected = [] } = current; + const { selected = [] } = current if (selected.length === 0) { - debugLog("[ActivateSelected] 当前profile无保存的代理选择,跳过"); - return; + debugLog('[ActivateSelected] 当前profile无保存的代理选择,跳过') + return } debugLog( `[ActivateSelected] 当前profile有 ${selected.length} 个代理选择配置`, - ); + ) const selectedMap = Object.fromEntries( selected.map((each) => [each.name!, each.now!]), - ); + ) - let hasChange = false; - const newSelected: typeof selected = []; - const { global, groups } = proxiesData; + let hasChange = false + const newSelected: typeof selected = [] + const { global, groups } = proxiesData const selectableTypes = new Set([ - "Selector", - "URLTest", - "Fallback", - "LoadBalance", - ]); + 'Selector', + 'URLTest', + 'Fallback', + 'LoadBalance', + ]) // 处理所有代理组 - [global, ...groups].forEach((group) => { + ;[global, ...groups].forEach((group) => { if (!group) { - return; + return } - const { type, name, now } = group; - const savedProxy = selectedMap[name]; - const availableProxies = Array.isArray(group.all) ? group.all : []; + const { type, name, now } = group + const savedProxy = selectedMap[name] + const availableProxies = Array.isArray(group.all) ? group.all : [] if (!selectableTypes.has(type)) { if (savedProxy != null || now != null) { - const preferredProxy = now ? now : savedProxy; - newSelected.push({ name, now: preferredProxy }); + const preferredProxy = now ? now : savedProxy + newSelected.push({ name, now: preferredProxy }) } - return; + return } if (savedProxy == null) { if (now != null) { - newSelected.push({ name, now }); + newSelected.push({ name, now }) } - return; + return } const existsInGroup = availableProxies.some((proxy) => { - if (typeof proxy === "string") { - return proxy === savedProxy; + if (typeof proxy === 'string') { + return proxy === savedProxy } - return proxy?.name === savedProxy; - }); + return proxy?.name === savedProxy + }) if (!existsInGroup) { console.warn( `[ActivateSelected] 保存的代理 ${savedProxy} 不存在于代理组 ${name}`, - ); - hasChange = true; - newSelected.push({ name, now: now ?? savedProxy }); - return; + ) + hasChange = true + newSelected.push({ name, now: now ?? savedProxy }) + return } if (savedProxy !== now) { debugLog( `[ActivateSelected] 需要切换代理组 ${name}: ${now} -> ${savedProxy}`, - ); - hasChange = true; - selectNodeForGroup(name, savedProxy); + ) + hasChange = true + selectNodeForGroup(name, savedProxy) } - newSelected.push({ name, now: savedProxy }); - }); + newSelected.push({ name, now: savedProxy }) + }) if (!hasChange) { - debugLog("[ActivateSelected] 所有代理选择已经是目标状态,无需更新"); - return; + debugLog('[ActivateSelected] 所有代理选择已经是目标状态,无需更新') + return } - debugLog(`[ActivateSelected] 完成代理切换,保存新的选择配置`); + debugLog(`[ActivateSelected] 完成代理切换,保存新的选择配置`) try { - await patchProfile(profileData.current!, { selected: newSelected }); - debugLog("[ActivateSelected] 代理选择配置保存成功"); + await patchProfile(profileData.current!, { selected: newSelected }) + debugLog('[ActivateSelected] 代理选择配置保存成功') setTimeout(() => { - mutate("getProxies", calcuProxies()); - }, 100); + mutate('getProxies', calcuProxies()) + }, 100) } catch (error: any) { - console.error( - "[ActivateSelected] 保存代理选择配置失败:", - error.message, - ); + console.error('[ActivateSelected] 保存代理选择配置失败:', error.message) } } catch (error: any) { - console.error("[ActivateSelected] 处理代理选择失败:", error.message); + console.error('[ActivateSelected] 处理代理选择失败:', error.message) } - }; + } return { profiles, @@ -208,5 +205,5 @@ export const useProfiles = () => { isLoading: isValidating, error, isStale: !profiles && !error && !isValidating, // 检测是否处于异常状态 - }; -}; + } +} diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index 0b689828e..5b8ceb10f 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -1,53 +1,53 @@ -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useMemo, useRef } from 'react' import { closeConnection, getConnections, selectNodeForGroup, -} from "tauri-plugin-mihomo-api"; +} from 'tauri-plugin-mihomo-api' -import { useProfiles } from "@/hooks/use-profiles"; -import { useVerge } from "@/hooks/use-verge"; -import { syncTrayProxySelection } from "@/services/cmds"; -import { debugLog } from "@/utils/debug"; +import { useProfiles } from '@/hooks/use-profiles' +import { useVerge } from '@/hooks/use-verge' +import { syncTrayProxySelection } from '@/services/cmds' +import { debugLog } from '@/utils/debug' // 缓存连接清理 const cleanupConnections = async (previousProxy: string) => { try { - const { connections } = await getConnections(); + const { connections } = await getConnections() const cleanupPromises = (connections ?? []) .filter((conn) => conn.chains.includes(previousProxy)) - .map((conn) => closeConnection(conn.id)); + .map((conn) => closeConnection(conn.id)) if (cleanupPromises.length > 0) { - await Promise.allSettled(cleanupPromises); - debugLog(`[ProxySelection] 清理了 ${cleanupPromises.length} 个连接`); + await Promise.allSettled(cleanupPromises) + debugLog(`[ProxySelection] 清理了 ${cleanupPromises.length} 个连接`) } } catch (error) { - console.warn("[ProxySelection] 连接清理失败:", error); + console.warn('[ProxySelection] 连接清理失败:', error) } -}; +} interface ProxySelectionOptions { - onSuccess?: () => void; - onError?: (error: any) => void; - enableConnectionCleanup?: boolean; + onSuccess?: () => void + onError?: (error: any) => void + enableConnectionCleanup?: boolean } interface ProxyChangeRequest { - groupName: string; - proxyName: string; - previousProxy?: string; - skipConfigSave: boolean; + groupName: string + proxyName: string + previousProxy?: string + skipConfigSave: boolean } // 代理选择 Hook export const useProxySelection = (options: ProxySelectionOptions = {}) => { - const { current, patchCurrent } = useProfiles(); - const { verge } = useVerge(); - const pendingRequestRef = useRef(null); - const isProcessingRef = useRef(false); + const { current, patchCurrent } = useProfiles() + const { verge } = useVerge() + const pendingRequestRef = useRef(null) + const isProcessingRef = useRef(false) - const { onSuccess, onError, enableConnectionCleanup = true } = options; + const { onSuccess, onError, enableConnectionCleanup = true } = options // 缓存 const config = useMemo( @@ -56,99 +56,99 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { enableConnectionCleanup, }), [verge?.auto_close_connection, enableConnectionCleanup], - ); + ) // 切换节点 const syncTraySelection = useCallback(() => { syncTrayProxySelection().catch((error) => { - console.error("[ProxySelection] 托盘状态同步失败:", error); - }); - }, []); + console.error('[ProxySelection] 托盘状态同步失败:', error) + }) + }, []) const persistSelection = useCallback( (groupName: string, proxyName: string, skipConfigSave: boolean) => { - if (!current || skipConfigSave) return; + if (!current || skipConfigSave) return - const selected = current.selected ? [...current.selected] : []; - const index = selected.findIndex((item) => item.name === groupName); + const selected = current.selected ? [...current.selected] : [] + const index = selected.findIndex((item) => item.name === groupName) if (index < 0) { - selected.push({ name: groupName, now: proxyName }); + selected.push({ name: groupName, now: proxyName }) } else { - selected[index] = { name: groupName, now: proxyName }; + selected[index] = { name: groupName, now: proxyName } } patchCurrent({ selected }).catch((error) => { - console.error("[ProxySelection] 保存代理选择失败:", error); - }); + console.error('[ProxySelection] 保存代理选择失败:', error) + }) }, [current, patchCurrent], - ); + ) const executeChange = useCallback( async (request: ProxyChangeRequest) => { - const { groupName, proxyName, previousProxy, skipConfigSave } = request; - debugLog(`[ProxySelection] 代理切换: ${groupName} -> ${proxyName}`); + const { groupName, proxyName, previousProxy, skipConfigSave } = request + debugLog(`[ProxySelection] 代理切换: ${groupName} -> ${proxyName}`) try { - await selectNodeForGroup(groupName, proxyName); - onSuccess?.(); - syncTraySelection(); - persistSelection(groupName, proxyName, skipConfigSave); + await selectNodeForGroup(groupName, proxyName) + onSuccess?.() + syncTraySelection() + persistSelection(groupName, proxyName, skipConfigSave) debugLog( `[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`, - ); + ) if ( config.enableConnectionCleanup && config.autoCloseConnection && previousProxy ) { - setTimeout(() => cleanupConnections(previousProxy), 0); + setTimeout(() => cleanupConnections(previousProxy), 0) } } catch (error) { console.error( `[ProxySelection] 代理切换失败: ${groupName} -> ${proxyName}`, error, - ); + ) try { - await selectNodeForGroup(groupName, proxyName); - onSuccess?.(); - syncTraySelection(); - persistSelection(groupName, proxyName, skipConfigSave); + await selectNodeForGroup(groupName, proxyName) + onSuccess?.() + syncTraySelection() + persistSelection(groupName, proxyName, skipConfigSave) debugLog( `[ProxySelection] 代理切换回退成功: ${groupName} -> ${proxyName}`, - ); + ) } catch (fallbackError) { console.error( `[ProxySelection] 代理切换回退也失败: ${groupName} -> ${proxyName}`, fallbackError, - ); - onError?.(fallbackError); + ) + onError?.(fallbackError) } } }, [config, onError, onSuccess, persistSelection, syncTraySelection], - ); + ) const flushChangeQueue = useCallback(async () => { - if (isProcessingRef.current) return; - isProcessingRef.current = true; + if (isProcessingRef.current) return + isProcessingRef.current = true try { while (pendingRequestRef.current) { - const request = pendingRequestRef.current; - pendingRequestRef.current = null; - await executeChange(request); + const request = pendingRequestRef.current + pendingRequestRef.current = null + await executeChange(request) } } finally { - isProcessingRef.current = false; + isProcessingRef.current = false if (pendingRequestRef.current) { - void flushChangeQueue(); + void flushChangeQueue() } } - }, [executeChange]); + }, [executeChange]) const changeProxy = useCallback( ( @@ -162,11 +162,11 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { proxyName, previousProxy, skipConfigSave, - }; - void flushChangeQueue(); + } + void flushChangeQueue() }, [flushChangeQueue], - ); + ) const handleSelectChange = useCallback( ( @@ -175,22 +175,22 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { skipConfigSave: boolean = false, ) => (event: { target: { value: string } }) => { - const newProxy = event.target.value; - changeProxy(groupName, newProxy, previousProxy, skipConfigSave); + const newProxy = event.target.value + changeProxy(groupName, newProxy, previousProxy, skipConfigSave) }, [changeProxy], - ); + ) const handleProxyGroupChange = useCallback( (group: { name: string; now?: string }, proxy: { name: string }) => { - changeProxy(group.name, proxy.name, group.now); + changeProxy(group.name, proxy.name, group.now) }, [changeProxy], - ); + ) return { changeProxy, handleSelectChange, handleProxyGroupChange, - }; -}; + } +} diff --git a/src/hooks/use-service-installer.ts b/src/hooks/use-service-installer.ts index efb5fd3e7..144e62db4 100644 --- a/src/hooks/use-service-installer.ts +++ b/src/hooks/use-service-installer.ts @@ -1,9 +1,9 @@ -import { useCallback } from "react"; +import { useCallback } from 'react' -import { installService, restartCore } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; +import { installService, restartCore } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' -import { useSystemState } from "./use-system-state"; +import { useSystemState } from './use-system-state' const executeWithErrorHandling = async ( operation: () => Promise, @@ -11,34 +11,34 @@ const executeWithErrorHandling = async ( successKey?: string, ) => { try { - showNotice.info(loadingKey); - await operation(); + showNotice.info(loadingKey) + await operation() if (successKey) { - showNotice.success(successKey); + showNotice.success(successKey) } } catch (err) { - showNotice.error(err); - throw err; + showNotice.error(err) + throw err } -}; +} export const useServiceInstaller = () => { - const { mutateSystemState } = useSystemState(); + const { mutateSystemState } = useSystemState() const installServiceAndRestartCore = useCallback(async () => { await executeWithErrorHandling( () => installService(), - "settings.statuses.clashService.installing", - "settings.feedback.notifications.clashService.installSuccess", - ); + 'settings.statuses.clashService.installing', + 'settings.feedback.notifications.clashService.installSuccess', + ) await executeWithErrorHandling( () => restartCore(), - "settings.statuses.clash.restarting", - "settings.feedback.notifications.clash.restartSuccess", - ); + 'settings.statuses.clash.restarting', + 'settings.feedback.notifications.clash.restartSuccess', + ) - await mutateSystemState(); - }, [mutateSystemState]); - return { installServiceAndRestartCore }; -}; + await mutateSystemState() + }, [mutateSystemState]) + return { installServiceAndRestartCore } +} diff --git a/src/hooks/use-service-uninstaller.ts b/src/hooks/use-service-uninstaller.ts index 5685be600..0843967d7 100644 --- a/src/hooks/use-service-uninstaller.ts +++ b/src/hooks/use-service-uninstaller.ts @@ -1,9 +1,9 @@ -import { useCallback } from "react"; +import { useCallback } from 'react' -import { restartCore, stopCore, uninstallService } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; +import { restartCore, stopCore, uninstallService } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' -import { useSystemState } from "./use-system-state"; +import { useSystemState } from './use-system-state' const executeWithErrorHandling = async ( operation: () => Promise, @@ -11,41 +11,41 @@ const executeWithErrorHandling = async ( successKey?: string, ) => { try { - showNotice.info(loadingKey); - await operation(); + showNotice.info(loadingKey) + await operation() if (successKey) { - showNotice.success(successKey); + showNotice.success(successKey) } } catch (err) { - showNotice.error(err); - throw err; + showNotice.error(err) + throw err } -}; +} export const useServiceUninstaller = () => { - const { mutateSystemState } = useSystemState(); + const { mutateSystemState } = useSystemState() const uninstallServiceAndRestartCore = useCallback(async () => { try { await executeWithErrorHandling( () => stopCore(), - "settings.statuses.clash.stopping", - ); + 'settings.statuses.clash.stopping', + ) await executeWithErrorHandling( () => uninstallService(), - "settings.statuses.clashService.uninstalling", - "settings.feedback.notifications.clashService.uninstallSuccess", - ); + 'settings.statuses.clashService.uninstalling', + 'settings.feedback.notifications.clashService.uninstallSuccess', + ) } catch (ignore) { } finally { await executeWithErrorHandling( () => restartCore(), - "settings.statuses.clash.restarting", - "settings.feedback.notifications.clash.restartSuccess", - ); - await mutateSystemState(); + 'settings.statuses.clash.restarting', + 'settings.feedback.notifications.clash.restartSuccess', + ) + await mutateSystemState() } - }, [mutateSystemState]); + }, [mutateSystemState]) - return { uninstallServiceAndRestartCore }; -}; + return { uninstallServiceAndRestartCore } +} diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index a04e12377..d19466669 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -1,71 +1,71 @@ -import { useLockFn } from "ahooks"; -import useSWR, { mutate } from "swr"; -import { closeAllConnections } from "tauri-plugin-mihomo-api"; +import { useLockFn } from 'ahooks' +import useSWR, { mutate } from 'swr' +import { closeAllConnections } from 'tauri-plugin-mihomo-api' -import { useVerge } from "@/hooks/use-verge"; -import { useAppData } from "@/providers/app-data-context"; -import { getAutotemProxy } from "@/services/cmds"; +import { useVerge } from '@/hooks/use-verge' +import { useAppData } from '@/providers/app-data-context' +import { getAutotemProxy } from '@/services/cmds' // 系统代理状态检测统一逻辑 export const useSystemProxyState = () => { - const { verge, mutateVerge, patchVerge } = useVerge(); - const { sysproxy } = useAppData(); - const { data: autoproxy } = useSWR("getAutotemProxy", getAutotemProxy, { + const { verge, mutateVerge, patchVerge } = useVerge() + const { sysproxy } = useAppData() + const { data: autoproxy } = useSWR('getAutotemProxy', getAutotemProxy, { revalidateOnFocus: true, revalidateOnReconnect: true, - }); + }) - const { enable_system_proxy, proxy_auto_config } = verge ?? {}; + const { enable_system_proxy, proxy_auto_config } = verge ?? {} const getSystemProxyActualState = () => { - const userEnabled = enable_system_proxy ?? false; + const userEnabled = enable_system_proxy ?? false // 用户配置状态应该与系统实际状态一致 // 如果用户启用了系统代理,检查实际的系统状态 if (userEnabled) { if (proxy_auto_config) { - return autoproxy?.enable ?? false; + return autoproxy?.enable ?? false } else { - return sysproxy?.enable ?? false; + return sysproxy?.enable ?? false } } // 用户没有启用时,返回 false - return false; - }; + return false + } const getSystemProxyIndicator = () => { if (proxy_auto_config) { - return autoproxy?.enable ?? false; + return autoproxy?.enable ?? false } else { - return sysproxy?.enable ?? false; + return sysproxy?.enable ?? false } - }; + } const updateProxyStatus = async (isEnabling: boolean) => { // 关闭时更快响应,开启时等待系统确认 - const delay = isEnabling ? 20 : 10; - await new Promise((resolve) => setTimeout(resolve, delay)); - await mutate("getSystemProxy"); - await mutate("getAutotemProxy"); - }; + const delay = isEnabling ? 20 : 10 + await new Promise((resolve) => setTimeout(resolve, delay)) + await mutate('getSystemProxy') + await mutate('getAutotemProxy') + } const toggleSystemProxy = useLockFn(async (enabled: boolean) => { - mutateVerge({ ...verge, enable_system_proxy: enabled }, false); + mutateVerge({ ...verge, enable_system_proxy: enabled }, false) try { if (!enabled && verge?.auto_close_connection) { - await closeAllConnections(); + await closeAllConnections() } - await patchVerge({ enable_system_proxy: enabled }); - await updateProxyStatus(enabled); + await patchVerge({ enable_system_proxy: enabled }) + await updateProxyStatus(enabled) } catch (error) { - console.warn("[useSystemProxyState] toggleSystemProxy failed:", error); - mutateVerge({ ...verge, enable_system_proxy: !enabled }, false); - await updateProxyStatus(!enabled); - throw error; + console.warn('[useSystemProxyState] toggleSystemProxy failed:', error) + mutateVerge({ ...verge, enable_system_proxy: !enabled }, false) + await updateProxyStatus(!enabled) + throw error } - }); + }) return { actualState: getSystemProxyActualState(), @@ -75,5 +75,5 @@ export const useSystemProxyState = () => { autoproxy, proxy_auto_config, toggleSystemProxy, - }; -}; + } +} diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index 58f14d55a..4b82c4244 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -1,60 +1,60 @@ -import { useEffect } from "react"; -import useSWR from "swr"; +import { useEffect } from 'react' +import useSWR from 'swr' -import { getRunningMode, isAdmin, isServiceAvailable } from "@/services/cmds"; -import { showNotice } from "@/services/notice-service"; +import { getRunningMode, isAdmin, isServiceAvailable } from '@/services/cmds' +import { showNotice } from '@/services/notice-service' -import { useVerge } from "./use-verge"; +import { useVerge } from './use-verge' export interface SystemState { - runningMode: "Sidecar" | "Service"; - isAdminMode: boolean; - isServiceOk: boolean; + runningMode: 'Sidecar' | 'Service' + isAdminMode: boolean + isServiceOk: boolean } const defaultSystemState = { - runningMode: "Sidecar", + runningMode: 'Sidecar', isAdminMode: false, isServiceOk: false, -} as SystemState; +} as SystemState -let disablingTunMode = false; +let disablingTunMode = false /** * 自定义 hook 用于获取系统运行状态 * 包括运行模式、管理员状态、系统服务是否可用 */ export function useSystemState() { - const { verge, patchVerge } = useVerge(); + const { verge, patchVerge } = useVerge() const { data: systemState, mutate: mutateSystemState, isLoading, } = useSWR( - "getSystemState", + 'getSystemState', async () => { const [runningMode, isAdminMode, isServiceOk] = await Promise.all([ getRunningMode(), isAdmin(), isServiceAvailable(), - ]); - return { runningMode, isAdminMode, isServiceOk } as SystemState; + ]) + return { runningMode, isAdminMode, isServiceOk } as SystemState }, { suspense: true, refreshInterval: 30000, fallback: defaultSystemState, }, - ); + ) - const isSidecarMode = systemState.runningMode === "Sidecar"; - const isServiceMode = systemState.runningMode === "Service"; - const isTunModeAvailable = systemState.isAdminMode || systemState.isServiceOk; + const isSidecarMode = systemState.runningMode === 'Sidecar' + const isServiceMode = systemState.runningMode === 'Service' + const isTunModeAvailable = systemState.isAdminMode || systemState.isServiceOk - const enable_tun_mode = verge?.enable_tun_mode; + const enable_tun_mode = verge?.enable_tun_mode useEffect(() => { - if (enable_tun_mode === undefined) return; + if (enable_tun_mode === undefined) return if ( !disablingTunMode && @@ -62,28 +62,28 @@ export function useSystemState() { !isTunModeAvailable && !isLoading ) { - disablingTunMode = true; + disablingTunMode = true patchVerge({ enable_tun_mode: false }) .then(() => { showNotice.info( - "settings.sections.system.notifications.tunMode.autoDisabled", - ); + 'settings.sections.system.notifications.tunMode.autoDisabled', + ) }) .catch((err) => { - console.error("[useVerge] 自动关闭TUN模式失败:", err); + console.error('[useVerge] 自动关闭TUN模式失败:', err) showNotice.error( - "settings.sections.system.notifications.tunMode.autoDisableFailed", - ); + 'settings.sections.system.notifications.tunMode.autoDisableFailed', + ) }) .finally(() => { const tid = setTimeout(() => { // 避免 verge 数据更新不及时导致重复执行关闭 Tun 模式 - disablingTunMode = false; - clearTimeout(tid); - }, 1000); - }); + disablingTunMode = false + clearTimeout(tid) + }, 1000) + }) } - }, [enable_tun_mode, isTunModeAvailable, patchVerge, isLoading]); + }, [enable_tun_mode, isTunModeAvailable, patchVerge, isLoading]) return { runningMode: systemState.runningMode, @@ -94,5 +94,5 @@ export function useSystemState() { isTunModeAvailable, mutateSystemState, isLoading, - }; + } } diff --git a/src/hooks/use-traffic-data.ts b/src/hooks/use-traffic-data.ts index aa897b955..b220c3eae 100644 --- a/src/hooks/use-traffic-data.ts +++ b/src/hooks/use-traffic-data.ts @@ -1,39 +1,39 @@ -import { MihomoWebSocket, Traffic } from "tauri-plugin-mihomo-api"; +import { MihomoWebSocket, Traffic } from 'tauri-plugin-mihomo-api' -import { useMihomoWsSubscription } from "./use-mihomo-ws-subscription"; -import { useTrafficMonitorEnhanced } from "./use-traffic-monitor"; +import { useMihomoWsSubscription } from './use-mihomo-ws-subscription' +import { useTrafficMonitorEnhanced } from './use-traffic-monitor' -const FALLBACK_TRAFFIC: Traffic = { up: 0, down: 0 }; +const FALLBACK_TRAFFIC: Traffic = { up: 0, down: 0 } export const useTrafficData = (options?: { enabled?: boolean }) => { - const enabled = options?.enabled ?? true; + const enabled = options?.enabled ?? true const { graphData: { appendData }, - } = useTrafficMonitorEnhanced({ subscribe: false, enabled }); + } = useTrafficMonitorEnhanced({ subscribe: false, enabled }) const { response, refresh } = useMihomoWsSubscription({ - storageKey: "mihomo_traffic_date", + storageKey: 'mihomo_traffic_date', buildSubscriptKey: (date) => `getClashTraffic-${date}`, fallbackData: FALLBACK_TRAFFIC, connect: () => MihomoWebSocket.connect_traffic(), setupHandlers: ({ next, scheduleReconnect }) => ({ handleMessage: (data) => { - if (data.startsWith("Websocket error")) { - next(data, FALLBACK_TRAFFIC); - void scheduleReconnect(); - return; + if (data.startsWith('Websocket error')) { + next(data, FALLBACK_TRAFFIC) + void scheduleReconnect() + return } try { - const parsed = JSON.parse(data) as Traffic; - appendData(parsed); - next(null, parsed); + const parsed = JSON.parse(data) as Traffic + appendData(parsed) + next(null, parsed) } catch (error) { - next(error, FALLBACK_TRAFFIC); + next(error, FALLBACK_TRAFFIC) } }, }), - }); + }) - return { response, refreshGetClashTraffic: refresh }; -}; + return { response, refreshGetClashTraffic: refresh } +} diff --git a/src/hooks/use-traffic-monitor.ts b/src/hooks/use-traffic-monitor.ts index 75fcfb1a2..a901a480b 100644 --- a/src/hooks/use-traffic-monitor.ts +++ b/src/hooks/use-traffic-monitor.ts @@ -5,42 +5,42 @@ import { useReducer, useRef, useState, -} from "react"; -import { Traffic } from "tauri-plugin-mihomo-api"; +} from 'react' +import { Traffic } from 'tauri-plugin-mihomo-api' -import { debugLog } from "@/utils/debug"; -import { TrafficDataSampler, formatTrafficName } from "@/utils/traffic-sampler"; +import { debugLog } from '@/utils/debug' +import { TrafficDataSampler, formatTrafficName } from '@/utils/traffic-sampler' // 引用计数管理器 class ReferenceCounter { - private count = 0; - private callbacks = new Set<() => void>(); + private count = 0 + private callbacks = new Set<() => void>() private notify() { - this.callbacks.forEach((cb) => cb()); + this.callbacks.forEach((cb) => cb()) } increment(): () => void { - this.count++; - debugLog(`[ReferenceCounter] 引用计数增加: ${this.count}`); + this.count++ + debugLog(`[ReferenceCounter] 引用计数增加: ${this.count}`) - this.notify(); + this.notify() return () => { - this.count--; - debugLog(`[ReferenceCounter] 引用计数减少: ${this.count}`); + this.count-- + debugLog(`[ReferenceCounter] 引用计数减少: ${this.count}`) - this.notify(); - }; + this.notify() + } } onCountChange(callback: () => void) { - this.callbacks.add(callback); - return () => this.callbacks.delete(callback); + this.callbacks.add(callback) + return () => this.callbacks.delete(callback) } getCount(): number { - return this.count; + return this.count } } @@ -50,133 +50,133 @@ const WORKER_CONFIG = { compressionRatio: 5, snapshotIntervalMs: 100, defaultRangeMinutes: 10, -}; +} class InlineTrafficMonitor { - private config = { ...WORKER_CONFIG }; - private sampler = new TrafficDataSampler(this.config); - private throttleTimer: ReturnType | null = null; - private currentRange = this.config.defaultRangeMinutes; - private lastTimestamp: number | undefined; + private config = { ...WORKER_CONFIG } + private sampler = new TrafficDataSampler(this.config) + private throttleTimer: ReturnType | null = null + private currentRange = this.config.defaultRangeMinutes + private lastTimestamp: number | undefined constructor( private emit: (snapshot: ITrafficWorkerSnapshotMessage) => void, ) {} start(rangeMinutes?: number) { - this.currentRange = rangeMinutes ?? this.currentRange; + this.currentRange = rangeMinutes ?? this.currentRange this.handle({ - type: "init", + type: 'init', config: { ...this.config, defaultRangeMinutes: this.currentRange, }, - }); + }) } stop() { if (this.throttleTimer !== null) { - clearTimeout(this.throttleTimer); - this.throttleTimer = null; + clearTimeout(this.throttleTimer) + this.throttleTimer = null } - this.sampler.clear(); - this.lastTimestamp = undefined; + this.sampler.clear() + this.lastTimestamp = undefined } handle(message: TrafficWorkerRequestMessage) { switch (message.type) { - case "init": { - this.config = { ...message.config }; - this.sampler = new TrafficDataSampler(this.config); - this.currentRange = message.config.defaultRangeMinutes; - this.emitSnapshot("init"); - break; + case 'init': { + this.config = { ...message.config } + this.sampler = new TrafficDataSampler(this.config) + this.currentRange = message.config.defaultRangeMinutes + this.emitSnapshot('init') + break } - case "append": { - const timestamp = message.payload.timestamp ?? Date.now(); + case 'append': { + const timestamp = message.payload.timestamp ?? Date.now() const dataPoint: ITrafficDataPoint = { up: message.payload.up || 0, down: message.payload.down || 0, timestamp, name: formatTrafficName(timestamp), - }; - - this.lastTimestamp = timestamp; - this.sampler.addDataPoint(dataPoint); - this.scheduleSnapshot("append-throttle"); - break; - } - case "clear": { - this.sampler.clear(); - this.lastTimestamp = undefined; - this.emitSnapshot("clear"); - break; - } - case "setRange": { - if (this.currentRange !== message.minutes) { - this.currentRange = message.minutes; - this.emitSnapshot("range-change"); } - break; + + this.lastTimestamp = timestamp + this.sampler.addDataPoint(dataPoint) + this.scheduleSnapshot('append-throttle') + break } - case "requestSnapshot": { - this.emitSnapshot("request"); - break; + case 'clear': { + this.sampler.clear() + this.lastTimestamp = undefined + this.emitSnapshot('clear') + break + } + case 'setRange': { + if (this.currentRange !== message.minutes) { + this.currentRange = message.minutes + this.emitSnapshot('range-change') + } + break + } + case 'requestSnapshot': { + this.emitSnapshot('request') + break } default: - break; + break } } - private emitSnapshot(reason: ITrafficWorkerSnapshotMessage["reason"]) { - const dataPoints = this.sampler.getDataForTimeRange(this.currentRange); + private emitSnapshot(reason: ITrafficWorkerSnapshotMessage['reason']) { + const dataPoints = this.sampler.getDataForTimeRange(this.currentRange) const availableDataPoints = this.sampler.getDataForTimeRange( this.config.compressedDataMinutes, - ); + ) this.emit({ - type: "snapshot", + type: 'snapshot', dataPoints, availableDataPoints, samplerStats: this.sampler.getStats(), rangeMinutes: this.currentRange, lastTimestamp: this.lastTimestamp, reason, - }); + }) } - private scheduleSnapshot(reason: ITrafficWorkerSnapshotMessage["reason"]) { - if (this.throttleTimer !== null) return; + private scheduleSnapshot(reason: ITrafficWorkerSnapshotMessage['reason']) { + if (this.throttleTimer !== null) return this.throttleTimer = setTimeout(() => { - this.throttleTimer = null; - this.emitSnapshot(reason); - }, this.config.snapshotIntervalMs); + this.throttleTimer = null + this.emitSnapshot(reason) + }, this.config.snapshotIntervalMs) } } class TrafficWorkerClient { - private worker: Worker | null = null; - private inlineMonitor: InlineTrafficMonitor | null = null; - private mode: "worker" | "inline" | null = null; + private worker: Worker | null = null + private inlineMonitor: InlineTrafficMonitor | null = null + private mode: 'worker' | 'inline' | null = null private listeners = new Set< (snapshot: ITrafficWorkerSnapshotMessage) => void - >(); - private pendingMessages: TrafficWorkerRequestMessage[] = []; - private ready = false; - private currentRange = WORKER_CONFIG.defaultRangeMinutes; + >() + private pendingMessages: TrafficWorkerRequestMessage[] = [] + private ready = false + private currentRange = WORKER_CONFIG.defaultRangeMinutes start(rangeMinutes?: number) { - if (typeof window === "undefined") { - debugLog("[TrafficWorkerClient] Window not available, skip start"); - return; + if (typeof window === 'undefined') { + debugLog('[TrafficWorkerClient] Window not available, skip start') + return } - this.currentRange = rangeMinutes ?? this.currentRange; + this.currentRange = rangeMinutes ?? this.currentRange - if (this.ready) return; + if (this.ready) return const initMessage: TrafficWorkerRequestMessage = { - type: "init", + type: 'init', config: { rawDataMinutes: WORKER_CONFIG.rawDataMinutes, compressedDataMinutes: WORKER_CONFIG.compressedDataMinutes, @@ -184,201 +184,201 @@ class TrafficWorkerClient { snapshotIntervalMs: WORKER_CONFIG.snapshotIntervalMs, defaultRangeMinutes: this.currentRange, }, - }; + } - if (typeof Worker !== "undefined") { + if (typeof Worker !== 'undefined') { try { this.worker = new Worker( - new URL("../services/traffic-monitor-worker.ts", import.meta.url), - { type: "module" }, - ); - this.mode = "worker"; + new URL('../services/traffic-monitor-worker.ts', import.meta.url), + { type: 'module' }, + ) + this.mode = 'worker' this.worker.onmessage = ( event: MessageEvent, ) => { - const message = event.data; - if (message.type === "snapshot") { - this.listeners.forEach((listener) => listener(message)); + const message = event.data + if (message.type === 'snapshot') { + this.listeners.forEach((listener) => listener(message)) } - }; + } this.worker.onerror = (error) => { - debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`); - }; + debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`) + } - this.ready = true; - this.post(initMessage); - this.flushQueue(); - return; + this.ready = true + this.post(initMessage) + this.flushQueue() + return } catch (error) { debugLog( `[TrafficWorkerClient] Worker initialization failed, falling back to inline sampler: ${String(error)}`, - ); - this.worker = null; - this.mode = null; + ) + this.worker = null + this.mode = null } } else { debugLog( - "[TrafficWorkerClient] Worker not supported, using inline sampler", - ); + '[TrafficWorkerClient] Worker not supported, using inline sampler', + ) } - this.startInline(initMessage); + this.startInline(initMessage) } private startInline(initMessage: TrafficWorkerRequestMessage) { this.inlineMonitor = new InlineTrafficMonitor((snapshot) => this.listeners.forEach((listener) => listener(snapshot)), - ); - this.mode = "inline"; - this.ready = true; - this.post(initMessage); - this.flushQueue(); + ) + this.mode = 'inline' + this.ready = true + this.post(initMessage) + this.flushQueue() } stop() { if (this.worker) { - this.worker.terminate(); + this.worker.terminate() } if (this.inlineMonitor) { - this.inlineMonitor.stop(); + this.inlineMonitor.stop() } - this.worker = null; - this.inlineMonitor = null; - this.mode = null; - this.ready = false; - this.pendingMessages = []; + this.worker = null + this.inlineMonitor = null + this.mode = null + this.ready = false + this.pendingMessages = [] } onSnapshot(listener: (snapshot: ITrafficWorkerSnapshotMessage) => void) { - this.listeners.add(listener); + this.listeners.add(listener) return () => { - this.listeners.delete(listener); - }; + this.listeners.delete(listener) + } } private post(message: TrafficWorkerRequestMessage) { if (!this.ready) { - this.pendingMessages.push(message); - return; + this.pendingMessages.push(message) + return } - if (this.mode === "worker" && this.worker) { - this.worker.postMessage(message); - return; + if (this.mode === 'worker' && this.worker) { + this.worker.postMessage(message) + return } - if (this.mode === "inline" && this.inlineMonitor) { - this.inlineMonitor.handle(message); - return; + if (this.mode === 'inline' && this.inlineMonitor) { + this.inlineMonitor.handle(message) + return } - this.pendingMessages.push(message); + this.pendingMessages.push(message) } private flushQueue() { if (!this.ready || this.pendingMessages.length === 0) { - return; + return } - const queued = [...this.pendingMessages]; - this.pendingMessages = []; + const queued = [...this.pendingMessages] + this.pendingMessages = [] queued.forEach((message) => { - this.post(message); - }); + this.post(message) + }) } private ensureStarted() { if (!this.ready) { - this.start(this.currentRange); + this.start(this.currentRange) } } appendData(traffic: Traffic) { - this.ensureStarted(); + this.ensureStarted() this.post({ - type: "append", + type: 'append', payload: { up: traffic?.up ?? 0, down: traffic?.down ?? 0, timestamp: Date.now(), }, - }); + }) } clearData() { - this.ensureStarted(); - this.post({ type: "clear" }); + this.ensureStarted() + this.post({ type: 'clear' }) } setRange(minutes: number) { - this.currentRange = minutes; - this.post({ type: "setRange", minutes }); + this.currentRange = minutes + this.post({ type: 'setRange', minutes }) } requestSnapshot() { - this.ensureStarted(); - this.post({ type: "requestSnapshot" }); + this.ensureStarted() + this.post({ type: 'requestSnapshot' }) } } -const refCounter = new ReferenceCounter(); -let workerClient: TrafficWorkerClient | null = null; +const refCounter = new ReferenceCounter() +let workerClient: TrafficWorkerClient | null = null const getWorkerClient = () => { if (!workerClient) { - workerClient = new TrafficWorkerClient(); + workerClient = new TrafficWorkerClient() } - return workerClient; -}; + return workerClient +} const EMPTY_STATS: ISamplerStats = { rawBufferSize: 0, compressedBufferSize: 0, compressionQueueSize: 0, totalMemoryPoints: 0, -}; +} /** * 增强的流量监控Hook - Web Worker驱动的数据采样与压缩 */ export const useTrafficMonitorEnhanced = (options?: { - subscribe?: boolean; - enabled?: boolean; + subscribe?: boolean + enabled?: boolean }) => { - const subscribeToSnapshots = options?.subscribe ?? true; - const enabled = options?.enabled ?? true; + const subscribeToSnapshots = options?.subscribe ?? true + const enabled = options?.enabled ?? true const [latestSnapshot, setLatestSnapshot] = useState<{ - availableDataPoints: ITrafficDataPoint[]; - samplerStats: ISamplerStats; - lastTimestamp?: number; + availableDataPoints: ITrafficDataPoint[] + samplerStats: ISamplerStats + lastTimestamp?: number }>({ availableDataPoints: [], samplerStats: EMPTY_STATS, lastTimestamp: undefined, - }); + }) const [rangeMinutes, setRangeMinutes] = useState( WORKER_CONFIG.defaultRangeMinutes, - ); - const [now, setNow] = useState(() => Date.now()); - const [, forceRefCountRender] = useReducer((value) => value + 1, 0); + ) + const [now, setNow] = useState(() => Date.now()) + const [, forceRefCountRender] = useReducer((value) => value + 1, 0) - const clientRef = useRef(getWorkerClient()); - const currentRangeRef = useRef(WORKER_CONFIG.defaultRangeMinutes); + const clientRef = useRef(getWorkerClient()) + const currentRangeRef = useRef(WORKER_CONFIG.defaultRangeMinutes) // 注册引用计数与Worker生命周期 useEffect(() => { - if (!enabled) return; + if (!enabled) return - const client = getWorkerClient(); - clientRef.current = client; + const client = getWorkerClient() + clientRef.current = client const stopWatchRefCount = refCounter.onCountChange(() => forceRefCountRender(), - ); - const cleanup = refCounter.increment(); - client.start(currentRangeRef.current); + ) + const cleanup = refCounter.increment() + client.start(currentRangeRef.current) - let unsubscribe: (() => void) | undefined; + let unsubscribe: (() => void) | undefined if (subscribeToSnapshots) { unsubscribe = client.onSnapshot((message) => { setLatestSnapshot({ @@ -386,67 +386,67 @@ export const useTrafficMonitorEnhanced = (options?: { message.availableDataPoints ?? message.dataPoints, samplerStats: message.samplerStats, lastTimestamp: message.lastTimestamp, - }); - }); + }) + }) - client.requestSnapshot(); + client.requestSnapshot() } return () => { - unsubscribe?.(); - stopWatchRefCount(); - cleanup(); + unsubscribe?.() + stopWatchRefCount() + cleanup() if (refCounter.getCount() === 0) { - client.stop(); + client.stop() } - }; - }, [enabled, subscribeToSnapshots]); + } + }, [enabled, subscribeToSnapshots]) // Periodically refresh "now" so idle streams age out of the selected window when subscribed useEffect(() => { - if (!enabled || !subscribeToSnapshots) return; + if (!enabled || !subscribeToSnapshots) return const timer = window.setInterval(() => { - setNow(Date.now()); - }, 1000); + setNow(Date.now()) + }, 1000) - return () => window.clearInterval(timer); - }, [enabled, subscribeToSnapshots]); + return () => window.clearInterval(timer) + }, [enabled, subscribeToSnapshots]) // 添加流量数据 const appendData = useCallback( (traffic: Traffic) => { - if (!enabled) return; - clientRef.current?.appendData(traffic); + if (!enabled) return + clientRef.current?.appendData(traffic) }, [enabled], - ); + ) // 请求不同时间范围的数据 const requestRange = useCallback( (minutes: number) => { - if (!enabled) return; - currentRangeRef.current = minutes; - setRangeMinutes(minutes); - clientRef.current?.setRange(minutes); + if (!enabled) return + currentRangeRef.current = minutes + setRangeMinutes(minutes) + clientRef.current?.setRange(minutes) }, [enabled], - ); + ) // 清空数据 const clearData = useCallback(() => { - if (!enabled) return; - clientRef.current?.clearData(); - }, [enabled]); + if (!enabled) return + clientRef.current?.clearData() + }, [enabled]) const filteredDataPoints = useMemo(() => { - if (!enabled) return []; - const sourceData = latestSnapshot.availableDataPoints; - if (sourceData.length === 0) return []; + if (!enabled) return [] + const sourceData = latestSnapshot.availableDataPoints + if (sourceData.length === 0) return [] - const cutoff = now - rangeMinutes * 60 * 1000; - return sourceData.filter((point) => point.timestamp > cutoff); - }, [enabled, latestSnapshot.availableDataPoints, rangeMinutes, now]); + const cutoff = now - rangeMinutes * 60 * 1000 + return sourceData.filter((point) => point.timestamp > cutoff) + }, [enabled, latestSnapshot.availableDataPoints, rangeMinutes, now]) return { graphData: { @@ -458,19 +458,19 @@ export const useTrafficMonitorEnhanced = (options?: { }, samplerStats: latestSnapshot.samplerStats, referenceCount: refCounter.getCount(), - }; -}; + } +} /** * 图表数据Hook */ export const useTrafficGraphDataEnhanced = () => { const { graphData, samplerStats, referenceCount } = - useTrafficMonitorEnhanced(); + useTrafficMonitorEnhanced() return { ...graphData, samplerStats, referenceCount, - }; -}; + } +} diff --git a/src/hooks/use-update.ts b/src/hooks/use-update.ts index 76dc578b8..eb954aee8 100644 --- a/src/hooks/use-update.ts +++ b/src/hooks/use-update.ts @@ -1,34 +1,34 @@ -import useSWR, { SWRConfiguration } from "swr"; +import useSWR, { SWRConfiguration } from 'swr' -import { checkUpdateSafe } from "@/services/update"; +import { checkUpdateSafe } from '@/services/update' -import { useVerge } from "./use-verge"; +import { useVerge } from './use-verge' export interface UpdateInfo { - version: string; - body: string; - date: string; - available: boolean; - downloadAndInstall: (onEvent?: any) => Promise; + version: string + body: string + date: string + available: boolean + downloadAndInstall: (onEvent?: any) => Promise } export const useUpdate = ( enabled: boolean = true, options?: SWRConfiguration, ) => { - const { verge } = useVerge(); - const { auto_check_update } = verge || {}; + const { verge } = useVerge() + const { auto_check_update } = verge || {} // Determine if we should check for updates // If enabled is explicitly false, don't check // Otherwise, respect the auto_check_update setting (or default to true if null/undefined for manual triggers) - const shouldCheck = enabled && auto_check_update !== false; + const shouldCheck = enabled && auto_check_update !== false const { data: updateInfo, mutate: checkUpdate, isValidating, - } = useSWR(shouldCheck ? "checkUpdate" : null, checkUpdateSafe, { + } = useSWR(shouldCheck ? 'checkUpdate' : null, checkUpdateSafe, { errorRetryCount: 2, revalidateIfStale: false, revalidateOnFocus: false, @@ -36,11 +36,11 @@ export const useUpdate = ( refreshInterval: 24 * 60 * 60 * 1000, // 24 hours dedupingInterval: 60 * 60 * 1000, // 1 hour ...options, - }); + }) return { updateInfo, checkUpdate, loading: isValidating, - }; -}; + } +} diff --git a/src/hooks/use-verge.ts b/src/hooks/use-verge.ts index 8750eaaec..9855e2014 100644 --- a/src/hooks/use-verge.ts +++ b/src/hooks/use-verge.ts @@ -1,31 +1,31 @@ -import useSWR from "swr"; +import useSWR from 'swr' -import { getVergeConfig, patchVergeConfig } from "@/services/cmds"; -import { getPreloadConfig, setPreloadConfig } from "@/services/preload"; +import { getVergeConfig, patchVergeConfig } from '@/services/cmds' +import { getPreloadConfig, setPreloadConfig } from '@/services/preload' export const useVerge = () => { - const initialVergeConfig = getPreloadConfig(); + const initialVergeConfig = getPreloadConfig() const { data: verge, mutate: mutateVerge } = useSWR( - "getVergeConfig", + 'getVergeConfig', async () => { - const config = await getVergeConfig(); - setPreloadConfig(config); - return config; + const config = await getVergeConfig() + setPreloadConfig(config) + return config }, { fallbackData: initialVergeConfig ?? undefined, revalidateOnMount: !initialVergeConfig, }, - ); + ) const patchVerge = async (value: Partial) => { - await patchVergeConfig(value); - mutateVerge(); - }; + await patchVergeConfig(value) + mutateVerge() + } return { verge, mutateVerge, patchVerge, - }; -}; + } +} diff --git a/src/hooks/use-visibility.ts b/src/hooks/use-visibility.ts index ea166937a..31589c862 100644 --- a/src/hooks/use-visibility.ts +++ b/src/hooks/use-visibility.ts @@ -1,30 +1,30 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState } from 'react' export const useVisibility = () => { const [visible, setVisible] = useState(() => - typeof document === "undefined" + typeof document === 'undefined' ? true - : document.visibilityState === "visible", - ); + : document.visibilityState === 'visible', + ) useEffect(() => { const handleVisibilityChange = () => { - setVisible(document.visibilityState === "visible"); - }; + setVisible(document.visibilityState === 'visible') + } - const handleFocus = () => setVisible(true); - const handlePointerDown = () => setVisible(true); + const handleFocus = () => setVisible(true) + const handlePointerDown = () => setVisible(true) - document.addEventListener("focus", handleFocus); - document.addEventListener("pointerdown", handlePointerDown); - document.addEventListener("visibilitychange", handleVisibilityChange); + document.addEventListener('focus', handleFocus) + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('visibilitychange', handleVisibilityChange) return () => { - document.removeEventListener("focus", handleFocus); - document.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, []); + document.removeEventListener('focus', handleFocus) + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, []) - return visible; -}; + return visible +} diff --git a/src/hooks/use-window.ts b/src/hooks/use-window.ts index ea2632b43..387e5cc3d 100644 --- a/src/hooks/use-window.ts +++ b/src/hooks/use-window.ts @@ -1,14 +1,14 @@ -import { use } from "react"; +import { use } from 'react' -import { WindowContext, type WindowContextType } from "@/providers/window"; +import { WindowContext, type WindowContextType } from '@/providers/window' export const useWindow = () => { - const context = use(WindowContext); + const context = use(WindowContext) if (context === undefined) { - throw new Error("useWindow must be used within WindowProvider"); + throw new Error('useWindow must be used within WindowProvider') } - return context; -}; + return context +} export const useWindowControls = () => { const { @@ -18,7 +18,7 @@ export const useWindowControls = () => { close, toggleFullscreen, currentWindow, - } = useWindow(); + } = useWindow() return { maximized, minimize, @@ -28,19 +28,19 @@ export const useWindowControls = () => { currentWindow, } satisfies Pick< WindowContextType, - | "maximized" - | "minimize" - | "toggleMaximize" - | "close" - | "toggleFullscreen" - | "currentWindow" - >; -}; + | 'maximized' + | 'minimize' + | 'toggleMaximize' + | 'close' + | 'toggleFullscreen' + | 'currentWindow' + > +} export const useWindowDecorations = () => { - const { decorated, toggleDecorations, refreshDecorated } = useWindow(); + const { decorated, toggleDecorations, refreshDecorated } = useWindow() return { decorated, toggleDecorations, refreshDecorated } satisfies Pick< WindowContextType, - "decorated" | "toggleDecorations" | "refreshDecorated" - >; -}; + 'decorated' | 'toggleDecorations' | 'refreshDecorated' + > +} diff --git a/src/index.html b/src/index.html index a148cfdc8..25dddf465 100644 --- a/src/index.html +++ b/src/index.html @@ -60,11 +60,11 @@ justify-content: center; z-index: 9999; font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; transition: opacity 0.3s ease; } - #initial-loading-overlay[data-hidden="true"] { + #initial-loading-overlay[data-hidden='true'] { opacity: 0; pointer-events: none; } diff --git a/src/locales/ar/index.ts b/src/locales/ar/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/ar/index.ts +++ b/src/locales/ar/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/de/index.ts b/src/locales/de/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/de/index.ts +++ b/src/locales/de/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/en/index.ts +++ b/src/locales/en/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/es/index.ts b/src/locales/es/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/es/index.ts +++ b/src/locales/es/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/fa/index.ts b/src/locales/fa/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/fa/index.ts +++ b/src/locales/fa/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/id/index.ts b/src/locales/id/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/id/index.ts +++ b/src/locales/id/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/jp/index.ts b/src/locales/jp/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/jp/index.ts +++ b/src/locales/jp/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/ko/index.ts b/src/locales/ko/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/ko/index.ts +++ b/src/locales/ko/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/ru/index.ts b/src/locales/ru/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/ru/index.ts +++ b/src/locales/ru/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/tr/index.ts b/src/locales/tr/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/tr/index.ts +++ b/src/locales/tr/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/tt/index.ts b/src/locales/tt/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/tt/index.ts +++ b/src/locales/tt/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/zh/index.ts b/src/locales/zh/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/zh/index.ts +++ b/src/locales/zh/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/locales/zhtw/index.ts b/src/locales/zhtw/index.ts index 3ece5e367..0cc99846b 100644 --- a/src/locales/zhtw/index.ts +++ b/src/locales/zhtw/index.ts @@ -1,13 +1,13 @@ -import connections from "./connections.json"; -import home from "./home.json"; -import layout from "./layout.json"; -import logs from "./logs.json"; -import profiles from "./profiles.json"; -import proxies from "./proxies.json"; -import rules from "./rules.json"; -import settings from "./settings.json"; -import shared from "./shared.json"; -import tests from "./tests.json"; +import connections from './connections.json' +import home from './home.json' +import layout from './layout.json' +import logs from './logs.json' +import profiles from './profiles.json' +import proxies from './proxies.json' +import rules from './rules.json' +import settings from './settings.json' +import shared from './shared.json' +import tests from './tests.json' const resources = { connections: connections, @@ -20,6 +20,6 @@ const resources = { settings: settings, shared: shared, tests: tests, -}; +} -export default resources; +export default resources diff --git a/src/main.tsx b/src/main.tsx index 5d37c61a6..dda5b959f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,55 +1,53 @@ /// /// -import "./assets/styles/index.scss"; -import "./services/monaco"; +import './assets/styles/index.scss' +import './services/monaco' -import { ResizeObserver } from "@juggle/resize-observer"; -import { ComposeContextProvider } from "foxact/compose-context-provider"; -import React from "react"; -import { createRoot } from "react-dom/client"; -import { RouterProvider } from "react-router"; -import { MihomoWebSocket } from "tauri-plugin-mihomo-api"; +import { ResizeObserver } from '@juggle/resize-observer' +import { ComposeContextProvider } from 'foxact/compose-context-provider' +import React from 'react' +import { createRoot } from 'react-dom/client' +import { RouterProvider } from 'react-router' +import { MihomoWebSocket } from 'tauri-plugin-mihomo-api' -import { BaseErrorBoundary } from "./components/base"; -import { router } from "./pages/_routers"; -import { AppDataProvider } from "./providers/app-data-provider"; -import { WindowProvider } from "./providers/window"; -import { FALLBACK_LANGUAGE, initializeLanguage } from "./services/i18n"; +import { BaseErrorBoundary } from './components/base' +import { router } from './pages/_routers' +import { AppDataProvider } from './providers/app-data-provider' +import { WindowProvider } from './providers/window' +import { FALLBACK_LANGUAGE, initializeLanguage } from './services/i18n' import { preloadAppData, resolveThemeMode, getPreloadConfig, -} from "./services/preload"; +} from './services/preload' import { LoadingCacheProvider, ThemeModeProvider, UpdateStateProvider, -} from "./services/states"; -import { disableWebViewShortcuts } from "./utils/disable-webview-shortcuts"; +} from './services/states' +import { disableWebViewShortcuts } from './utils/disable-webview-shortcuts' if (!window.ResizeObserver) { - window.ResizeObserver = ResizeObserver; + window.ResizeObserver = ResizeObserver } -const mainElementId = "root"; -const container = document.getElementById(mainElementId); +const mainElementId = 'root' +const container = document.getElementById(mainElementId) if (!container) { - throw new Error( - `No container '${mainElementId}' found to render application`, - ); + throw new Error(`No container '${mainElementId}' found to render application`) } -disableWebViewShortcuts(); +disableWebViewShortcuts() -const initializeApp = (initialThemeMode: "light" | "dark") => { +const initializeApp = (initialThemeMode: 'light' | 'dark') => { const contexts = [ , , , - ]; + ] - const root = createRoot(container); + const root = createRoot(container) root.render( @@ -62,48 +60,48 @@ const initializeApp = (initialThemeMode: "light" | "dark") => { , - ); -}; + ) +} const bootstrap = async () => { - const { initialThemeMode } = await preloadAppData(); - initializeApp(initialThemeMode); -}; + const { initialThemeMode } = await preloadAppData() + initializeApp(initialThemeMode) +} bootstrap().catch((error) => { console.error( - "[main.tsx] App bootstrap failed, falling back to default language:", + '[main.tsx] App bootstrap failed, falling back to default language:', error, - ); + ) initializeLanguage(FALLBACK_LANGUAGE) .catch((fallbackError) => { console.error( - "[main.tsx] Fallback language initialization failed:", + '[main.tsx] Fallback language initialization failed:', fallbackError, - ); + ) }) .finally(() => { - initializeApp(resolveThemeMode(getPreloadConfig())); - }); -}); + initializeApp(resolveThemeMode(getPreloadConfig())) + }) +}) // Error handling -window.addEventListener("error", (event) => { - console.error("[main.tsx] Global error:", event.error); -}); +window.addEventListener('error', (event) => { + console.error('[main.tsx] Global error:', event.error) +}) -window.addEventListener("unhandledrejection", (event) => { - console.error("[main.tsx] Unhandled promise rejection:", event.reason); -}); +window.addEventListener('unhandledrejection', (event) => { + console.error('[main.tsx] Unhandled promise rejection:', event.reason) +}) // Page close/refresh events -window.addEventListener("beforeunload", () => { +window.addEventListener('beforeunload', () => { // Clean up all WebSocket instances to prevent memory leaks - MihomoWebSocket.cleanupAll(); -}); + MihomoWebSocket.cleanupAll() +}) // Page loaded event -window.addEventListener("DOMContentLoaded", () => { +window.addEventListener('DOMContentLoaded', () => { // Clean up all WebSocket instances to prevent memory leaks - MihomoWebSocket.cleanupAll(); -}); + MihomoWebSocket.cleanupAll() +}) diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 9ba2576fb..410e136cc 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -5,13 +5,13 @@ import { closestCenter, useSensor, useSensors, -} from "@dnd-kit/core"; +} from '@dnd-kit/core' import { SortableContext, sortableKeyboardCoordinates, useSortable, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { Box, List, @@ -20,29 +20,29 @@ import { Paper, SvgIcon, ThemeProvider, -} from "@mui/material"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; -import type { CSSProperties } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Outlet, useNavigate } from "react-router"; -import { SWRConfig } from "swr"; +} from '@mui/material' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import type { CSSProperties } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Outlet, useNavigate } from 'react-router' +import { SWRConfig } from 'swr' -import iconDark from "@/assets/image/icon_dark.svg?react"; -import iconLight from "@/assets/image/icon_light.svg?react"; -import LogoSvg from "@/assets/image/logo.svg?react"; -import { BaseErrorBoundary } from "@/components/base"; -import { LayoutItem } from "@/components/layout/layout-item"; -import { LayoutTraffic } from "@/components/layout/layout-traffic"; -import { NoticeManager } from "@/components/layout/notice-manager"; -import { UpdateButton } from "@/components/layout/update-button"; -import { WindowControls } from "@/components/layout/window-controller"; -import { useI18n } from "@/hooks/use-i18n"; -import { useVerge } from "@/hooks/use-verge"; -import { useWindowDecorations } from "@/hooks/use-window"; -import { useThemeMode } from "@/services/states"; -import getSystem from "@/utils/get-system"; +import iconDark from '@/assets/image/icon_dark.svg?react' +import iconLight from '@/assets/image/icon_light.svg?react' +import LogoSvg from '@/assets/image/logo.svg?react' +import { BaseErrorBoundary } from '@/components/base' +import { LayoutItem } from '@/components/layout/layout-item' +import { LayoutTraffic } from '@/components/layout/layout-traffic' +import { NoticeManager } from '@/components/layout/notice-manager' +import { UpdateButton } from '@/components/layout/update-button' +import { WindowControls } from '@/components/layout/window-controller' +import { useI18n } from '@/hooks/use-i18n' +import { useVerge } from '@/hooks/use-verge' +import { useWindowDecorations } from '@/hooks/use-window' +import { useThemeMode } from '@/services/states' +import getSystem from '@/utils/get-system' import { useAppInitialization, @@ -50,22 +50,22 @@ import { useLayoutEvents, useLoadingOverlay, useNavMenuOrder, -} from "./_layout/hooks"; -import { handleNoticeMessage } from "./_layout/utils"; -import { navItems } from "./_routers"; +} from './_layout/hooks' +import { handleNoticeMessage } from './_layout/utils' +import { navItems } from './_routers' -import "dayjs/locale/ru"; -import "dayjs/locale/zh-cn"; +import 'dayjs/locale/ru' +import 'dayjs/locale/zh-cn' -export const portableFlag = false; +export const portableFlag = false -type NavItem = (typeof navItems)[number]; +type NavItem = (typeof navItems)[number] -type MenuContextPosition = { top: number; left: number }; +type MenuContextPosition = { top: number; left: number } interface SortableNavMenuItemProps { - item: NavItem; - label: string; + item: NavItem + label: string } const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => { @@ -78,15 +78,15 @@ const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => { isDragging, } = useSortable({ id: item.path, - }); + }) const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition, - }; + } if (isDragging) { - style.zIndex = 100; + style.zIndex = 100 } return ( @@ -103,31 +103,31 @@ const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => { > {label} - ); -}; + ) +} -dayjs.extend(relativeTime); +dayjs.extend(relativeTime) -const OS = getSystem(); +const OS = getSystem() const Layout = () => { - const mode = useThemeMode(); - const isDark = mode !== "light"; - const { t } = useTranslation(); - const { theme } = useCustomTheme(); - const { verge, mutateVerge, patchVerge } = useVerge(); - const { language } = verge ?? {}; - const navCollapsed = verge?.collapse_navbar ?? false; - const { switchLanguage } = useI18n(); - const navigate = useNavigate(); - const themeReady = useMemo(() => Boolean(theme), [theme]); + const mode = useThemeMode() + const isDark = mode !== 'light' + const { t } = useTranslation() + const { theme } = useCustomTheme() + const { verge, mutateVerge, patchVerge } = useVerge() + const { language } = verge ?? {} + const navCollapsed = verge?.collapse_navbar ?? false + const { switchLanguage } = useI18n() + const navigate = useNavigate() + const themeReady = useMemo(() => Boolean(theme), [theme]) - const [menuUnlocked, setMenuUnlocked] = useState(false); + const [menuUnlocked, setMenuUnlocked] = useState(false) const [menuContextPosition, setMenuContextPosition] = - useState(null); + useState(null) - const windowControlsRef = useRef(null); - const { decorated } = useWindowDecorations(); + const windowControlsRef = useRef(null) + const { decorated } = useWindowDecorations() const sensors = useSensors( useSensor(PointerSensor, { @@ -138,22 +138,22 @@ const Layout = () => { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), - ); + ) const handleMenuOrderOptimisticUpdate = useCallback( (order: string[]) => { mutateVerge( (prev) => (prev ? { ...prev, menu_order: order } : prev), false, - ); + ) }, [mutateVerge], - ); + ) const handleMenuOrderPersist = useCallback( (order: string[]) => patchVerge({ menu_order: order }), [patchVerge], - ); + ) const { menuOrder, @@ -167,40 +167,40 @@ const Layout = () => { storedOrder: verge?.menu_order, onOptimisticUpdate: handleMenuOrderOptimisticUpdate, onPersist: handleMenuOrderPersist, - }); + }) const handleMenuContextMenu = useCallback( (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - setMenuContextPosition({ top: event.clientY, left: event.clientX }); + event.preventDefault() + event.stopPropagation() + setMenuContextPosition({ top: event.clientY, left: event.clientX }) }, [], - ); + ) const handleMenuContextClose = useCallback(() => { - setMenuContextPosition(null); - }, []); + setMenuContextPosition(null) + }, []) const handleResetMenuOrder = useCallback(() => { - setMenuContextPosition(null); - void resetMenuOrder(); - }, [resetMenuOrder]); + setMenuContextPosition(null) + void resetMenuOrder() + }, [resetMenuOrder]) const handleUnlockMenu = useCallback(() => { - setMenuUnlocked(true); - setMenuContextPosition(null); - }, []); + setMenuUnlocked(true) + setMenuContextPosition(null) + }, []) const handleLockMenu = useCallback(() => { - setMenuUnlocked(false); - setMenuContextPosition(null); - }, []); + setMenuUnlocked(false) + setMenuContextPosition(null) + }, []) const handleToggleNavCollapsed = useCallback(() => { - setMenuContextPosition(null); - void patchVerge({ collapse_navbar: !navCollapsed }); - }, [navCollapsed, patchVerge]); + setMenuContextPosition(null) + void patchVerge({ collapse_navbar: !navCollapsed }) + }, [navCollapsed, patchVerge]) const customTitlebar = useMemo( () => @@ -210,47 +210,47 @@ const Layout = () => {

    ) : null, [decorated], - ); + ) - useLoadingOverlay(themeReady); - useAppInitialization(); + useLoadingOverlay(themeReady) + useAppInitialization() const handleNotice = useCallback( (payload: [string, string]) => { - const [status, msg] = payload; + const [status, msg] = payload try { - handleNoticeMessage(status, msg, t, navigate); + handleNoticeMessage(status, msg, t, navigate) } catch (error) { - console.error("[通知处理] 失败:", error); + console.error('[通知处理] 失败:', error) } }, [t, navigate], - ); + ) - useLayoutEvents(handleNotice); + useLayoutEvents(handleNotice) useEffect(() => { if (language) { - dayjs.locale(language === "zh" ? "zh-cn" : language); - switchLanguage(language); + dayjs.locale(language === 'zh' ? 'zh-cn' : language) + switchLanguage(language) } - }, [language, switchLanguage]); + }, [language, switchLanguage]) if (!themeReady) { return (
    - ); + ) } return ( @@ -261,20 +261,16 @@ const Layout = () => { errorRetryInterval: 5000, onError: (error, key) => { // FIXME the condition should not be handle gllobally - if (key !== "getAutotemProxy") { - console.error(`SWR Error for ${key}:`, error); - return; + if (key !== 'getAutotemProxy') { + console.error(`SWR Error for ${key}:`, error) + return } // FIXME we need a better way to handle the retry when first booting app - const silentKeys = [ - "getVersion", - "getClashConfig", - "getAutotemProxy", - ]; - if (silentKeys.includes(key)) return; + const silentKeys = ['getVersion', 'getClashConfig', 'getAutotemProxy'] + if (silentKeys.includes(key)) return - console.error(`[SWR Error] Key: ${key}, Error:`, error); + console.error(`[SWR Error] Key: ${key}, Error:`, error) }, dedupingInterval: 2000, }} @@ -284,8 +280,8 @@ const Layout = () => {