refactor: lint (#6511)

* refactor: lint

* chore: remove eslint-plugin/config-prettier
This commit is contained in:
Slinetrac
2026-03-15 07:40:11 +00:00
committed by GitHub
parent 41c3a166a5
commit c672a6fef3
264 changed files with 18749 additions and 18942 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
name: 问题反馈 / Bug report name: 问题反馈 / Bug report
title: "[BUG] " title: '[BUG] '
description: 反馈你遇到的问题 / Report the issue you are experiencing description: 反馈你遇到的问题 / Report the issue you are experiencing
labels: ["bug"] labels: ['bug']
type: "Bug" type: 'Bug'
body: body:
- type: markdown - type: markdown
+3 -3
View File
@@ -1,8 +1,8 @@
name: 功能请求 / Feature request name: 功能请求 / Feature request
title: "[Feature] " title: '[Feature] '
description: 提出你的功能请求 / Propose your feature request description: 提出你的功能请求 / Propose your feature request
labels: ["enhancement"] labels: ['enhancement']
type: "Feature" type: 'Feature'
body: body:
- type: markdown - type: markdown
+3 -3
View File
@@ -1,8 +1,8 @@
name: I18N / 多语言相关 name: I18N / 多语言相关
title: "[I18N] " title: '[I18N] '
description: 用于多语言翻译、国际化相关问题或建议 / For issues or suggestions related to translations and internationalization description: 用于多语言翻译、国际化相关问题或建议 / For issues or suggestions related to translations and internationalization
labels: ["I18n"] labels: ['I18n']
type: "Task" type: 'Task'
body: body:
- type: markdown - type: markdown
+12 -12
View File
@@ -27,7 +27,7 @@ env:
RUST_BACKTRACE: short RUST_BACKTRACE: short
HUSKY: 0 HUSKY: 0
concurrency: concurrency:
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}" group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
jobs: jobs:
check_alpha_tag: check_alpha_tag:
@@ -249,7 +249,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
body_path: release.txt body_path: release.txt
prerelease: true prerelease: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
@@ -307,7 +307,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -325,7 +325,7 @@ jobs:
- name: Tauri build - name: Tauri build
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -337,8 +337,8 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with: with:
tagName: ${{ env.TAG_NAME }} tagName: ${{ env.TAG_NAME }}
releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" releaseName: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
releaseBody: "More new features are now supported." releaseBody: 'More new features are now supported.'
releaseDraft: false releaseDraft: false
prerelease: true prerelease: true
tauriScript: pnpm tauriScript: pnpm
@@ -377,7 +377,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -453,7 +453,7 @@ jobs:
fi fi
pnpm build --target ${{ matrix.target }} pnpm build --target ${{ matrix.target }}
env: 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: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -468,7 +468,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
prerelease: true prerelease: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
files: | files: |
@@ -505,7 +505,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -531,7 +531,7 @@ jobs:
id: build id: build
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -563,7 +563,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
prerelease: true prerelease: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup* files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
+30 -30
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00 # UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
- cron: "0 4,10 * * *" - cron: '0 4,10 * * *'
permissions: write-all permissions: write-all
env: env:
TAG_NAME: autobuild TAG_NAME: autobuild
@@ -13,7 +13,7 @@ env:
RUST_BACKTRACE: short RUST_BACKTRACE: short
HUSKY: 0 HUSKY: 0
concurrency: 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' }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
@@ -46,7 +46,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -105,7 +105,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
body_path: release.txt body_path: release.txt
prerelease: true prerelease: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
@@ -147,7 +147,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: "1.91.0" toolchain: '1.91.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -157,8 +157,8 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust" prefix-key: 'v1-rust'
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -196,14 +196,14 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
cache: "pnpm" cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: | restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }} pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
@@ -225,7 +225,7 @@ jobs:
- name: Tauri build for Windows-macOS-Linux - name: Tauri build for Windows-macOS-Linux
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -237,8 +237,8 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with: with:
tagName: ${{ env.TAG_NAME }} tagName: ${{ env.TAG_NAME }}
releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" releaseName: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
releaseBody: "More new features are now supported." releaseBody: 'More new features are now supported.'
releaseDraft: false releaseDraft: false
prerelease: true prerelease: true
tauriScript: pnpm tauriScript: pnpm
@@ -269,7 +269,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: "1.91.0" toolchain: '1.91.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -279,8 +279,8 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust" prefix-key: 'v1-rust'
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -294,14 +294,14 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
cache: "pnpm" cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: | restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }} pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
@@ -313,7 +313,7 @@ jobs:
- name: Release ${{ env.TAG_CHANNEL }} Version - name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest run: pnpm release-version autobuild-latest
- name: "Setup for linux" - name: 'Setup for linux'
run: |- run: |-
sudo ls -lR /etc/apt/ sudo ls -lR /etc/apt/
@@ -376,7 +376,7 @@ jobs:
fi fi
pnpm build --target ${{ matrix.target }} pnpm build --target ${{ matrix.target }}
env: 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: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -391,7 +391,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
prerelease: true prerelease: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
files: | files: |
@@ -424,8 +424,8 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust" prefix-key: 'v1-rust'
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -439,14 +439,14 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
cache: "pnpm" cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: | restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }} pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
@@ -476,7 +476,7 @@ jobs:
id: build id: build
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -509,7 +509,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}" name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
prerelease: true prerelease: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
files: target/${{ matrix.target }}/release/bundle/nsis/*setup* files: target/${{ matrix.target }}/release/bundle/nsis/*setup*
@@ -541,7 +541,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4.4.0 - uses: pnpm/action-setup@v4.4.0
name: Install pnpm name: Install pnpm
@@ -4,36 +4,36 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag_name: tag_name:
description: "Release tag name to check against (default: autobuild)" description: 'Release tag name to check against (default: autobuild)'
required: false required: false
default: "autobuild" default: 'autobuild'
type: string type: string
force_build: force_build:
description: "Force build regardless of checks" description: 'Force build regardless of checks'
required: false required: false
default: false default: false
type: boolean type: boolean
workflow_call: workflow_call:
inputs: inputs:
tag_name: tag_name:
description: "Release tag name to check against (default: autobuild)" description: 'Release tag name to check against (default: autobuild)'
required: false required: false
default: "autobuild" default: 'autobuild'
type: string type: string
force_build: force_build:
description: "Force build regardless of checks" description: 'Force build regardless of checks'
required: false required: false
default: false default: false
type: boolean type: boolean
outputs: outputs:
should_run: should_run:
description: "Whether the build should run" description: 'Whether the build should run'
value: ${{ jobs.check_commit.outputs.should_run }} value: ${{ jobs.check_commit.outputs.should_run }}
last_tauri_commit: 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 }} value: ${{ jobs.check_commit.outputs.last_tauri_commit }}
autobuild_version: autobuild_version:
description: "The generated autobuild version string" description: 'The generated autobuild version string'
value: ${{ jobs.check_commit.outputs.autobuild_version }} value: ${{ jobs.check_commit.outputs.autobuild_version }}
permissions: permissions:
+6 -6
View File
@@ -4,24 +4,24 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag_name: tag_name:
description: "Release tag name to clean (default: autobuild)" description: 'Release tag name to clean (default: autobuild)'
required: false required: false
default: "autobuild" default: 'autobuild'
type: string type: string
dry_run: dry_run:
description: "Dry run mode (only show what would be deleted)" description: 'Dry run mode (only show what would be deleted)'
required: false required: false
default: false default: false
type: boolean type: boolean
workflow_call: workflow_call:
inputs: inputs:
tag_name: tag_name:
description: "Release tag name to clean (default: autobuild)" description: 'Release tag name to clean (default: autobuild)'
required: false required: false
default: "autobuild" default: 'autobuild'
type: string type: string
dry_run: dry_run:
description: "Dry run mode (only show what would be deleted)" description: 'Dry run mode (only show what would be deleted)'
required: false required: false
default: false default: false
type: boolean type: boolean
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
cargo-check: cargo-check:
# Treat all Rust compiler warnings as errors # Treat all Rust compiler warnings as errors
env: env:
RUSTFLAGS: "-D warnings" RUSTFLAGS: '-D warnings'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -43,7 +43,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
+11 -11
View File
@@ -4,22 +4,22 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
run_windows: run_windows:
description: "运行 Windows" description: '运行 Windows'
required: false required: false
type: boolean type: boolean
default: true default: true
run_macos_aarch64: run_macos_aarch64:
description: "运行 macOS aarch64" description: '运行 macOS aarch64'
required: false required: false
type: boolean type: boolean
default: true default: true
run_windows_arm64: run_windows_arm64:
description: "运行 Windows ARM64" description: '运行 Windows ARM64'
required: false required: false
type: boolean type: boolean
default: true default: true
run_linux_amd64: run_linux_amd64:
description: "运行 Linux amd64" description: '运行 Linux amd64'
required: false required: false
type: boolean type: boolean
default: true default: true
@@ -32,7 +32,7 @@ env:
RUST_BACKTRACE: short RUST_BACKTRACE: short
HUSKY: 0 HUSKY: 0
concurrency: 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' }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
@@ -80,8 +80,8 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust" prefix-key: 'v1-rust'
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -103,14 +103,14 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
cache: "pnpm" cache: 'pnpm'
- name: Pnpm Cache - name: Pnpm Cache
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: ~/.pnpm-store path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: | restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }} pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
lookup-only: true lookup-only: true
@@ -137,7 +137,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true' if: github.event.inputs[matrix.input] == 'true'
uses: tauri-apps/tauri-action@v0 uses: tauri-apps/tauri-action@v0
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+2 -2
View File
@@ -47,8 +47,8 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
if: steps.check_frontend.outputs.frontend == 'true' if: steps.check_frontend.outputs.frontend == 'true'
with: with:
node-version: "24.14.0" node-version: '24.14.0'
cache: "pnpm" cache: 'pnpm'
- name: Restore pnpm cache - name: Restore pnpm cache
if: steps.check_frontend.outputs.frontend == 'true' if: steps.check_frontend.outputs.frontend == 'true'
+2 -2
View File
@@ -59,8 +59,8 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust" prefix-key: 'v1-rust'
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
+30 -30
View File
@@ -7,7 +7,7 @@ on:
push: push:
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新 # -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
tags: tags:
- "v*.*.*" - 'v*.*.*'
permissions: write-all permissions: write-all
env: env:
CARGO_INCREMENTAL: 0 CARGO_INCREMENTAL: 0
@@ -15,7 +15,7 @@ env:
HUSKY: 0 HUSKY: 0
concurrency: concurrency:
# only allow per workflow per commit (and not pr) to run at a time # 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' }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
@@ -129,7 +129,7 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_NAME }}" name: 'Clash Verge Rev ${{ env.TAG_NAME }}'
body_path: release.txt body_path: release.txt
draft: false draft: false
prerelease: ${{ contains(github.ref_name, '-rc') }} prerelease: ${{ contains(github.ref_name, '-rc') }}
@@ -162,7 +162,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: "1.91.0" toolchain: '1.91.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -172,8 +172,8 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust" prefix-key: 'v1-rust'
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -197,7 +197,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -220,7 +220,7 @@ jobs:
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本 # 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
uses: tauri-apps/tauri-action@v0.6.2 uses: tauri-apps/tauri-action@v0.6.2
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -232,8 +232,8 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with: with:
tagName: ${{ github.ref_name }} tagName: ${{ github.ref_name }}
releaseName: "Clash Verge Rev ${{ github.ref_name }}" releaseName: 'Clash Verge Rev ${{ github.ref_name }}'
releaseBody: "Draft release, will be updated later." releaseBody: 'Draft release, will be updated later.'
releaseDraft: true releaseDraft: true
prerelease: ${{ contains(github.ref_name, '-rc') }} prerelease: ${{ contains(github.ref_name, '-rc') }}
tauriScript: pnpm tauriScript: pnpm
@@ -261,7 +261,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: "1.91.0" toolchain: '1.91.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -271,8 +271,8 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust" prefix-key: 'v1-rust'
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -281,7 +281,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
@@ -293,7 +293,7 @@ jobs:
pnpm i pnpm i
pnpm run prebuild ${{ matrix.target }} pnpm run prebuild ${{ matrix.target }}
- name: "Setup for linux" - name: 'Setup for linux'
run: |- run: |-
sudo ls -lR /etc/apt/ sudo ls -lR /etc/apt/
@@ -323,14 +323,14 @@ jobs:
patchelf:${{ matrix.arch }} \ patchelf:${{ matrix.arch }} \
librsvg2-dev:${{ matrix.arch }} librsvg2-dev:${{ matrix.arch }}
- name: "Install aarch64 tools" - name: 'Install aarch64 tools'
if: matrix.target == 'aarch64-unknown-linux-gnu' if: matrix.target == 'aarch64-unknown-linux-gnu'
run: | run: |
sudo apt install -y \ sudo apt install -y \
gcc-aarch64-linux-gnu \ gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu g++-aarch64-linux-gnu
- name: "Install armv7 tools" - name: 'Install armv7 tools'
if: matrix.target == 'armv7-unknown-linux-gnueabihf' if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: | run: |
sudo apt install -y \ sudo apt install -y \
@@ -356,7 +356,7 @@ jobs:
fi fi
pnpm build --target ${{ matrix.target }} pnpm build --target ${{ matrix.target }}
env: 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: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -371,8 +371,8 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{env.VERSION}} tag_name: v${{env.VERSION}}
name: "Clash Verge Rev v${{env.VERSION}}" name: 'Clash Verge Rev v${{env.VERSION}}'
body: "See release notes for detailed changelog." body: 'See release notes for detailed changelog.'
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
prerelease: ${{ contains(github.ref_name, '-rc') }} prerelease: ${{ contains(github.ref_name, '-rc') }}
files: | files: |
@@ -400,7 +400,7 @@ jobs:
- name: Install Rust Stable - name: Install Rust Stable
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: "1.91.0" toolchain: '1.91.0'
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Add Rust Target - name: Add Rust Target
@@ -410,8 +410,8 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
with: with:
save-if: ${{ github.ref == 'refs/heads/dev' }} save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust" prefix-key: 'v1-rust'
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}" key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: | workspaces: |
. -> target . -> target
cache-all-crates: true cache-all-crates: true
@@ -420,7 +420,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -450,7 +450,7 @@ jobs:
id: build id: build
uses: tauri-apps/tauri-action@v0.6.2 uses: tauri-apps/tauri-action@v0.6.2
env: env:
NODE_OPTIONS: "--max_old_space_size=4096" NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -482,8 +482,8 @@ jobs:
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: v${{steps.build.outputs.appVersion}} tag_name: v${{steps.build.outputs.appVersion}}
name: "Clash Verge Rev v${{steps.build.outputs.appVersion}}" name: 'Clash Verge Rev v${{steps.build.outputs.appVersion}}'
body: "See release notes for detailed changelog." body: 'See release notes for detailed changelog.'
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
prerelease: ${{ contains(github.ref_name, '-rc') }} prerelease: ${{ contains(github.ref_name, '-rc') }}
files: target/${{ matrix.target }}/release/bundle/nsis/*setup* files: target/${{ matrix.target }}/release/bundle/nsis/*setup*
@@ -505,7 +505,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -531,7 +531,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -593,7 +593,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node - name: Install Node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24.14.0" node-version: '24.14.0'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
name: Install pnpm name: Install pnpm
+1
View File
@@ -9,3 +9,4 @@ src-tauri/target/
src-tauri/gen/ src-tauri/gen/
target target
Cargo.lock
+3 -14
View File
@@ -1,16 +1,5 @@
{ {
"printWidth": 80, "semi": false,
"tabWidth": 2, "singleQuote": true,
"useTabs": false, "plugins": ["prettier-plugin-toml"]
"semi": true,
"singleQuote": false,
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css",
"endOfLine": "auto",
"embeddedLanguageFormatting": "auto"
} }
-1
View File
@@ -10,7 +10,6 @@ members = [
] ]
resolver = "2" resolver = "2"
[profile.release] [profile.release]
panic = "abort" panic = "abort"
codegen-units = 1 codegen-units = 1
+1 -1
View File
@@ -5,7 +5,7 @@ notifications:
body: ダッシュボードの表示状態が更新されました。 body: ダッシュボードの表示状態が更新されました。
clashModeChanged: clashModeChanged:
title: モード切り替え title: モード切り替え
body: "{mode} に切り替えました。" body: '{mode} に切り替えました。'
systemProxyToggled: systemProxyToggled:
title: システムプロキシ title: システムプロキシ
body: システムプロキシの状態が更新されました。 body: システムプロキシの状態が更新されました。
+1 -1
View File
@@ -5,7 +5,7 @@ notifications:
body: 대시보드 표시 상태가 업데이트되었습니다. body: 대시보드 표시 상태가 업데이트되었습니다.
clashModeChanged: clashModeChanged:
title: 모드 전환 title: 모드 전환
body: "{mode}(으)로 전환되었습니다." body: '{mode}(으)로 전환되었습니다.'
systemProxyToggled: systemProxyToggled:
title: 시스템 프록시 title: 시스템 프록시
body: 시스템 프록시 상태가 업데이트되었습니다. body: 시스템 프록시 상태가 업데이트되었습니다.
+1 -1
View File
@@ -5,7 +5,7 @@ notifications:
body: Gösterge panelinin görünürlüğü güncellendi. body: Gösterge panelinin görünürlüğü güncellendi.
clashModeChanged: clashModeChanged:
title: Mod Değişimi title: Mod Değişimi
body: "{mode} moduna geçildi." body: '{mode} moduna geçildi.'
systemProxyToggled: systemProxyToggled:
title: Sistem Vekil'i title: Sistem Vekil'i
body: Sistem vekil'i durumu güncellendi. body: Sistem vekil'i durumu güncellendi.
+1 -1
View File
@@ -5,7 +5,7 @@ notifications:
body: Идарә панеленең күренеше яңартылды. body: Идарә панеленең күренеше яңартылды.
clashModeChanged: clashModeChanged:
title: Режим алыштыру title: Режим алыштыру
body: "{mode} режимына күчтел." body: '{mode} режимына күчтел.'
systemProxyToggled: systemProxyToggled:
title: Системалы прокси title: Системалы прокси
body: Системалы прокси хәле яңартылды. body: Системалы прокси хәле яңартылды.
+83 -86
View File
@@ -1,148 +1,145 @@
import eslintJS from "@eslint/js"; import eslintJS from '@eslint/js'
import eslintReact from "@eslint-react/eslint-plugin"; import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from "eslint/config"; import { defineConfig } from 'eslint/config'
import configPrettier from "eslint-config-prettier"; import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript"; import pluginImportX from 'eslint-plugin-import-x'
import pluginImportX from "eslint-plugin-import-x"; import pluginReactCompiler from 'eslint-plugin-react-compiler'
import pluginPrettier from "eslint-plugin-prettier"; import pluginReactHooks from 'eslint-plugin-react-hooks'
import pluginReactCompiler from "eslint-plugin-react-compiler"; import pluginReactRefresh from 'eslint-plugin-react-refresh'
import pluginReactHooks from "eslint-plugin-react-hooks"; import pluginUnusedImports from 'eslint-plugin-unused-imports'
import pluginReactRefresh from "eslint-plugin-react-refresh"; import globals from 'globals'
import pluginUnusedImports from "eslint-plugin-unused-imports"; import tseslint from 'typescript-eslint'
import globals from "globals";
import tseslint from "typescript-eslint";
export default defineConfig([ export default defineConfig([
{ {
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
plugins: { plugins: {
js: eslintJS, js: eslintJS,
// @ts-expect-error -- https://github.com/typescript-eslint/typescript-eslint/issues/11543 // @ts-expect-error -- https://github.com/typescript-eslint/typescript-eslint/issues/11543
"react-hooks": pluginReactHooks, 'react-hooks': pluginReactHooks,
"react-compiler": pluginReactCompiler, 'react-compiler': pluginReactCompiler,
// @ts-expect-error -- https://github.com/un-ts/eslint-plugin-import-x/issues/421 // @ts-expect-error -- https://github.com/un-ts/eslint-plugin-import-x/issues/421
"import-x": pluginImportX, 'import-x': pluginImportX,
"react-refresh": pluginReactRefresh, 'react-refresh': pluginReactRefresh,
"unused-imports": pluginUnusedImports, 'unused-imports': pluginUnusedImports,
prettier: pluginPrettier,
}, },
extends: [ extends: [
eslintJS.configs.recommended, eslintJS.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
eslintReact.configs["recommended-typescript"], eslintReact.configs['recommended-typescript'],
configPrettier,
], ],
languageOptions: { languageOptions: {
globals: globals.browser, globals: globals.browser,
parserOptions: { parserOptions: {
projectService: { projectService: {
allowDefaultProject: ["src/polyfills/*.js"], allowDefaultProject: [
'eslint.config.ts',
'src/polyfills/*.js',
'src-tauri/src/enhance/builtin/*.js',
],
}, },
}, },
}, },
settings: { settings: {
react: { react: {
version: "detect", version: 'detect',
}, },
"import-x/resolver-next": [ 'import-x/resolver-next': [
createTypeScriptImportResolver({ createTypeScriptImportResolver({
project: "./tsconfig.json", project: './tsconfig.json',
}), }),
], ],
}, },
rules: { rules: {
// React // React
"react-hooks/rules-of-hooks": "error", 'react-hooks/rules-of-hooks': 'error',
"react-hooks/exhaustive-deps": "error", 'react-hooks/exhaustive-deps': 'error',
"react-compiler/react-compiler": "error", 'react-compiler/react-compiler': 'error',
"react-refresh/only-export-components": [ 'react-refresh/only-export-components': [
"warn", 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
"@eslint-react/no-forward-ref": "off", '@eslint-react/no-forward-ref': 'off',
// React performance and production quality rules // React performance and production quality rules
"@eslint-react/no-array-index-key": "warn", '@eslint-react/no-array-index-key': 'warn',
"@eslint-react/no-children-count": "error", '@eslint-react/no-children-count': 'error',
"@eslint-react/no-children-for-each": "error", '@eslint-react/no-children-for-each': 'error',
"@eslint-react/no-children-map": "error", '@eslint-react/no-children-map': 'error',
"@eslint-react/no-children-only": "error", '@eslint-react/no-children-only': 'error',
"@eslint-react/no-children-prop": "error", '@eslint-react/no-children-prop': 'error',
"@eslint-react/no-children-to-array": "error", '@eslint-react/no-children-to-array': 'error',
"@eslint-react/no-class-component": "error", '@eslint-react/no-class-component': 'error',
"@eslint-react/no-clone-element": "error", '@eslint-react/no-clone-element': 'error',
"@eslint-react/no-create-ref": "error", '@eslint-react/no-create-ref': 'error',
"@eslint-react/no-default-props": "error", '@eslint-react/no-default-props': 'error',
"@eslint-react/no-direct-mutation-state": "error", '@eslint-react/no-direct-mutation-state': 'error',
"@eslint-react/no-implicit-key": "error", '@eslint-react/no-implicit-key': 'error',
"@eslint-react/no-prop-types": "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-mount': 'error',
"@eslint-react/no-set-state-in-component-did-update": "error", '@eslint-react/no-set-state-in-component-did-update': 'error',
"@eslint-react/no-set-state-in-component-will-update": "error", '@eslint-react/no-set-state-in-component-will-update': 'error',
"@eslint-react/no-string-refs": "error", '@eslint-react/no-string-refs': 'error',
"@eslint-react/no-unstable-context-value": "warn", '@eslint-react/no-unstable-context-value': 'warn',
"@eslint-react/no-unstable-default-props": "warn", '@eslint-react/no-unstable-default-props': 'warn',
"@eslint-react/no-unused-class-component-members": "error", '@eslint-react/no-unused-class-component-members': 'error',
"@eslint-react/no-unused-state": "error", '@eslint-react/no-unused-state': 'error',
"@eslint-react/no-useless-fragment": "warn", '@eslint-react/no-useless-fragment': 'warn',
"@eslint-react/prefer-destructuring-assignment": "warn", '@eslint-react/prefer-destructuring-assignment': 'warn',
// TypeScript // TypeScript
"@typescript-eslint/no-explicit-any": "off", '@typescript-eslint/no-explicit-any': 'off',
// unused-imports 代替 no-unused-vars // unused-imports 代替 no-unused-vars
"@typescript-eslint/no-unused-vars": "off", '@typescript-eslint/no-unused-vars': 'off',
"unused-imports/no-unused-imports": "error", 'unused-imports/no-unused-imports': 'error',
"unused-imports/no-unused-vars": [ 'unused-imports/no-unused-vars': [
"warn", 'warn',
{ {
vars: "all", vars: 'all',
varsIgnorePattern: "^_", varsIgnorePattern: '^_',
args: "after-used", args: 'after-used',
argsIgnorePattern: "^_", argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: "^ignore", caughtErrorsIgnorePattern: '^ignore',
}, },
], ],
// Import // Import
"import-x/no-unresolved": "error", 'import-x/no-unresolved': 'error',
"import-x/order": [ 'import-x/order': [
"warn", 'warn',
{ {
groups: [ groups: [
"builtin", 'builtin',
"external", 'external',
"internal", 'internal',
"parent", 'parent',
"sibling", 'sibling',
"index", 'index',
], ],
"newlines-between": "always", 'newlines-between': 'always',
alphabetize: { alphabetize: {
order: "asc", order: 'asc',
caseInsensitive: true, caseInsensitive: true,
}, },
}, },
], ],
// 其他常见 // 其他常见
"prefer-const": "warn", 'prefer-const': 'warn',
"no-case-declarations": "error", 'no-case-declarations': 'error',
"no-fallthrough": "error", 'no-fallthrough': 'error',
"no-empty": ["warn", { allowEmptyCatch: true }], 'no-empty': ['warn', { allowEmptyCatch: true }],
// Prettier 格式化问题
"prettier/prettier": "warn",
}, },
}, },
{ {
files: ["scripts/**/*.{js,mjs,cjs}", "scripts-workflow/**/*.{js,mjs,cjs}"], files: ['scripts/**/*.{js,mjs,cjs}'],
languageOptions: { languageOptions: {
globals: { globals: {
@@ -151,4 +148,4 @@ export default defineConfig([
}, },
}, },
}, },
]); ])
+1 -2
View File
@@ -99,10 +99,8 @@
"commander": "^14.0.3", "commander": "^14.0.3",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4", "eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1", "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-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
@@ -115,6 +113,7 @@
"lint-staged": "^16.3.2", "lint-staged": "^16.3.2",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-toml": "^2.0.6",
"sass": "^1.97.3", "sass": "^1.97.3",
"tar": "^7.5.10", "tar": "^7.5.10",
"terser": "^5.46.0", "terser": "^5.46.0",
+23 -63
View File
@@ -198,18 +198,12 @@ importers:
eslint: eslint:
specifier: ^10.0.3 specifier: ^10.0.3
version: 10.0.3(jiti@2.6.1) 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: eslint-import-resolver-typescript:
specifier: ^4.4.4 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)) 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: eslint-plugin-import-x:
specifier: ^4.16.1 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)) 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: eslint-plugin-react-compiler:
specifier: 19.1.0-rc.2 specifier: 19.1.0-rc.2
version: 19.1.0-rc.2(eslint@10.0.3(jiti@2.6.1)) version: 19.1.0-rc.2(eslint@10.0.3(jiti@2.6.1))
@@ -246,6 +240,9 @@ importers:
prettier: prettier:
specifier: ^3.8.1 specifier: ^3.8.1
version: 3.8.1 version: 3.8.1
prettier-plugin-toml:
specifier: ^2.0.6
version: 2.0.6(prettier@3.8.1)
sass: sass:
specifier: ^1.97.3 specifier: ^1.97.3
version: 1.97.3 version: 1.97.3
@@ -1407,10 +1404,6 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'} 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': '@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
@@ -1750,6 +1743,12 @@ packages:
'@tanstack/virtual-core@3.13.21': '@tanstack/virtual-core@3.13.21':
resolution: {integrity: sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==} 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': '@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
@@ -2460,12 +2459,6 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} 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: eslint-import-context@0.1.9:
resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -2501,20 +2494,6 @@ packages:
eslint-import-resolver-node: eslint-import-resolver-node:
optional: true 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: eslint-plugin-react-compiler@19.1.0-rc.2:
resolution: {integrity: sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==} resolution: {integrity: sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==}
engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0} engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0}
@@ -2656,9 +2635,6 @@ packages:
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 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: fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -3373,9 +3349,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier-linter-helpers@1.0.1: prettier-plugin-toml@2.0.6:
resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} resolution: {integrity: sha512-12N/wBuHa9jd/KVy9pRP20NMKxQfQLMseQCt66lIbLaPLItvGUcSIryE1eZZMJ7loSws6Ig3M2Elc2EreNh76w==}
engines: {node: '>=6.0.0'} engines: {node: '>=16.0.0'}
peerDependencies:
prettier: ^3.0.3
prettier@3.8.1: prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
@@ -3652,10 +3630,6 @@ packages:
peerDependencies: peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 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: systemjs@6.15.1:
resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==} resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==}
@@ -5217,8 +5191,6 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6
optional: true optional: true
'@pkgr/core@0.2.9': {}
'@popperjs/core@2.11.8': {} '@popperjs/core@2.11.8': {}
'@rolldown/binding-android-arm64@1.0.0-rc.9': '@rolldown/binding-android-arm64@1.0.0-rc.9':
@@ -5441,6 +5413,12 @@ snapshots:
'@tanstack/virtual-core@3.13.21': {} '@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/api@2.10.1': {}
'@tauri-apps/cli-darwin-arm64@2.10.1': '@tauri-apps/cli-darwin-arm64@2.10.1':
@@ -6122,10 +6100,6 @@ snapshots:
escape-string-regexp@4.0.0: {} 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): eslint-import-context@0.1.9(unrs-resolver@1.11.1):
dependencies: dependencies:
get-tsconfig: 4.13.6 get-tsconfig: 4.13.6
@@ -6165,15 +6139,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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)): eslint-plugin-react-compiler@19.1.0-rc.2(eslint@10.0.3(jiti@2.6.1)):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
@@ -6406,8 +6371,6 @@ snapshots:
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {} fast-levenshtein@2.0.6: {}
@@ -7248,9 +7211,10 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1: prettier-plugin-toml@2.0.6(prettier@3.8.1):
dependencies: dependencies:
fast-diff: 1.3.0 '@taplo/lib': 0.5.0
prettier: 3.8.1
prettier@3.8.1: {} prettier@3.8.1: {}
@@ -7562,10 +7526,6 @@ snapshots:
react: 19.2.4 react: 19.2.4
use-sync-external-store: 1.6.0(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: {} systemjs@6.15.1: {}
tar@7.5.10: tar@7.5.10:
File diff suppressed because it is too large Load Diff
+26 -29
View File
@@ -1,26 +1,26 @@
import { exec } from "child_process"; import { exec } from 'child_process'
import fs from "fs/promises"; import fs from 'fs/promises'
import path from "path"; import path from 'path'
import { promisify } from "util"; import { promisify } from 'util'
/** /**
* 为Alpha版本重命名版本号 * 为Alpha版本重命名版本号
*/ */
const execPromise = promisify(exec); const execPromise = promisify(exec)
/** /**
* 标准输出HEAD hash * 标准输出HEAD hash
*/ */
async function getLatestCommitHash() { async function getLatestCommitHash() {
try { try {
const { stdout } = await execPromise("git rev-parse HEAD"); const { stdout } = await execPromise('git rev-parse HEAD')
const commitHash = stdout.trim(); const commitHash = stdout.trim()
// 格式化,只截取前7位字符 // 格式化,只截取前7位字符
const formathash = commitHash.substring(0, 7); const formathash = commitHash.substring(0, 7)
console.log(`Found the latest commit hash code: ${commitHash}`); console.log(`Found the latest commit hash code: ${commitHash}`)
return formathash; return formathash
} catch (error) { } 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) { async function updatePackageVersion(newVersion) {
// 获取内容根目录 // 获取内容根目录
const _dirname = process.cwd(); const _dirname = process.cwd()
const packageJsonPath = path.join(_dirname, "package.json"); const packageJsonPath = path.join(_dirname, 'package.json')
try { try {
// 读取文件 // 读取文件
const data = await fs.readFile(packageJsonPath, "utf8"); const data = await fs.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(data); const packageJson = JSON.parse(data)
// 获取键值替换 // 获取键值替换
let result = packageJson.version.replace("alpha", newVersion); let result = packageJson.version.replace('alpha', newVersion)
// 检查当前版本号是否已经包含了 alpha- 后缀 // 检查当前版本号是否已经包含了 alpha- 后缀
if (!packageJson.version.includes(`alpha-`)) { if (!packageJson.version.includes(`alpha-`)) {
// 如果只有 alpha 而没有 alpha-,则替换为 alpha-newVersion // 如果只有 alpha 而没有 alpha-,则替换为 alpha-newVersion
result = packageJson.version.replace("alpha", `alpha-${newVersion}`); result = packageJson.version.replace('alpha', `alpha-${newVersion}`)
} else { } else {
// 如果已经是 alpha-xxx 格式,则更新 xxx 部分 // 如果已经是 alpha-xxx 格式,则更新 xxx 部分
result = packageJson.version.replace( result = packageJson.version.replace(/alpha-[^-]*/, `alpha-${newVersion}`)
/alpha-[^-]*/,
`alpha-${newVersion}`,
);
} }
console.log("[INFO]: Current version is: ", result); console.log('[INFO]: Current version is: ', result)
packageJson.version = result; packageJson.version = result
// 写入版本号 // 写入版本号
await fs.writeFile( await fs.writeFile(
packageJsonPath, packageJsonPath,
JSON.stringify(packageJson, null, 2), JSON.stringify(packageJson, null, 2),
"utf8", 'utf8',
); )
console.log(`[INFO]: Alpha version update to: ${newVersion}`); console.log(`[INFO]: Alpha version update to: ${newVersion}`)
} catch (error) { } catch (error) {
console.error("pnpm run fix-alpha-version ERROR", error); console.error('pnpm run fix-alpha-version ERROR', error)
} }
} }
const newVersion = await getLatestCommitHash(); const newVersion = await getLatestCommitHash()
updatePackageVersion(newVersion).catch(console.error); updatePackageVersion(newVersion).catch(console.error)
+57 -57
View File
@@ -1,98 +1,98 @@
#!/usr/bin/env node #!/usr/bin/env node
import { promises as fs } from "node:fs"; import { promises as fs } from 'node:fs'
import path from "node:path"; import path from 'node:path'
import { fileURLToPath } from "node:url"; import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename)
const ROOT_DIR = path.resolve(__dirname, ".."); const ROOT_DIR = path.resolve(__dirname, '..')
const LOCALE_DIR = path.resolve(ROOT_DIR, "src/locales/en"); const LOCALE_DIR = path.resolve(ROOT_DIR, 'src/locales/en')
const KEY_OUTPUT = path.resolve(ROOT_DIR, "src/types/generated/i18n-keys.ts"); const KEY_OUTPUT = path.resolve(ROOT_DIR, 'src/types/generated/i18n-keys.ts')
const RESOURCE_OUTPUT = path.resolve( const RESOURCE_OUTPUT = path.resolve(
ROOT_DIR, ROOT_DIR,
"src/types/generated/i18n-resources.ts", 'src/types/generated/i18n-resources.ts',
); )
const isPlainObject = (value) => const isPlainObject = (value) =>
typeof value === "object" && value !== null && !Array.isArray(value); typeof value === 'object' && value !== null && !Array.isArray(value)
const flattenKeys = (data, prefix = "") => { const flattenKeys = (data, prefix = '') => {
const keys = []; const keys = []
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
const nextPrefix = prefix ? `${prefix}.${key}` : key; const nextPrefix = prefix ? `${prefix}.${key}` : key
if (isPlainObject(value)) { if (isPlainObject(value)) {
keys.push(...flattenKeys(value, nextPrefix)); keys.push(...flattenKeys(value, nextPrefix))
} else { } else {
keys.push(nextPrefix); keys.push(nextPrefix)
} }
} }
return keys; return keys
}; }
const buildType = (data, indent = 0) => { const buildType = (data, indent = 0) => {
if (!isPlainObject(data)) { if (!isPlainObject(data)) {
return "string"; return 'string'
} }
const entries = Object.entries(data).sort(([a], [b]) => a.localeCompare(b)); const entries = Object.entries(data).sort(([a], [b]) => a.localeCompare(b))
const pad = " ".repeat(indent); const pad = ' '.repeat(indent)
const inner = entries const inner = entries
.map(([key, value]) => { .map(([key, value]) => {
const typeStr = buildType(value, indent + 2); const typeStr = buildType(value, indent + 2)
return `${" ".repeat(indent + 2)}${JSON.stringify(key)}: ${typeStr};`; return `${' '.repeat(indent + 2)}${JSON.stringify(key)}: ${typeStr};`
}) })
.join("\n"); .join('\n')
return entries.length return entries.length
? `{ ? `{
${inner} ${inner}
${pad}}` ${pad}}`
: "{}"; : '{}'
}; }
const loadNamespaceJson = async () => { const loadNamespaceJson = async () => {
const dirents = await fs.readdir(LOCALE_DIR, { withFileTypes: true }); const dirents = await fs.readdir(LOCALE_DIR, { withFileTypes: true })
const namespaces = []; const namespaces = []
for (const dirent of dirents) { for (const dirent of dirents) {
if (!dirent.isFile() || !dirent.name.endsWith(".json")) continue; if (!dirent.isFile() || !dirent.name.endsWith('.json')) continue
const name = dirent.name.replace(/\.json$/, ""); const name = dirent.name.replace(/\.json$/, '')
const filePath = path.join(LOCALE_DIR, dirent.name); const filePath = path.join(LOCALE_DIR, dirent.name)
const raw = await fs.readFile(filePath, "utf8"); const raw = await fs.readFile(filePath, 'utf8')
const json = JSON.parse(raw); const json = JSON.parse(raw)
namespaces.push({ name, json }); namespaces.push({ name, json })
} }
namespaces.sort((a, b) => a.name.localeCompare(b.name)); namespaces.sort((a, b) => a.name.localeCompare(b.name))
return namespaces; return namespaces
}; }
const buildKeysFile = (keys) => { const buildKeysFile = (keys) => {
const arrayLiteral = keys.map((key) => ` "${key}"`).join(",\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`; 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 buildResourcesFile = (namespaces) => {
const namespaceEntries = namespaces const namespaceEntries = namespaces
.map(({ name, json }) => { .map(({ name, json }) => {
const typeStr = buildType(json, 4); const typeStr = buildType(json, 4)
return ` ${JSON.stringify(name)}: ${typeStr};`; 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 main = async () => {
const namespaces = await loadNamespaceJson(); const namespaces = await loadNamespaceJson()
const keys = namespaces.flatMap(({ name, json }) => flattenKeys(json, name)); const keys = namespaces.flatMap(({ name, json }) => flattenKeys(json, name))
const keysContent = buildKeysFile(keys); const keysContent = buildKeysFile(keys)
const resourcesContent = buildResourcesFile(namespaces); const resourcesContent = buildResourcesFile(namespaces)
await fs.mkdir(path.dirname(KEY_OUTPUT), { recursive: true }); await fs.mkdir(path.dirname(KEY_OUTPUT), { recursive: true })
await fs.writeFile(KEY_OUTPUT, keysContent, "utf8"); await fs.writeFile(KEY_OUTPUT, keysContent, 'utf8')
await fs.writeFile(RESOURCE_OUTPUT, resourcesContent, "utf8"); await fs.writeFile(RESOURCE_OUTPUT, resourcesContent, 'utf8')
console.log(`Generated ${keys.length} translation keys.`); console.log(`Generated ${keys.length} translation keys.`)
}; }
main().catch((error) => { main().catch((error) => {
console.error("Failed to generate i18n metadata:", error); console.error('Failed to generate i18n metadata:', error)
process.exitCode = 1; process.exitCode = 1
}); })
+50 -50
View File
@@ -1,104 +1,104 @@
import fs from "fs"; import fs from 'fs'
import fsp from "fs/promises"; import fsp from 'fs/promises'
import { createRequire } from "module"; import { createRequire } from 'module'
import path from "path"; import path from 'path'
import { context, getOctokit } from "@actions/github"; import { context, getOctokit } from '@actions/github'
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 alpha = process.argv.slice(2)[1]; const alpha = process.argv.slice(2)[1]
const ARCH_MAP = { const ARCH_MAP = {
"x86_64-pc-windows-msvc": "x64", 'x86_64-pc-windows-msvc': 'x64',
"i686-pc-windows-msvc": "x86", 'i686-pc-windows-msvc': 'x86',
"aarch64-pc-windows-msvc": "arm64", 'aarch64-pc-windows-msvc': 'arm64',
}; }
const PROCESS_MAP = { const PROCESS_MAP = {
x64: "x64", x64: 'x64',
ia32: "x86", ia32: 'x86',
arm64: "arm64", arm64: 'arm64',
}; }
const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch]; const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch]
/// Script for ci /// Script for ci
/// 打包绿色版/便携版 (only Windows) /// 打包绿色版/便携版 (only Windows)
async function resolvePortable() { async function resolvePortable() {
if (process.platform !== "win32") return; if (process.platform !== 'win32') return
const releaseDir = target const releaseDir = target
? `./src-tauri/target/${target}/release` ? `./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)) { 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 }); await fsp.mkdir(configDir, { recursive: true })
if (!fs.existsSync(path.join(configDir, "PORTABLE"))) { if (!fs.existsSync(path.join(configDir, 'PORTABLE'))) {
await fsp.writeFile(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, 'Clash Verge.exe'))
zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe")); zip.addLocalFile(path.join(releaseDir, 'verge-mihomo.exe'))
zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe")); zip.addLocalFile(path.join(releaseDir, 'verge-mihomo-alpha.exe'))
zip.addLocalFolder(path.join(releaseDir, "resources"), "resources"); zip.addLocalFolder(path.join(releaseDir, 'resources'), 'resources')
zip.addLocalFolder( zip.addLocalFolder(
path.join( path.join(
releaseDir, releaseDir,
`Microsoft.WebView2.FixedVersionRuntime.133.0.3065.92.${arch}`, `Microsoft.WebView2.FixedVersionRuntime.133.0.3065.92.${arch}`,
), ),
`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 require = createRequire(import.meta.url)
const packageJson = require("../package.json"); const packageJson = require('../package.json')
const { version } = packageJson; const { version } = packageJson
const zipFile = `Clash.Verge_${version}_${arch}_fixed_webview2_portable.zip`; const zipFile = `Clash.Verge_${version}_${arch}_fixed_webview2_portable.zip`
zip.writeZip(zipFile); zip.writeZip(zipFile)
console.log("[INFO]: create portable zip successfully"); console.log('[INFO]: create portable zip successfully')
// push release assets // push release assets
if (process.env.GITHUB_TOKEN === undefined) { 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 options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN); const github = getOctokit(process.env.GITHUB_TOKEN)
const tag = alpha ? "alpha" : process.env.TAG_NAME || `v${version}`; const tag = alpha ? 'alpha' : process.env.TAG_NAME || `v${version}`
console.log("[INFO]: upload to ", tag); console.log('[INFO]: upload to ', tag)
const { data: release } = await github.rest.repos.getReleaseByTag({ const { data: release } = await github.rest.repos.getReleaseByTag({
...options, ...options,
tag, tag,
}); })
const assets = release.assets.filter((x) => { const assets = release.assets.filter((x) => {
return x.name === zipFile; return x.name === zipFile
}); })
if (assets.length > 0) { if (assets.length > 0) {
const id = assets[0].id; const id = assets[0].id
await github.rest.repos.deleteReleaseAsset({ await github.rest.repos.deleteReleaseAsset({
...options, ...options,
asset_id: id, asset_id: id,
}); })
} }
console.log(release.name); console.log(release.name)
await github.rest.repos.uploadReleaseAsset({ await github.rest.repos.uploadReleaseAsset({
...options, ...options,
release_id: release.id, release_id: release.id,
name: zipFile, name: zipFile,
data: zip.toBuffer(), data: zip.toBuffer(),
}); })
} }
resolvePortable().catch(console.error); resolvePortable().catch(console.error)
+33 -33
View File
@@ -1,53 +1,53 @@
import fs from "fs"; import fs from 'fs'
import fsp from "fs/promises"; import fsp from 'fs/promises'
import { createRequire } from "module"; import { createRequire } from 'module'
import path from "path"; 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 = { const ARCH_MAP = {
"x86_64-pc-windows-msvc": "x64", 'x86_64-pc-windows-msvc': 'x64',
"aarch64-pc-windows-msvc": "arm64", 'aarch64-pc-windows-msvc': 'arm64',
}; }
const PROCESS_MAP = { const PROCESS_MAP = {
x64: "x64", x64: 'x64',
arm64: "arm64", arm64: 'arm64',
}; }
const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch]; const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch]
/// Script for ci /// Script for ci
/// 打包绿色版/便携版 (only Windows) /// 打包绿色版/便携版 (only Windows)
async function resolvePortable() { async function resolvePortable() {
if (process.platform !== "win32") return; if (process.platform !== 'win32') return
const releaseDir = target const releaseDir = target
? `./src-tauri/target/${target}/release` ? `./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)) { 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 }); await fsp.mkdir(configDir, { recursive: true })
if (!fs.existsSync(path.join(configDir, "PORTABLE"))) { if (!fs.existsSync(path.join(configDir, 'PORTABLE'))) {
await fsp.writeFile(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, 'clash-verge.exe'))
zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe")); zip.addLocalFile(path.join(releaseDir, 'verge-mihomo.exe'))
zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe")); zip.addLocalFile(path.join(releaseDir, 'verge-mihomo-alpha.exe'))
zip.addLocalFolder(path.join(releaseDir, "resources"), "resources"); zip.addLocalFolder(path.join(releaseDir, 'resources'), 'resources')
zip.addLocalFolder(configDir, ".config"); zip.addLocalFolder(configDir, '.config')
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url)
const packageJson = require("../package.json"); const packageJson = require('../package.json')
const { version } = packageJson; const { version } = packageJson
const zipFile = `Clash.Verge_${version}_${arch}_portable.zip`; const zipFile = `Clash.Verge_${version}_${arch}_portable.zip`
zip.writeZip(zipFile); zip.writeZip(zipFile)
console.log("[INFO]: create portable zip successfully"); console.log('[INFO]: create portable zip successfully')
} }
resolvePortable().catch(console.error); resolvePortable().catch(console.error)
+353 -357
View File
File diff suppressed because it is too large Load Diff
+33 -33
View File
@@ -1,66 +1,66 @@
// scripts/publish-version.mjs // scripts/publish-version.mjs
import { spawn } from "child_process"; import { spawn } from 'child_process'
import { existsSync } from "fs"; import { existsSync } from 'fs'
import path from "path"; import path from 'path'
const rootDir = process.cwd(); const rootDir = process.cwd()
const scriptPath = path.join(rootDir, "scripts", "release-version.mjs"); const scriptPath = path.join(rootDir, 'scripts', 'release-version.mjs')
if (!existsSync(scriptPath)) { if (!existsSync(scriptPath)) {
console.error("release-version.mjs not found!"); console.error('release-version.mjs not found!')
process.exit(1); process.exit(1)
} }
const versionArg = process.argv[2]; const versionArg = process.argv[2]
if (!versionArg) { if (!versionArg) {
console.error("Usage: pnpm publish-version <version>"); console.error('Usage: pnpm publish-version <version>')
process.exit(1); process.exit(1)
} }
// 1. 调用 release-version.mjs // 1. 调用 release-version.mjs
const runRelease = () => const runRelease = () =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const child = spawn("node", [scriptPath, versionArg], { stdio: "inherit" }); const child = spawn('node', [scriptPath, versionArg], { stdio: 'inherit' })
child.on("exit", (code) => { child.on('exit', (code) => {
if (code === 0) resolve(); if (code === 0) resolve()
else reject(new Error("release-version failed")); else reject(new Error('release-version failed'))
}); })
}); })
// 2. 判断是否需要打 tag // 2. 判断是否需要打 tag
function isSemver(version) { 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() { async function run() {
await runRelease(); await runRelease()
let tag = null; let tag = null
if (versionArg === "alpha") { if (versionArg === 'alpha') {
// 读取 package.json 里的主版本 // 读取 package.json 里的主版本
const pkg = await import(path.join(rootDir, "package.json"), { const pkg = await import(path.join(rootDir, 'package.json'), {
assert: { type: "json" }, assert: { type: 'json' },
}); })
tag = `v${pkg.default.version}-alpha`; tag = `v${pkg.default.version}-alpha`
} else if (isSemver(versionArg)) { } else if (isSemver(versionArg)) {
// 1.2.3 或 v1.2.3 // 1.2.3 或 v1.2.3
tag = versionArg.startsWith("v") ? versionArg : `v${versionArg}`; tag = versionArg.startsWith('v') ? versionArg : `v${versionArg}`
} }
if (tag) { if (tag) {
// 打 tag 并推送 // 打 tag 并推送
const { execSync } = await import("child_process"); const { execSync } = await import('child_process')
try { try {
execSync(`git tag ${tag}`, { stdio: "inherit" }); execSync(`git tag ${tag}`, { stdio: 'inherit' })
execSync(`git push origin ${tag}`, { stdio: "inherit" }); execSync(`git push origin ${tag}`, { stdio: 'inherit' })
console.log(`[INFO]: Git tag ${tag} created and pushed.`); console.log(`[INFO]: Git tag ${tag} created and pushed.`)
} catch { } catch {
console.error(`[ERROR]: Failed to create or push git tag: ${tag}`); console.error(`[ERROR]: Failed to create or push git tag: ${tag}`)
process.exit(1); process.exit(1)
} }
} else { } else {
console.log("[INFO]: No git tag created for this version."); console.log('[INFO]: No git tag created for this version.')
} }
} }
run(); run()
+115 -115
View File
@@ -29,11 +29,11 @@
* Errors are logged and the process exits with code 1 on failure. * Errors are logged and the process exits with code 1 on failure.
*/ */
import { execSync } from "child_process"; import { execSync } from 'child_process'
import fs from "fs/promises"; import fs from 'fs/promises'
import path from "path"; import path from 'path'
import { program } from "commander"; import { program } from 'commander'
/** /**
* 获取当前 git 短 commit hash * 获取当前 git 短 commit hash
@@ -41,10 +41,10 @@ import { program } from "commander";
*/ */
function getGitShortCommit() { function getGitShortCommit() {
try { try {
return execSync("git rev-parse --short HEAD").toString().trim(); return execSync('git rev-parse --short HEAD').toString().trim()
} catch { } catch {
console.warn("[WARN]: Failed to get git short commit, fallback to 'nogit'"); console.warn("[WARN]: Failed to get git short commit, fallback to 'nogit'")
return "nogit"; return 'nogit'
} }
} }
@@ -55,21 +55,21 @@ function getGitShortCommit() {
function getLatestTauriCommit() { function getLatestTauriCommit() {
try { try {
const fullHash = execSync( const fullHash = execSync(
"bash ./scripts-workflow/get_latest_tauri_commit.bash", 'bash ./scripts-workflow/get_latest_tauri_commit.bash',
) )
.toString() .toString()
.trim(); .trim()
const shortHash = execSync(`git rev-parse --short ${fullHash}`) const shortHash = execSync(`git rev-parse --short ${fullHash}`)
.toString() .toString()
.trim(); .trim()
console.log(`[INFO]: Latest Tauri-related commit: ${shortHash}`); console.log(`[INFO]: Latest Tauri-related commit: ${shortHash}`)
return shortHash; return shortHash
} catch (error) { } catch (error) {
console.warn( console.warn(
"[WARN]: Failed to get latest Tauri commit, fallback to current git short commit", '[WARN]: Failed to get latest Tauri commit, fallback to current git short commit',
); )
console.warn(`[WARN]: Error details: ${error.message}`); console.warn(`[WARN]: Error details: ${error.message}`)
return getGitShortCommit(); return getGitShortCommit()
} }
} }
@@ -81,25 +81,25 @@ function getLatestTauriCommit() {
* @returns {string} * @returns {string}
*/ */
function generateShortTimestamp(withCommit = false, useTauriCommit = false) { function generateShortTimestamp(withCommit = false, useTauriCommit = false) {
const now = new Date(); const now = new Date()
const formatter = new Intl.DateTimeFormat("en-CA", { const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: "Asia/Shanghai", timeZone: 'Asia/Shanghai',
month: "2-digit", month: '2-digit',
day: "2-digit", day: '2-digit',
}); })
const parts = formatter.formatToParts(now); const parts = formatter.formatToParts(now)
const month = parts.find((part) => part.type === "month").value; const month = parts.find((part) => part.type === 'month').value
const day = parts.find((part) => part.type === "day").value; const day = parts.find((part) => part.type === 'day').value
if (withCommit) { if (withCommit) {
const gitShort = useTauriCommit const gitShort = useTauriCommit
? getLatestTauriCommit() ? getLatestTauriCommit()
: getGitShortCommit(); : getGitShortCommit()
return `${month}${day}.${gitShort}`; return `${month}${day}.${gitShort}`
} }
return `${month}${day}`; return `${month}${day}`
} }
/** /**
@@ -110,7 +110,7 @@ function generateShortTimestamp(withCommit = false, useTauriCommit = false) {
function isValidVersion(version) { function isValidVersion(version) {
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test( return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test(
version, version,
); )
} }
/** /**
@@ -119,7 +119,7 @@ function isValidVersion(version) {
* @returns {string} * @returns {string}
*/ */
function normalizeVersion(version) { 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} * @returns {string}
*/ */
function getBaseVersion(version) { function getBaseVersion(version) {
let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, ""); let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, '')
base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, ""); base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, '')
return base; return base
} }
/** /**
@@ -138,30 +138,30 @@ function getBaseVersion(version) {
* @param {string} newVersion * @param {string} newVersion
*/ */
async function updatePackageVersion(newVersion) { async function updatePackageVersion(newVersion) {
const _dirname = process.cwd(); const _dirname = process.cwd()
const packageJsonPath = path.join(_dirname, "package.json"); const packageJsonPath = path.join(_dirname, 'package.json')
try { try {
const data = await fs.readFile(packageJsonPath, "utf8"); const data = await fs.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(data); const packageJson = JSON.parse(data)
console.log( console.log(
"[INFO]: Current package.json version is: ", '[INFO]: Current package.json version is: ',
packageJson.version, packageJson.version,
); )
packageJson.version = newVersion.startsWith("v") packageJson.version = newVersion.startsWith('v')
? newVersion.slice(1) ? newVersion.slice(1)
: newVersion; : newVersion
await fs.writeFile( await fs.writeFile(
packageJsonPath, packageJsonPath,
JSON.stringify(packageJson, null, 2), JSON.stringify(packageJson, null, 2),
"utf8", 'utf8',
); )
console.log( console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`, `[INFO]: package.json version updated to: ${packageJson.version}`,
); )
} catch (error) { } catch (error) {
console.error("Error updating package.json version:", error); console.error('Error updating package.json version:', error)
throw error; throw error
} }
} }
@@ -170,30 +170,30 @@ async function updatePackageVersion(newVersion) {
* @param {string} newVersion * @param {string} newVersion
*/ */
async function updateCargoVersion(newVersion) { async function updateCargoVersion(newVersion) {
const _dirname = process.cwd(); const _dirname = process.cwd()
const cargoTomlPath = path.join(_dirname, "src-tauri", "Cargo.toml"); const cargoTomlPath = path.join(_dirname, 'src-tauri', 'Cargo.toml')
try { try {
const data = await fs.readFile(cargoTomlPath, "utf8"); const data = await fs.readFile(cargoTomlPath, 'utf8')
const lines = data.split("\n"); const lines = data.split('\n')
const versionWithoutV = newVersion.startsWith("v") const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1) ? newVersion.slice(1)
: newVersion; : newVersion
const updatedLines = lines.map((line) => { const updatedLines = lines.map((line) => {
if (line.trim().startsWith("version =")) { if (line.trim().startsWith('version =')) {
return line.replace( return line.replace(
/version\s*=\s*"[^"]+"/, /version\s*=\s*"[^"]+"/,
`version = "${versionWithoutV}"`, `version = "${versionWithoutV}"`,
); )
} }
return line; return line
}); })
await fs.writeFile(cargoTomlPath, updatedLines.join("\n"), "utf8"); await fs.writeFile(cargoTomlPath, updatedLines.join('\n'), 'utf8')
console.log(`[INFO]: Cargo.toml version updated to: ${versionWithoutV}`); console.log(`[INFO]: Cargo.toml version updated to: ${versionWithoutV}`)
} catch (error) { } catch (error) {
console.error("Error updating Cargo.toml version:", error); console.error('Error updating Cargo.toml version:', error)
throw error; throw error
} }
} }
@@ -202,34 +202,34 @@ async function updateCargoVersion(newVersion) {
* @param {string} newVersion * @param {string} newVersion
*/ */
async function updateTauriConfigVersion(newVersion) { async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd(); const _dirname = process.cwd()
const tauriConfigPath = path.join(_dirname, "src-tauri", "tauri.conf.json"); const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
try { try {
const data = await fs.readFile(tauriConfigPath, "utf8"); const data = await fs.readFile(tauriConfigPath, 'utf8')
const tauriConfig = JSON.parse(data); const tauriConfig = JSON.parse(data)
const versionWithoutV = newVersion.startsWith("v") const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1) ? newVersion.slice(1)
: newVersion; : newVersion
console.log( console.log(
"[INFO]: Current tauri.conf.json version is: ", '[INFO]: Current tauri.conf.json version is: ',
tauriConfig.version, tauriConfig.version,
); )
// 使用完整版本信息,包含build metadata // 使用完整版本信息,包含build metadata
tauriConfig.version = versionWithoutV; tauriConfig.version = versionWithoutV
await fs.writeFile( await fs.writeFile(
tauriConfigPath, tauriConfigPath,
JSON.stringify(tauriConfig, null, 2), JSON.stringify(tauriConfig, null, 2),
"utf8", 'utf8',
); )
console.log( console.log(
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`, `[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
); )
} catch (error) { } catch (error) {
console.error("Error updating tauri.conf.json version:", error); console.error('Error updating tauri.conf.json version:', error)
throw error; throw error
} }
} }
@@ -237,15 +237,15 @@ async function updateTauriConfigVersion(newVersion) {
* 获取当前版本号 * 获取当前版本号
*/ */
async function getCurrentVersion() { async function getCurrentVersion() {
const _dirname = process.cwd(); const _dirname = process.cwd()
const packageJsonPath = path.join(_dirname, "package.json"); const packageJsonPath = path.join(_dirname, 'package.json')
try { try {
const data = await fs.readFile(packageJsonPath, "utf8"); const data = await fs.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(data); const packageJson = JSON.parse(data)
return packageJson.version; return packageJson.version
} catch (error) { } catch (error) {
console.error("Error getting current version:", error); console.error('Error getting current version:', error)
throw error; throw error
} }
} }
@@ -254,62 +254,62 @@ async function getCurrentVersion() {
*/ */
async function main(versionArg) { async function main(versionArg) {
if (!versionArg) { if (!versionArg) {
console.error("Error: Version argument is required"); console.error('Error: Version argument is required')
process.exit(1); process.exit(1)
} }
try { try {
let newVersion; let newVersion
const validTags = [ const validTags = [
"alpha", 'alpha',
"beta", 'beta',
"rc", 'rc',
"autobuild", 'autobuild',
"autobuild-latest", 'autobuild-latest',
"deploytest", 'deploytest',
]; ]
if (validTags.includes(versionArg.toLowerCase())) { if (validTags.includes(versionArg.toLowerCase())) {
const currentVersion = await getCurrentVersion(); const currentVersion = await getCurrentVersion()
const baseVersion = getBaseVersion(currentVersion); const baseVersion = getBaseVersion(currentVersion)
if (versionArg.toLowerCase() === "autobuild") { if (versionArg.toLowerCase() === 'autobuild') {
// 格式: 2.3.0+autobuild.1004.cc39b27 // 格式: 2.3.0+autobuild.1004.cc39b27
// 使用 Tauri 相关的最新 commit hash // 使用 Tauri 相关的最新 commit hash
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}`; newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}`
} else if (versionArg.toLowerCase() === "autobuild-latest") { } else if (versionArg.toLowerCase() === 'autobuild-latest') {
// 格式: 2.3.0+autobuild.1004.a1b2c3d (使用最新 Tauri 提交) // 格式: 2.3.0+autobuild.1004.a1b2c3d (使用最新 Tauri 提交)
const latestTauriCommit = getLatestTauriCommit(); const latestTauriCommit = getLatestTauriCommit()
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}`; newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}`
} else if (versionArg.toLowerCase() === "deploytest") { } else if (versionArg.toLowerCase() === 'deploytest') {
// 格式: 2.3.0+deploytest.1004.cc39b27 // 格式: 2.3.0+deploytest.1004.cc39b27
// 使用 Tauri 相关的最新 commit hash // 使用 Tauri 相关的最新 commit hash
newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}`; newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}`
} else { } else {
newVersion = `${baseVersion}-${versionArg.toLowerCase()}`; newVersion = `${baseVersion}-${versionArg.toLowerCase()}`
} }
} else { } else {
if (!isValidVersion(versionArg)) { if (!isValidVersion(versionArg)) {
console.error("Error: Invalid version format"); console.error('Error: Invalid version format')
process.exit(1); process.exit(1)
} }
newVersion = normalizeVersion(versionArg); newVersion = normalizeVersion(versionArg)
} }
console.log(`[INFO]: Updating versions to: ${newVersion}`); console.log(`[INFO]: Updating versions to: ${newVersion}`)
await updatePackageVersion(newVersion); await updatePackageVersion(newVersion)
await updateCargoVersion(newVersion); await updateCargoVersion(newVersion)
await updateTauriConfigVersion(newVersion); await updateTauriConfigVersion(newVersion)
console.log("[SUCCESS]: All version updates completed successfully!"); console.log('[SUCCESS]: All version updates completed successfully!')
} catch (error) { } catch (error) {
console.error("[ERROR]: Failed to update versions:", error); console.error('[ERROR]: Failed to update versions:', error)
process.exit(1); process.exit(1)
} }
} }
program program
.name("pnpm release-version") .name('pnpm release-version')
.description("Update project version numbers") .description('Update project version numbers')
.argument("<version>", "version tag or full version") .argument('<version>', 'version tag or full version')
.action(main) .action(main)
.parse(process.argv); .parse(process.argv)
+54 -57
View File
@@ -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_RELEASE = '@clash_verge_re' // 正式发布频道
const CHAT_ID_TEST = "@vergetest"; // 测试频道 const CHAT_ID_TEST = '@vergetest' // 测试频道
async function sendTelegramNotification() { async function sendTelegramNotification() {
if (!process.env.TELEGRAM_BOT_TOKEN) { if (!process.env.TELEGRAM_BOT_TOKEN) {
throw new Error("TELEGRAM_BOT_TOKEN is required"); throw new Error('TELEGRAM_BOT_TOKEN is required')
} }
const version = const version =
process.env.VERSION || process.env.VERSION ||
(() => { (() => {
const pkg = readFileSync("package.json", "utf-8"); const pkg = readFileSync('package.json', 'utf-8')
return JSON.parse(pkg).version; return JSON.parse(pkg).version
})(); })()
const downloadUrl = const downloadUrl =
process.env.DOWNLOAD_URL || 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 = const isAutobuild =
process.env.BUILD_TYPE === "autobuild" || version.includes("autobuild"); process.env.BUILD_TYPE === 'autobuild' || version.includes('autobuild')
const chatId = isAutobuild ? CHAT_ID_TEST : CHAT_ID_RELEASE; const chatId = isAutobuild ? CHAT_ID_TEST : CHAT_ID_RELEASE
const buildType = isAutobuild ? "滚动更新版" : "正式版"; const buildType = isAutobuild ? '滚动更新版' : '正式版'
log_info(`Preparing Telegram notification for ${buildType} ${version}`); log_info(`Preparing Telegram notification for ${buildType} ${version}`)
log_info(`Target channel: ${chatId}`); log_info(`Target channel: ${chatId}`)
log_info(`Download URL: ${downloadUrl}`); log_info(`Download URL: ${downloadUrl}`)
// 读取发布说明和下载地址 // 读取发布说明和下载地址
let releaseContent = ""; let releaseContent = ''
try { try {
releaseContent = readFileSync("release.txt", "utf-8"); releaseContent = readFileSync('release.txt', 'utf-8')
log_info("成功读取 release.txt 文件"); log_info('成功读取 release.txt 文件')
} catch (error) { } catch (error) {
log_error("无法读取 release.txt,使用默认发布说明", error); log_error('无法读取 release.txt,使用默认发布说明', error)
releaseContent = "更多新功能现已支持,详细更新日志请查看发布页面。"; releaseContent = '更多新功能现已支持,详细更新日志请查看发布页面。'
} }
// Markdown 转换为 HTML // Markdown 转换为 HTML
function convertMarkdownToTelegramHTML(content) { function convertMarkdownToTelegramHTML(content) {
return content return content
.split("\n") .split('\n')
.map((line) => { .map((line) => {
if (line.trim().length === 0) { if (line.trim().length === 0) {
return ""; return ''
} else if (line.startsWith("## ")) { } else if (line.startsWith('## ')) {
return `<b>${line.replace("## ", "")}</b>`; return `<b>${line.replace('## ', '')}</b>`
} else if (line.startsWith("### ")) { } else if (line.startsWith('### ')) {
return `<b>${line.replace("### ", "")}</b>`; return `<b>${line.replace('### ', '')}</b>`
} else if (line.startsWith("#### ")) { } else if (line.startsWith('#### ')) {
return `<b>${line.replace("#### ", "")}</b>`; return `<b>${line.replace('#### ', '')}</b>`
} else { } else {
let processedLine = line.replace( let processedLine = line.replace(
/\[([^\]]+)\]\(([^)]+)\)/g, /\[([^\]]+)\]\(([^)]+)\)/g,
(match, text, url) => { (match, text, url) => {
const encodedUrl = encodeURI(url); const encodedUrl = encodeURI(url)
return `<a href="${encodedUrl}">${text}</a>`; return `<a href="${encodedUrl}">${text}</a>`
}, },
); )
processedLine = processedLine.replace( processedLine = processedLine.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
/\*\*([^*]+)\*\*/g, return processedLine
"<b>$1</b>",
);
return processedLine;
} }
}) })
.join("\n"); .join('\n')
} }
function normalizeDetailsTags(content) { function normalizeDetailsTags(content) {
return content return content
.replace( .replace(
/<summary>\s*<strong>\s*(.*?)\s*<\/strong>\s*<\/summary>/g, /<summary>\s*<strong>\s*(.*?)\s*<\/strong>\s*<\/summary>/g,
"\n<b>$1</b>\n", '\n<b>$1</b>\n',
) )
.replace(/<summary>\s*(.*?)\s*<\/summary>/g, "\n<b>$1</b>\n") .replace(/<summary>\s*(.*?)\s*<\/summary>/g, '\n<b>$1</b>\n')
.replace(/<\/?details>/g, "") .replace(/<\/?details>/g, '')
.replace(/<\/?strong>/g, (m) => (m === "</strong>" ? "</b>" : "<b>")) .replace(/<\/?strong>/g, (m) => (m === '</strong>' ? '</b>' : '<b>'))
.replace(/<br\s*\/?>/g, "\n"); .replace(/<br\s*\/?>/g, '\n')
} }
releaseContent = normalizeDetailsTags(releaseContent); releaseContent = normalizeDetailsTags(releaseContent)
const formattedContent = convertMarkdownToTelegramHTML(releaseContent); const formattedContent = convertMarkdownToTelegramHTML(releaseContent)
const releaseTitle = isAutobuild ? "滚动更新版发布" : "正式发布"; const releaseTitle = isAutobuild ? '滚动更新版发布' : '正式发布'
const encodedVersion = encodeURIComponent(version); const encodedVersion = encodeURIComponent(version)
const releaseTag = isAutobuild ? "autobuild" : `v${version}`; const releaseTag = isAutobuild ? 'autobuild' : `v${version}`
const content = `<b>🎉 <a href="https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/${releaseTag}">Clash Verge Rev v${version}</a> ${releaseTitle}</b>\n\n${formattedContent}`; const content = `<b>🎉 <a href="https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/${releaseTag}">Clash Verge Rev v${version}</a> ${releaseTitle}</b>\n\n${formattedContent}`
// 发送到 Telegram // 发送到 Telegram
try { try {
@@ -105,22 +102,22 @@ async function sendTelegramNotification() {
url: `https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${encodedVersion}`, url: `https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${encodedVersion}`,
prefer_large_media: true, prefer_large_media: true,
}, },
parse_mode: "HTML", parse_mode: 'HTML',
}, },
); )
log_success(`✅ Telegram 通知发送成功到 ${chatId}`); log_success(`✅ Telegram 通知发送成功到 ${chatId}`)
} catch (error) { } catch (error) {
log_error( log_error(
`❌ Telegram 通知发送失败到 ${chatId}:`, `❌ Telegram 通知发送失败到 ${chatId}:`,
error.response?.data || error.message, error.response?.data || error.message,
error, error,
); )
process.exit(1); process.exit(1)
} }
} }
// 执行函数 // 执行函数
sendTelegramNotification().catch((error) => { sendTelegramNotification().catch((error) => {
log_error("脚本执行失败:", error); log_error('脚本执行失败:', error)
process.exit(1); process.exit(1)
}); })
+38 -38
View File
@@ -1,84 +1,84 @@
import fs from "fs"; import fs from 'fs'
import fsp from "fs/promises"; import fsp from 'fs/promises'
import path from "path"; import path from 'path'
const UPDATE_LOG = "Changelog.md"; const UPDATE_LOG = 'Changelog.md'
// parse the Changelog.md // parse the Changelog.md
export async function resolveUpdateLog(tag) { export async function resolveUpdateLog(tag) {
const cwd = process.cwd(); const cwd = process.cwd()
const reTitle = /^## v[\d.]+/; const reTitle = /^## v[\d.]+/
const reEnd = /^---/; const reEnd = /^---/
const file = path.join(cwd, UPDATE_LOG); const file = path.join(cwd, UPDATE_LOG)
if (!fs.existsSync(file)) { 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 = {}; const map = {}
let p = ""; let p = ''
data.split("\n").forEach((line) => { data.split('\n').forEach((line) => {
if (reTitle.test(line)) { if (reTitle.test(line)) {
p = line.slice(3).trim(); p = line.slice(3).trim()
if (!map[p]) { if (!map[p]) {
map[p] = []; map[p] = []
} else { } else {
throw new Error(`Tag ${p} dup`); throw new Error(`Tag ${p} dup`)
} }
} else if (reEnd.test(line)) { } else if (reEnd.test(line)) {
p = ""; p = ''
} else if (p) { } else if (p) {
map[p].push(line); map[p].push(line)
} }
}); })
if (!map[tag]) { 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() { export async function resolveUpdateLogDefault() {
const cwd = process.cwd(); const cwd = process.cwd()
const file = path.join(cwd, UPDATE_LOG); const file = path.join(cwd, UPDATE_LOG)
if (!fs.existsSync(file)) { 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 reTitle = /^## v[\d.]+/
const reEnd = /^---/; const reEnd = /^---/
let isCapturing = false; let isCapturing = false
const content = []; const content = []
let firstTag = ""; let firstTag = ''
for (const line of data.split("\n")) { for (const line of data.split('\n')) {
if (reTitle.test(line) && !isCapturing) { if (reTitle.test(line) && !isCapturing) {
isCapturing = true; isCapturing = true
firstTag = line.slice(3).trim(); firstTag = line.slice(3).trim()
continue; continue
} }
if (isCapturing) { if (isCapturing) {
if (reEnd.test(line)) { if (reEnd.test(line)) {
break; break
} }
content.push(line); content.push(line)
} }
} }
if (!firstTag) { 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()
} }
+57 -57
View File
@@ -1,117 +1,117 @@
import { context, getOctokit } from "@actions/github"; import { context, getOctokit } from '@actions/github'
import fetch from "node-fetch"; import fetch from 'node-fetch'
import { resolveUpdateLog } from "./updatelog.mjs"; import { resolveUpdateLog } from './updatelog.mjs'
const UPDATE_TAG_NAME = "updater"; const UPDATE_TAG_NAME = 'updater'
const UPDATE_JSON_FILE = "update-fixed-webview2.json"; const UPDATE_JSON_FILE = 'update-fixed-webview2.json'
const UPDATE_JSON_PROXY = "update-fixed-webview2-proxy.json"; const UPDATE_JSON_PROXY = 'update-fixed-webview2-proxy.json'
/// generate update.json /// generate update.json
/// upload to update tag's release asset /// upload to update tag's release asset
async function resolveUpdater() { async function resolveUpdater() {
if (process.env.GITHUB_TOKEN === undefined) { 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 options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN); const github = getOctokit(process.env.GITHUB_TOKEN)
const { data: tags } = await github.rest.repos.listTags({ const { data: tags } = await github.rest.repos.listTags({
...options, ...options,
per_page: 10, per_page: 10,
page: 1, page: 1,
}); })
// get the latest publish tag // 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(tag)
console.log(); console.log()
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({ const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
...options, ...options,
tag: tag.name, tag: tag.name,
}); })
const updateData = { const updateData = {
name: tag.name, name: tag.name,
notes: await resolveUpdateLog(tag.name), // use Changelog.md notes: await resolveUpdateLog(tag.name), // use Changelog.md
pub_date: new Date().toISOString(), pub_date: new Date().toISOString(),
platforms: { platforms: {
"windows-x86_64": { signature: "", url: "" }, 'windows-x86_64': { signature: '', url: '' },
"windows-aarch64": { signature: "", url: "" }, 'windows-aarch64': { signature: '', url: '' },
"windows-x86": { signature: "", url: "" }, 'windows-x86': { signature: '', url: '' },
"windows-i686": { signature: "", url: "" }, 'windows-i686': { signature: '', url: '' },
}, },
}; }
const promises = latestRelease.assets.map(async (asset) => { const promises = latestRelease.assets.map(async (asset) => {
const { name, browser_download_url } = asset; const { name, browser_download_url } = asset
// win64 url // win64 url
if (name.endsWith("x64_fixed_webview2-setup.exe")) { if (name.endsWith('x64_fixed_webview2-setup.exe')) {
updateData.platforms["windows-x86_64"].url = browser_download_url; updateData.platforms['windows-x86_64'].url = browser_download_url
} }
// win64 signature // win64 signature
if (name.endsWith("x64_fixed_webview2-setup.exe.sig")) { if (name.endsWith('x64_fixed_webview2-setup.exe.sig')) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url)
updateData.platforms["windows-x86_64"].signature = sig; updateData.platforms['windows-x86_64'].signature = sig
} }
// win32 url // win32 url
if (name.endsWith("x86_fixed_webview2-setup.exe")) { if (name.endsWith('x86_fixed_webview2-setup.exe')) {
updateData.platforms["windows-x86"].url = browser_download_url; updateData.platforms['windows-x86'].url = browser_download_url
updateData.platforms["windows-i686"].url = browser_download_url; updateData.platforms['windows-i686'].url = browser_download_url
} }
// win32 signature // win32 signature
if (name.endsWith("x86_fixed_webview2-setup.exe.sig")) { if (name.endsWith('x86_fixed_webview2-setup.exe.sig')) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url)
updateData.platforms["windows-x86"].signature = sig; updateData.platforms['windows-x86'].signature = sig
updateData.platforms["windows-i686"].signature = sig; updateData.platforms['windows-i686'].signature = sig
} }
// win arm url // win arm url
if (name.endsWith("arm64_fixed_webview2-setup.exe")) { if (name.endsWith('arm64_fixed_webview2-setup.exe')) {
updateData.platforms["windows-aarch64"].url = browser_download_url; updateData.platforms['windows-aarch64'].url = browser_download_url
} }
// win arm signature // win arm signature
if (name.endsWith("arm64_fixed_webview2-setup.exe.sig")) { if (name.endsWith('arm64_fixed_webview2-setup.exe.sig')) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url)
updateData.platforms["windows-aarch64"].signature = sig; updateData.platforms['windows-aarch64'].signature = sig
} }
}); })
await Promise.allSettled(promises); await Promise.allSettled(promises)
console.log(updateData); console.log(updateData)
// maybe should test the signature as well // maybe should test the signature as well
// delete the null field // delete the null field
Object.entries(updateData.platforms).forEach(([key, value]) => { Object.entries(updateData.platforms).forEach(([key, value]) => {
if (!value.url) { if (!value.url) {
console.log(`[Error]: failed to parse release for "${key}"`); console.log(`[Error]: failed to parse release for "${key}"`)
delete updateData.platforms[key]; delete updateData.platforms[key]
} }
}); })
// 生成一个代理github的更新文件 // 生成一个代理github的更新文件
// 使用 https://hub.fastgit.xyz/ 做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]) => { Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
if (value.url) { if (value.url) {
updateDataNew.platforms[key].url = updateDataNew.platforms[key].url =
"https://download.clashverge.dev/" + value.url; 'https://download.clashverge.dev/' + value.url
} else { } else {
console.log(`[Error]: updateDataNew.platforms.${key} is null`); console.log(`[Error]: updateDataNew.platforms.${key} is null`)
} }
}); })
// update the update.json // update the update.json
const { data: updateRelease } = await github.rest.repos.getReleaseByTag({ const { data: updateRelease } = await github.rest.repos.getReleaseByTag({
...options, ...options,
tag: UPDATE_TAG_NAME, tag: UPDATE_TAG_NAME,
}); })
// delete the old assets // delete the old assets
for (const asset of updateRelease.assets) { for (const asset of updateRelease.assets) {
@@ -119,13 +119,13 @@ async function resolveUpdater() {
await github.rest.repos.deleteReleaseAsset({ await github.rest.repos.deleteReleaseAsset({
...options, ...options,
asset_id: asset.id, asset_id: asset.id,
}); })
} }
if (asset.name === UPDATE_JSON_PROXY) { if (asset.name === UPDATE_JSON_PROXY) {
await github.rest.repos await github.rest.repos
.deleteReleaseAsset({ ...options, asset_id: asset.id }) .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, release_id: updateRelease.id,
name: UPDATE_JSON_FILE, name: UPDATE_JSON_FILE,
data: JSON.stringify(updateData, null, 2), data: JSON.stringify(updateData, null, 2),
}); })
await github.rest.repos.uploadReleaseAsset({ await github.rest.repos.uploadReleaseAsset({
...options, ...options,
release_id: updateRelease.id, release_id: updateRelease.id,
name: UPDATE_JSON_PROXY, name: UPDATE_JSON_PROXY,
data: JSON.stringify(updateDataNew, null, 2), data: JSON.stringify(updateDataNew, null, 2),
}); })
} }
// get the signature file content // get the signature file content
async function getSignature(url) { async function getSignature(url) {
const response = await fetch(url, { const response = await fetch(url, {
method: "GET", method: 'GET',
headers: { "Content-Type": "application/octet-stream" }, headers: { 'Content-Type': 'application/octet-stream' },
}); })
return response.text(); return response.text()
} }
resolveUpdater().catch(console.error); resolveUpdater().catch(console.error)
+140 -143
View File
@@ -1,263 +1,263 @@
import { getOctokit, context } from "@actions/github"; import { getOctokit, context } from '@actions/github'
import fetch from "node-fetch"; import fetch from 'node-fetch'
import { resolveUpdateLog, resolveUpdateLogDefault } from "./updatelog.mjs"; import { resolveUpdateLog, resolveUpdateLogDefault } from './updatelog.mjs'
// Add stable update JSON filenames // Add stable update JSON filenames
const UPDATE_TAG_NAME = "updater"; const UPDATE_TAG_NAME = 'updater'
const UPDATE_JSON_FILE = "update.json"; const UPDATE_JSON_FILE = 'update.json'
const UPDATE_JSON_PROXY = "update-proxy.json"; const UPDATE_JSON_PROXY = 'update-proxy.json'
// Add alpha update JSON filenames // Add alpha update JSON filenames
const ALPHA_TAG_NAME = "updater-alpha"; const ALPHA_TAG_NAME = 'updater-alpha'
const ALPHA_UPDATE_JSON_FILE = "update.json"; const ALPHA_UPDATE_JSON_FILE = 'update.json'
const ALPHA_UPDATE_JSON_PROXY = "update-proxy.json"; const ALPHA_UPDATE_JSON_PROXY = 'update-proxy.json'
/// generate update.json /// generate update.json
/// upload to update tag's release asset /// upload to update tag's release asset
async function resolveUpdater() { async function resolveUpdater() {
if (process.env.GITHUB_TOKEN === undefined) { 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 options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN); const github = getOctokit(process.env.GITHUB_TOKEN)
// Fetch all tags using pagination // Fetch all tags using pagination
let allTags = []; let allTags = []
let page = 1; let page = 1
const perPage = 100; const perPage = 100
while (true) { while (true) {
const { data: pageTags } = await github.rest.repos.listTags({ const { data: pageTags } = await github.rest.repos.listTags({
...options, ...options,
per_page: perPage, per_page: perPage,
page: page, page: page,
}); })
allTags = allTags.concat(pageTags); allTags = allTags.concat(pageTags)
// Break if we received fewer tags than requested (last page) // Break if we received fewer tags than requested (last page)
if (pageTags.length < perPage) { if (pageTags.length < perPage) {
break; break
} }
page++; page++
} }
const tags = allTags; const tags = allTags
console.log(`Retrieved ${tags.length} tags in total`); console.log(`Retrieved ${tags.length} tags in total`)
// More flexible tag detection with regex patterns // 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 = /^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 // Get the latest stable tag and pre-release tag
const stableTag = tags.find((t) => stableTagRegex.test(t.name)); const stableTag = tags.find((t) => stableTagRegex.test(t.name))
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name)); const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
console.log("All tags:", tags.map((t) => t.name).join(", ")); console.log('All tags:', tags.map((t) => t.name).join(', '))
console.log("Stable tag:", stableTag ? stableTag.name : "None found"); console.log('Stable tag:', stableTag ? stableTag.name : 'None found')
console.log( console.log(
"Pre-release tag:", 'Pre-release tag:',
preReleaseTag ? preReleaseTag.name : "None found", preReleaseTag ? preReleaseTag.name : 'None found',
); )
console.log(); console.log()
// Process stable release // Process stable release
if (stableTag) { if (stableTag) {
await processRelease(github, options, stableTag, false); await processRelease(github, options, stableTag, false)
} }
// Process pre-release if found // Process pre-release if found
if (preReleaseTag) { if (preReleaseTag) {
await processRelease(github, options, preReleaseTag, true); await processRelease(github, options, preReleaseTag, true)
} }
} }
// Process a release (stable or alpha) and generate update files // Process a release (stable or alpha) and generate update files
async function processRelease(github, options, tag, isAlpha) { async function processRelease(github, options, tag, isAlpha) {
if (!tag) return; if (!tag) return
try { try {
const { data: release } = await github.rest.repos.getReleaseByTag({ const { data: release } = await github.rest.repos.getReleaseByTag({
...options, ...options,
tag: tag.name, tag: tag.name,
}); })
const updateData = { const updateData = {
name: tag.name, name: tag.name,
notes: await resolveUpdateLog(tag.name).catch(() => notes: await resolveUpdateLog(tag.name).catch(() =>
resolveUpdateLogDefault().catch(() => "No changelog available"), resolveUpdateLogDefault().catch(() => 'No changelog available'),
), ),
pub_date: new Date().toISOString(), pub_date: new Date().toISOString(),
platforms: { platforms: {
win64: { signature: "", url: "" }, // compatible with older formats win64: { signature: '', url: '' }, // compatible with older formats
linux: { signature: "", url: "" }, // compatible with older formats linux: { signature: '', url: '' }, // compatible with older formats
darwin: { signature: "", url: "" }, // compatible with older formats darwin: { signature: '', url: '' }, // compatible with older formats
"darwin-aarch64": { signature: "", url: "" }, 'darwin-aarch64': { signature: '', url: '' },
"darwin-intel": { signature: "", url: "" }, 'darwin-intel': { signature: '', url: '' },
"darwin-x86_64": { signature: "", url: "" }, 'darwin-x86_64': { signature: '', url: '' },
"linux-x86_64": { signature: "", url: "" }, 'linux-x86_64': { signature: '', url: '' },
"linux-x86": { signature: "", url: "" }, 'linux-x86': { signature: '', url: '' },
"linux-i686": { signature: "", url: "" }, 'linux-i686': { signature: '', url: '' },
"linux-aarch64": { signature: "", url: "" }, 'linux-aarch64': { signature: '', url: '' },
"linux-armv7": { signature: "", url: "" }, 'linux-armv7': { signature: '', url: '' },
"windows-x86_64": { signature: "", url: "" }, 'windows-x86_64': { signature: '', url: '' },
"windows-aarch64": { signature: "", url: "" }, 'windows-aarch64': { signature: '', url: '' },
"windows-x86": { signature: "", url: "" }, 'windows-x86': { signature: '', url: '' },
"windows-i686": { signature: "", url: "" }, 'windows-i686': { signature: '', url: '' },
}, },
}; }
const promises = release.assets.map(async (asset) => { 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 // Process all the platform URL and signature data
// win64 url // win64 url
if (name.endsWith("x64-setup.exe")) { if (name.endsWith('x64-setup.exe')) {
updateData.platforms.win64.url = browser_download_url; updateData.platforms.win64.url = browser_download_url
updateData.platforms["windows-x86_64"].url = browser_download_url; updateData.platforms['windows-x86_64'].url = browser_download_url
} }
// win64 signature // win64 signature
if (name.endsWith("x64-setup.exe.sig")) { if (name.endsWith('x64-setup.exe.sig')) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url)
updateData.platforms.win64.signature = sig; updateData.platforms.win64.signature = sig
updateData.platforms["windows-x86_64"].signature = sig; updateData.platforms['windows-x86_64'].signature = sig
} }
// win32 url // win32 url
if (name.endsWith("x86-setup.exe")) { if (name.endsWith('x86-setup.exe')) {
updateData.platforms["windows-x86"].url = browser_download_url; updateData.platforms['windows-x86'].url = browser_download_url
updateData.platforms["windows-i686"].url = browser_download_url; updateData.platforms['windows-i686'].url = browser_download_url
} }
// win32 signature // win32 signature
if (name.endsWith("x86-setup.exe.sig")) { if (name.endsWith('x86-setup.exe.sig')) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url)
updateData.platforms["windows-x86"].signature = sig; updateData.platforms['windows-x86'].signature = sig
updateData.platforms["windows-i686"].signature = sig; updateData.platforms['windows-i686'].signature = sig
} }
// win arm url // win arm url
if (name.endsWith("arm64-setup.exe")) { if (name.endsWith('arm64-setup.exe')) {
updateData.platforms["windows-aarch64"].url = browser_download_url; updateData.platforms['windows-aarch64'].url = browser_download_url
} }
// win arm signature // win arm signature
if (name.endsWith("arm64-setup.exe.sig")) { if (name.endsWith('arm64-setup.exe.sig')) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url)
updateData.platforms["windows-aarch64"].signature = sig; updateData.platforms['windows-aarch64'].signature = sig
} }
// darwin url (intel) // darwin url (intel)
if (name.endsWith(".app.tar.gz") && !name.includes("aarch")) { if (name.endsWith('.app.tar.gz') && !name.includes('aarch')) {
updateData.platforms.darwin.url = browser_download_url; updateData.platforms.darwin.url = browser_download_url
updateData.platforms["darwin-intel"].url = browser_download_url; updateData.platforms['darwin-intel'].url = browser_download_url
updateData.platforms["darwin-x86_64"].url = browser_download_url; updateData.platforms['darwin-x86_64'].url = browser_download_url
} }
// darwin signature (intel) // darwin signature (intel)
if (name.endsWith(".app.tar.gz.sig") && !name.includes("aarch")) { if (name.endsWith('.app.tar.gz.sig') && !name.includes('aarch')) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url)
updateData.platforms.darwin.signature = sig; updateData.platforms.darwin.signature = sig
updateData.platforms["darwin-intel"].signature = sig; updateData.platforms['darwin-intel'].signature = sig
updateData.platforms["darwin-x86_64"].signature = sig; updateData.platforms['darwin-x86_64'].signature = sig
} }
// darwin url (aarch) // darwin url (aarch)
if (name.endsWith("aarch64.app.tar.gz")) { if (name.endsWith('aarch64.app.tar.gz')) {
updateData.platforms["darwin-aarch64"].url = browser_download_url; updateData.platforms['darwin-aarch64'].url = browser_download_url
// 使linux可以检查更新 // 使linux可以检查更新
updateData.platforms.linux.url = browser_download_url; updateData.platforms.linux.url = browser_download_url
updateData.platforms["linux-x86_64"].url = browser_download_url; updateData.platforms['linux-x86_64'].url = browser_download_url
updateData.platforms["linux-x86"].url = browser_download_url; updateData.platforms['linux-x86'].url = browser_download_url
updateData.platforms["linux-i686"].url = browser_download_url; updateData.platforms['linux-i686'].url = browser_download_url
updateData.platforms["linux-aarch64"].url = browser_download_url; updateData.platforms['linux-aarch64'].url = browser_download_url
updateData.platforms["linux-armv7"].url = browser_download_url; updateData.platforms['linux-armv7'].url = browser_download_url
} }
// darwin signature (aarch) // darwin signature (aarch)
if (name.endsWith("aarch64.app.tar.gz.sig")) { if (name.endsWith('aarch64.app.tar.gz.sig')) {
const sig = await getSignature(browser_download_url); const sig = await getSignature(browser_download_url)
updateData.platforms["darwin-aarch64"].signature = sig; updateData.platforms['darwin-aarch64'].signature = sig
updateData.platforms.linux.signature = sig; updateData.platforms.linux.signature = sig
updateData.platforms["linux-x86_64"].signature = sig; updateData.platforms['linux-x86_64'].signature = sig
updateData.platforms["linux-x86"].url = browser_download_url; updateData.platforms['linux-x86'].url = browser_download_url
updateData.platforms["linux-i686"].url = browser_download_url; updateData.platforms['linux-i686'].url = browser_download_url
updateData.platforms["linux-aarch64"].signature = sig; updateData.platforms['linux-aarch64'].signature = sig
updateData.platforms["linux-armv7"].signature = sig; updateData.platforms['linux-armv7'].signature = sig
} }
}); })
await Promise.allSettled(promises); await Promise.allSettled(promises)
console.log(updateData); console.log(updateData)
// maybe should test the signature as well // maybe should test the signature as well
// delete the null field // delete the null field
Object.entries(updateData.platforms).forEach(([key, value]) => { Object.entries(updateData.platforms).forEach(([key, value]) => {
if (!value.url) { if (!value.url) {
console.log(`[Error]: failed to parse release for "${key}"`); console.log(`[Error]: failed to parse release for "${key}"`)
delete updateData.platforms[key]; delete updateData.platforms[key]
} }
}); })
// Generate a proxy update file for accelerated GitHub resources // 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]) => { Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
if (value.url) { if (value.url) {
updateDataNew.platforms[key].url = updateDataNew.platforms[key].url =
"https://download.clashverge.dev/" + value.url; 'https://download.clashverge.dev/' + value.url
} else { } 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 // 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( console.log(
`Processing ${isAlpha ? "alpha" : "stable"} release:`, `Processing ${isAlpha ? 'alpha' : 'stable'} release:`,
releaseTag, releaseTag,
); )
try { try {
let updateRelease; let updateRelease
try { try {
// Try to get the existing release // Try to get the existing release
const response = await github.rest.repos.getReleaseByTag({ const response = await github.rest.repos.getReleaseByTag({
...options, ...options,
tag: releaseTag, tag: releaseTag,
}); })
updateRelease = response.data; updateRelease = response.data
console.log( console.log(
`Found existing ${releaseTag} release with ID: ${updateRelease.id}`, `Found existing ${releaseTag} release with ID: ${updateRelease.id}`,
); )
} catch (error) { } catch (error) {
// If release doesn't exist, create it // If release doesn't exist, create it
if (error.status === 404) { if (error.status === 404) {
console.log( console.log(
`Release with tag ${releaseTag} not found, creating new release...`, `Release with tag ${releaseTag} not found, creating new release...`,
); )
const createResponse = await github.rest.repos.createRelease({ const createResponse = await github.rest.repos.createRelease({
...options, ...options,
tag_name: releaseTag, tag_name: releaseTag,
name: isAlpha name: isAlpha
? "Auto-update Alpha Channel" ? 'Auto-update Alpha Channel'
: "Auto-update Stable Channel", : 'Auto-update Stable Channel',
body: `This release contains the update information for ${isAlpha ? "alpha" : "stable"} channel.`, body: `This release contains the update information for ${isAlpha ? 'alpha' : 'stable'} channel.`,
prerelease: isAlpha, prerelease: isAlpha,
}); })
updateRelease = createResponse.data; updateRelease = createResponse.data
console.log( console.log(
`Created new ${releaseTag} release with ID: ${updateRelease.id}`, `Created new ${releaseTag} release with ID: ${updateRelease.id}`,
); )
} else { } else {
// If it's another error, throw it // If it's another error, throw it
throw error; throw error
} }
} }
// File names based on release type // File names based on release type
const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE; const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE
const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY; const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY
// Delete existing assets with these names // Delete existing assets with these names
for (const asset of updateRelease.assets) { for (const asset of updateRelease.assets) {
@@ -265,13 +265,13 @@ async function processRelease(github, options, tag, isAlpha) {
await github.rest.repos.deleteReleaseAsset({ await github.rest.repos.deleteReleaseAsset({
...options, ...options,
asset_id: asset.id, asset_id: asset.id,
}); })
} }
if (asset.name === proxyFile) { if (asset.name === proxyFile) {
await github.rest.repos await github.rest.repos
.deleteReleaseAsset({ ...options, asset_id: asset.id }) .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, release_id: updateRelease.id,
name: jsonFile, name: jsonFile,
data: JSON.stringify(updateData, null, 2), data: JSON.stringify(updateData, null, 2),
}); })
await github.rest.repos.uploadReleaseAsset({ await github.rest.repos.uploadReleaseAsset({
...options, ...options,
release_id: updateRelease.id, release_id: updateRelease.id,
name: proxyFile, name: proxyFile,
data: JSON.stringify(updateDataNew, null, 2), data: JSON.stringify(updateDataNew, null, 2),
}); })
console.log( console.log(
`Successfully uploaded ${isAlpha ? "alpha" : "stable"} update files to ${releaseTag}`, `Successfully uploaded ${isAlpha ? 'alpha' : 'stable'} update files to ${releaseTag}`,
); )
} catch (error) { } catch (error) {
console.error( console.error(
`Failed to process ${isAlpha ? "alpha" : "stable"} release:`, `Failed to process ${isAlpha ? 'alpha' : 'stable'} release:`,
error.message, error.message,
); )
} }
} catch (error) { } catch (error) {
if (error.status === 404) { 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 { } else {
console.error( console.error(`Failed to get release for tag: ${tag.name}`, error.message)
`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 // get the signature file content
async function getSignature(url) { async function getSignature(url) {
const response = await fetch(url, { const response = await fetch(url, {
method: "GET", method: 'GET',
headers: { "Content-Type": "application/octet-stream" }, headers: { 'Content-Type': 'application/octet-stream' },
}); })
return response.text(); return response.text()
} }
resolveUpdater().catch(console.error); resolveUpdater().catch(console.error)
+6 -6
View File
@@ -1,11 +1,11 @@
import clc from "cli-color"; import clc from 'cli-color'
export const log_success = (msg, ...optionalParams) => export const log_success = (msg, ...optionalParams) =>
console.log(clc.green(msg), ...optionalParams); console.log(clc.green(msg), ...optionalParams)
export const log_error = (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) => export const log_info = (msg, ...optionalParams) =>
console.log(clc.bgBlue(msg), ...optionalParams); console.log(clc.bgBlue(msg), ...optionalParams)
var debugMsg = clc.xterm(245); var debugMsg = clc.xterm(245)
export const log_debug = (msg, ...optionalParams) => export const log_debug = (msg, ...optionalParams) =>
console.log(debugMsg(msg), ...optionalParams); console.log(debugMsg(msg), ...optionalParams)
+4 -4
View File
@@ -1,8 +1,8 @@
// This function is exported for use by the Clash core // 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) { function main(config, _name) {
if (config.mode === "script") { if (config.mode === 'script') {
config.mode = "rule"; config.mode = 'rule'
} }
return config; return config
} }
@@ -1,12 +1,12 @@
// This function is exported for use by the Clash core // 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) { function main(config, _name) {
if (Array.isArray(config.proxies)) { if (Array.isArray(config.proxies)) {
config.proxies.forEach((p, i) => { config.proxies.forEach((p, i) => {
if (p.type === "hysteria" && typeof p.alpn === "string") { if (p.type === 'hysteria' && typeof p.alpn === 'string') {
config.proxies[i].alpn = [p.alpn]; config.proxies[i].alpn = [p.alpn]
} }
}); })
} }
return config; return config
} }
+2 -2
View File
@@ -1,4 +1,4 @@
@font-face { @font-face {
font-family: "twemoji mozilla"; font-family: 'twemoji mozilla';
src: url("../fonts/Twemoji.Mozilla.ttf"); src: url('../fonts/Twemoji.Mozilla.ttf');
} }
+5 -5
View File
@@ -1,12 +1,12 @@
@use "./layout.scss"; @use './layout.scss';
@use "./page.scss"; @use './page.scss';
@use "./font.scss"; @use './font.scss';
body { body {
margin: 0; margin: 0;
font-family: font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
user-select: none; user-select: none;
+21 -21
View File
@@ -1,4 +1,4 @@
import { LoadingButton } from "@mui/lab"; import { LoadingButton } from '@mui/lab'
import { import {
Button, Button,
Dialog, Dialog,
@@ -7,29 +7,29 @@ import {
DialogTitle, DialogTitle,
type SxProps, type SxProps,
type Theme, type Theme,
} from "@mui/material"; } from '@mui/material'
import { ReactNode } from "react"; import { ReactNode } from 'react'
interface Props { interface Props {
title: ReactNode; title: ReactNode
open: boolean; open: boolean
okBtn?: ReactNode; okBtn?: ReactNode
cancelBtn?: ReactNode; cancelBtn?: ReactNode
disableEnforceFocus?: boolean; disableEnforceFocus?: boolean
disableOk?: boolean; disableOk?: boolean
disableCancel?: boolean; disableCancel?: boolean
disableFooter?: boolean; disableFooter?: boolean
contentSx?: SxProps<Theme>; contentSx?: SxProps<Theme>
children?: ReactNode; children?: ReactNode
loading?: boolean; loading?: boolean
onOk?: () => void; onOk?: () => void
onCancel?: () => void; onCancel?: () => void
onClose?: () => void; onClose?: () => void
} }
export interface DialogRef { export interface DialogRef {
open: () => void; open: () => void
close: () => void; close: () => void
} }
export const BaseDialog: React.FC<Props> = ({ export const BaseDialog: React.FC<Props> = ({
@@ -73,5 +73,5 @@ export const BaseDialog: React.FC<Props> = ({
</DialogActions> </DialogActions>
)} )}
</Dialog> </Dialog>
); )
}; }
+21 -21
View File
@@ -1,40 +1,40 @@
import { InboxRounded } from "@mui/icons-material"; import { InboxRounded } from '@mui/icons-material'
import { alpha, Box, Typography } from "@mui/material"; import { alpha, Box, Typography } from '@mui/material'
import type { ReactNode } from "react"; import type { ReactNode } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import type { TranslationKey } from "@/types/generated/i18n-keys"; import type { TranslationKey } from '@/types/generated/i18n-keys'
interface Props { interface Props {
text?: ReactNode; text?: ReactNode
textKey?: TranslationKey; textKey?: TranslationKey
extra?: ReactNode; extra?: ReactNode
} }
export const BaseEmpty = ({ export const BaseEmpty = ({
text, text,
textKey = "shared.statuses.empty", textKey = 'shared.statuses.empty',
extra, extra,
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation()
const resolvedText: ReactNode = text !== undefined ? text : t(textKey); const resolvedText: ReactNode = text !== undefined ? text : t(textKey)
return ( return (
<Box <Box
sx={({ palette }) => ({ sx={({ palette }) => ({
width: "100%", width: '100%',
height: "100%", height: '100%',
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
color: alpha(palette.text.secondary, 0.75), color: alpha(palette.text.secondary, 0.75),
})} })}
> >
<InboxRounded sx={{ fontSize: "4em" }} /> <InboxRounded sx={{ fontSize: '4em' }} />
<Typography sx={{ fontSize: "1.25em" }}>{resolvedText}</Typography> <Typography sx={{ fontSize: '1.25em' }}>{resolvedText}</Typography>
{extra} {extra}
</Box> </Box>
); )
}; }
+8 -8
View File
@@ -1,9 +1,9 @@
import { ReactNode } from "react"; import { ReactNode } from 'react'
import { ErrorBoundary, FallbackProps } from "react-error-boundary"; import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
function ErrorFallback({ error }: FallbackProps) { function ErrorFallback({ error }: FallbackProps) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : undefined; const errorStack = error instanceof Error ? error.stack : undefined
return ( return (
<div role="alert" style={{ padding: 16 }}> <div role="alert" style={{ padding: 16 }}>
@@ -16,15 +16,15 @@ function ErrorFallback({ error }: FallbackProps) {
<pre>{errorStack}</pre> <pre>{errorStack}</pre>
</details> </details>
</div> </div>
); )
} }
interface Props { interface Props {
children?: ReactNode; children?: ReactNode
} }
export const BaseErrorBoundary = ({ children }: Props) => { export const BaseErrorBoundary = ({ children }: Props) => {
return ( return (
<ErrorBoundary FallbackComponent={ErrorFallback}>{children}</ErrorBoundary> <ErrorBoundary FallbackComponent={ErrorFallback}>{children}</ErrorBoundary>
); )
}; }
+23 -23
View File
@@ -1,13 +1,13 @@
import { Box, styled } from "@mui/material"; import { Box, styled } from '@mui/material'
import React from "react"; import React from 'react'
type Props = { type Props = {
label: string; label: string
fontSize?: string; fontSize?: string
width?: string; width?: string
padding?: string; padding?: string
children?: React.ReactNode; children?: React.ReactNode
}; }
export const BaseFieldset: React.FC<Props> = ({ export const BaseFieldset: React.FC<Props> = ({
label, label,
@@ -17,28 +17,28 @@ export const BaseFieldset: React.FC<Props> = ({
children, children,
}: Props) => { }: Props) => {
const Fieldset = styled(Box)<{ component?: string }>(() => ({ const Fieldset = styled(Box)<{ component?: string }>(() => ({
position: "relative", position: 'relative',
border: "1px solid #bbb", border: '1px solid #bbb',
borderRadius: "5px", borderRadius: '5px',
width: width ?? "auto", width: width ?? 'auto',
padding: padding ?? "15px", padding: padding ?? '15px',
})); }))
const Label = styled("legend")(({ theme }) => ({ const Label = styled('legend')(({ theme }) => ({
position: "absolute", position: 'absolute',
top: "-10px", top: '-10px',
left: padding ?? "15px", left: padding ?? '15px',
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
backgroundImage: 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, color: theme.palette.text.primary,
fontSize: fontSize ?? "1em", fontSize: fontSize ?? '1em',
})); }))
return ( return (
<Fieldset component="fieldset"> <Fieldset component="fieldset">
<Label>{label}</Label> <Label>{label}</Label>
{children} {children}
</Fieldset> </Fieldset>
); )
}; }
+13 -13
View File
@@ -1,35 +1,35 @@
import { Box, CircularProgress } from "@mui/material"; import { Box, CircularProgress } from '@mui/material'
import React from "react"; import React from 'react'
interface BaseLoadingOverlayProps { interface BaseLoadingOverlayProps {
isLoading: boolean; isLoading: boolean
} }
export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({ export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({
isLoading, isLoading,
}) => { }) => {
if (!isLoading) return null; if (!isLoading) return null
return ( return (
<Box <Box
sx={{ sx={{
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
display: "flex", display: 'flex',
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
// Respect current theme; avoid bright flash in dark mode // Respect current theme; avoid bright flash in dark mode
backgroundColor: (theme) => backgroundColor: (theme) =>
theme.palette.mode === "dark" theme.palette.mode === 'dark'
? "rgba(0, 0, 0, 0.5)" ? 'rgba(0, 0, 0, 0.5)'
: "rgba(255, 255, 255, 0.7)", : 'rgba(255, 255, 255, 0.7)',
zIndex: 1000, zIndex: 1000,
}} }}
> >
<CircularProgress /> <CircularProgress />
</Box> </Box>
); )
}; }
+7 -7
View File
@@ -1,6 +1,6 @@
import { styled } from "@mui/material"; import { styled } from '@mui/material'
const Loading = styled("div")` const Loading = styled('div')`
position: relative; position: relative;
display: flex; display: flex;
height: 100%; height: 100%;
@@ -31,11 +31,11 @@ const Loading = styled("div")`
transform: scale(1); transform: scale(1);
} }
} }
`; `
const LoadingItem = styled("div")(({ theme }) => ({ const LoadingItem = styled('div')(({ theme }) => ({
background: theme.palette.text.secondary, background: theme.palette.text.secondary,
})); }))
export const BaseLoading = () => { export const BaseLoading = () => {
return ( return (
@@ -44,5 +44,5 @@ export const BaseLoading = () => {
<LoadingItem /> <LoadingItem />
<LoadingItem /> <LoadingItem />
</Loading> </Loading>
); )
}; }
+19 -19
View File
@@ -1,29 +1,29 @@
import { Typography } from "@mui/material"; import { Typography } from '@mui/material'
import { useTheme } from "@mui/material/styles"; import { useTheme } from '@mui/material/styles'
import React, { ReactNode } from "react"; import React, { ReactNode } from 'react'
import { BaseErrorBoundary } from "./base-error-boundary"; import { BaseErrorBoundary } from './base-error-boundary'
interface Props { interface Props {
title?: React.ReactNode; // the page title title?: React.ReactNode // the page title
header?: React.ReactNode; // something behind title header?: React.ReactNode // something behind title
contentStyle?: React.CSSProperties; contentStyle?: React.CSSProperties
children?: ReactNode; children?: ReactNode
full?: boolean; full?: boolean
} }
export const BasePage: React.FC<Props> = (props) => { export const BasePage: React.FC<Props> = (props) => {
const { title, header, contentStyle, full, children } = props; const { title, header, contentStyle, full, children } = props
const theme = useTheme(); const theme = useTheme()
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === 'dark'
return ( return (
<BaseErrorBoundary> <BaseErrorBoundary>
<div className="base-page"> <div className="base-page">
<header data-tauri-drag-region="true" style={{ userSelect: "none" }}> <header data-tauri-drag-region="true" style={{ userSelect: 'none' }}>
<Typography <Typography
sx={{ fontSize: "20px", fontWeight: "700 " }} sx={{ fontSize: '20px', fontWeight: '700 ' }}
data-tauri-drag-region="true" data-tauri-drag-region="true"
> >
{title} {title}
@@ -33,12 +33,12 @@ export const BasePage: React.FC<Props> = (props) => {
</header> </header>
<div <div
className={full ? "base-container no-padding" : "base-container"} className={full ? 'base-container no-padding' : 'base-container'}
style={{ backgroundColor: isDark ? "#1e1f27" : "#ffffff" }} style={{ backgroundColor: isDark ? '#1e1f27' : '#ffffff' }}
> >
<section <section
style={{ style={{
backgroundColor: isDark ? "#1e1f27" : "var(--background-color)", backgroundColor: isDark ? '#1e1f27' : 'var(--background-color)',
}} }}
> >
<div className="base-content" style={contentStyle}> <div className="base-content" style={contentStyle}>
@@ -48,5 +48,5 @@ export const BasePage: React.FC<Props> = (props) => {
</div> </div>
</div> </div>
</BaseErrorBoundary> </BaseErrorBoundary>
); )
}; }
+97 -97
View File
@@ -1,6 +1,6 @@
import { ClearRounded } from "@mui/icons-material"; import { ClearRounded } from '@mui/icons-material'
import { Box, SvgIcon, TextField, styled, IconButton } from "@mui/material"; import { Box, SvgIcon, TextField, styled, IconButton } from '@mui/material'
import Tooltip from "@mui/material/Tooltip"; import Tooltip from '@mui/material/Tooltip'
import { import {
ChangeEvent, ChangeEvent,
useCallback, useCallback,
@@ -8,67 +8,67 @@ import {
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import matchCaseIcon from "@/assets/image/component/match_case.svg?react"; import matchCaseIcon from '@/assets/image/component/match_case.svg?react'
import matchWholeWordIcon from "@/assets/image/component/match_whole_word.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 UseRegularExpressionIcon from '@/assets/image/component/use_regular_expression.svg?react'
import { buildRegex, compileStringMatcher } from "@/utils/search-matcher"; import { buildRegex, compileStringMatcher } from '@/utils/search-matcher'
export type SearchState = { export type SearchState = {
text: string; text: string
matchCase: boolean; matchCase: boolean
matchWholeWord: boolean; matchWholeWord: boolean
useRegularExpression: boolean; useRegularExpression: boolean
}; }
type SearchOptionState = Omit<SearchState, "text">; type SearchOptionState = Omit<SearchState, 'text'>
type SearchProps = { type SearchProps = {
value?: string; value?: string
defaultValue?: string; defaultValue?: string
autoFocus?: boolean; autoFocus?: boolean
placeholder?: string; placeholder?: string
matchCase?: boolean; matchCase?: boolean
matchWholeWord?: boolean; matchWholeWord?: boolean
useRegularExpression?: boolean; useRegularExpression?: boolean
searchState?: Partial<SearchOptionState>; searchState?: Partial<SearchOptionState>
onSearch: (match: (content: string) => boolean, state: SearchState) => void; onSearch: (match: (content: string) => boolean, state: SearchState) => void
}; }
const StyledTextField = styled(TextField)(({ theme }) => ({ const StyledTextField = styled(TextField)(({ theme }) => ({
"& .MuiInputBase-root": { '& .MuiInputBase-root': {
background: theme.palette.mode === "light" ? "#fff" : undefined, background: theme.palette.mode === 'light' ? '#fff' : undefined,
paddingRight: "4px", paddingRight: '4px',
}, },
"& .MuiInputBase-root svg[aria-label='active'] path": { "& .MuiInputBase-root svg[aria-label='active'] path": {
fill: theme.palette.primary.light, fill: theme.palette.primary.light,
}, },
"& .MuiInputBase-root svg[aria-label='inactive'] path": { "& .MuiInputBase-root svg[aria-label='inactive'] path": {
fill: "#A7A7A7", fill: '#A7A7A7',
}, },
})); }))
const useControllableState = <T,>(options: { const useControllableState = <T,>(options: {
controlled: T | undefined; controlled: T | undefined
defaultValue: T; defaultValue: T
}) => { }) => {
const { controlled, defaultValue } = options; const { controlled, defaultValue } = options
const [uncontrolled, setUncontrolled] = useState(defaultValue); const [uncontrolled, setUncontrolled] = useState(defaultValue)
const isControlled = controlled !== undefined; const isControlled = controlled !== undefined
const value = isControlled ? controlled : uncontrolled; const value = isControlled ? controlled : uncontrolled
const setValue = useCallback( const setValue = useCallback(
(next: T) => { (next: T) => {
if (!isControlled) setUncontrolled(next); if (!isControlled) setUncontrolled(next)
}, },
[isControlled], [isControlled],
); )
return [value, setValue] as const; return [value, setValue] as const
}; }
export const BaseSearchBox = ({ export const BaseSearchBox = ({
value, value,
@@ -81,113 +81,113 @@ export const BaseSearchBox = ({
useRegularExpression: defaultUseRegularExpression = false, useRegularExpression: defaultUseRegularExpression = false,
onSearch, onSearch,
}: SearchProps) => { }: SearchProps) => {
const { t } = useTranslation(); const { t } = useTranslation()
const onSearchRef = useRef(onSearch); const onSearchRef = useRef(onSearch)
const lastSearchStateRef = useRef<SearchState | null>(null); const lastSearchStateRef = useRef<SearchState | null>(null)
const [text, setText] = useControllableState<string>({ const [text, setText] = useControllableState<string>({
controlled: value, controlled: value,
defaultValue: defaultValue ?? "", defaultValue: defaultValue ?? '',
}); })
const [matchCase, setMatchCase] = useControllableState<boolean>({ const [matchCase, setMatchCase] = useControllableState<boolean>({
controlled: searchState?.matchCase, controlled: searchState?.matchCase,
defaultValue: defaultMatchCase, defaultValue: defaultMatchCase,
}); })
const [matchWholeWord, setMatchWholeWord] = useControllableState<boolean>({ const [matchWholeWord, setMatchWholeWord] = useControllableState<boolean>({
controlled: searchState?.matchWholeWord, controlled: searchState?.matchWholeWord,
defaultValue: defaultMatchWholeWord, defaultValue: defaultMatchWholeWord,
}); })
const [useRegularExpression, setUseRegularExpression] = const [useRegularExpression, setUseRegularExpression] =
useControllableState<boolean>({ useControllableState<boolean>({
controlled: searchState?.useRegularExpression, controlled: searchState?.useRegularExpression,
defaultValue: defaultUseRegularExpression, defaultValue: defaultUseRegularExpression,
}); })
const iconStyle = { const iconStyle = {
style: { style: {
height: "24px", height: '24px',
width: "24px", width: '24px',
cursor: "pointer", cursor: 'pointer',
} as React.CSSProperties, } as React.CSSProperties,
inheritViewBox: true, inheritViewBox: true,
}; }
useEffect(() => { useEffect(() => {
onSearchRef.current = onSearch; onSearchRef.current = onSearch
}, [onSearch]); }, [onSearch])
const emitSearch = useCallback((nextState: SearchState) => { const emitSearch = useCallback((nextState: SearchState) => {
const prevState = lastSearchStateRef.current; const prevState = lastSearchStateRef.current
const isSameState = const isSameState =
!!prevState && !!prevState &&
prevState.text === nextState.text && prevState.text === nextState.text &&
prevState.matchCase === nextState.matchCase && prevState.matchCase === nextState.matchCase &&
prevState.matchWholeWord === nextState.matchWholeWord && prevState.matchWholeWord === nextState.matchWholeWord &&
prevState.useRegularExpression === nextState.useRegularExpression; prevState.useRegularExpression === nextState.useRegularExpression
if (isSameState) return; if (isSameState) return
const compiled = compileStringMatcher(nextState.text, nextState); const compiled = compileStringMatcher(nextState.text, nextState)
onSearchRef.current(compiled.matcher, nextState); onSearchRef.current(compiled.matcher, nextState)
lastSearchStateRef.current = nextState; lastSearchStateRef.current = nextState
}, []); }, [])
useEffect(() => { useEffect(() => {
emitSearch({ text, matchCase, matchWholeWord, useRegularExpression }); emitSearch({ text, matchCase, matchWholeWord, useRegularExpression })
}, [emitSearch, matchCase, matchWholeWord, text, useRegularExpression]); }, [emitSearch, matchCase, matchWholeWord, text, useRegularExpression])
const effectiveErrorMessage = useMemo(() => { const effectiveErrorMessage = useMemo(() => {
if (!useRegularExpression || !text) return ""; if (!useRegularExpression || !text) return ''
const flags = matchCase ? "" : "i"; const flags = matchCase ? '' : 'i'
return buildRegex(text, flags) ? "" : t("shared.validation.invalidRegex"); return buildRegex(text, flags) ? '' : t('shared.validation.invalidRegex')
}, [matchCase, t, text, useRegularExpression]); }, [matchCase, t, text, useRegularExpression])
const handleChangeText = ( const handleChangeText = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => { ) => {
const nextText = e.target?.value ?? ""; const nextText = e.target?.value ?? ''
setText(nextText); setText(nextText)
emitSearch({ emitSearch({
text: nextText, text: nextText,
matchCase, matchCase,
matchWholeWord, matchWholeWord,
useRegularExpression, useRegularExpression,
}); })
}; }
const handleToggleUseRegularExpression = () => { const handleToggleUseRegularExpression = () => {
const next = !useRegularExpression; const next = !useRegularExpression
setUseRegularExpression(next); setUseRegularExpression(next)
emitSearch({ emitSearch({
text, text,
matchCase, matchCase,
matchWholeWord, matchWholeWord,
useRegularExpression: next, useRegularExpression: next,
}); })
}; }
const handleClearInput = () => { const handleClearInput = () => {
setText(""); setText('')
emitSearch({ text: "", matchCase, matchWholeWord, useRegularExpression }); emitSearch({ text: '', matchCase, matchWholeWord, useRegularExpression })
}; }
const handleToggleMatchCase = () => { const handleToggleMatchCase = () => {
const next = !matchCase; const next = !matchCase
setMatchCase(next); setMatchCase(next)
emitSearch({ text, matchCase: next, matchWholeWord, useRegularExpression }); emitSearch({ text, matchCase: next, matchWholeWord, useRegularExpression })
}; }
const handleToggleMatchWholeWord = () => { const handleToggleMatchWholeWord = () => {
const next = !matchWholeWord; const next = !matchWholeWord
setMatchWholeWord(next); setMatchWholeWord(next)
emitSearch({ text, matchCase, matchWholeWord: next, useRegularExpression }); emitSearch({ text, matchCase, matchWholeWord: next, useRegularExpression })
}; }
return ( return (
<Tooltip title={effectiveErrorMessage || ""} placement="bottom-start"> <Tooltip title={effectiveErrorMessage || ''} placement="bottom-start">
<StyledTextField <StyledTextField
autoComplete="new-password" autoComplete="new-password"
hiddenLabel hiddenLabel
@@ -196,7 +196,7 @@ export const BaseSearchBox = ({
variant="outlined" variant="outlined"
autoFocus={autoFocus} autoFocus={autoFocus}
spellCheck="false" spellCheck="false"
placeholder={placeholder ?? t("shared.placeholders.filter")} placeholder={placeholder ?? t('shared.placeholders.filter')}
sx={{ input: { py: 0.65, px: 1.25 } }} sx={{ input: { py: 0.65, px: 1.25 } }}
value={text} value={text}
onChange={handleChangeText} onChange={handleChangeText}
@@ -207,7 +207,7 @@ export const BaseSearchBox = ({
endAdornment: ( endAdornment: (
<Box display="flex"> <Box display="flex">
{!!text && ( {!!text && (
<Tooltip title={t("shared.placeholders.resetInput")}> <Tooltip title={t('shared.placeholders.resetInput')}>
<IconButton <IconButton
size="small" size="small"
{...iconStyle} {...iconStyle}
@@ -217,26 +217,26 @@ export const BaseSearchBox = ({
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
<Tooltip title={t("shared.placeholders.matchCase")}> <Tooltip title={t('shared.placeholders.matchCase')}>
<SvgIcon <SvgIcon
component={matchCaseIcon} component={matchCaseIcon}
{...iconStyle} {...iconStyle}
aria-label={matchCase ? "active" : "inactive"} aria-label={matchCase ? 'active' : 'inactive'}
onClick={handleToggleMatchCase} onClick={handleToggleMatchCase}
/> />
</Tooltip> </Tooltip>
<Tooltip title={t("shared.placeholders.matchWholeWord")}> <Tooltip title={t('shared.placeholders.matchWholeWord')}>
<SvgIcon <SvgIcon
component={matchWholeWordIcon} component={matchWholeWordIcon}
{...iconStyle} {...iconStyle}
aria-label={matchWholeWord ? "active" : "inactive"} aria-label={matchWholeWord ? 'active' : 'inactive'}
onClick={handleToggleMatchWholeWord} onClick={handleToggleMatchWholeWord}
/> />
</Tooltip> </Tooltip>
<Tooltip title={t("shared.placeholders.useRegex")}> <Tooltip title={t('shared.placeholders.useRegex')}>
<SvgIcon <SvgIcon
component={UseRegularExpressionIcon} component={UseRegularExpressionIcon}
aria-label={useRegularExpression ? "active" : "inactive"} aria-label={useRegularExpression ? 'active' : 'inactive'}
{...iconStyle} {...iconStyle}
onClick={handleToggleUseRegularExpression} onClick={handleToggleUseRegularExpression}
/> />
@@ -247,5 +247,5 @@ export const BaseSearchBox = ({
}} }}
/> />
</Tooltip> </Tooltip>
); )
}; }
+74 -74
View File
@@ -1,4 +1,4 @@
import { CodeRounded, ViewModuleRounded } from "@mui/icons-material"; import { CodeRounded, ViewModuleRounded } from '@mui/icons-material'
import { import {
Box, Box,
Button, Button,
@@ -8,51 +8,51 @@ import {
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
} from "@mui/material"; } from '@mui/material'
import type { ReactNode } from "react"; import type { ReactNode } from 'react'
import { useMemo, useState } from "react"; import { useMemo, useState } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
export type BaseSplitChipEditorMode = "visual" | "advanced"; export type BaseSplitChipEditorMode = 'visual' | 'advanced'
interface BaseSplitChipEditorProps { interface BaseSplitChipEditorProps {
value?: string; value?: string
onChange: (value: string) => void; onChange: (value: string) => void
disabled?: boolean; disabled?: boolean
error?: boolean; error?: boolean
helperText?: ReactNode; helperText?: ReactNode
placeholder?: string; placeholder?: string
rows?: number; rows?: number
separator?: string; separator?: string
splitPattern?: RegExp; splitPattern?: RegExp
defaultMode?: BaseSplitChipEditorMode; defaultMode?: BaseSplitChipEditorMode
showModeToggle?: boolean; showModeToggle?: boolean
ariaLabel?: string; ariaLabel?: string
addLabel?: ReactNode; addLabel?: ReactNode
emptyLabel?: ReactNode; emptyLabel?: ReactNode
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>; modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
renderHeader?: (modeToggle: ReactNode) => ReactNode; renderHeader?: (modeToggle: ReactNode) => ReactNode
} }
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/; const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
const splitValue = (value: string, splitPattern: RegExp) => const splitValue = (value: string, splitPattern: RegExp) =>
value value
.split(splitPattern) .split(splitPattern)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean); .filter(Boolean)
export const BaseSplitChipEditor = ({ export const BaseSplitChipEditor = ({
value = "", value = '',
onChange, onChange,
disabled = false, disabled = false,
error = false, error = false,
helperText, helperText,
placeholder, placeholder,
rows = 4, rows = 4,
separator = ",", separator = ',',
splitPattern = DEFAULT_SPLIT_PATTERN, splitPattern = DEFAULT_SPLIT_PATTERN,
defaultMode = "visual", defaultMode = 'visual',
showModeToggle = true, showModeToggle = true,
ariaLabel, ariaLabel,
addLabel, addLabel,
@@ -60,58 +60,58 @@ export const BaseSplitChipEditor = ({
modeLabels, modeLabels,
renderHeader, renderHeader,
}: BaseSplitChipEditorProps) => { }: BaseSplitChipEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation()
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode); const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState('')
const resolvedLabels = useMemo( const resolvedLabels = useMemo(
() => ({ () => ({
visual: modeLabels?.visual ?? t("shared.editorModes.visualization"), visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
advanced: modeLabels?.advanced ?? t("shared.editorModes.advanced"), advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
add: addLabel ?? t("shared.actions.new"), add: addLabel ?? t('shared.actions.new'),
empty: emptyLabel ?? t("shared.statuses.empty"), empty: emptyLabel ?? t('shared.statuses.empty'),
}), }),
[t, modeLabels, addLabel, emptyLabel], [t, modeLabels, addLabel, emptyLabel],
); )
const values = useMemo( const values = useMemo(
() => splitValue(value, splitPattern), () => splitValue(value, splitPattern),
[value, splitPattern], [value, splitPattern],
); )
const items = useMemo(() => { const items = useMemo(() => {
const counts = new Map<string, number>(); const counts = new Map<string, number>()
return values.map((item) => { return values.map((item) => {
const nextCount = (counts.get(item) ?? 0) + 1; const nextCount = (counts.get(item) ?? 0) + 1
counts.set(item, nextCount); counts.set(item, nextCount)
return { return {
key: `${item}-${nextCount}`, key: `${item}-${nextCount}`,
value: item, value: item,
}; }
}); })
}, [values]); }, [values])
const handleAddDraft = () => { const handleAddDraft = () => {
const nextValues = splitValue(draft, splitPattern); const nextValues = splitValue(draft, splitPattern)
if (!nextValues.length) { 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 handleRemoveItem = (index: number) => {
const nextValue = values.filter((_, itemIndex) => itemIndex !== index); const nextValue = values.filter((_, itemIndex) => itemIndex !== index)
onChange(nextValue.join(separator)); onChange(nextValue.join(separator))
}; }
const nextMode = mode === "visual" ? "advanced" : "visual"; const nextMode = mode === 'visual' ? 'advanced' : 'visual'
const toggleLabel = const toggleLabel =
nextMode === "visual" ? resolvedLabels.visual : resolvedLabels.advanced; nextMode === 'visual' ? resolvedLabels.visual : resolvedLabels.advanced
const ToggleIcon = nextMode === "visual" ? ViewModuleRounded : CodeRounded; const ToggleIcon = nextMode === 'visual' ? ViewModuleRounded : CodeRounded
const resolvedAriaLabel = const resolvedAriaLabel =
ariaLabel ?? (typeof toggleLabel === "string" ? toggleLabel : undefined); ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
const modeToggle = showModeToggle ? ( const modeToggle = showModeToggle ? (
<Tooltip title={toggleLabel}> <Tooltip title={toggleLabel}>
@@ -119,26 +119,26 @@ export const BaseSplitChipEditor = ({
size="small" size="small"
aria-label={resolvedAriaLabel} aria-label={resolvedAriaLabel}
onClick={() => { onClick={() => {
setMode(nextMode); setMode(nextMode)
if (nextMode === "visual") { if (nextMode === 'visual') {
setDraft(""); setDraft('')
} }
}} }}
> >
<ToggleIcon fontSize="small" /> <ToggleIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
) : null; ) : null
return ( return (
<> <>
{renderHeader ? renderHeader(modeToggle) : modeToggle} {renderHeader ? renderHeader(modeToggle) : modeToggle}
{mode === "visual" ? ( {mode === 'visual' ? (
<Box sx={{ padding: "0 2px 5px" }}> <Box sx={{ padding: '0 2px 5px' }}>
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
flexWrap: "wrap", flexWrap: 'wrap',
gap: 1, gap: 1,
minHeight: 32, minHeight: 32,
}} }}
@@ -161,7 +161,7 @@ export const BaseSplitChipEditor = ({
)} )}
</Box> </Box>
<Box <Box
sx={{ display: "flex", gap: 1, marginTop: 1, alignItems: "center" }} sx={{ display: 'flex', gap: 1, marginTop: 1, alignItems: 'center' }}
> >
<TextField <TextField
disabled={disabled} disabled={disabled}
@@ -171,14 +171,14 @@ export const BaseSplitChipEditor = ({
placeholder={placeholder} placeholder={placeholder}
error={error} error={error}
sx={{ sx={{
"& .MuiInputBase-root": { minHeight: 32 }, '& .MuiInputBase-root': { minHeight: 32 },
"& .MuiInputBase-input": { padding: "4px 8px" }, '& .MuiInputBase-input': { padding: '4px 8px' },
}} }}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault()
handleAddDraft(); handleAddDraft()
} }
}} }}
/> />
@@ -187,7 +187,7 @@ export const BaseSplitChipEditor = ({
size="small" size="small"
onClick={handleAddDraft} onClick={handleAddDraft}
disabled={disabled || !draft.trim()} disabled={disabled || !draft.trim()}
sx={{ minHeight: 32, padding: "2px 8px" }} sx={{ minHeight: 32, padding: '2px 8px' }}
> >
{resolvedLabels.add} {resolvedLabels.add}
</Button> </Button>
@@ -203,14 +203,14 @@ export const BaseSplitChipEditor = ({
size="small" size="small"
multiline multiline
rows={rows} rows={rows}
sx={{ width: "100%" }} sx={{ width: '100%' }}
value={value} value={value}
helperText={helperText} helperText={helperText}
onChange={(event) => { onChange={(event) => {
onChange(event.target.value); onChange(event.target.value)
}} }}
/> />
)} )}
</> </>
); )
}; }
+4 -4
View File
@@ -1,4 +1,4 @@
import { Select, SelectProps, styled } from "@mui/material"; import { Select, SelectProps, styled } from '@mui/material'
export const BaseStyledSelect = styled((props: SelectProps<string>) => { export const BaseStyledSelect = styled((props: SelectProps<string>) => {
return ( return (
@@ -13,7 +13,7 @@ export const BaseStyledSelect = styled((props: SelectProps<string>) => {
}} }}
{...props} {...props}
/> />
); )
})(({ theme }) => ({ })(({ theme }) => ({
background: theme.palette.mode === "light" ? "#fff" : undefined, background: theme.palette.mode === 'light' ? '#fff' : undefined,
})); }))
@@ -1,8 +1,8 @@
import { TextField, type TextFieldProps, styled } from "@mui/material"; import { TextField, type TextFieldProps, styled } from '@mui/material'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
export const BaseStyledTextField = styled((props: TextFieldProps) => { export const BaseStyledTextField = styled((props: TextFieldProps) => {
const { t } = useTranslation(); const { t } = useTranslation()
return ( return (
<TextField <TextField
@@ -12,13 +12,13 @@ export const BaseStyledTextField = styled((props: TextFieldProps) => {
size="small" size="small"
variant="outlined" variant="outlined"
spellCheck="false" spellCheck="false"
placeholder={t("shared.placeholders.filter")} placeholder={t('shared.placeholders.filter')}
sx={{ input: { py: 0.65, px: 1.25 } }} sx={{ input: { py: 0.65, px: 1.25 } }}
{...props} {...props}
/> />
); )
})(({ theme }) => ({ })(({ theme }) => ({
"& .MuiInputBase-root": { '& .MuiInputBase-root': {
background: theme.palette.mode === "light" ? "#fff" : undefined, background: theme.palette.mode === 'light' ? '#fff' : undefined,
}, },
})); }))
+22 -22
View File
@@ -1,5 +1,5 @@
import { styled } from "@mui/material/styles"; import { styled } from '@mui/material/styles'
import { default as MuiSwitch, SwitchProps } from "@mui/material/Switch"; import { default as MuiSwitch, SwitchProps } from '@mui/material/Switch'
export const Switch = styled((props: SwitchProps) => ( export const Switch = styled((props: SwitchProps) => (
<MuiSwitch <MuiSwitch
@@ -12,47 +12,47 @@ export const Switch = styled((props: SwitchProps) => (
height: 26, height: 26,
padding: 0, padding: 0,
marginRight: 1, marginRight: 1,
"& .MuiSwitch-switchBase": { '& .MuiSwitch-switchBase': {
padding: 0, padding: 0,
margin: 2, margin: 2,
transitionDuration: "300ms", transitionDuration: '300ms',
"&.Mui-checked": { '&.Mui-checked': {
transform: "translateX(16px)", transform: 'translateX(16px)',
color: "#fff", color: '#fff',
"& + .MuiSwitch-track": { '& + .MuiSwitch-track': {
backgroundColor: theme.palette.primary.main, backgroundColor: theme.palette.primary.main,
opacity: 1, opacity: 1,
border: 0, border: 0,
}, },
"&.Mui-disabled + .MuiSwitch-track": { '&.Mui-disabled + .MuiSwitch-track': {
opacity: 0.5, opacity: 0.5,
}, },
}, },
"&.Mui-focusVisible .MuiSwitch-thumb": { '&.Mui-focusVisible .MuiSwitch-thumb': {
color: "#33cf4d", color: '#33cf4d',
border: "6px solid #fff", border: '6px solid #fff',
}, },
"&.Mui-disabled .MuiSwitch-thumb": { '&.Mui-disabled .MuiSwitch-thumb': {
color: color:
theme.palette.mode === "light" theme.palette.mode === 'light'
? theme.palette.grey[100] ? theme.palette.grey[100]
: theme.palette.grey[600], : theme.palette.grey[600],
}, },
"&.Mui-disabled + .MuiSwitch-track": { '&.Mui-disabled + .MuiSwitch-track': {
opacity: theme.palette.mode === "light" ? 0.7 : 0.3, opacity: theme.palette.mode === 'light' ? 0.7 : 0.3,
}, },
}, },
"& .MuiSwitch-thumb": { '& .MuiSwitch-thumb': {
boxSizing: "border-box", boxSizing: 'border-box',
width: 22, width: 22,
height: 22, height: 22,
}, },
"& .MuiSwitch-track": { '& .MuiSwitch-track': {
borderRadius: 26 / 2, borderRadius: 26 / 2,
backgroundColor: theme.palette.mode === "light" ? "#BBBBBB" : "#39393D", backgroundColor: theme.palette.mode === 'light' ? '#BBBBBB' : '#39393D',
opacity: 1, opacity: 1,
transition: theme.transitions.create(["background-color"], { transition: theme.transitions.create(['background-color'], {
duration: 500, duration: 500,
}), }),
}, },
})); }))
+8 -8
View File
@@ -1,24 +1,24 @@
import { InfoRounded } from "@mui/icons-material"; import { InfoRounded } from '@mui/icons-material'
import { import {
Tooltip, Tooltip,
IconButton, IconButton,
IconButtonProps, IconButtonProps,
SvgIconProps, SvgIconProps,
} from "@mui/material"; } from '@mui/material'
interface Props extends IconButtonProps { interface Props extends IconButtonProps {
title?: string; title?: string
icon?: React.ElementType<SvgIconProps>; icon?: React.ElementType<SvgIconProps>
} }
export const TooltipIcon: React.FC<Props> = (props: Props) => { export const TooltipIcon: React.FC<Props> = (props: Props) => {
const { title = "", icon: Icon = InfoRounded, ...restProps } = props; const { title = '', icon: Icon = InfoRounded, ...restProps } = props
return ( return (
<Tooltip title={title} placement="top"> <Tooltip title={title} placement="top">
<IconButton color="inherit" size="small" {...restProps}> <IconButton color="inherit" size="small" {...restProps}>
<Icon fontSize="inherit" style={{ cursor: "pointer", opacity: 0.75 }} /> <Icon fontSize="inherit" style={{ cursor: 'pointer', opacity: 0.75 }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
); )
}; }
+13 -13
View File
@@ -1,16 +1,16 @@
export { BaseDialog, type DialogRef } from "./base-dialog"; export { BaseDialog, type DialogRef } from './base-dialog'
export { BaseEmpty } from "./base-empty"; export { BaseEmpty } from './base-empty'
export { BaseErrorBoundary } from "./base-error-boundary"; export { BaseErrorBoundary } from './base-error-boundary'
export { BaseFieldset } from "./base-fieldset"; export { BaseFieldset } from './base-fieldset'
export { BaseLoading } from "./base-loading"; export { BaseLoading } from './base-loading'
export { BaseLoadingOverlay } from "./base-loading-overlay"; export { BaseLoadingOverlay } from './base-loading-overlay'
export { BasePage } from "./base-page"; export { BasePage } from './base-page'
export { BaseSearchBox, type SearchState } from "./base-search-box"; export { BaseSearchBox, type SearchState } from './base-search-box'
export { export {
BaseSplitChipEditor, BaseSplitChipEditor,
type BaseSplitChipEditorMode, type BaseSplitChipEditorMode,
} from "./base-split-chip-editor"; } from './base-split-chip-editor'
export { BaseStyledSelect } from "./base-styled-select"; export { BaseStyledSelect } from './base-styled-select'
export { BaseStyledTextField } from "./base-styled-text-field"; export { BaseStyledTextField } from './base-styled-text-field'
export { Switch } from "./base-switch"; export { Switch } from './base-switch'
export { TooltipIcon } from "./base-tooltip-icon"; export { TooltipIcon } from './base-tooltip-icon'
@@ -5,10 +5,10 @@ import {
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
} from "@dnd-kit/core"; } from '@dnd-kit/core'
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable"; import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable'
import { CSS } from "@dnd-kit/utilities"; import { CSS } from '@dnd-kit/utilities'
import { DragIndicatorRounded } from "@mui/icons-material"; import { DragIndicatorRounded } from '@mui/icons-material'
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -20,17 +20,17 @@ import {
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
} from "@mui/material"; } from '@mui/material'
import type { Column } from "@tanstack/react-table"; import type { Column } from '@tanstack/react-table'
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
interface Props { interface Props {
open: boolean; open: boolean
columns: Column<IConnectionsItem, unknown>[]; columns: Column<IConnectionsItem, unknown>[]
onClose: () => void; onClose: () => void
onOrderChange: (order: string[]) => void; onOrderChange: (order: string[]) => void
onReset: () => void; onReset: () => void
} }
export const ConnectionColumnManager = ({ export const ConnectionColumnManager = ({
@@ -44,34 +44,34 @@ export const ConnectionColumnManager = ({
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 6 }, 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( const visibleCount = useMemo(
() => columns.filter((column) => column.getIsVisible()).length, () => columns.filter((column) => column.getIsVisible()).length,
[columns], [columns],
); )
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return
const order = columns.map((column) => column.id); const order = columns.map((column) => column.id)
const oldIndex = order.indexOf(active.id as string); const oldIndex = order.indexOf(active.id as string)
const newIndex = order.indexOf(over.id as string); const newIndex = order.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return; if (oldIndex === -1 || newIndex === -1) return
onOrderChange(arrayMove(order, oldIndex, newIndex)); onOrderChange(arrayMove(order, oldIndex, newIndex))
}, },
[columns, onOrderChange], [columns, onOrderChange],
); )
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle> <DialogTitle>
{t("connections.components.columnManager.title")} {t('connections.components.columnManager.title')}
</DialogTitle> </DialogTitle>
<DialogContent sx={{ pt: 1 }}> <DialogContent sx={{ pt: 1 }}>
<DndContext <DndContext
@@ -83,7 +83,7 @@ export const ConnectionColumnManager = ({
<List <List
dense dense
disablePadding disablePadding
sx={{ display: "flex", flexDirection: "column", gap: 1 }} sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}
> >
{columns.map((column) => ( {columns.map((column) => (
<SortableColumnItem <SortableColumnItem
@@ -91,7 +91,7 @@ export const ConnectionColumnManager = ({
column={column} column={column}
label={getColumnLabel(column)} label={getColumnLabel(column)}
dragHandleLabel={t( dragHandleLabel={t(
"connections.components.columnManager.dragHandle", 'connections.components.columnManager.dragHandle',
)} )}
disableToggle={ disableToggle={
!column.getCanHide() || !column.getCanHide() ||
@@ -105,21 +105,21 @@ export const ConnectionColumnManager = ({
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="text" onClick={onReset}> <Button variant="text" onClick={onReset}>
{t("shared.actions.resetToDefault")} {t('shared.actions.resetToDefault')}
</Button> </Button>
<Button variant="contained" onClick={onClose}> <Button variant="contained" onClick={onClose}>
{t("shared.actions.close")} {t('shared.actions.close')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); )
}; }
interface SortableColumnItemProps { interface SortableColumnItemProps {
column: Column<IConnectionsItem, unknown>; column: Column<IConnectionsItem, unknown>
label: string; label: string
dragHandleLabel: string; dragHandleLabel: string
disableToggle?: boolean; disableToggle?: boolean
} }
const SortableColumnItem = ({ const SortableColumnItem = ({
@@ -135,7 +135,7 @@ const SortableColumnItem = ({
transform, transform,
transition, transition,
isDragging, isDragging,
} = useSortable({ id: column.id }); } = useSortable({ id: column.id })
const style = useMemo( const style = useMemo(
() => ({ () => ({
@@ -143,7 +143,7 @@ const SortableColumnItem = ({
transition, transition,
}), }),
[transform, transition], [transform, transition],
); )
return ( return (
<ListItem <ListItem
@@ -154,9 +154,9 @@ const SortableColumnItem = ({
py: 0.5, py: 0.5,
borderRadius: 1, borderRadius: 1,
border: (theme) => `1px solid ${theme.palette.divider}`, border: (theme) => `1px solid ${theme.palette.divider}`,
backgroundColor: isDragging ? "action.hover" : "transparent", backgroundColor: isDragging ? 'action.hover' : 'transparent',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
gap: 1, gap: 1,
}} }}
style={style} style={style}
@@ -169,13 +169,13 @@ const SortableColumnItem = ({
/> />
<ListItemText <ListItemText
primary={label} primary={label}
slotProps={{ primary: { variant: "body2" } }} slotProps={{ primary: { variant: 'body2' } }}
sx={{ mr: 1 }} sx={{ mr: 1 }}
/> />
<IconButton <IconButton
edge="end" edge="end"
size="small" size="small"
sx={{ cursor: isDragging ? "grabbing" : "grab" }} sx={{ cursor: isDragging ? 'grabbing' : 'grab' }}
aria-label={dragHandleLabel} aria-label={dragHandleLabel}
{...attributes} {...attributes}
{...listeners} {...listeners}
@@ -183,13 +183,13 @@ const SortableColumnItem = ({
<DragIndicatorRounded fontSize="small" /> <DragIndicatorRounded fontSize="small" />
</IconButton> </IconButton>
</ListItem> </ListItem>
); )
}; }
const getColumnLabel = (column: Column<IConnectionsItem, unknown>) => { const getColumnLabel = (column: Column<IConnectionsItem, unknown>) => {
const meta = column.columnDef.meta as { label?: string } | undefined; const meta = column.columnDef.meta as { label?: string } | undefined
if (meta?.label) return meta.label; if (meta?.label) return meta.label
const header = column.columnDef.header; const header = column.columnDef.header
return typeof header === "string" ? header : column.id; return typeof header === 'string' ? header : column.id
}; }
+63 -63
View File
@@ -1,43 +1,43 @@
import { Box, Button, Snackbar, useTheme } from "@mui/material"; import { Box, Button, Snackbar, useTheme } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import dayjs from "dayjs"; import dayjs from 'dayjs'
import { useImperativeHandle, useState, type Ref } from "react"; import { useImperativeHandle, useState, type Ref } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { closeConnection } from "tauri-plugin-mihomo-api"; import { closeConnection } from 'tauri-plugin-mihomo-api'
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from '@/utils/parse-traffic'
export interface ConnectionDetailRef { export interface ConnectionDetailRef {
open: (detail: IConnectionsItem, closed: boolean) => void; open: (detail: IConnectionsItem, closed: boolean) => void
} }
export function ConnectionDetail({ ref }: { ref?: Ref<ConnectionDetailRef> }) { export function ConnectionDetail({ ref }: { ref?: Ref<ConnectionDetailRef> }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
const [detail, setDetail] = useState<IConnectionsItem>(null!); const [detail, setDetail] = useState<IConnectionsItem>(null!)
const [closed, setClosed] = useState(false); const [closed, setClosed] = useState(false)
const theme = useTheme(); const theme = useTheme()
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: (detail: IConnectionsItem, closed: boolean) => { open: (detail: IConnectionsItem, closed: boolean) => {
if (open) return; if (open) return
setOpen(true); setOpen(true)
setDetail(detail); setDetail(detail)
setClosed(closed); setClosed(closed)
}, },
})); }))
const onClose = () => setOpen(false); const onClose = () => setOpen(false)
return ( return (
<Snackbar <Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "right" }} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
open={open} open={open}
onClose={onClose} onClose={onClose}
sx={{ sx={{
".MuiSnackbarContent-root": { '.MuiSnackbarContent-root': {
maxWidth: "520px", maxWidth: '520px',
maxHeight: "480px", maxHeight: '480px',
overflowY: "auto", overflowY: 'auto',
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary, color: theme.palette.text.primary,
}, },
@@ -52,87 +52,87 @@ export function ConnectionDetail({ ref }: { ref?: Ref<ConnectionDetailRef> }) {
) : null ) : null
} }
/> />
); )
} }
interface InnerProps { interface InnerProps {
data: IConnectionsItem; data: IConnectionsItem
closed: boolean; closed: boolean
onClose?: () => void; onClose?: () => void
} }
const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => { const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => {
const { t } = useTranslation(); const { t } = useTranslation()
const { metadata, rulePayload } = data; const { metadata, rulePayload } = data
const theme = useTheme(); const theme = useTheme()
const chains = [...data.chains].reverse().join(" / "); const chains = [...data.chains].reverse().join(' / ')
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule; const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule
const host = metadata.host const host = metadata.host
? `${metadata.host}:${metadata.destinationPort}` ? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`; : `${metadata.remoteDestination}:${metadata.destinationPort}`
const Destination = metadata.destinationIP const Destination = metadata.destinationIP
? metadata.destinationIP ? metadata.destinationIP
: metadata.remoteDestination; : metadata.remoteDestination
const information = [ const information = [
{ label: t("connections.components.fields.host"), value: host }, { label: t('connections.components.fields.host'), value: host },
{ {
label: t("shared.labels.downloaded"), label: t('shared.labels.downloaded'),
value: parseTraffic(data.download).join(" "), value: parseTraffic(data.download).join(' '),
}, },
{ {
label: t("shared.labels.uploaded"), label: t('shared.labels.uploaded'),
value: parseTraffic(data.upload).join(" "), value: parseTraffic(data.upload).join(' '),
}, },
{ {
label: t("connections.components.fields.dlSpeed"), label: t('connections.components.fields.dlSpeed'),
value: parseTraffic(data.curDownload ?? -1).join(" ") + "/s", value: parseTraffic(data.curDownload ?? -1).join(' ') + '/s',
}, },
{ {
label: t("connections.components.fields.ulSpeed"), label: t('connections.components.fields.ulSpeed'),
value: parseTraffic(data.curUpload ?? -1).join(" ") + "/s", value: parseTraffic(data.curUpload ?? -1).join(' ') + '/s',
}, },
{ {
label: t("connections.components.fields.chains"), label: t('connections.components.fields.chains'),
value: 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"), label: t('connections.components.fields.process'),
value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ""}`, value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ''}`,
}, },
{ {
label: t("connections.components.fields.time"), label: t('connections.components.fields.time'),
value: dayjs(data.start).fromNow(), value: dayjs(data.start).fromNow(),
}, },
{ {
label: t("connections.components.fields.source"), label: t('connections.components.fields.source'),
value: `${metadata.sourceIP}:${metadata.sourcePort}`, value: `${metadata.sourceIP}:${metadata.sourcePort}`,
}, },
{ {
label: t("connections.components.fields.destination"), label: t('connections.components.fields.destination'),
value: Destination, value: Destination,
}, },
{ {
label: t("connections.components.fields.destinationPort"), label: t('connections.components.fields.destinationPort'),
value: `${metadata.destinationPort}`, value: `${metadata.destinationPort}`,
}, },
{ {
label: t("connections.components.fields.type"), label: t('connections.components.fields.type'),
value: `${metadata.type}(${metadata.network})`, value: `${metadata.type}(${metadata.network})`,
}, },
]; ]
const onDelete = useLockFn(async () => closeConnection(data.id)); const onDelete = useLockFn(async () => closeConnection(data.id))
return ( return (
<Box sx={{ userSelect: "text", color: theme.palette.text.secondary }}> <Box sx={{ userSelect: 'text', color: theme.palette.text.secondary }}>
{information.map((each) => ( {information.map((each) => (
<div key={each.label}> <div key={each.label}>
<b>{each.label}</b> <b>{each.label}</b>
<span <span
style={{ style={{
wordBreak: "break-all", wordBreak: 'break-all',
color: theme.palette.text.primary, color: theme.palette.text.primary,
}} }}
> >
@@ -142,19 +142,19 @@ const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => {
))} ))}
{!closed && ( {!closed && (
<Box sx={{ textAlign: "right" }}> <Box sx={{ textAlign: 'right' }}>
<Button <Button
variant="contained" variant="contained"
title={t("connections.components.actions.closeConnection")} title={t('connections.components.actions.closeConnection')}
onClick={() => { onClick={() => {
onDelete(); onDelete()
onClose?.(); onClose?.()
}} }}
> >
{t("connections.components.actions.closeConnection")} {t('connections.components.actions.closeConnection')}
</Button> </Button>
</Box> </Box>
)} )}
</Box> </Box>
); )
}; }
+31 -31
View File
@@ -1,4 +1,4 @@
import { CloseRounded } from "@mui/icons-material"; import { CloseRounded } from '@mui/icons-material'
import { import {
styled, styled,
ListItem, ListItem,
@@ -6,52 +6,52 @@ import {
ListItemText, ListItemText,
Box, Box,
alpha, alpha,
} from "@mui/material"; } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import dayjs from "dayjs"; import dayjs from 'dayjs'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { closeConnection } from "tauri-plugin-mihomo-api"; import { closeConnection } from 'tauri-plugin-mihomo-api'
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from '@/utils/parse-traffic'
const Tag = styled("span")(({ theme }) => ({ const Tag = styled('span')(({ theme }) => ({
fontSize: "10px", fontSize: '10px',
padding: "0 4px", padding: '0 4px',
lineHeight: 1.375, lineHeight: 1.375,
border: "1px solid", border: '1px solid',
borderRadius: 4, borderRadius: 4,
borderColor: alpha(theme.palette.text.secondary, 0.35), borderColor: alpha(theme.palette.text.secondary, 0.35),
marginTop: "4px", marginTop: '4px',
marginRight: "4px", marginRight: '4px',
})); }))
interface Props { interface Props {
value: IConnectionsItem; value: IConnectionsItem
closed: boolean; closed: boolean
onShowDetail?: () => void; onShowDetail?: () => void
} }
export const ConnectionItem = (props: Props) => { export const ConnectionItem = (props: Props) => {
const { value, closed, onShowDetail } = props; const { value, closed, onShowDetail } = props
const { id, metadata, chains, start, curUpload, curDownload } = value; const { id, metadata, chains, start, curUpload, curDownload } = value
const { t } = useTranslation(); const { t } = useTranslation()
const onDelete = useLockFn(async () => closeConnection(id)); const onDelete = useLockFn(async () => closeConnection(id))
const showTraffic = curUpload! >= 100 || curDownload! >= 100; const showTraffic = curUpload! >= 100 || curDownload! >= 100
return ( return (
<ListItem <ListItem
dense dense
sx={{ borderBottom: "1px solid var(--divider-color)" }} sx={{ borderBottom: '1px solid var(--divider-color)' }}
secondaryAction={ secondaryAction={
!closed && ( !closed && (
<IconButton <IconButton
edge="end" edge="end"
color="inherit" color="inherit"
onClick={onDelete} onClick={onDelete}
title={t("connections.components.actions.closeConnection")} title={t('connections.components.actions.closeConnection')}
aria-label={t("connections.components.actions.closeConnection")} aria-label={t('connections.components.actions.closeConnection')}
> >
<CloseRounded /> <CloseRounded />
</IconButton> </IconButton>
@@ -59,12 +59,12 @@ export const ConnectionItem = (props: Props) => {
} }
> >
<ListItemText <ListItemText
sx={{ userSelect: "text", cursor: "pointer" }} sx={{ userSelect: 'text', cursor: 'pointer' }}
primary={metadata.host || metadata.destinationIP} primary={metadata.host || metadata.destinationIP}
onClick={onShowDetail} onClick={onShowDetail}
secondary={ secondary={
<Box sx={{ display: "flex", flexWrap: "wrap" }}> <Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
<Tag sx={{ textTransform: "uppercase", color: "success" }}> <Tag sx={{ textTransform: 'uppercase', color: 'success' }}>
{metadata.network} {metadata.network}
</Tag> </Tag>
@@ -73,7 +73,7 @@ export const ConnectionItem = (props: Props) => {
{!!metadata.process && <Tag>{metadata.process}</Tag>} {!!metadata.process && <Tag>{metadata.process}</Tag>}
{chains?.length > 0 && ( {chains?.length > 0 && (
<Tag>{[...chains].reverse().join(" / ")}</Tag> <Tag>{[...chains].reverse().join(' / ')}</Tag>
)} )}
<Tag>{dayjs(start).fromNow()}</Tag> <Tag>{dayjs(start).fromNow()}</Tag>
@@ -87,5 +87,5 @@ export const ConnectionItem = (props: Props) => {
} }
/> />
</ListItem> </ListItem>
); )
}; }
+249 -249
View File
@@ -1,5 +1,5 @@
import { ViewColumnRounded } from "@mui/icons-material"; import { ViewColumnRounded } from '@mui/icons-material'
import { Box, IconButton, Tooltip } from "@mui/material"; import { Box, IconButton, Tooltip } from '@mui/material'
import { import {
ColumnDef, ColumnDef,
ColumnOrderState, ColumnOrderState,
@@ -11,10 +11,10 @@ import {
Updater, Updater,
useReactTable, useReactTable,
VisibilityState, VisibilityState,
} from "@tanstack/react-table"; } from '@tanstack/react-table'
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from '@tanstack/react-virtual'
import dayjs from "dayjs"; import dayjs from 'dayjs'
import { useLocalStorage } from "foxact/use-local-storage"; import { useLocalStorage } from 'foxact/use-local-storage'
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -22,15 +22,15 @@ import {
useRef, useRef,
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from '@/utils/parse-traffic'
import { truncateStr } from "@/utils/truncate-str"; 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 * Reconcile stored column order with base columns to handle added/removed fields
@@ -39,68 +39,68 @@ const reconcileColumnOrder = (
storedOrder: string[], storedOrder: string[],
baseFields: string[], baseFields: string[],
): string[] => { ): string[] => {
const filtered = storedOrder.filter((field) => baseFields.includes(field)); const filtered = storedOrder.filter((field) => baseFields.includes(field))
const missing = baseFields.filter((field) => !filtered.includes(field)); const missing = baseFields.filter((field) => !filtered.includes(field))
return [...filtered, ...missing]; return [...filtered, ...missing]
}; }
type ColumnField = type ColumnField =
| "host" | 'host'
| "download" | 'download'
| "upload" | 'upload'
| "dlSpeed" | 'dlSpeed'
| "ulSpeed" | 'ulSpeed'
| "chains" | 'chains'
| "rule" | 'rule'
| "process" | 'process'
| "time" | 'time'
| "source" | 'source'
| "remoteDestination" | 'remoteDestination'
| "type"; | 'type'
const getConnectionCellValue = (field: ColumnField, each: IConnectionsItem) => { const getConnectionCellValue = (field: ColumnField, each: IConnectionsItem) => {
const { metadata, rulePayload } = each; const { metadata, rulePayload } = each
switch (field) { switch (field) {
case "host": case 'host':
return metadata.host return metadata.host
? `${metadata.host}:${metadata.destinationPort}` ? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`; : `${metadata.remoteDestination}:${metadata.destinationPort}`
case "download": case 'download':
return each.download; return each.download
case "upload": case 'upload':
return each.upload; return each.upload
case "dlSpeed": case 'dlSpeed':
return each.curDownload; return each.curDownload
case "ulSpeed": case 'ulSpeed':
return each.curUpload; return each.curUpload
case "chains": case 'chains':
return [...each.chains].reverse().join(" / "); return [...each.chains].reverse().join(' / ')
case "rule": case 'rule':
return rulePayload ? `${each.rule}(${rulePayload})` : each.rule; return rulePayload ? `${each.rule}(${rulePayload})` : each.rule
case "process": case 'process':
return truncateStr(metadata.process || metadata.processPath); return truncateStr(metadata.process || metadata.processPath)
case "time": case 'time':
return each.start; return each.start
case "source": case 'source':
return `${metadata.sourceIP}:${metadata.sourcePort}`; return `${metadata.sourceIP}:${metadata.sourcePort}`
case "remoteDestination": case 'remoteDestination':
return metadata.destinationIP return metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}` ? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`; : `${metadata.remoteDestination}:${metadata.destinationPort}`
case "type": case 'type':
return `${metadata.type}(${metadata.network})`; return `${metadata.type}(${metadata.network})`
default: default:
return ""; return ''
} }
}; }
interface Props { interface Props {
connections: IConnectionsItem[]; connections: IConnectionsItem[]
onShowDetail: (data: IConnectionsItem) => void; onShowDetail: (data: IConnectionsItem) => void
columnManagerOpen: boolean; columnManagerOpen: boolean
onOpenColumnManager: () => void; onOpenColumnManager: () => void
onCloseColumnManager: () => void; onCloseColumnManager: () => void
} }
export const ConnectionTable = (props: Props) => { export const ConnectionTable = (props: Props) => {
@@ -110,215 +110,215 @@ export const ConnectionTable = (props: Props) => {
columnManagerOpen, columnManagerOpen,
onOpenColumnManager, onOpenColumnManager,
onCloseColumnManager, onCloseColumnManager,
} = props; } = props
const { t } = useTranslation(); const { t } = useTranslation()
const [columnWidths, setColumnWidths] = useLocalStorage<ColumnSizingState>( const [columnWidths, setColumnWidths] = useLocalStorage<ColumnSizingState>(
"connection-table-widths", 'connection-table-widths',
// server-side value, this is the default value used by server-side rendering (if any) // 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) // Do not omit (otherwise a Suspense boundary will be triggered)
{}, {},
); )
const [columnVisibilityModel, setColumnVisibilityModel] = const [columnVisibilityModel, setColumnVisibilityModel] =
useLocalStorage<VisibilityState>( useLocalStorage<VisibilityState>(
"connection-table-visibility", 'connection-table-visibility',
{}, {},
{ {
serializer: JSON.stringify, serializer: JSON.stringify,
deserializer: (value) => { deserializer: (value) => {
try { try {
const parsed = JSON.parse(value); const parsed = JSON.parse(value)
if (parsed && typeof parsed === "object") return parsed; if (parsed && typeof parsed === 'object') return parsed
} catch (err) { } 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<string[]>( const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
"connection-table-order", 'connection-table-order',
[], [],
{ {
serializer: JSON.stringify, serializer: JSON.stringify,
deserializer: (value) => { deserializer: (value) => {
try { try {
const parsed = JSON.parse(value); const parsed = JSON.parse(value)
if (Array.isArray(parsed)) return parsed; if (Array.isArray(parsed)) return parsed
} catch (err) { } catch (err) {
console.warn("Failed to parse connection-table-order", err); console.warn('Failed to parse connection-table-order', err)
} }
return []; return []
}, },
}, },
); )
interface BaseColumn { interface BaseColumn {
field: ColumnField; field: ColumnField
headerName: string; headerName: string
width?: number; width?: number
minWidth?: number; minWidth?: number
align?: "left" | "right"; align?: 'left' | 'right'
cell?: (row: IConnectionsItem) => ReactNode; cell?: (row: IConnectionsItem) => ReactNode
} }
const baseColumns = useMemo<BaseColumn[]>(() => { const baseColumns = useMemo<BaseColumn[]>(() => {
return [ return [
{ {
field: "host", field: 'host',
headerName: t("connections.components.fields.host"), headerName: t('connections.components.fields.host'),
width: 180, width: 180,
minWidth: 140, minWidth: 140,
}, },
{ {
field: "download", field: 'download',
headerName: t("shared.labels.downloaded"), headerName: t('shared.labels.downloaded'),
width: 76, width: 76,
minWidth: 60, minWidth: 60,
align: "right", align: 'right',
cell: (row) => parseTraffic(row.download).join(" "), cell: (row) => parseTraffic(row.download).join(' '),
}, },
{ {
field: "upload", field: 'upload',
headerName: t("shared.labels.uploaded"), headerName: t('shared.labels.uploaded'),
width: 76, width: 76,
minWidth: 60, minWidth: 60,
align: "right", align: 'right',
cell: (row) => parseTraffic(row.upload).join(" "), cell: (row) => parseTraffic(row.upload).join(' '),
}, },
{ {
field: "dlSpeed", field: 'dlSpeed',
headerName: t("connections.components.fields.dlSpeed"), headerName: t('connections.components.fields.dlSpeed'),
width: 76, width: 76,
minWidth: 60, minWidth: 60,
align: "right", align: 'right',
cell: (row) => `${parseTraffic(row.curDownload).join(" ")}/s`, cell: (row) => `${parseTraffic(row.curDownload).join(' ')}/s`,
}, },
{ {
field: "ulSpeed", field: 'ulSpeed',
headerName: t("connections.components.fields.ulSpeed"), headerName: t('connections.components.fields.ulSpeed'),
width: 76, width: 76,
minWidth: 60, minWidth: 60,
align: "right", align: 'right',
cell: (row) => `${parseTraffic(row.curUpload).join(" ")}/s`, cell: (row) => `${parseTraffic(row.curUpload).join(' ')}/s`,
}, },
{ {
field: "chains", field: 'chains',
headerName: t("connections.components.fields.chains"), headerName: t('connections.components.fields.chains'),
width: 280, width: 280,
minWidth: 160, minWidth: 160,
}, },
{ {
field: "rule", field: 'rule',
headerName: t("connections.components.fields.rule"), headerName: t('connections.components.fields.rule'),
width: 220, width: 220,
minWidth: 160, minWidth: 160,
}, },
{ {
field: "process", field: 'process',
headerName: t("connections.components.fields.process"), headerName: t('connections.components.fields.process'),
width: 180, width: 180,
minWidth: 140, minWidth: 140,
}, },
{ {
field: "time", field: 'time',
headerName: t("connections.components.fields.time"), headerName: t('connections.components.fields.time'),
width: 100, width: 100,
minWidth: 80, minWidth: 80,
align: "right", align: 'right',
// cell filled later with shared relativeNow ticker // cell filled later with shared relativeNow ticker
}, },
{ {
field: "source", field: 'source',
headerName: t("connections.components.fields.source"), headerName: t('connections.components.fields.source'),
width: 160, width: 160,
minWidth: 120, minWidth: 120,
}, },
{ {
field: "remoteDestination", field: 'remoteDestination',
headerName: t("connections.components.fields.destination"), headerName: t('connections.components.fields.destination'),
width: 160, width: 160,
minWidth: 120, minWidth: 120,
}, },
{ {
field: "type", field: 'type',
headerName: t("connections.components.fields.type"), headerName: t('connections.components.fields.type'),
width: 120, width: 120,
minWidth: 80, minWidth: 80,
}, },
]; ]
}, [t]); }, [t])
useEffect(() => { useEffect(() => {
setColumnOrder((prevValue) => { setColumnOrder((prevValue) => {
const baseFields = baseColumns.map((col) => col.field); const baseFields = baseColumns.map((col) => col.field)
const prev = Array.isArray(prevValue) ? prevValue : []; const prev = Array.isArray(prevValue) ? prevValue : []
const reconciled = reconcileColumnOrder(prev, baseFields); const reconciled = reconcileColumnOrder(prev, baseFields)
if ( if (
reconciled.length === prev.length && reconciled.length === prev.length &&
reconciled.every((field, i) => field === prev[i]) reconciled.every((field, i) => field === prev[i])
) { ) {
return prevValue; return prevValue
} }
return reconciled; return reconciled
}); })
}, [baseColumns, setColumnOrder]); }, [baseColumns, setColumnOrder])
const handleColumnVisibilityChange = useCallback( const handleColumnVisibilityChange = useCallback(
(update: Updater<VisibilityState>) => { (update: Updater<VisibilityState>) => {
setColumnVisibilityModel((prev) => { setColumnVisibilityModel((prev) => {
const current = prev ?? {}; const current = prev ?? {}
const nextState = const nextState =
typeof update === "function" ? update(current) : update; typeof update === 'function' ? update(current) : update
const visibleCount = baseColumns.reduce((count, column) => { const visibleCount = baseColumns.reduce((count, column) => {
const isVisible = (nextState[column.field] ?? true) !== false; const isVisible = (nextState[column.field] ?? true) !== false
return count + (isVisible ? 1 : 0); return count + (isVisible ? 1 : 0)
}, 0); }, 0)
if (visibleCount === 0) { if (visibleCount === 0) {
return current; return current
} }
const sanitized: VisibilityState = {}; const sanitized: VisibilityState = {}
baseColumns.forEach((column) => { baseColumns.forEach((column) => {
if (nextState[column.field] === false) { if (nextState[column.field] === false) {
sanitized[column.field] = false; sanitized[column.field] = false
} }
}); })
return sanitized; return sanitized
}); })
}, },
[baseColumns, setColumnVisibilityModel], [baseColumns, setColumnVisibilityModel],
); )
const handleColumnOrderChange = useCallback( const handleColumnOrderChange = useCallback(
(update: Updater<ColumnOrderState>) => { (update: Updater<ColumnOrderState>) => {
setColumnOrder((prev) => { setColumnOrder((prev) => {
const current = Array.isArray(prev) ? prev : []; const current = Array.isArray(prev) ? prev : []
const nextState = const nextState =
typeof update === "function" ? update(current) : update; typeof update === 'function' ? update(current) : update
const baseFields = baseColumns.map((col) => col.field); const baseFields = baseColumns.map((col) => col.field)
return reconcileColumnOrder(nextState, baseFields); return reconcileColumnOrder(nextState, baseFields)
}); })
}, },
[baseColumns, setColumnOrder], [baseColumns, setColumnOrder],
); )
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([])
const [relativeNow, setRelativeNow] = useState(() => Date.now()); const [relativeNow, setRelativeNow] = useState(() => Date.now())
const columnDefs = useMemo<ColumnDef<IConnectionsItem>[]>(() => { const columnDefs = useMemo<ColumnDef<IConnectionsItem>[]>(() => {
return baseColumns.map((column) => { return baseColumns.map((column) => {
const baseCell: ColumnDef<IConnectionsItem>["cell"] = column.cell const baseCell: ColumnDef<IConnectionsItem>['cell'] = column.cell
? (ctx) => column.cell?.(ctx.row.original) ? (ctx) => column.cell?.(ctx.row.original)
: (ctx) => ctx.getValue() as ReactNode; : (ctx) => ctx.getValue() as ReactNode
const cell: ColumnDef<IConnectionsItem>["cell"] = const cell: ColumnDef<IConnectionsItem>['cell'] =
column.field === "time" column.field === 'time'
? (ctx) => dayjs(ctx.getValue() as string).from(relativeNow) ? (ctx) => dayjs(ctx.getValue() as string).from(relativeNow)
: baseCell; : baseCell
return { return {
id: column.field, id: column.field,
@@ -327,42 +327,42 @@ export const ConnectionTable = (props: Props) => {
size: column.width, size: column.width,
minSize: column.minWidth, minSize: column.minWidth,
meta: { meta: {
align: column.align ?? "left", align: column.align ?? 'left',
field: column.field, field: column.field,
label: column.headerName, label: column.headerName,
}, },
cell, cell,
} satisfies ColumnDef<IConnectionsItem>; } satisfies ColumnDef<IConnectionsItem>
}); })
}, [baseColumns, relativeNow]); }, [baseColumns, relativeNow])
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return undefined; if (typeof window === 'undefined') return undefined
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
setRelativeNow(Date.now()); setRelativeNow(Date.now())
}, 5000); }, 5000)
return () => window.clearInterval(timer); return () => window.clearInterval(timer)
}, []); }, [])
const handleColumnSizingChange = useCallback( const handleColumnSizingChange = useCallback(
(updater: Updater<ColumnSizingState>) => { (updater: Updater<ColumnSizingState>) => {
setColumnWidths((prev) => { setColumnWidths((prev) => {
const prevState = prev ?? {}; const prevState = prev ?? {}
const nextState = const nextState =
typeof updater === "function" ? updater(prevState) : updater; typeof updater === 'function' ? updater(prevState) : updater
const sanitized: ColumnSizingState = {}; const sanitized: ColumnSizingState = {}
Object.entries(nextState).forEach(([key, size]) => { Object.entries(nextState).forEach(([key, size]) => {
if (typeof size === "number" && Number.isFinite(size)) { if (typeof size === 'number' && Number.isFinite(size)) {
sanitized[key] = size; sanitized[key] = size
} }
}); })
return sanitized; return sanitized
}); })
}, },
[setColumnWidths], [setColumnWidths],
); )
const table = useReactTable({ const table = useReactTable({
data: connections, data: connections,
@@ -379,7 +379,7 @@ export const ConnectionTable = (props: Props) => {
minSize: 80, minSize: 80,
enableResizing: true, enableResizing: true,
}, },
columnResizeMode: "onChange", columnResizeMode: 'onChange',
enableSortingRemoval: true, enableSortingRemoval: true,
getRowId: (row) => row.id, getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@@ -389,61 +389,61 @@ export const ConnectionTable = (props: Props) => {
onColumnVisibilityChange: handleColumnVisibilityChange, onColumnVisibilityChange: handleColumnVisibilityChange,
onColumnOrderChange: handleColumnOrderChange, onColumnOrderChange: handleColumnOrderChange,
columns: columnDefs, columns: columnDefs,
}); })
const handleManagerOrderChange = useCallback( const handleManagerOrderChange = useCallback(
(order: string[]) => { (order: string[]) => {
const baseFields = baseColumns.map((col) => col.field); const baseFields = baseColumns.map((col) => col.field)
table.setColumnOrder(reconcileColumnOrder(order, baseFields)); table.setColumnOrder(reconcileColumnOrder(order, baseFields))
}, },
[baseColumns, table], [baseColumns, table],
); )
const handleResetColumns = useCallback(() => { const handleResetColumns = useCallback(() => {
table.resetColumnVisibility(); table.resetColumnVisibility()
table.resetColumnOrder(); table.resetColumnOrder()
}, [table]); }, [table])
const rows = table.getRowModel().rows; const rows = table.getRowModel().rows
const tableContainerRef = useRef<HTMLDivElement | null>(null); const tableContainerRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: rows.length, count: rows.length,
getScrollElement: () => tableContainerRef.current, getScrollElement: () => tableContainerRef.current,
estimateSize: () => ROW_HEIGHT, estimateSize: () => ROW_HEIGHT,
overscan: 4, overscan: 4,
}); })
const virtualRows = rowVirtualizer.getVirtualItems(); const virtualRows = rowVirtualizer.getVirtualItems()
const totalSize = rowVirtualizer.getTotalSize(); const totalSize = rowVirtualizer.getTotalSize()
const tableWidth = table.getTotalSize(); const tableWidth = table.getTotalSize()
const managerColumns = table.getAllLeafColumns(); const managerColumns = table.getAllLeafColumns()
return ( return (
<> <>
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
position: "relative", position: 'relative',
fontFamily: (theme) => theme.typography.fontFamily, fontFamily: (theme) => theme.typography.fontFamily,
}} }}
> >
<Tooltip title={t("connections.components.columnManager.title")}> <Tooltip title={t('connections.components.columnManager.title')}>
<IconButton <IconButton
size="small" size="small"
onClick={onOpenColumnManager} onClick={onOpenColumnManager}
sx={{ sx={{
position: "absolute", position: 'absolute',
top: 4, top: 4,
right: 4, right: 4,
zIndex: 3, zIndex: 3,
backgroundColor: (theme) => backgroundColor: (theme) =>
theme.palette.mode === "dark" theme.palette.mode === 'dark'
? theme.palette.background.default ? theme.palette.background.default
: theme.palette.background.paper, : theme.palette.background.paper,
"&:hover": { '&:hover': {
backgroundColor: (theme) => theme.palette.action.hover, backgroundColor: (theme) => theme.palette.action.hover,
}, },
}} }}
@@ -456,25 +456,25 @@ export const ConnectionTable = (props: Props) => {
sx={{ sx={{
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
overflow: "auto", overflow: 'auto',
WebkitOverflowScrolling: "touch", WebkitOverflowScrolling: 'touch',
overscrollBehavior: "contain", overscrollBehavior: 'contain',
borderRadius: 1, borderRadius: 1,
border: "none", border: 'none',
"&::-webkit-scrollbar": { '&::-webkit-scrollbar': {
height: 8, height: 8,
}, },
}} }}
> >
<Box <Box
sx={{ sx={{
minWidth: "100%", minWidth: '100%',
width: tableWidth, width: tableWidth,
}} }}
> >
<Box <Box
sx={{ sx={{
position: "sticky", position: 'sticky',
top: 0, top: 0,
zIndex: 2, zIndex: 2,
}} }}
@@ -483,7 +483,7 @@ export const ConnectionTable = (props: Props) => {
<Box <Box
key={headerGroup.id} key={headerGroup.id}
sx={{ sx={{
display: "flex", display: 'flex',
borderBottom: (theme) => borderBottom: (theme) =>
`1px solid ${theme.palette.divider}`, `1px solid ${theme.palette.divider}`,
backgroundColor: (theme) => theme.palette.background.paper, backgroundColor: (theme) => theme.palette.background.paper,
@@ -491,12 +491,12 @@ export const ConnectionTable = (props: Props) => {
> >
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
if (header.isPlaceholder) { if (header.isPlaceholder) {
return null; return null
} }
const meta = header.column.columnDef.meta as { const meta = header.column.columnDef.meta as {
align?: "left" | "right"; align?: 'left' | 'right'
field: string; field: string
}; }
return ( return (
<Box <Box
key={header.id} key={header.id}
@@ -504,15 +504,15 @@ export const ConnectionTable = (props: Props) => {
flex: `0 0 ${header.getSize()}px`, flex: `0 0 ${header.getSize()}px`,
minWidth: header.column.columnDef.minSize || 80, minWidth: header.column.columnDef.minSize || 80,
maxWidth: header.column.columnDef.maxSize, maxWidth: header.column.columnDef.maxSize,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
position: "relative", position: 'relative',
boxSizing: "border-box", boxSizing: 'border-box',
fontSize: 13, fontSize: 13,
fontWeight: 600, fontWeight: 600,
color: "text.secondary", color: 'text.secondary',
userSelect: "none", userSelect: 'none',
"&:hover": { '&:hover': {
backgroundColor: (theme) => backgroundColor: (theme) =>
theme.palette.action.hover, theme.palette.action.hover,
}, },
@@ -527,18 +527,18 @@ export const ConnectionTable = (props: Props) => {
} }
sx={{ sx={{
flex: 1, flex: 1,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: justifyContent:
meta?.align === "right" meta?.align === 'right'
? "flex-end" ? 'flex-end'
: "flex-start", : 'flex-start',
gap: 0.5, gap: 0.5,
px: 1, px: 1,
py: 1, py: 1,
cursor: header.column.getCanSort() cursor: header.column.getCanSort()
? "pointer" ? 'pointer'
: "default", : 'default',
}} }}
> >
{flexRender( {flexRender(
@@ -546,30 +546,30 @@ export const ConnectionTable = (props: Props) => {
header.getContext(), header.getContext(),
)} )}
{{ {{
asc: "▲", asc: '▲',
desc: "▼", desc: '▼',
}[header.column.getIsSorted() as string] ?? null} }[header.column.getIsSorted() as string] ?? null}
</Box> </Box>
{header.column.getCanResize() && ( {header.column.getCanResize() && (
<Box <Box
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => { onMouseDown={(event) => {
event.stopPropagation(); event.stopPropagation()
header.getResizeHandler()(event); header.getResizeHandler()(event)
}} }}
onTouchStart={(event) => { onTouchStart={(event) => {
event.stopPropagation(); event.stopPropagation()
header.getResizeHandler()(event); header.getResizeHandler()(event)
}} }}
sx={{ sx={{
cursor: "col-resize", cursor: 'col-resize',
position: "absolute", position: 'absolute',
right: 0, right: 0,
top: 0, top: 0,
width: 4, width: 4,
height: "100%", height: '100%',
transform: "translateX(50%)", transform: 'translateX(50%)',
"&:hover": { '&:hover': {
backgroundColor: (theme) => backgroundColor: (theme) =>
theme.palette.action.active, theme.palette.action.active,
}, },
@@ -577,44 +577,44 @@ export const ConnectionTable = (props: Props) => {
/> />
)} )}
</Box> </Box>
); )
})} })}
</Box> </Box>
))} ))}
</Box> </Box>
<Box <Box
sx={{ sx={{
position: "relative", position: 'relative',
height: totalSize, height: totalSize,
}} }}
> >
{virtualRows.map((virtualRow) => { {virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]; const row = rows[virtualRow.index]
if (!row) return null; if (!row) return null
return ( return (
<Box <Box
key={row.id} key={row.id}
onClick={() => onShowDetail(row.original)} onClick={() => onShowDetail(row.original)}
sx={{ sx={{
display: "flex", display: 'flex',
position: "absolute", position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
height: virtualRow.size, height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
borderBottom: (theme) => borderBottom: (theme) =>
`1px solid ${theme.palette.divider}`, `1px solid ${theme.palette.divider}`,
cursor: "pointer", cursor: 'pointer',
"&:hover": { '&:hover': {
backgroundColor: (theme) => theme.palette.action.hover, backgroundColor: (theme) => theme.palette.action.hover,
}, },
}} }}
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as { const meta = cell.column.columnDef.meta as {
align?: "left" | "right"; align?: 'left' | 'right'
}; }
return ( return (
<Box <Box
key={cell.id} key={cell.id}
@@ -622,18 +622,18 @@ export const ConnectionTable = (props: Props) => {
flex: `0 0 ${cell.column.getSize()}px`, flex: `0 0 ${cell.column.getSize()}px`,
minWidth: cell.column.columnDef.minSize || 80, minWidth: cell.column.columnDef.minSize || 80,
maxWidth: cell.column.columnDef.maxSize, maxWidth: cell.column.columnDef.maxSize,
boxSizing: "border-box", boxSizing: 'border-box',
px: 1, px: 1,
fontSize: 13, fontSize: 13,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: justifyContent:
meta?.align === "right" meta?.align === 'right'
? "flex-end" ? 'flex-end'
: "flex-start", : 'flex-start',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
}} }}
> >
{flexRender( {flexRender(
@@ -641,10 +641,10 @@ export const ConnectionTable = (props: Props) => {
cell.getContext(), cell.getContext(),
)} )}
</Box> </Box>
); )
})} })}
</Box> </Box>
); )
})} })}
</Box> </Box>
</Box> </Box>
@@ -658,5 +658,5 @@ export const ConnectionTable = (props: Props) => {
onReset={handleResetColumns} onReset={handleResetColumns}
/> />
</> </>
); )
}; }
+29 -29
View File
@@ -1,47 +1,47 @@
import { DeveloperBoardOutlined } from "@mui/icons-material"; import { DeveloperBoardOutlined } from '@mui/icons-material'
import { Divider, Stack, Typography } from "@mui/material"; import { Divider, Stack, Typography } from '@mui/material'
import { useMemo } from "react"; import { useMemo } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { useClash } from "@/hooks/use-clash"; import { useClash } from '@/hooks/use-clash'
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from '@/providers/app-data-context'
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from './enhanced-card'
// 将毫秒转换为时:分:秒格式的函数 // 将毫秒转换为时:分:秒格式的函数
const formatUptime = (uptimeMs: number) => { const formatUptime = (uptimeMs: number) => {
const hours = Math.floor(uptimeMs / 3600000); const hours = Math.floor(uptimeMs / 3600000)
const minutes = Math.floor((uptimeMs % 3600000) / 60000); const minutes = Math.floor((uptimeMs % 3600000) / 60000)
const seconds = Math.floor((uptimeMs % 60000) / 1000); const seconds = Math.floor((uptimeMs % 60000) / 1000)
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}; }
export const ClashInfoCard = () => { export const ClashInfoCard = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const { version: clashVersion } = useClash(); const { version: clashVersion } = useClash()
const { clashConfig, rules, uptime, systemProxyAddress } = useAppData(); const { clashConfig, rules, uptime, systemProxyAddress } = useAppData()
// 使用useMemo缓存格式化后的uptime,避免频繁计算 // 使用useMemo缓存格式化后的uptime,避免频繁计算
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]); const formattedUptime = useMemo(() => formatUptime(uptime), [uptime])
// 使用备忘录组件内容,减少重新渲染 // 使用备忘录组件内容,减少重新渲染
const cardContent = useMemo(() => { const cardContent = useMemo(() => {
if (!clashConfig) return null; if (!clashConfig) return null
return ( return (
<Stack spacing={1.5}> <Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between"> <Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.coreVersion")} {t('home.components.clashInfo.fields.coreVersion')}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{clashVersion || "-"} {clashVersion || '-'}
</Typography> </Typography>
</Stack> </Stack>
<Divider /> <Divider />
<Stack direction="row" justifyContent="space-between"> <Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.systemProxyAddress")} {t('home.components.clashInfo.fields.systemProxyAddress')}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{systemProxyAddress} {systemProxyAddress}
@@ -50,16 +50,16 @@ export const ClashInfoCard = () => {
<Divider /> <Divider />
<Stack direction="row" justifyContent="space-between"> <Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.mixedPort")} {t('home.components.clashInfo.fields.mixedPort')}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{clashConfig.mixedPort || "-"} {clashConfig.mixedPort || '-'}
</Typography> </Typography>
</Stack> </Stack>
<Divider /> <Divider />
<Stack direction="row" justifyContent="space-between"> <Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.uptime")} {t('home.components.clashInfo.fields.uptime')}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{formattedUptime} {formattedUptime}
@@ -68,14 +68,14 @@ export const ClashInfoCard = () => {
<Divider /> <Divider />
<Stack direction="row" justifyContent="space-between"> <Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.rulesCount")} {t('home.components.clashInfo.fields.rulesCount')}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{rules.length} {rules.length}
</Typography> </Typography>
</Stack> </Stack>
</Stack> </Stack>
); )
}, [ }, [
clashConfig, clashConfig,
clashVersion, clashVersion,
@@ -83,16 +83,16 @@ export const ClashInfoCard = () => {
formattedUptime, formattedUptime,
rules.length, rules.length,
systemProxyAddress, systemProxyAddress,
]); ])
return ( return (
<EnhancedCard <EnhancedCard
title={t("home.components.clashInfo.title")} title={t('home.components.clashInfo.title')}
icon={<DeveloperBoardOutlined />} icon={<DeveloperBoardOutlined />}
iconColor="warning" iconColor="warning"
action={null} action={null}
> >
{cardContent} {cardContent}
</EnhancedCard> </EnhancedCard>
); )
}; }
+77 -77
View File
@@ -2,63 +2,63 @@ import {
DirectionsRounded, DirectionsRounded,
LanguageRounded, LanguageRounded,
MultipleStopRounded, MultipleStopRounded,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { Box, Paper, Stack, Typography } from "@mui/material"; import { Box, Paper, Stack, Typography } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import { useMemo } from "react"; import { useMemo } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { closeAllConnections } from "tauri-plugin-mihomo-api"; import { closeAllConnections } from 'tauri-plugin-mihomo-api'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from '@/providers/app-data-context'
import { patchClashMode } from "@/services/cmds"; import { patchClashMode } from '@/services/cmds'
import type { TranslationKey } from "@/types/generated/i18n-keys"; import type { TranslationKey } from '@/types/generated/i18n-keys'
const CLASH_MODES = ["rule", "global", "direct"] as const; const CLASH_MODES = ['rule', 'global', 'direct'] as const
type ClashMode = (typeof CLASH_MODES)[number]; type ClashMode = (typeof CLASH_MODES)[number]
const isClashMode = (mode: string): mode is ClashMode => 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< const MODE_META: Record<
ClashMode, ClashMode,
{ label: TranslationKey; description: TranslationKey } { label: TranslationKey; description: TranslationKey }
> = { > = {
rule: { rule: {
label: "home.components.clashMode.labels.rule", label: 'home.components.clashMode.labels.rule',
description: "home.components.clashMode.descriptions.rule", description: 'home.components.clashMode.descriptions.rule',
}, },
global: { global: {
label: "home.components.clashMode.labels.global", label: 'home.components.clashMode.labels.global',
description: "home.components.clashMode.descriptions.global", description: 'home.components.clashMode.descriptions.global',
}, },
direct: { direct: {
label: "home.components.clashMode.labels.direct", label: 'home.components.clashMode.labels.direct',
description: "home.components.clashMode.descriptions.direct", description: 'home.components.clashMode.descriptions.direct',
}, },
}; }
export const ClashModeCard = () => { export const ClashModeCard = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const { verge } = useVerge(); const { verge } = useVerge()
const { clashConfig, refreshClashConfig } = useAppData(); const { clashConfig, refreshClashConfig } = useAppData()
// 支持的模式列表 // 支持的模式列表
const modeList = CLASH_MODES; const modeList = CLASH_MODES
// 直接使用API返回的模式,不维护本地状态 // 直接使用API返回的模式,不维护本地状态
const currentMode = clashConfig?.mode?.toLowerCase(); const currentMode = clashConfig?.mode?.toLowerCase()
const currentModeKey = const currentModeKey =
typeof currentMode === "string" && isClashMode(currentMode) typeof currentMode === 'string' && isClashMode(currentMode)
? currentMode ? currentMode
: undefined; : undefined
const modeDescription = useMemo(() => { const modeDescription = useMemo(() => {
if (currentModeKey) { if (currentModeKey) {
return t(MODE_META[currentModeKey].description); return t(MODE_META[currentModeKey].description)
} }
return t("home.components.clashMode.errors.communication"); return t('home.components.clashMode.errors.communication')
}, [currentModeKey, t]); }, [currentModeKey, t])
// 模式图标映射 // 模式图标映射
const modeIcons = useMemo( const modeIcons = useMemo(
@@ -68,87 +68,87 @@ export const ClashModeCard = () => {
direct: <DirectionsRounded fontSize="small" />, direct: <DirectionsRounded fontSize="small" />,
}), }),
[], [],
); )
// 切换模式的处理函数 // 切换模式的处理函数
const onChangeMode = useLockFn(async (mode: ClashMode) => { const onChangeMode = useLockFn(async (mode: ClashMode) => {
if (mode === currentModeKey) return; if (mode === currentModeKey) return
if (verge?.auto_close_connection) { if (verge?.auto_close_connection) {
closeAllConnections(); closeAllConnections()
} }
try { try {
await patchClashMode(mode); await patchClashMode(mode)
// 使用共享的刷新方法 // 使用共享的刷新方法
refreshClashConfig(); refreshClashConfig()
} catch (error) { } catch (error) {
console.error("Failed to change mode:", error); console.error('Failed to change mode:', error)
} }
}); })
// 按钮样式 // 按钮样式
const buttonStyles = (mode: ClashMode) => ({ const buttonStyles = (mode: ClashMode) => ({
cursor: "pointer", cursor: 'pointer',
px: 2, px: 2,
py: 1.2, py: 1.2,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
gap: 1, gap: 1,
bgcolor: mode === currentModeKey ? "primary.main" : "background.paper", bgcolor: mode === currentModeKey ? 'primary.main' : 'background.paper',
color: mode === currentModeKey ? "primary.contrastText" : "text.primary", color: mode === currentModeKey ? 'primary.contrastText' : 'text.primary',
borderRadius: 1.5, borderRadius: 1.5,
transition: "all 0.2s ease-in-out", transition: 'all 0.2s ease-in-out',
position: "relative", position: 'relative',
overflow: "visible", overflow: 'visible',
"&:hover": { '&:hover': {
transform: "translateY(-1px)", transform: 'translateY(-1px)',
boxShadow: 1, boxShadow: 1,
}, },
"&:active": { '&:active': {
transform: "translateY(1px)", transform: 'translateY(1px)',
}, },
"&::after": '&::after':
mode === currentModeKey mode === currentModeKey
? { ? {
content: '""', content: '""',
position: "absolute", position: 'absolute',
bottom: -16, bottom: -16,
left: "50%", left: '50%',
width: 2, width: 2,
height: 16, height: 16,
bgcolor: "primary.main", bgcolor: 'primary.main',
transform: "translateX(-50%)", transform: 'translateX(-50%)',
} }
: {}, : {},
}); })
// 描述样式 // 描述样式
const descriptionStyles = { const descriptionStyles = {
width: "95%", width: '95%',
textAlign: "center", textAlign: 'center',
color: "text.secondary", color: 'text.secondary',
p: 0.8, p: 0.8,
borderRadius: 1, borderRadius: 1,
borderColor: "primary.main", borderColor: 'primary.main',
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: 'solid',
backgroundColor: "background.paper", backgroundColor: 'background.paper',
wordBreak: "break-word", wordBreak: 'break-word',
hyphens: "auto", hyphens: 'auto',
}; }
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}> <Box sx={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
{/* 模式选择按钮组 */} {/* 模式选择按钮组 */}
<Stack <Stack
direction="row" direction="row"
spacing={1} spacing={1}
sx={{ sx={{
display: "flex", display: 'flex',
justifyContent: "center", justifyContent: 'center',
py: 1, py: 1,
position: "relative", position: 'relative',
zIndex: 2, zIndex: 2,
}} }}
> >
@@ -163,7 +163,7 @@ export const ClashModeCard = () => {
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
textTransform: "capitalize", textTransform: 'capitalize',
fontWeight: mode === currentModeKey ? 600 : 400, fontWeight: mode === currentModeKey ? 600 : 400,
}} }}
> >
@@ -176,12 +176,12 @@ export const ClashModeCard = () => {
{/* 说明文本区域 */} {/* 说明文本区域 */}
<Box <Box
sx={{ sx={{
width: "100%", width: '100%',
my: 1, my: 1,
position: "relative", position: 'relative',
display: "flex", display: 'flex',
justifyContent: "center", justifyContent: 'center',
overflow: "visible", overflow: 'visible',
}} }}
> >
<Typography variant="caption" component="div" sx={descriptionStyles}> <Typography variant="caption" component="div" sx={descriptionStyles}>
@@ -189,5 +189,5 @@ export const ClashModeCard = () => {
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
); )
}; }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37 -43
View File
@@ -1,21 +1,15 @@
import { Box, Typography, alpha, useTheme } from "@mui/material"; import { Box, Typography, alpha, useTheme } from '@mui/material'
import React, { forwardRef, ReactNode } from "react"; import React, { forwardRef, ReactNode } from 'react'
// 自定义卡片组件接口 // 自定义卡片组件接口
interface EnhancedCardProps { interface EnhancedCardProps {
title: ReactNode; title: ReactNode
icon: ReactNode; icon: ReactNode
action?: ReactNode; action?: ReactNode
children: ReactNode; children: ReactNode
iconColor?: iconColor?: 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'
| "primary" minHeight?: number | string
| "secondary" noContentPadding?: boolean
| "error"
| "warning"
| "info"
| "success";
minHeight?: number | string;
noContentPadding?: boolean;
} }
// 自定义卡片组件 // 自定义卡片组件
@@ -26,33 +20,33 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
icon, icon,
action, action,
children, children,
iconColor = "primary", iconColor = 'primary',
minHeight, minHeight,
noContentPadding = false, noContentPadding = false,
}, },
ref, ref,
) => { ) => {
const theme = useTheme(); const theme = useTheme()
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === 'dark'
// 统一的标题截断样式 // 统一的标题截断样式
const titleTruncateStyle = { const titleTruncateStyle = {
minWidth: 0, minWidth: 0,
maxWidth: "100%", maxWidth: '100%',
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
display: "block", display: 'block',
}; }
return ( return (
<Box <Box
sx={{ sx={{
height: "100%", height: '100%',
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
borderRadius: 2, borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff", backgroundColor: isDark ? '#282a36' : '#ffffff',
}} }}
ref={ref} ref={ref}
> >
@@ -60,27 +54,27 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
sx={{ sx={{
px: 2, px: 2,
py: 1, py: 1,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
borderBottom: 1, borderBottom: 1,
borderColor: "divider", borderColor: 'divider',
}} }}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
minWidth: 0, minWidth: 0,
flex: 1, flex: 1,
overflow: "hidden", overflow: 'hidden',
}} }}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
borderRadius: 1.5, borderRadius: 1.5,
width: 38, width: 38,
height: 38, height: 38,
@@ -93,7 +87,7 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
{icon} {icon}
</Box> </Box>
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
{typeof title === "string" ? ( {typeof title === 'string' ? (
<Typography <Typography
variant="h6" variant="h6"
fontWeight="medium" fontWeight="medium"
@@ -113,8 +107,8 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
<Box <Box
sx={{ sx={{
flex: 1, flex: 1,
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
p: noContentPadding ? 0 : 2, p: noContentPadding ? 0 : 2,
...(minHeight && { minHeight }), ...(minHeight && { minHeight }),
}} }}
@@ -122,6 +116,6 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
{children} {children}
</Box> </Box>
</Box> </Box>
); )
}, },
); )
+82 -82
View File
@@ -5,7 +5,7 @@ import {
CloudUploadRounded, CloudUploadRounded,
LinkRounded, LinkRounded,
MemoryRounded, MemoryRounded,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { import {
Grid, Grid,
PaletteColor, PaletteColor,
@@ -13,74 +13,74 @@ import {
Typography, Typography,
alpha, alpha,
useTheme, useTheme,
} from "@mui/material"; } from '@mui/material'
import { ReactNode, memo, useMemo, useRef } from "react"; import { ReactNode, memo, useMemo, useRef } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { TrafficErrorBoundary } from "@/components/shared/traffic-error-boundary"; import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useConnectionData } from "@/hooks/use-connection-data"; import { useConnectionData } from '@/hooks/use-connection-data'
import { useMemoryData } from "@/hooks/use-memory-data"; import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from "@/hooks/use-traffic-data"; import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import { useVisibility } from "@/hooks/use-visibility"; import { useVisibility } from '@/hooks/use-visibility'
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from '@/utils/parse-traffic'
import { import {
EnhancedCanvasTrafficGraph, EnhancedCanvasTrafficGraph,
type EnhancedCanvasTrafficGraphRef, type EnhancedCanvasTrafficGraphRef,
} from "./enhanced-canvas-traffic-graph"; } from './enhanced-canvas-traffic-graph'
interface StatCardProps { interface StatCardProps {
icon: ReactNode; icon: ReactNode
title: string; title: string
value: string | number; value: string | number
unit: string; unit: string
color: "primary" | "secondary" | "error" | "warning" | "info" | "success"; color: 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'
onClick?: () => void; onClick?: () => void
} }
// 全局变量类型定义 // 全局变量类型定义
declare global { declare global {
interface Window { interface Window {
animationFrameId?: number; animationFrameId?: number
lastTrafficData?: { lastTrafficData?: {
up: number; up: number
down: number; down: number
}; }
} }
} }
// 统计卡片组件 - 使用memo优化 // 统计卡片组件 - 使用memo优化
const CompactStatCard = memo( const CompactStatCard = memo(
({ icon, title, value, unit, color, onClick }: StatCardProps) => { ({ icon, title, value, unit, color, onClick }: StatCardProps) => {
const theme = useTheme(); const theme = useTheme()
// 获取调色板颜色 - 使用useMemo避免重复计算 // 获取调色板颜色 - 使用useMemo避免重复计算
const colorValue = useMemo(() => { const colorValue = useMemo(() => {
const palette = theme.palette; const palette = theme.palette
if ( if (
color in palette && color in palette &&
palette[color as keyof typeof 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; return palette.primary.main
}, [theme.palette, color]); }, [theme.palette, color])
return ( return (
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
borderRadius: 2, borderRadius: 2,
bgcolor: alpha(colorValue, 0.05), bgcolor: alpha(colorValue, 0.05),
border: `1px solid ${alpha(colorValue, 0.15)}`, border: `1px solid ${alpha(colorValue, 0.15)}`,
padding: "8px", padding: '8px',
transition: "all 0.2s ease-in-out", transition: 'all 0.2s ease-in-out',
cursor: onClick ? "pointer" : "default", cursor: onClick ? 'pointer' : 'default',
"&:hover": onClick '&:hover': onClick
? { ? {
bgcolor: alpha(colorValue, 0.1), bgcolor: alpha(colorValue, 0.1),
border: `1px solid ${alpha(colorValue, 0.3)}`, border: `1px solid ${alpha(colorValue, 0.3)}`,
@@ -95,13 +95,13 @@ const CompactStatCard = memo(
component="div" component="div"
sx={{ sx={{
mr: 1, mr: 1,
ml: "2px", ml: '2px',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
width: 32, width: 32,
height: 32, height: 32,
borderRadius: "50%", borderRadius: '50%',
bgcolor: alpha(colorValue, 0.1), bgcolor: alpha(colorValue, 0.1),
color: colorValue, color: colorValue,
}} }}
@@ -116,7 +116,7 @@ const CompactStatCard = memo(
</Typography> </Typography>
<Grid <Grid
component="div" component="div"
sx={{ display: "flex", alignItems: "baseline" }} sx={{ display: 'flex', alignItems: 'baseline' }}
> >
<Typography <Typography
variant="body1" variant="body1"
@@ -132,48 +132,48 @@ const CompactStatCard = memo(
</Grid> </Grid>
</Grid> </Grid>
</Paper> </Paper>
); )
}, },
); )
// 添加显示名称 // 添加显示名称
CompactStatCard.displayName = "CompactStatCard"; CompactStatCard.displayName = 'CompactStatCard'
export const EnhancedTrafficStats = () => { export const EnhancedTrafficStats = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const theme = useTheme(); const theme = useTheme()
const { verge } = useVerge(); const { verge } = useVerge()
const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null); const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null)
const pageVisible = useVisibility(); const pageVisible = useVisibility()
// 是否显示流量图表 // 是否显示流量图表
const trafficGraph = verge?.traffic_graph ?? true; const trafficGraph = verge?.traffic_graph ?? true
const { const {
response: { data: traffic }, response: { data: traffic },
} = useTrafficData({ enabled: trafficGraph && pageVisible }); } = useTrafficData({ enabled: trafficGraph && pageVisible })
const { const {
response: { data: memory }, response: { data: memory },
} = useMemoryData(); } = useMemoryData()
const { const {
response: { data: connections }, response: { data: connections },
} = useConnectionData(); } = useConnectionData()
// Canvas组件现在直接从全局Hook获取数据,无需手动添加数据点 // Canvas组件现在直接从全局Hook获取数据,无需手动添加数据点
// 使用useMemo计算解析后的流量数据 // 使用useMemo计算解析后的流量数据
const parsedData = useMemo(() => { const parsedData = useMemo(() => {
const [up, upUnit] = parseTraffic(traffic?.up || 0); const [up, upUnit] = parseTraffic(traffic?.up || 0)
const [down, downUnit] = parseTraffic(traffic?.down || 0); const [down, downUnit] = parseTraffic(traffic?.down || 0)
const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0); const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0)
const [uploadTotal, uploadTotalUnit] = parseTraffic( const [uploadTotal, uploadTotalUnit] = parseTraffic(
connections?.uploadTotal, connections?.uploadTotal,
); )
const [downloadTotal, downloadTotalUnit] = parseTraffic( const [downloadTotal, downloadTotalUnit] = parseTraffic(
connections?.downloadTotal, connections?.downloadTotal,
); )
return { return {
up, up,
@@ -187,86 +187,86 @@ export const EnhancedTrafficStats = () => {
downloadTotal, downloadTotal,
downloadTotalUnit, downloadTotalUnit,
connectionsCount: connections?.activeConnections.length, connectionsCount: connections?.activeConnections.length,
}; }
}, [traffic, memory, connections]); }, [traffic, memory, connections])
// 渲染流量图表 - 使用useMemo缓存渲染结果 // 渲染流量图表 - 使用useMemo缓存渲染结果
const trafficGraphComponent = useMemo(() => { const trafficGraphComponent = useMemo(() => {
if (!trafficGraph || !pageVisible) return null; if (!trafficGraph || !pageVisible) return null
return ( return (
<Paper <Paper
elevation={0} elevation={0}
sx={{ sx={{
height: 130, height: 130,
cursor: "pointer", cursor: 'pointer',
border: `1px solid ${alpha(theme.palette.divider, 0.2)}`, border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
borderRadius: 2, borderRadius: 2,
overflow: "hidden", overflow: 'hidden',
}} }}
onClick={() => trafficRef.current?.toggleStyle()} onClick={() => trafficRef.current?.toggleStyle()}
> >
<div style={{ height: "100%", position: "relative" }}> <div style={{ height: '100%', position: 'relative' }}>
<EnhancedCanvasTrafficGraph ref={trafficRef} /> <EnhancedCanvasTrafficGraph ref={trafficRef} />
</div> </div>
</Paper> </Paper>
); )
}, [trafficGraph, pageVisible, theme.palette.divider]); }, [trafficGraph, pageVisible, theme.palette.divider])
// 使用useMemo计算统计卡片配置 // 使用useMemo计算统计卡片配置
const statCards = useMemo( const statCards = useMemo(
() => [ () => [
{ {
icon: <ArrowUpwardRounded fontSize="small" />, icon: <ArrowUpwardRounded fontSize="small" />,
title: t("home.components.traffic.metrics.uploadSpeed"), title: t('home.components.traffic.metrics.uploadSpeed'),
value: parsedData.up, value: parsedData.up,
unit: `${parsedData.upUnit}/s`, unit: `${parsedData.upUnit}/s`,
color: "secondary" as const, color: 'secondary' as const,
}, },
{ {
icon: <ArrowDownwardRounded fontSize="small" />, icon: <ArrowDownwardRounded fontSize="small" />,
title: t("home.components.traffic.metrics.downloadSpeed"), title: t('home.components.traffic.metrics.downloadSpeed'),
value: parsedData.down, value: parsedData.down,
unit: `${parsedData.downUnit}/s`, unit: `${parsedData.downUnit}/s`,
color: "primary" as const, color: 'primary' as const,
}, },
{ {
icon: <LinkRounded fontSize="small" />, icon: <LinkRounded fontSize="small" />,
title: t("home.components.traffic.metrics.activeConnections"), title: t('home.components.traffic.metrics.activeConnections'),
value: parsedData.connectionsCount, value: parsedData.connectionsCount,
unit: "", unit: '',
color: "success" as const, color: 'success' as const,
}, },
{ {
icon: <CloudUploadRounded fontSize="small" />, icon: <CloudUploadRounded fontSize="small" />,
title: t("shared.labels.uploaded"), title: t('shared.labels.uploaded'),
value: parsedData.uploadTotal, value: parsedData.uploadTotal,
unit: parsedData.uploadTotalUnit, unit: parsedData.uploadTotalUnit,
color: "secondary" as const, color: 'secondary' as const,
}, },
{ {
icon: <CloudDownloadRounded fontSize="small" />, icon: <CloudDownloadRounded fontSize="small" />,
title: t("shared.labels.downloaded"), title: t('shared.labels.downloaded'),
value: parsedData.downloadTotal, value: parsedData.downloadTotal,
unit: parsedData.downloadTotalUnit, unit: parsedData.downloadTotalUnit,
color: "primary" as const, color: 'primary' as const,
}, },
{ {
icon: <MemoryRounded fontSize="small" />, icon: <MemoryRounded fontSize="small" />,
title: t("home.components.traffic.metrics.memoryUsage"), title: t('home.components.traffic.metrics.memoryUsage'),
value: parsedData.inuse, value: parsedData.inuse,
unit: parsedData.inuseUnit, unit: parsedData.inuseUnit,
color: "error" as const, color: 'error' as const,
onClick: undefined, onClick: undefined,
}, },
], ],
[t, parsedData], [t, parsedData],
); )
return ( return (
<TrafficErrorBoundary <TrafficErrorBoundary
onError={(error, errorInfo) => { onError={(error, errorInfo) => {
console.error("[EnhancedTrafficStats] 组件错误:", error, errorInfo); console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
}} }}
> >
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}> <Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
@@ -284,5 +284,5 @@ export const EnhancedTrafficStats = () => {
))} ))}
</Grid> </Grid>
</TrafficErrorBoundary> </TrafficErrorBoundary>
); )
}; }
+116 -116
View File
@@ -6,7 +6,7 @@ import {
SpeedOutlined, SpeedOutlined,
StorageOutlined, StorageOutlined,
UpdateOutlined, UpdateOutlined,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { import {
Box, Box,
Button, Button,
@@ -17,62 +17,62 @@ import {
alpha, alpha,
keyframes, keyframes,
useTheme, useTheme,
} from "@mui/material"; } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import dayjs from "dayjs"; import dayjs from 'dayjs'
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { useNavigate } from "react-router"; import { useNavigate } from 'react-router'
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from '@/providers/app-data-context'
import { openWebUrl, updateProfile } from "@/services/cmds"; import { openWebUrl, updateProfile } from '@/services/cmds'
import { showNotice } from "@/services/notice-service"; import { showNotice } from '@/services/notice-service'
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from '@/utils/parse-traffic'
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from './enhanced-card'
// 定义旋转动画 // 定义旋转动画
const round = keyframes` const round = keyframes`
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
`; `
// 辅助函数解析URL和过期时间 // 辅助函数解析URL和过期时间
const parseUrl = (url?: string) => { const parseUrl = (url?: string) => {
if (!url) return "-"; if (!url) return '-'
if (url.startsWith("http")) return new URL(url).host; if (url.startsWith('http')) return new URL(url).host
return "local"; return 'local'
}; }
const parseExpire = (expire?: number) => { const parseExpire = (expire?: number) => {
if (!expire) return "-"; if (!expire) return '-'
return dayjs(expire * 1000).format("YYYY-MM-DD"); return dayjs(expire * 1000).format('YYYY-MM-DD')
}; }
// 使用类型定义,而不是导入 // 使用类型定义,而不是导入
interface ProfileExtra { interface ProfileExtra {
upload: number; upload: number
download: number; download: number
total: number; total: number
expire: number; expire: number
} }
interface ProfileItem { interface ProfileItem {
uid: string; uid: string
type?: "local" | "remote" | "merge" | "script"; type?: 'local' | 'remote' | 'merge' | 'script'
name?: string; name?: string
desc?: string; desc?: string
file?: string; file?: string
url?: string; url?: string
updated?: number; updated?: number
extra?: ProfileExtra; extra?: ProfileExtra
home?: string; home?: string
option?: any; option?: any
} }
interface HomeProfileCardProps { interface HomeProfileCardProps {
current: ProfileItem | null | undefined; current: ProfileItem | null | undefined
onProfileUpdated?: () => void; onProfileUpdated?: () => void
} }
// 提取独立组件减少主组件复杂度 // 提取独立组件减少主组件复杂度
@@ -81,23 +81,23 @@ const ProfileDetails = ({
onUpdateProfile, onUpdateProfile,
updating, updating,
}: { }: {
current: ProfileItem; current: ProfileItem
onUpdateProfile: () => void; onUpdateProfile: () => void
updating: boolean; updating: boolean
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation()
const theme = useTheme(); const theme = useTheme()
const usedTraffic = useMemo(() => { const usedTraffic = useMemo(() => {
if (!current.extra) return 0; if (!current.extra) return 0
return current.extra.upload + current.extra.download; return current.extra.upload + current.extra.download
}, [current.extra]); }, [current.extra])
const trafficPercentage = useMemo(() => { const trafficPercentage = useMemo(() => {
if (!current.extra || !current.extra.total || current.extra.total <= 0) if (!current.extra || !current.extra.total || current.extra.total <= 0)
return 0; return 0
return Math.min(Math.round((usedTraffic / current.extra.total) * 100), 100); return Math.min(Math.round((usedTraffic / current.extra.total) * 100), 100)
}, [current.extra, usedTraffic]); }, [current.extra, usedTraffic])
return ( return (
<Box> <Box>
@@ -109,19 +109,19 @@ const ProfileDetails = ({
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
noWrap noWrap
sx={{ display: "flex", alignItems: "center" }} sx={{ display: 'flex', alignItems: 'center' }}
> >
<span style={{ flexShrink: 0 }}>{t("shared.labels.from")}: </span> <span style={{ flexShrink: 0 }}>{t('shared.labels.from')}: </span>
{current.home ? ( {current.home ? (
<Link <Link
component="button" component="button"
fontWeight="medium" fontWeight="medium"
onClick={() => current.home && openWebUrl(current.home)} onClick={() => current.home && openWebUrl(current.home)}
sx={{ sx={{
display: "inline-flex", display: 'inline-flex',
alignItems: "center", alignItems: 'center',
minWidth: 0, minWidth: 0,
maxWidth: "calc(100% - 40px)", maxWidth: 'calc(100% - 40px)',
ml: 0.5, ml: 0.5,
}} }}
title={parseUrl(current.url)} title={parseUrl(current.url)}
@@ -129,9 +129,9 @@ const ProfileDetails = ({
<Typography <Typography
component="span" component="span"
sx={{ sx={{
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
minWidth: 0, minWidth: 0,
flex: 1, flex: 1,
}} }}
@@ -142,7 +142,7 @@ const ProfileDetails = ({
fontSize="inherit" fontSize="inherit"
sx={{ sx={{
ml: 0.5, ml: 0.5,
fontSize: "0.8rem", fontSize: '0.8rem',
opacity: 0.7, opacity: 0.7,
flexShrink: 0, flexShrink: 0,
}} }}
@@ -153,9 +153,9 @@ const ProfileDetails = ({
component="span" component="span"
fontWeight="medium" fontWeight="medium"
sx={{ sx={{
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
minWidth: 0, minWidth: 0,
flex: 1, flex: 1,
ml: 0.5, ml: 0.5,
@@ -175,20 +175,20 @@ const ProfileDetails = ({
fontSize="small" fontSize="small"
color="action" color="action"
sx={{ sx={{
cursor: "pointer", cursor: 'pointer',
animation: updating ? `${round} 1.5s linear infinite` : "none", animation: updating ? `${round} 1.5s linear infinite` : 'none',
}} }}
onClick={onUpdateProfile} onClick={onUpdateProfile}
/> />
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ cursor: "pointer" }} sx={{ cursor: 'pointer' }}
onClick={onUpdateProfile} onClick={onUpdateProfile}
> >
{t("shared.labels.updateTime")}:{" "} {t('shared.labels.updateTime')}:{' '}
<Box component="span" fontWeight="medium"> <Box component="span" fontWeight="medium">
{dayjs(current.updated * 1000).format("YYYY-MM-DD HH:mm")} {dayjs(current.updated * 1000).format('YYYY-MM-DD HH:mm')}
</Box> </Box>
</Typography> </Typography>
</Stack> </Stack>
@@ -199,9 +199,9 @@ const ProfileDetails = ({
<Stack direction="row" alignItems="center" spacing={1}> <Stack direction="row" alignItems="center" spacing={1}>
<SpeedOutlined fontSize="small" color="action" /> <SpeedOutlined fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("shared.labels.usedTotal")}:{" "} {t('shared.labels.usedTotal')}:{' '}
<Box component="span" fontWeight="medium"> <Box component="span" fontWeight="medium">
{parseTraffic(usedTraffic)} /{" "} {parseTraffic(usedTraffic)} /{' '}
{parseTraffic(current.extra.total)} {parseTraffic(current.extra.total)}
</Box> </Box>
</Typography> </Typography>
@@ -211,7 +211,7 @@ const ProfileDetails = ({
<Stack direction="row" alignItems="center" spacing={1}> <Stack direction="row" alignItems="center" spacing={1}>
<EventOutlined fontSize="small" color="action" /> <EventOutlined fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("shared.labels.expireTime")}:{" "} {t('shared.labels.expireTime')}:{' '}
<Box component="span" fontWeight="medium"> <Box component="span" fontWeight="medium">
{parseExpire(current.extra.expire)} {parseExpire(current.extra.expire)}
</Box> </Box>
@@ -223,7 +223,7 @@ const ProfileDetails = ({
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ mb: 0.5, display: "block" }} sx={{ mb: 0.5, display: 'block' }}
> >
{trafficPercentage}% {trafficPercentage}%
</Typography> </Typography>
@@ -241,78 +241,78 @@ const ProfileDetails = ({
)} )}
</Stack> </Stack>
</Box> </Box>
); )
}; }
// 提取空配置组件 // 提取空配置组件
const EmptyProfile = ({ onClick }: { onClick: () => void }) => { const EmptyProfile = ({ onClick }: { onClick: () => void }) => {
const { t } = useTranslation(); const { t } = useTranslation()
return ( return (
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
py: 2.4, py: 2.4,
cursor: "pointer", cursor: 'pointer',
"&:hover": { bgcolor: "action.hover" }, '&:hover': { bgcolor: 'action.hover' },
borderRadius: 2, borderRadius: 2,
}} }}
onClick={onClick} onClick={onClick}
> >
<CloudUploadOutlined <CloudUploadOutlined
sx={{ fontSize: 60, color: "primary.main", mb: 2 }} sx={{ fontSize: 60, color: 'primary.main', mb: 2 }}
/> />
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
{t("profiles.page.actions.import")} {t("profiles.page.title")} {t('profiles.page.actions.import')} {t('profiles.page.title')}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("profiles.components.card.labels.clickToImport")} {t('profiles.components.card.labels.clickToImport')}
</Typography> </Typography>
</Box> </Box>
); )
}; }
export const HomeProfileCard = ({ export const HomeProfileCard = ({
current, current,
onProfileUpdated, onProfileUpdated,
}: HomeProfileCardProps) => { }: HomeProfileCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation()
const navigate = useNavigate(); const navigate = useNavigate()
const { refreshAll } = useAppData(); const { refreshAll } = useAppData()
// 更新当前订阅 // 更新当前订阅
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false)
const onUpdateProfile = useLockFn(async () => { const onUpdateProfile = useLockFn(async () => {
if (!current?.uid) return; if (!current?.uid) return
setUpdating(true); setUpdating(true)
try { try {
await updateProfile(current.uid, current.option); await updateProfile(current.uid, current.option)
onProfileUpdated?.(); onProfileUpdated?.()
// 刷新首页数据 // 刷新首页数据
refreshAll(); refreshAll()
} catch (err) { } catch (err) {
showNotice.error(err, 3000); showNotice.error(err, 3000)
} finally { } finally {
setUpdating(false); setUpdating(false)
} }
}); })
// 导航到订阅页面 // 导航到订阅页面
const goToProfiles = useCallback(() => { const goToProfiles = useCallback(() => {
navigate("/profile"); navigate('/profile')
}, [navigate]); }, [navigate])
// 卡片标题 // 卡片标题
const cardTitle = useMemo(() => { 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 ( return (
<Link <Link
@@ -322,16 +322,16 @@ export const HomeProfileCard = ({
fontSize={18} fontSize={18}
onClick={() => current.home && openWebUrl(current.home)} onClick={() => current.home && openWebUrl(current.home)}
sx={{ sx={{
color: "inherit", color: 'inherit',
textDecoration: "none", textDecoration: 'none',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
minWidth: 0, minWidth: 0,
maxWidth: "100%", maxWidth: '100%',
"& > span": { '& > span': {
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
flex: 1, flex: 1,
}, },
}} }}
@@ -342,18 +342,18 @@ export const HomeProfileCard = ({
fontSize="inherit" fontSize="inherit"
sx={{ sx={{
ml: 0.5, ml: 0.5,
fontSize: "0.8rem", fontSize: '0.8rem',
opacity: 0.7, opacity: 0.7,
flexShrink: 0, flexShrink: 0,
}} }}
/> />
</Link> </Link>
); )
}, [current, t]); }, [current, t])
// 卡片操作按钮 // 卡片操作按钮
const cardAction = useMemo(() => { const cardAction = useMemo(() => {
if (!current) return null; if (!current) return null
return ( return (
<Button <Button
@@ -363,10 +363,10 @@ export const HomeProfileCard = ({
endIcon={<StorageOutlined fontSize="small" />} endIcon={<StorageOutlined fontSize="small" />}
sx={{ borderRadius: 1.5 }} sx={{ borderRadius: 1.5 }}
> >
{t("layout.components.navigation.tabs.profiles")} {t('layout.components.navigation.tabs.profiles')}
</Button> </Button>
); )
}, [current, goToProfiles, t]); }, [current, goToProfiles, t])
return ( return (
<EnhancedCard <EnhancedCard
@@ -385,5 +385,5 @@ export const HomeProfileCard = ({
<EmptyProfile onClick={goToProfiles} /> <EmptyProfile onClick={goToProfiles} />
)} )}
</EnhancedCard> </EnhancedCard>
); )
}; }
+146 -146
View File
@@ -3,12 +3,12 @@ import {
RefreshOutlined, RefreshOutlined,
VisibilityOffOutlined, VisibilityOffOutlined,
VisibilityOutlined, VisibilityOutlined,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material"; import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material'
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useEffect } from "foxact/use-abortable-effect"; import { useEffect } from 'foxact/use-abortable-effect'
import { useIntersection } from "foxact/use-intersection"; import { useIntersection } from 'foxact/use-intersection'
import type { XOR } from "foxts/ts-xor"; import type { XOR } from 'foxts/ts-xor'
import { import {
memo, memo,
useCallback, useCallback,
@@ -16,26 +16,26 @@ import {
useEffectEvent, useEffectEvent,
useMemo, useMemo,
forwardRef, forwardRef,
} from "react"; } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import useSWRImmutable from "swr/immutable"; import useSWRImmutable from 'swr/immutable'
import { getIpInfo } from "@/services/api"; import { getIpInfo } from '@/services/api'
import { SWR_EXTERNAL_API } from "@/services/config"; import { SWR_EXTERNAL_API } from '@/services/config'
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from './enhanced-card'
// 定义刷新时间(秒) // 定义刷新时间(秒)
const IP_REFRESH_SECONDS = 300; const IP_REFRESH_SECONDS = 300
const COUNTDOWN_TICK_INTERVAL = 5_000; const COUNTDOWN_TICK_INTERVAL = 5_000
const IP_INFO_CACHE_KEY = "cv_ip_info_cache"; const IP_INFO_CACHE_KEY = 'cv_ip_info_cache'
const InfoItem = memo(({ label, value }: { label: string; value?: string }) => ( const InfoItem = memo(({ label, value }: { label: string; value?: string }) => (
<Box sx={{ mb: 0.7, display: "flex", alignItems: "flex-start" }}> <Box sx={{ mb: 0.7, display: 'flex', alignItems: 'flex-start' }}>
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ minwidth: 60, mr: 0.5, flexShrink: 0, textAlign: "right" }} sx={{ minwidth: 60, mr: 0.5, flexShrink: 0, textAlign: 'right' }}
> >
{label}: {label}:
</Typography> </Typography>
@@ -43,46 +43,46 @@ const InfoItem = memo(({ label, value }: { label: string; value?: string }) => (
variant="body2" variant="body2"
sx={{ sx={{
ml: 0.5, ml: 0.5,
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
wordBreak: "break-word", wordBreak: 'break-word',
whiteSpace: "normal", whiteSpace: 'normal',
flexGrow: 1, flexGrow: 1,
}} }}
> >
{value || "Unknown"} {value || 'Unknown'}
</Typography> </Typography>
</Box> </Box>
)); ))
// 获取国旗表情 // 获取国旗表情
const getCountryFlag = (countryCode: string | undefined) => { const getCountryFlag = (countryCode: string | undefined) => {
if (!countryCode) return ""; if (!countryCode) return ''
const codePoints = countryCode const codePoints = countryCode
.toUpperCase() .toUpperCase()
.split("") .split('')
.map((char) => 127397 + char.charCodeAt(0)); .map((char) => 127397 + char.charCodeAt(0))
return String.fromCodePoint(...codePoints); return String.fromCodePoint(...codePoints)
}; }
type CountDownState = XOR< type CountDownState = XOR<
{ {
type: "countdown"; type: 'countdown'
remainingSeconds: number; remainingSeconds: number
}, },
{ {
type: "revalidating"; type: 'revalidating'
} }
>; >
const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>( const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
({ children }, ref) => { ({ children }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation()
const { mutate } = useIPInfo(); const { mutate } = useIPInfo()
return ( return (
<EnhancedCard <EnhancedCard
title={t("home.components.ipInfo.title")} title={t('home.components.ipInfo.title')}
icon={<LocationOnOutlined />} icon={<LocationOnOutlined />}
iconColor="info" iconColor="info"
ref={ref} ref={ref}
@@ -94,41 +94,41 @@ const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
> >
{children} {children}
</EnhancedCard> </EnhancedCard>
); )
}, },
); )
// IP信息卡片组件 // IP信息卡片组件
export const IpInfoCard = () => { export const IpInfoCard = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const [showIp, setShowIp] = useState(false); const [showIp, setShowIp] = useState(false)
const appWindow = useMemo(() => getCurrentWebviewWindow(), []); const appWindow = useMemo(() => getCurrentWebviewWindow(), [])
// track ip info card has been in viewport or not // track ip info card has been in viewport or not
// hasIntersected default to false, and will be true once the card is in viewport // 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 // and will never be false again afterwards (unless resetIntersected is called or
// the component is unmounted) // the component is unmounted)
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({ const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
rootMargin: "0px", rootMargin: '0px',
}); })
const [countdown, setCountdown] = useState<CountDownState>({ const [countdown, setCountdown] = useState<CountDownState>({
type: "countdown", type: 'countdown',
remainingSeconds: IP_REFRESH_SECONDS, remainingSeconds: IP_REFRESH_SECONDS,
}); })
const { data: ipInfo, error, isLoading, mutate } = useIPInfo(); const { data: ipInfo, error, isLoading, mutate } = useIPInfo()
// function useEffectEvent // function useEffectEvent
const onCountdownTick = useEffectEvent(async () => { const onCountdownTick = useEffectEvent(async () => {
const now = Date.now(); const now = Date.now()
const ts = ipInfo?.lastFetchTs; const ts = ipInfo?.lastFetchTs
if (!ts) { if (!ts) {
return; return
} }
const elapsed = Math.floor((now - ts) / 1000); const elapsed = Math.floor((now - ts) / 1000)
const remaining = IP_REFRESH_SECONDS - elapsed; const remaining = IP_REFRESH_SECONDS - elapsed
if (remaining <= 0) { if (remaining <= 0) {
if ( if (
@@ -139,21 +139,21 @@ export const IpInfoCard = () => {
// is online // is online
navigator.onLine && navigator.onLine &&
// there is no ongoing revalidation already scheduled // there is no ongoing revalidation already scheduled
countdown.type !== "revalidating" && countdown.type !== 'revalidating' &&
// window is visible // window is visible
(await appWindow.isVisible()) (await appWindow.isVisible())
) { ) {
setCountdown({ type: "revalidating" }); setCountdown({ type: 'revalidating' })
// we do not care about the result of mutate here. after mutate is done, // we do not care about the result of mutate here. after mutate is done,
// simply wait for next interval tick with `setCountdown({ type: "countdown", ... })` // simply wait for next interval tick with `setCountdown({ type: "countdown", ... })`
try { try {
await mutate(); await mutate()
} finally { } finally {
// in case mutate throws error, we still need to reset the countdown state // in case mutate throws error, we still need to reset the countdown state
setCountdown({ setCountdown({
type: "countdown", type: 'countdown',
remainingSeconds: IP_REFRESH_SECONDS, remainingSeconds: IP_REFRESH_SECONDS,
}); })
} }
} else { } else {
// do nothing. we even skip "setCountdown" to reduce re-renders // do nothing. we even skip "setCountdown" to reduce re-renders
@@ -164,15 +164,15 @@ export const IpInfoCard = () => {
} }
} else { } else {
setCountdown({ setCountdown({
type: "countdown", type: 'countdown',
remainingSeconds: remaining, remainingSeconds: remaining,
}); })
} }
}); })
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired // Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
useEffect(() => { 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. // 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. // instead of Tauri, which is reliable enough.
if (hasIntersected) { if (hasIntersected) {
console.debug( console.debug(
"IP info card has entered the viewport, starting the countdown interval.", 'IP info card has entered the viewport, starting the countdown interval.',
); )
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL); timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL)
} else { } else {
console.debug( 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 // 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: // Tauri's visibility change detection is actually broken on some platforms:
// https://github.com/tauri-apps/tauri/issues/10592 // https://github.com/tauri-apps/tauri/issues/10592
// //
@@ -199,100 +199,100 @@ export const IpInfoCard = () => {
// reduce power consumption. // reduce power consumption.
function onVisibilityChange() { function onVisibilityChange() {
if (document.hidden) { if (document.hidden) {
console.debug("Document hidden, pause the interval"); console.debug('Document hidden, pause the interval')
// Pause the timer // Pause the timer
if (timer != null) { if (timer != null) {
clearInterval(timer); clearInterval(timer)
timer = null; timer = null
} }
} else if (hasIntersected) { } 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 // Resume the timer only when previous one is cleared
if (timer == null) { if (timer == null) {
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL); timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL)
} }
} else { } else {
console.debug( 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 () => { return () => {
if (timer != null) clearInterval(timer); if (timer != null) clearInterval(timer)
document.removeEventListener("visibilitychange", onVisibilityChange); document.removeEventListener('visibilitychange', onVisibilityChange)
}; }
}, [hasIntersected]); }, [hasIntersected])
const toggleShowIp = useCallback(() => { const toggleShowIp = useCallback(() => {
setShowIp((prev) => !prev); setShowIp((prev) => !prev)
}, []); }, [])
let mainElement: React.ReactElement; let mainElement: React.ReactElement
switch (true) { switch (true) {
case isLoading: case isLoading:
mainElement = ( mainElement = (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Skeleton variant="text" width="60%" height={30} /> <Skeleton variant="text" width="60%" height={30} />
<Skeleton variant="text" width="80%" height={24} /> <Skeleton variant="text" width="80%" height={24} />
<Skeleton variant="text" width="70%" height={24} /> <Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="50%" height={24} /> <Skeleton variant="text" width="50%" height={24} />
</Box> </Box>
); )
break; break
case !!error: case !!error:
mainElement = ( mainElement = (
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
height: "100%", height: '100%',
color: "error.main", color: 'error.main',
}} }}
> >
<Typography variant="body1" color="error"> <Typography variant="body1" color="error">
{error instanceof Error {error instanceof Error
? error.message ? error.message
: t("home.components.ipInfo.errors.load")} : t('home.components.ipInfo.errors.load')}
</Typography> </Typography>
<Button onClick={() => mutate()} sx={{ mt: 2 }}> <Button onClick={() => mutate()} sx={{ mt: 2 }}>
{t("shared.actions.retry")} {t('shared.actions.retry')}
</Button> </Button>
</Box> </Box>
); )
break; break
default: // Normal render default: // Normal render
mainElement = ( mainElement = (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}> <Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
flexDirection: "row", flexDirection: 'row',
flex: 1, flex: 1,
overflow: "hidden", overflow: 'hidden',
}} }}
> >
{/* 左侧:国家和IP地址 */} {/* 左侧:国家和IP地址 */}
<Box sx={{ width: "40%", overflow: "hidden" }}> <Box sx={{ width: '40%', overflow: 'hidden' }}>
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
mb: 1, mb: 1,
overflow: "hidden", overflow: 'hidden',
}} }}
> >
<Box <Box
component="span" component="span"
sx={{ sx={{
fontSize: "1.5rem", fontSize: '1.5rem',
mr: 1, mr: 1,
display: "inline-block", display: 'inline-block',
width: 28, width: 28,
textAlign: "center", textAlign: 'center',
flexShrink: 0, flexShrink: 0,
fontFamily: '"twemoji mozilla", sans-serif', fontFamily: '"twemoji mozilla", sans-serif',
}} }}
@@ -302,46 +302,46 @@ export const IpInfoCard = () => {
<Typography <Typography
variant="subtitle1" variant="subtitle1"
sx={{ sx={{
fontWeight: "medium", fontWeight: 'medium',
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
maxWidth: "100%", maxWidth: '100%',
}} }}
> >
{ipInfo?.country || {ipInfo?.country ||
t("home.components.ipInfo.labels.unknown")} t('home.components.ipInfo.labels.unknown')}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ flexShrink: 0 }} sx={{ flexShrink: 0 }}
> >
{t("home.components.ipInfo.labels.ip")}: {t('home.components.ipInfo.labels.ip')}:
</Typography> </Typography>
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
ml: 1, ml: 1,
overflow: "hidden", overflow: 'hidden',
maxWidth: "calc(100% - 30px)", maxWidth: 'calc(100% - 30px)',
}} }}
> >
<Typography <Typography
variant="body2" variant="body2"
sx={{ sx={{
fontFamily: "monospace", fontFamily: 'monospace',
fontSize: "0.75rem", fontSize: '0.75rem',
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
wordBreak: "break-all", wordBreak: 'break-all',
}} }}
> >
{showIp ? ipInfo?.ip : "••••••••••"} {showIp ? ipInfo?.ip : '••••••••••'}
</Typography> </Typography>
<IconButton size="small" onClick={toggleShowIp}> <IconButton size="small" onClick={toggleShowIp}>
{showIp ? ( {showIp ? (
@@ -354,29 +354,29 @@ export const IpInfoCard = () => {
</Box> </Box>
<InfoItem <InfoItem
label={t("home.components.ipInfo.labels.asn")} label={t('home.components.ipInfo.labels.asn')}
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"} value={ipInfo?.asn ? `AS${ipInfo.asn}` : 'N/A'}
/> />
</Box> </Box>
{/* 右侧:组织、ISP和位置信息 */} {/* 右侧:组织、ISP和位置信息 */}
<Box sx={{ width: "60%", overflow: "auto" }}> <Box sx={{ width: '60%', overflow: 'auto' }}>
<InfoItem <InfoItem
label={t("home.components.ipInfo.labels.isp")} label={t('home.components.ipInfo.labels.isp')}
value={ipInfo?.organization} value={ipInfo?.organization}
/> />
<InfoItem <InfoItem
label={t("home.components.ipInfo.labels.org")} label={t('home.components.ipInfo.labels.org')}
value={ipInfo?.asn_organization} value={ipInfo?.asn_organization}
/> />
<InfoItem <InfoItem
label={t("home.components.ipInfo.labels.location")} label={t('home.components.ipInfo.labels.location')}
value={[ipInfo?.city, ipInfo?.region] value={[ipInfo?.city, ipInfo?.region]
.filter(Boolean) .filter(Boolean)
.join(", ")} .join(', ')}
/> />
<InfoItem <InfoItem
label={t("home.components.ipInfo.labels.timezone")} label={t('home.components.ipInfo.labels.timezone')}
value={ipInfo?.timezone} value={ipInfo?.timezone}
/> />
</Box> </Box>
@@ -384,43 +384,43 @@ export const IpInfoCard = () => {
<Box <Box
sx={{ sx={{
mt: "auto", mt: 'auto',
pt: 0.5, pt: 0.5,
borderTop: 1, borderTop: 1,
borderColor: "divider", borderColor: 'divider',
display: "flex", display: 'flex',
justifyContent: "space-between", justifyContent: 'space-between',
alignItems: "center", alignItems: 'center',
opacity: 0.7, opacity: 0.7,
fontSize: "0.7rem", fontSize: '0.7rem',
}} }}
> >
<Typography variant="caption"> <Typography variant="caption">
{t("home.components.ipInfo.labels.autoRefresh")} {t('home.components.ipInfo.labels.autoRefresh')}
{countdown.type === "countdown" {countdown.type === 'countdown'
? `: ${countdown.remainingSeconds}s` ? `: ${countdown.remainingSeconds}s`
: "..."} : '...'}
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
textOverflow: "ellipsis", textOverflow: 'ellipsis',
overflow: "hidden", overflow: 'hidden',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
}} }}
> >
{`${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'}`}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
); )
} }
return ( return (
<IPInfoCardContainer ref={containerRef}>{mainElement}</IPInfoCardContainer> <IPInfoCardContainer ref={containerRef}>{mainElement}</IPInfoCardContainer>
); )
}; }
function useIPInfo() { function useIPInfo() {
return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, SWR_EXTERNAL_API); return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, SWR_EXTERNAL_API)
} }
+92 -92
View File
@@ -3,7 +3,7 @@ import {
TroubleshootRounded, TroubleshootRounded,
HelpOutlineRounded, HelpOutlineRounded,
SvgIconComponent, SvgIconComponent,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { import {
Box, Box,
Typography, Typography,
@@ -13,24 +13,24 @@ import {
alpha, alpha,
useTheme, useTheme,
Fade, Fade,
} from "@mui/material"; } from '@mui/material'
import { useState, useMemo, memo, FC } from "react"; import { useState, useMemo, memo, FC } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import ProxyControlSwitches from "@/components/shared/proxy-control-switches"; import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
import { useSystemProxyState } from "@/hooks/use-system-proxy-state"; import { useSystemProxyState } from '@/hooks/use-system-proxy-state'
import { useSystemState } from "@/hooks/use-system-state"; import { useSystemState } from '@/hooks/use-system-state'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import { showNotice } from "@/services/notice-service"; 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 { interface TabButtonProps {
isActive: boolean; isActive: boolean
onClick: () => void; onClick: () => void
icon: SvgIconComponent; icon: SvgIconComponent
label: string; label: string
hasIndicator?: boolean; hasIndicator?: boolean
} }
// Tab组件 // Tab组件
@@ -40,34 +40,34 @@ const TabButton: FC<TabButtonProps> = memo(
elevation={isActive ? 2 : 0} elevation={isActive ? 2 : 0}
onClick={onClick} onClick={onClick}
sx={{ sx={{
cursor: "pointer", cursor: 'pointer',
px: 2, px: 2,
py: 1, py: 1,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
gap: 1, gap: 1,
bgcolor: isActive ? "primary.main" : "background.paper", bgcolor: isActive ? 'primary.main' : 'background.paper',
color: isActive ? "primary.contrastText" : "text.primary", color: isActive ? 'primary.contrastText' : 'text.primary',
borderRadius: 1.5, borderRadius: 1.5,
flex: 1, flex: 1,
maxWidth: 160, maxWidth: 160,
transition: "all 0.2s ease-in-out", transition: 'all 0.2s ease-in-out',
position: "relative", position: 'relative',
"&:hover": { '&:hover': {
transform: "translateY(-1px)", transform: 'translateY(-1px)',
boxShadow: 1, boxShadow: 1,
}, },
"&:after": isActive '&:after': isActive
? { ? {
content: '""', content: '""',
position: "absolute", position: 'absolute',
bottom: -9, bottom: -9,
left: "50%", left: '50%',
width: 2, width: 2,
height: 9, height: 9,
bgcolor: "primary.main", bgcolor: 'primary.main',
transform: "translateX(-50%)", transform: 'translateX(-50%)',
} }
: {}, : {},
}} }}
@@ -81,9 +81,9 @@ const TabButton: FC<TabButtonProps> = memo(
sx={{ sx={{
width: 8, width: 8,
height: 8, height: 8,
borderRadius: "50%", borderRadius: '50%',
bgcolor: isActive ? "#fff" : "success.main", bgcolor: isActive ? '#fff' : 'success.main',
position: "absolute", position: 'absolute',
top: 8, top: 8,
right: 8, right: 8,
}} }}
@@ -91,11 +91,11 @@ const TabButton: FC<TabButtonProps> = memo(
)} )}
</Paper> </Paper>
), ),
); )
interface TabDescriptionProps { interface TabDescriptionProps {
description: string; description: string
tooltipTitle: string; tooltipTitle: string
} }
// 描述文本组件 // 描述文本组件
@@ -106,21 +106,21 @@ const TabDescription: FC<TabDescriptionProps> = memo(
variant="caption" variant="caption"
component="div" component="div"
sx={{ sx={{
width: "95%", width: '95%',
textAlign: "center", textAlign: 'center',
color: "text.secondary", color: 'text.secondary',
p: 0.8, p: 0.8,
borderRadius: 1, borderRadius: 1,
borderColor: "primary.main", borderColor: 'primary.main',
borderWidth: 1, borderWidth: 1,
borderStyle: "solid", borderStyle: 'solid',
backgroundColor: "background.paper", backgroundColor: 'background.paper',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
gap: 0.5, gap: 0.5,
wordBreak: "break-word", wordBreak: 'break-word',
hyphens: "auto", hyphens: 'auto',
}} }}
> >
{description} {description}
@@ -132,47 +132,47 @@ const TabDescription: FC<TabDescriptionProps> = memo(
</Typography> </Typography>
</Fade> </Fade>
), ),
); )
export const ProxyTunCard: FC = () => { export const ProxyTunCard: FC = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const theme = useTheme(); const theme = useTheme()
const [activeTab, setActiveTab] = useState<string>( const [activeTab, setActiveTab] = useState<string>(
() => localStorage.getItem(LOCAL_STORAGE_TAB_KEY) || "system", () => localStorage.getItem(LOCAL_STORAGE_TAB_KEY) || 'system',
); )
const { verge } = useVerge(); const { verge } = useVerge()
const { isTunModeAvailable } = useSystemState(); const { isTunModeAvailable } = useSystemState()
const { configState: systemProxyConfigState } = useSystemProxyState(); const { configState: systemProxyConfigState } = useSystemProxyState()
const { enable_tun_mode } = verge ?? {}; const { enable_tun_mode } = verge ?? {}
const handleError = (err: unknown) => { const handleError = (err: unknown) => {
showNotice.error(err); showNotice.error(err)
}; }
const handleTabChange = (tab: string) => { const handleTabChange = (tab: string) => {
setActiveTab(tab); setActiveTab(tab)
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab); localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab)
}; }
const tabDescription = useMemo(() => { const tabDescription = useMemo(() => {
if (activeTab === "system") { if (activeTab === 'system') {
return { return {
text: systemProxyConfigState text: systemProxyConfigState
? t("home.components.proxyTun.status.systemProxyEnabled") ? t('home.components.proxyTun.status.systemProxyEnabled')
: t("home.components.proxyTun.status.systemProxyDisabled"), : t('home.components.proxyTun.status.systemProxyDisabled'),
tooltip: t("home.components.proxyTun.tooltips.systemProxy"), tooltip: t('home.components.proxyTun.tooltips.systemProxy'),
}; }
} else { } else {
return { return {
text: !isTunModeAvailable text: !isTunModeAvailable
? t("home.components.proxyTun.status.tunModeServiceRequired") ? t('home.components.proxyTun.status.tunModeServiceRequired')
: enable_tun_mode : enable_tun_mode
? t("home.components.proxyTun.status.tunModeEnabled") ? t('home.components.proxyTun.status.tunModeEnabled')
: t("home.components.proxyTun.status.tunModeDisabled"), : t('home.components.proxyTun.status.tunModeDisabled'),
tooltip: t("home.components.proxyTun.tooltips.tunMode"), tooltip: t('home.components.proxyTun.tooltips.tunMode'),
}; }
} }
}, [ }, [
activeTab, activeTab,
@@ -180,44 +180,44 @@ export const ProxyTunCard: FC = () => {
enable_tun_mode, enable_tun_mode,
isTunModeAvailable, isTunModeAvailable,
t, t,
]); ])
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}> <Box sx={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<Stack <Stack
direction="row" direction="row"
spacing={1} spacing={1}
sx={{ sx={{
display: "flex", display: 'flex',
justifyContent: "center", justifyContent: 'center',
position: "relative", position: 'relative',
zIndex: 2, zIndex: 2,
}} }}
> >
<TabButton <TabButton
isActive={activeTab === "system"} isActive={activeTab === 'system'}
onClick={() => handleTabChange("system")} onClick={() => handleTabChange('system')}
icon={ComputerRounded} icon={ComputerRounded}
label={t("settings.sections.system.toggles.systemProxy")} label={t('settings.sections.system.toggles.systemProxy')}
hasIndicator={systemProxyConfigState} hasIndicator={systemProxyConfigState}
/> />
<TabButton <TabButton
isActive={activeTab === "tun"} isActive={activeTab === 'tun'}
onClick={() => handleTabChange("tun")} onClick={() => handleTabChange('tun')}
icon={TroubleshootRounded} icon={TroubleshootRounded}
label={t("settings.sections.system.toggles.tunMode")} label={t('settings.sections.system.toggles.tunMode')}
hasIndicator={enable_tun_mode && isTunModeAvailable} hasIndicator={enable_tun_mode && isTunModeAvailable}
/> />
</Stack> </Stack>
<Box <Box
sx={{ sx={{
width: "100%", width: '100%',
my: 1, my: 1,
position: "relative", position: 'relative',
display: "flex", display: 'flex',
justifyContent: "center", justifyContent: 'center',
overflow: "visible", overflow: 'visible',
}} }}
> >
<TabDescription <TabDescription
@@ -237,13 +237,13 @@ export const ProxyTunCard: FC = () => {
<ProxyControlSwitches <ProxyControlSwitches
onError={handleError} onError={handleError}
label={ label={
activeTab === "system" activeTab === 'system'
? t("settings.sections.system.toggles.systemProxy") ? t('settings.sections.system.toggles.systemProxy')
: t("settings.sections.system.toggles.tunMode") : t('settings.sections.system.toggles.tunMode')
} }
noRightPadding={true} noRightPadding={true}
/> />
</Box> </Box>
</Box> </Box>
); )
}; }
+119 -119
View File
@@ -4,195 +4,195 @@ import {
AdminPanelSettingsOutlined, AdminPanelSettingsOutlined,
DnsOutlined, DnsOutlined,
ExtensionOutlined, ExtensionOutlined,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { Typography, Stack, Divider, Chip, IconButton } from "@mui/material"; import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useReducer } from "react"; import { useCallback, useEffect, useMemo, useReducer } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { useNavigate } from "react-router"; import { useNavigate } from 'react-router'
import { useServiceInstaller } from "@/hooks/use-service-installer"; import { useServiceInstaller } from '@/hooks/use-service-installer'
import { useSystemState } from "@/hooks/use-system-state"; import { useSystemState } from '@/hooks/use-system-state'
import { useUpdate } from "@/hooks/use-update"; import { useUpdate } from '@/hooks/use-update'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import { getSystemInfo } from "@/services/cmds"; import { getSystemInfo } from '@/services/cmds'
import { showNotice } from "@/services/notice-service"; import { showNotice } from '@/services/notice-service'
import { version as appVersion } from "@root/package.json"; import { version as appVersion } from '@root/package.json'
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from './enhanced-card'
interface SystemState { interface SystemState {
osInfo: string; osInfo: string
lastCheckUpdate: string; lastCheckUpdate: string
} }
type SystemStateAction = type SystemStateAction =
| { type: "set-os-info"; payload: string } | { type: 'set-os-info'; payload: string }
| { type: "set-last-check-update"; payload: string }; | { type: 'set-last-check-update'; payload: string }
const systemStateReducer = ( const systemStateReducer = (
state: SystemState, state: SystemState,
action: SystemStateAction, action: SystemStateAction,
): SystemState => { ): SystemState => {
switch (action.type) { switch (action.type) {
case "set-os-info": case 'set-os-info':
return { ...state, osInfo: action.payload }; return { ...state, osInfo: action.payload }
case "set-last-check-update": case 'set-last-check-update':
return { ...state, lastCheckUpdate: action.payload }; return { ...state, lastCheckUpdate: action.payload }
default: default:
return state; return state
} }
}; }
export const SystemInfoCard = () => { export const SystemInfoCard = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const { verge, patchVerge } = useVerge(); const { verge, patchVerge } = useVerge()
const navigate = useNavigate(); const navigate = useNavigate()
const { isAdminMode, isSidecarMode } = useSystemState(); const { isAdminMode, isSidecarMode } = useSystemState()
const { installServiceAndRestartCore } = useServiceInstaller(); const { installServiceAndRestartCore } = useServiceInstaller()
// 自动检查更新逻辑 // 自动检查更新逻辑
const { checkUpdate: triggerCheckUpdate } = useUpdate(true, { const { checkUpdate: triggerCheckUpdate } = useUpdate(true, {
onSuccess: () => { onSuccess: () => {
const now = Date.now(); const now = Date.now()
localStorage.setItem("last_check_update", now.toString()); localStorage.setItem('last_check_update', now.toString())
dispatchSystemState({ dispatchSystemState({
type: "set-last-check-update", type: 'set-last-check-update',
payload: new Date(now).toLocaleString(), payload: new Date(now).toLocaleString(),
}); })
}, },
}); })
// 系统信息状态 // 系统信息状态
const [systemState, dispatchSystemState] = useReducer(systemStateReducer, { const [systemState, dispatchSystemState] = useReducer(systemStateReducer, {
osInfo: "", osInfo: '',
lastCheckUpdate: "-", lastCheckUpdate: '-',
}); })
// 初始化系统信息 // 初始化系统信息
useEffect(() => { useEffect(() => {
let timeoutId: number | undefined; let timeoutId: number | undefined
getSystemInfo() getSystemInfo()
.then((info) => { .then((info) => {
const lines = info.split("\n"); const lines = info.split('\n')
if (lines.length > 0) { if (lines.length > 0) {
const sysName = lines[0].split(": ")[1] || ""; const sysName = lines[0].split(': ')[1] || ''
let sysVersion = lines[1].split(": ")[1] || ""; let sysVersion = lines[1].split(': ')[1] || ''
if ( if (
sysName && sysName &&
sysVersion.toLowerCase().startsWith(sysName.toLowerCase()) sysVersion.toLowerCase().startsWith(sysName.toLowerCase())
) { ) {
sysVersion = sysVersion.substring(sysName.length).trim(); sysVersion = sysVersion.substring(sysName.length).trim()
} }
dispatchSystemState({ dispatchSystemState({
type: "set-os-info", type: 'set-os-info',
payload: `${sysName} ${sysVersion}`, 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) { if (lastCheck) {
try { try {
const timestamp = parseInt(lastCheck, 10); const timestamp = parseInt(lastCheck, 10)
if (!isNaN(timestamp)) { if (!isNaN(timestamp)) {
dispatchSystemState({ dispatchSystemState({
type: "set-last-check-update", type: 'set-last-check-update',
payload: new Date(timestamp).toLocaleString(), payload: new Date(timestamp).toLocaleString(),
}); })
} }
} catch (e) { } 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) { } else if (verge?.auto_check_update) {
// 如果启用了自动检查更新但没有记录,设置当前时间并延迟检查 // 如果启用了自动检查更新但没有记录,设置当前时间并延迟检查
const now = Date.now(); const now = Date.now()
localStorage.setItem("last_check_update", now.toString()); localStorage.setItem('last_check_update', now.toString())
dispatchSystemState({ dispatchSystemState({
type: "set-last-check-update", type: 'set-last-check-update',
payload: new Date(now).toLocaleString(), payload: new Date(now).toLocaleString(),
}); })
timeoutId = window.setTimeout(() => { timeoutId = window.setTimeout(() => {
if (verge?.auto_check_update) { if (verge?.auto_check_update) {
triggerCheckUpdate().catch(console.error); triggerCheckUpdate().catch(console.error)
} }
}, 5000); }, 5000)
} }
return () => { return () => {
if (timeoutId !== undefined) { if (timeoutId !== undefined) {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId)
} }
}; }
}, [verge?.auto_check_update, dispatchSystemState, triggerCheckUpdate]); }, [verge?.auto_check_update, dispatchSystemState, triggerCheckUpdate])
// 导航到设置页面 // 导航到设置页面
const goToSettings = useCallback(() => { const goToSettings = useCallback(() => {
navigate("/settings"); navigate('/settings')
}, [navigate]); }, [navigate])
// 切换自启动状态 // 切换自启动状态
const toggleAutoLaunch = useCallback(async () => { const toggleAutoLaunch = useCallback(async () => {
if (!verge) return; if (!verge) return
try { try {
await patchVerge({ enable_auto_launch: !verge.enable_auto_launch }); await patchVerge({ enable_auto_launch: !verge.enable_auto_launch })
} catch (err) { } catch (err) {
console.error("切换开机自启动状态失败:", err); console.error('切换开机自启动状态失败:', err)
} }
}, [verge, patchVerge]); }, [verge, patchVerge])
// 点击运行模式处理,Sidecar或纯管理员模式允许安装服务 // 点击运行模式处理,Sidecar或纯管理员模式允许安装服务
const handleRunningModeClick = useCallback(() => { const handleRunningModeClick = useCallback(() => {
if (isSidecarMode || (isAdminMode && isSidecarMode)) { if (isSidecarMode || (isAdminMode && isSidecarMode)) {
installServiceAndRestartCore(); installServiceAndRestartCore()
} }
}, [isSidecarMode, isAdminMode, installServiceAndRestartCore]); }, [isSidecarMode, isAdminMode, installServiceAndRestartCore])
// 检查更新 // 检查更新
const onCheckUpdate = useLockFn(async () => { const onCheckUpdate = useLockFn(async () => {
try { try {
const info = await triggerCheckUpdate(); const info = await triggerCheckUpdate()
if (!info?.available) { if (!info?.available) {
showNotice.success( showNotice.success(
"settings.components.verge.advanced.notifications.latestVersion", 'settings.components.verge.advanced.notifications.latestVersion',
); )
} else { } else {
showNotice.info("shared.feedback.notifications.updateAvailable", 2000); showNotice.info('shared.feedback.notifications.updateAvailable', 2000)
goToSettings(); goToSettings()
} }
} catch (err) { } catch (err) {
showNotice.error(err); showNotice.error(err)
} }
}); })
// 是否启用自启动 // 是否启用自启动
const autoLaunchEnabled = useMemo( const autoLaunchEnabled = useMemo(
() => verge?.enable_auto_launch || false, () => verge?.enable_auto_launch || false,
[verge], [verge],
); )
// 运行模式样式 // 运行模式样式
const runningModeStyle = useMemo( const runningModeStyle = useMemo(
() => ({ () => ({
// Sidecar或纯管理员模式允许安装服务 // Sidecar或纯管理员模式允许安装服务
cursor: cursor:
isSidecarMode || (isAdminMode && isSidecarMode) ? "pointer" : "default", isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
textDecoration: textDecoration:
isSidecarMode || (isAdminMode && isSidecarMode) ? "underline" : "none", isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
gap: 0.5, gap: 0.5,
"&:hover": { '&:hover': {
opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1, opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1,
}, },
}), }),
[isSidecarMode, isAdminMode], [isSidecarMode, isAdminMode],
); )
// 获取模式图标和文本 // 获取模式图标和文本
const getModeIcon = () => { const getModeIcon = () => {
@@ -202,67 +202,67 @@ export const SystemInfoCard = () => {
return ( return (
<> <>
<AdminPanelSettingsOutlined <AdminPanelSettingsOutlined
sx={{ color: "primary.main", fontSize: 16 }} sx={{ color: 'primary.main', fontSize: 16 }}
titleAccess={t("home.components.systemInfo.badges.adminMode")} titleAccess={t('home.components.systemInfo.badges.adminMode')}
/> />
<DnsOutlined <DnsOutlined
sx={{ color: "success.main", fontSize: 16, ml: 0.5 }} sx={{ color: 'success.main', fontSize: 16, ml: 0.5 }}
titleAccess={t("home.components.systemInfo.badges.serviceMode")} titleAccess={t('home.components.systemInfo.badges.serviceMode')}
/> />
</> </>
); )
} }
return ( return (
<AdminPanelSettingsOutlined <AdminPanelSettingsOutlined
sx={{ color: "primary.main", fontSize: 16 }} sx={{ color: 'primary.main', fontSize: 16 }}
titleAccess={t("home.components.systemInfo.badges.adminMode")} titleAccess={t('home.components.systemInfo.badges.adminMode')}
/> />
); )
} else if (isSidecarMode) { } else if (isSidecarMode) {
return ( return (
<ExtensionOutlined <ExtensionOutlined
sx={{ color: "info.main", fontSize: 16 }} sx={{ color: 'info.main', fontSize: 16 }}
titleAccess={t("home.components.systemInfo.badges.sidecarMode")} titleAccess={t('home.components.systemInfo.badges.sidecarMode')}
/> />
); )
} else { } else {
return ( return (
<DnsOutlined <DnsOutlined
sx={{ color: "success.main", fontSize: 16 }} sx={{ color: 'success.main', fontSize: 16 }}
titleAccess={t("home.components.systemInfo.badges.serviceMode")} titleAccess={t('home.components.systemInfo.badges.serviceMode')}
/> />
); )
}
} }
};
// 获取模式文本 // 获取模式文本
const getModeText = () => { const getModeText = () => {
if (isAdminMode) { if (isAdminMode) {
// 判断是否同时处于服务模式 // 判断是否同时处于服务模式
if (!isSidecarMode) { 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) { } else if (isSidecarMode) {
return t("home.components.systemInfo.badges.sidecarMode"); return t('home.components.systemInfo.badges.sidecarMode')
} else { } else {
return t("home.components.systemInfo.badges.serviceMode"); return t('home.components.systemInfo.badges.serviceMode')
}
} }
};
// 只有当verge存在时才渲染内容 // 只有当verge存在时才渲染内容
if (!verge) return null; if (!verge) return null
return ( return (
<EnhancedCard <EnhancedCard
title={t("home.components.systemInfo.title")} title={t('home.components.systemInfo.title')}
icon={<InfoOutlined />} icon={<InfoOutlined />}
iconColor="error" iconColor="error"
action={ action={
<IconButton <IconButton
size="small" size="small"
onClick={goToSettings} onClick={goToSettings}
title={t("home.components.systemInfo.actions.settings")} title={t('home.components.systemInfo.actions.settings')}
> >
<SettingsOutlined fontSize="small" /> <SettingsOutlined fontSize="small" />
</IconButton> </IconButton>
@@ -271,7 +271,7 @@ export const SystemInfoCard = () => {
<Stack spacing={1.5}> <Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between"> <Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.osInfo")} {t('home.components.systemInfo.fields.osInfo')}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{systemState.osInfo} {systemState.osInfo}
@@ -284,20 +284,20 @@ export const SystemInfoCard = () => {
alignItems="center" alignItems="center"
> >
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.autoLaunch")} {t('home.components.systemInfo.fields.autoLaunch')}
</Typography> </Typography>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<Chip <Chip
size="small" size="small"
label={ label={
autoLaunchEnabled autoLaunchEnabled
? t("shared.statuses.enabled") ? t('shared.statuses.enabled')
: t("shared.statuses.disabled") : t('shared.statuses.disabled')
} }
color={autoLaunchEnabled ? "success" : "default"} color={autoLaunchEnabled ? 'success' : 'default'}
variant={autoLaunchEnabled ? "filled" : "outlined"} variant={autoLaunchEnabled ? 'filled' : 'outlined'}
onClick={toggleAutoLaunch} onClick={toggleAutoLaunch}
sx={{ cursor: "pointer" }} sx={{ cursor: 'pointer' }}
/> />
</Stack> </Stack>
</Stack> </Stack>
@@ -308,7 +308,7 @@ export const SystemInfoCard = () => {
alignItems="center" alignItems="center"
> >
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.runningMode")} {t('home.components.systemInfo.fields.runningMode')}
</Typography> </Typography>
<Typography <Typography
variant="body2" variant="body2"
@@ -323,16 +323,16 @@ export const SystemInfoCard = () => {
<Divider /> <Divider />
<Stack direction="row" justifyContent="space-between"> <Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.lastCheckUpdate")} {t('home.components.systemInfo.fields.lastCheckUpdate')}
</Typography> </Typography>
<Typography <Typography
variant="body2" variant="body2"
fontWeight="medium" fontWeight="medium"
onClick={onCheckUpdate} onClick={onCheckUpdate}
sx={{ sx={{
cursor: "pointer", cursor: 'pointer',
textDecoration: "underline", textDecoration: 'underline',
"&:hover": { opacity: 0.7 }, '&:hover': { opacity: 0.7 },
}} }}
> >
{systemState.lastCheckUpdate} {systemState.lastCheckUpdate}
@@ -341,7 +341,7 @@ export const SystemInfoCard = () => {
<Divider /> <Divider />
<Stack direction="row" justifyContent="space-between"> <Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.vergeVersion")} {t('home.components.systemInfo.fields.vergeVersion')}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
v{appVersion} v{appVersion}
@@ -349,5 +349,5 @@ export const SystemInfoCard = () => {
</Stack> </Stack>
</Stack> </Stack>
</EnhancedCard> </EnhancedCard>
); )
}; }
+74 -74
View File
@@ -6,145 +6,145 @@ import {
useSensors, useSensors,
DragEndEvent, DragEndEvent,
DragOverlay, DragOverlay,
} from "@dnd-kit/core"; } from '@dnd-kit/core'
import { SortableContext } from "@dnd-kit/sortable"; import { SortableContext } from '@dnd-kit/sortable'
import { Add, NetworkCheck } from "@mui/icons-material"; import { Add, NetworkCheck } from '@mui/icons-material'
import { Box, IconButton, Tooltip, alpha, styled, Grid } from "@mui/material"; import { Box, IconButton, Tooltip, alpha, styled, Grid } from '@mui/material'
import { emit } from "@tauri-apps/api/event"; import { emit } from '@tauri-apps/api/event'
import { nanoid } from "nanoid"; import { nanoid } from 'nanoid'
import { useEffect, useRef, useMemo, useCallback } from "react"; import { useEffect, useRef, useMemo, useCallback } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
// test icons // test icons
import apple from "@/assets/image/test/apple.svg?raw"; import apple from '@/assets/image/test/apple.svg?raw'
import github from "@/assets/image/test/github.svg?raw"; import github from '@/assets/image/test/github.svg?raw'
import google from "@/assets/image/test/google.svg?raw"; import google from '@/assets/image/test/google.svg?raw'
import youtube from "@/assets/image/test/youtube.svg?raw"; import youtube from '@/assets/image/test/youtube.svg?raw'
import { TestItem } from "@/components/test/test-item"; import { TestItem } from '@/components/test/test-item'
import { TestViewer, TestViewerRef } from "@/components/test/test-viewer"; import { TestViewer, TestViewerRef } from '@/components/test/test-viewer'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from './enhanced-card'
// 自定义滚动条样式 // 自定义滚动条样式
const ScrollBox = styled(Box)(({ theme }) => ({ const ScrollBox = styled(Box)(({ theme }) => ({
maxHeight: "180px", maxHeight: '180px',
overflowY: "auto", overflowY: 'auto',
overflowX: "hidden", overflowX: 'hidden',
"&::-webkit-scrollbar": { '&::-webkit-scrollbar': {
width: "6px", width: '6px',
}, },
"&::-webkit-scrollbar-thumb": { '&::-webkit-scrollbar-thumb': {
backgroundColor: alpha(theme.palette.text.primary, 0.2), backgroundColor: alpha(theme.palette.text.primary, 0.2),
borderRadius: "3px", borderRadius: '3px',
}, },
})); }))
// 默认测试列表,移到组件外部避免重复创建 // 默认测试列表,移到组件外部避免重复创建
const DEFAULT_TEST_LIST = [ const DEFAULT_TEST_LIST = [
{ {
uid: nanoid(), uid: nanoid(),
name: "Apple", name: 'Apple',
url: "https://www.apple.com", url: 'https://www.apple.com',
icon: apple, icon: apple,
}, },
{ {
uid: nanoid(), uid: nanoid(),
name: "GitHub", name: 'GitHub',
url: "https://www.github.com", url: 'https://www.github.com',
icon: github, icon: github,
}, },
{ {
uid: nanoid(), uid: nanoid(),
name: "Google", name: 'Google',
url: "https://www.google.com", url: 'https://www.google.com',
icon: google, icon: google,
}, },
{ {
uid: nanoid(), uid: nanoid(),
name: "YouTube", name: 'YouTube',
url: "https://www.youtube.com", url: 'https://www.youtube.com',
icon: youtube, icon: youtube,
}, },
]; ]
export const TestCard = () => { export const TestCard = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, activationConstraint: { distance: 8 },
}), }),
); )
const { verge, mutateVerge, patchVerge } = useVerge(); const { verge, mutateVerge, patchVerge } = useVerge()
const viewerRef = useRef<TestViewerRef>(null); const viewerRef = useRef<TestViewerRef>(null)
// 使用useMemo优化测试列表,避免每次渲染重新计算 // 使用useMemo优化测试列表,避免每次渲染重新计算
const testList = useMemo(() => { const testList = useMemo(() => {
return verge?.test_list ?? DEFAULT_TEST_LIST; return verge?.test_list ?? DEFAULT_TEST_LIST
}, [verge?.test_list]); }, [verge?.test_list])
// 使用useCallback优化函数引用,避免不必要的重新渲染 // 使用useCallback优化函数引用,避免不必要的重新渲染
const onTestListItemChange = useCallback( const onTestListItemChange = useCallback(
(uid: string, patch?: Partial<IVergeTestItem>) => { (uid: string, patch?: Partial<IVergeTestItem>) => {
if (!patch) { if (!patch) {
mutateVerge(); mutateVerge()
return; return
} }
const newList = testList.map((x) => const newList = testList.map((x) =>
x.uid === uid ? { ...x, ...patch } : x, x.uid === uid ? { ...x, ...patch } : x,
); )
mutateVerge({ ...verge, test_list: newList }, false); mutateVerge({ ...verge, test_list: newList }, false)
}, },
[testList, verge, mutateVerge], [testList, verge, mutateVerge],
); )
const onDeleteTestListItem = useCallback( const onDeleteTestListItem = useCallback(
(uid: string) => { (uid: string) => {
const newList = testList.filter((x) => x.uid !== uid); const newList = testList.filter((x) => x.uid !== uid)
patchVerge({ test_list: newList }); patchVerge({ test_list: newList })
mutateVerge({ ...verge, test_list: newList }, false); mutateVerge({ ...verge, test_list: newList }, false)
}, },
[testList, verge, patchVerge, mutateVerge], [testList, verge, patchVerge, mutateVerge],
); )
const onDragEnd = useCallback( const onDragEnd = useCallback(
async (event: DragEndEvent) => { async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return
const old_index = testList.findIndex((x) => x.uid === active.id); const old_index = testList.findIndex((x) => x.uid === active.id)
const new_index = testList.findIndex((x) => x.uid === over.id); const new_index = testList.findIndex((x) => x.uid === over.id)
if (old_index >= 0 && new_index >= 0) { if (old_index >= 0 && new_index >= 0) {
const newList = [...testList]; const newList = [...testList]
const [removed] = newList.splice(old_index, 1); const [removed] = newList.splice(old_index, 1)
newList.splice(new_index, 0, removed); newList.splice(new_index, 0, removed)
// 优化:先本地更新,再异步 patch,避免UI卡死 // 优化:先本地更新,再异步 patch,避免UI卡死
mutateVerge({ ...verge, test_list: newList }, false); mutateVerge({ ...verge, test_list: newList }, false)
const patchFn = () => { const patchFn = () => {
try { try {
patchVerge({ test_list: newList }); patchVerge({ test_list: newList })
} catch {} } catch {}
}; }
if (window.requestIdleCallback) { if (window.requestIdleCallback) {
window.requestIdleCallback(patchFn); window.requestIdleCallback(patchFn)
} else { } else {
setTimeout(patchFn, 0); setTimeout(patchFn, 0)
} }
} }
}, },
[testList, verge, mutateVerge, patchVerge], [testList, verge, mutateVerge, patchVerge],
); )
// 仅在verge首次加载时初始化测试列表 // 仅在verge首次加载时初始化测试列表
useEffect(() => { useEffect(() => {
if (verge && !verge.test_list) { if (verge && !verge.test_list) {
patchVerge({ test_list: DEFAULT_TEST_LIST }); patchVerge({ test_list: DEFAULT_TEST_LIST })
} }
}, [verge, patchVerge]); }, [verge, patchVerge])
// 使用useMemo优化UI内容,减少渲染计算 // 使用useMemo优化UI内容,减少渲染计算
const renderTestItems = useMemo( const renderTestItems = useMemo(
@@ -165,28 +165,28 @@ export const TestCard = () => {
</Grid> </Grid>
), ),
[testList, onDeleteTestListItem], [testList, onDeleteTestListItem],
); )
const handleTestAll = useCallback(() => { const handleTestAll = useCallback(() => {
emit("verge://test-all"); emit('verge://test-all')
}, []); }, [])
const handleCreateTest = useCallback(() => { const handleCreateTest = useCallback(() => {
viewerRef.current?.create(); viewerRef.current?.create()
}, []); }, [])
return ( return (
<EnhancedCard <EnhancedCard
title={t("home.components.tests.title")} title={t('home.components.tests.title')}
icon={<NetworkCheck />} icon={<NetworkCheck />}
action={ action={
<Box sx={{ display: "flex", gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title={t("tests.page.actions.testAll")} arrow> <Tooltip title={t('tests.page.actions.testAll')} arrow>
<IconButton size="small" onClick={handleTestAll}> <IconButton size="small" onClick={handleTestAll}>
<NetworkCheck fontSize="small" /> <NetworkCheck fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title={t("tests.modals.test.title.create")} arrow> <Tooltip title={t('tests.modals.test.title.create')} arrow>
<IconButton size="small" onClick={handleCreateTest}> <IconButton size="small" onClick={handleCreateTest}>
<Add fontSize="small" /> <Add fontSize="small" />
</IconButton> </IconButton>
@@ -207,5 +207,5 @@ export const TestCard = () => {
<TestViewer ref={viewerRef} onChange={onTestListItemChange} /> <TestViewer ref={viewerRef} onChange={onTestListItemChange} />
</EnhancedCard> </EnhancedCard>
); )
}; }
+49 -49
View File
@@ -1,60 +1,60 @@
import type { import type {
DraggableAttributes, DraggableAttributes,
DraggableSyntheticListeners, DraggableSyntheticListeners,
} from "@dnd-kit/core"; } from '@dnd-kit/core'
import { import {
alpha, alpha,
ListItem, ListItem,
ListItemButton, ListItemButton,
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
} from "@mui/material"; } from '@mui/material'
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from 'react'
import { useMatch, useNavigate, useResolvedPath } from "react-router"; import { useMatch, useNavigate, useResolvedPath } from 'react-router'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
interface SortableProps { interface SortableProps {
setNodeRef?: (element: HTMLElement | null) => void; setNodeRef?: (element: HTMLElement | null) => void
attributes?: DraggableAttributes; attributes?: DraggableAttributes
listeners?: DraggableSyntheticListeners; listeners?: DraggableSyntheticListeners
style?: CSSProperties; style?: CSSProperties
isDragging?: boolean; isDragging?: boolean
disabled?: boolean; disabled?: boolean
} }
interface Props { interface Props {
to: string; to: string
children: string; children: string
icon: ReactNode[]; icon: ReactNode[]
sortable?: SortableProps; sortable?: SortableProps
} }
export const LayoutItem = (props: Props) => { export const LayoutItem = (props: Props) => {
const { to, children, icon, sortable } = props; const { to, children, icon, sortable } = props
const { verge } = useVerge(); const { verge } = useVerge()
const { menu_icon } = verge ?? {}; const { menu_icon } = verge ?? {}
const navCollapsed = verge?.collapse_navbar ?? false; const navCollapsed = verge?.collapse_navbar ?? false
const resolved = useResolvedPath(to); const resolved = useResolvedPath(to)
const match = useMatch({ path: resolved.pathname, end: true }); const match = useMatch({ path: resolved.pathname, end: true })
const navigate = useNavigate(); const navigate = useNavigate()
const effectiveMenuIcon = const effectiveMenuIcon =
navCollapsed && menu_icon === "disable" ? "monochrome" : menu_icon; navCollapsed && menu_icon === 'disable' ? 'monochrome' : menu_icon
const { setNodeRef, attributes, listeners, style, isDragging, disabled } = const { setNodeRef, attributes, listeners, style, isDragging, disabled } =
sortable ?? {}; sortable ?? {}
const draggable = Boolean(sortable) && !disabled; const draggable = Boolean(sortable) && !disabled
const dragHandleProps = draggable const dragHandleProps = draggable
? { ...(attributes ?? {}), ...(listeners ?? {}) } ? { ...(attributes ?? {}), ...(listeners ?? {}) }
: undefined; : undefined
return ( return (
<ListItem <ListItem
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
sx={[ sx={[
{ py: 0.5, maxWidth: 250, mx: "auto", padding: "4px 0px" }, { py: 0.5, maxWidth: 250, mx: 'auto', padding: '4px 0px' },
isDragging ? { opacity: 0.78 } : {}, isDragging ? { opacity: 0.78 } : {},
]} ]}
> >
@@ -68,54 +68,54 @@ export const LayoutItem = (props: Props) => {
paddingLeft: 1, paddingLeft: 1,
paddingRight: 1, paddingRight: 1,
marginRight: 1.25, marginRight: 1.25,
cursor: draggable ? "grab" : "pointer", cursor: draggable ? 'grab' : 'pointer',
"&:active": draggable ? { cursor: "grabbing" } : {}, '&:active': draggable ? { cursor: 'grabbing' } : {},
"& .MuiListItemText-primary": { '& .MuiListItemText-primary': {
color: "text.primary", color: 'text.primary',
fontWeight: "700", fontWeight: '700',
}, },
}, },
({ palette: { mode, primary } }) => { ({ palette: { mode, primary } }) => {
const bgcolor = const bgcolor =
mode === "light" mode === 'light'
? alpha(primary.main, 0.15) ? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35); : alpha(primary.main, 0.35)
const color = mode === "light" ? "#1f1f1f" : "#ffffff"; const color = mode === 'light' ? '#1f1f1f' : '#ffffff'
return { return {
"&.Mui-selected": { bgcolor }, '&.Mui-selected': { bgcolor },
"&.Mui-selected:hover": { bgcolor }, '&.Mui-selected:hover': { bgcolor },
"&.Mui-selected .MuiListItemText-primary": { color }, '&.Mui-selected .MuiListItemText-primary': { color },
}; }
}, },
]} ]}
title={navCollapsed ? children : undefined} title={navCollapsed ? children : undefined}
aria-label={navCollapsed ? children : undefined} aria-label={navCollapsed ? children : undefined}
onClick={() => navigate(to)} onClick={() => navigate(to)}
> >
{(effectiveMenuIcon === "monochrome" || !effectiveMenuIcon) && ( {(effectiveMenuIcon === 'monochrome' || !effectiveMenuIcon) && (
<ListItemIcon <ListItemIcon
sx={{ sx={{
color: "text.primary", color: 'text.primary',
marginLeft: "6px", marginLeft: '6px',
cursor: draggable ? "grab" : "inherit", cursor: draggable ? 'grab' : 'inherit',
}} }}
> >
{icon[0]} {icon[0]}
</ListItemIcon> </ListItemIcon>
)} )}
{effectiveMenuIcon === "colorful" && ( {effectiveMenuIcon === 'colorful' && (
<ListItemIcon sx={{ cursor: draggable ? "grab" : "inherit" }}> <ListItemIcon sx={{ cursor: draggable ? 'grab' : 'inherit' }}>
{icon[1]} {icon[1]}
</ListItemIcon> </ListItemIcon>
)} )}
<ListItemText <ListItemText
sx={{ sx={{
textAlign: "center", textAlign: 'center',
marginLeft: effectiveMenuIcon === "disable" ? "" : "-35px", marginLeft: effectiveMenuIcon === 'disable' ? '' : '-35px',
}} }}
primary={children} primary={children}
/> />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
); )
}; }
+50 -50
View File
@@ -2,37 +2,37 @@ import {
ArrowDownwardRounded, ArrowDownwardRounded,
ArrowUpwardRounded, ArrowUpwardRounded,
MemoryRounded, MemoryRounded,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { Box, Typography } from "@mui/material"; import { Box, Typography } from '@mui/material'
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { LightweightTrafficErrorBoundary } from "@/components/shared/traffic-error-boundary"; import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useMemoryData } from "@/hooks/use-memory-data"; import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from "@/hooks/use-traffic-data"; import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import { useVisibility } from "@/hooks/use-visibility"; import { useVisibility } from '@/hooks/use-visibility'
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from '@/utils/parse-traffic'
import { TrafficGraph, type TrafficRef } from "./traffic-graph"; import { TrafficGraph, type TrafficRef } from './traffic-graph'
// setup the traffic // setup the traffic
export const LayoutTraffic = () => { export const LayoutTraffic = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const { verge } = useVerge(); const { verge } = useVerge()
// whether hide traffic graph // whether hide traffic graph
const trafficGraph = verge?.traffic_graph ?? true; const trafficGraph = verge?.traffic_graph ?? true
const trafficRef = useRef<TrafficRef>(null); const trafficRef = useRef<TrafficRef>(null)
const pageVisible = useVisibility(); const pageVisible = useVisibility()
const { const {
response: { data: traffic }, response: { data: traffic },
} = useTrafficData({ enabled: trafficGraph && pageVisible }); } = useTrafficData({ enabled: trafficGraph && pageVisible })
const { const {
response: { data: memory }, response: { data: memory },
} = useMemoryData(); } = useMemoryData()
// 监听数据变化,为图表添加数据点 // 监听数据变化,为图表添加数据点
useEffect(() => { useEffect(() => {
@@ -40,45 +40,45 @@ export const LayoutTraffic = () => {
trafficRef.current.appendData({ trafficRef.current.appendData({
up: traffic?.up || 0, up: traffic?.up || 0,
down: traffic?.down || 0, down: traffic?.down || 0,
}); })
} }
}, [traffic]); }, [traffic])
// 显示内存使用情况的设置 // 显示内存使用情况的设置
const displayMemory = verge?.enable_memory_usage ?? true; const displayMemory = verge?.enable_memory_usage ?? true
// 使用parseTraffic统一处理转换,保持与首页一致的显示格式 // 使用parseTraffic统一处理转换,保持与首页一致的显示格式
const [up, upUnit] = parseTraffic(traffic?.up || 0); const [up, upUnit] = parseTraffic(traffic?.up || 0)
const [down, downUnit] = parseTraffic(traffic?.down || 0); const [down, downUnit] = parseTraffic(traffic?.down || 0)
const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0); const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0)
const boxStyle: any = { const boxStyle: any = {
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
}; }
const iconStyle: any = { const iconStyle: any = {
sx: { mr: "8px", fontSize: 16 }, sx: { mr: '8px', fontSize: 16 },
}; }
const valStyle: any = { const valStyle: any = {
component: "span", component: 'span',
textAlign: "center", textAlign: 'center',
sx: { flex: "1 1 56px", userSelect: "none" }, sx: { flex: '1 1 56px', userSelect: 'none' },
}; }
const unitStyle: any = { const unitStyle: any = {
component: "span", component: 'span',
color: "grey.500", color: 'grey.500',
fontSize: "12px", fontSize: '12px',
textAlign: "right", textAlign: 'right',
sx: { flex: "0 1 27px", userSelect: "none" }, sx: { flex: '0 1 27px', userSelect: 'none' },
}; }
return ( return (
<LightweightTrafficErrorBoundary> <LightweightTrafficErrorBoundary>
<Box position="relative"> <Box position="relative">
{trafficGraph && pageVisible && ( {trafficGraph && pageVisible && (
<div <div
style={{ width: "100%", height: 60, marginBottom: 6 }} style={{ width: '100%', height: 60, marginBottom: 6 }}
onClick={trafficRef.current?.toggleStyle} onClick={trafficRef.current?.toggleStyle}
> >
<TrafficGraph ref={trafficRef} /> <TrafficGraph ref={trafficRef} />
@@ -87,7 +87,7 @@ export const LayoutTraffic = () => {
<Box display="flex" flexDirection="column" gap={0.75}> <Box display="flex" flexDirection="column" gap={0.75}>
<Box <Box
title={`${t("home.components.traffic.metrics.uploadSpeed")}`} title={`${t('home.components.traffic.metrics.uploadSpeed')}`}
{...boxStyle} {...boxStyle}
sx={{ sx={{
...boxStyle.sx, ...boxStyle.sx,
@@ -96,7 +96,7 @@ export const LayoutTraffic = () => {
> >
<ArrowUpwardRounded <ArrowUpwardRounded
{...iconStyle} {...iconStyle}
color={(traffic?.up || 0) > 0 ? "secondary" : "disabled"} color={(traffic?.up || 0) > 0 ? 'secondary' : 'disabled'}
/> />
<Typography {...valStyle} color="secondary"> <Typography {...valStyle} color="secondary">
{up} {up}
@@ -105,7 +105,7 @@ export const LayoutTraffic = () => {
</Box> </Box>
<Box <Box
title={`${t("home.components.traffic.metrics.downloadSpeed")}`} title={`${t('home.components.traffic.metrics.downloadSpeed')}`}
{...boxStyle} {...boxStyle}
sx={{ sx={{
...boxStyle.sx, ...boxStyle.sx,
@@ -114,7 +114,7 @@ export const LayoutTraffic = () => {
> >
<ArrowDownwardRounded <ArrowDownwardRounded
{...iconStyle} {...iconStyle}
color={(traffic?.down || 0) > 0 ? "primary" : "disabled"} color={(traffic?.down || 0) > 0 ? 'primary' : 'disabled'}
/> />
<Typography {...valStyle} color="primary"> <Typography {...valStyle} color="primary">
{down} {down}
@@ -124,13 +124,13 @@ export const LayoutTraffic = () => {
{displayMemory && ( {displayMemory && (
<Box <Box
title={`${t("home.components.traffic.metrics.memoryUsage")} `} title={`${t('home.components.traffic.metrics.memoryUsage')} `}
{...boxStyle} {...boxStyle}
sx={{ sx={{
cursor: "auto", cursor: 'auto',
// opacity: memory?.is_fresh ? 1 : 0.6, // opacity: memory?.is_fresh ? 1 : 0.6,
}} }}
color={"disabled"} color={'disabled'}
onClick={async () => { onClick={async () => {
// isDebug && (await gc()); // isDebug && (await gc());
}} }}
@@ -143,5 +143,5 @@ export const LayoutTraffic = () => {
</Box> </Box>
</Box> </Box>
</LightweightTrafficErrorBoundary> </LightweightTrafficErrorBoundary>
); )
}; }
+90 -90
View File
@@ -1,180 +1,180 @@
import { CloseRounded } from "@mui/icons-material"; import { CloseRounded } from '@mui/icons-material'
import { import {
Snackbar, Snackbar,
Alert, Alert,
IconButton, IconButton,
Box, Box,
type SnackbarOrigin, type SnackbarOrigin,
} from "@mui/material"; } from '@mui/material'
import React, { useCallback, useMemo, useSyncExternalStore } from "react"; import React, { useCallback, useMemo, useSyncExternalStore } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { import {
subscribeNotices, subscribeNotices,
hideNotice, hideNotice,
getSnapshotNotices, getSnapshotNotices,
showNotice, showNotice,
} from "@/services/notice-service"; } from '@/services/notice-service'
import type { TranslationKey } from "@/types/generated/i18n-keys"; import type { TranslationKey } from '@/types/generated/i18n-keys'
type NoticePosition = NonNullable<IVergeConfig["notice_position"]>; type NoticePosition = NonNullable<IVergeConfig['notice_position']>
type NoticeItem = ReturnType<typeof getSnapshotNotices>[number]; type NoticeItem = ReturnType<typeof getSnapshotNotices>[number]
type TranslationFn = ReturnType<typeof useTranslation>["t"]; type TranslationFn = ReturnType<typeof useTranslation>['t']
const VALID_POSITIONS: NoticePosition[] = [ const VALID_POSITIONS: NoticePosition[] = [
"top-left", 'top-left',
"top-right", 'top-right',
"bottom-left", 'bottom-left',
"bottom-right", 'bottom-right',
]; ]
const resolvePosition = (position?: NoticePosition | null): NoticePosition => { const resolvePosition = (position?: NoticePosition | null): NoticePosition => {
if (position && VALID_POSITIONS.includes(position)) { if (position && VALID_POSITIONS.includes(position)) {
return position; return position
} }
return "top-right"; return 'top-right'
}; }
const getAnchorOrigin = (position: NoticePosition): SnackbarOrigin => { const getAnchorOrigin = (position: NoticePosition): SnackbarOrigin => {
const [vertical, horizontal] = position.split("-") as [ const [vertical, horizontal] = position.split('-') as [
SnackbarOrigin["vertical"], SnackbarOrigin['vertical'],
SnackbarOrigin["horizontal"], SnackbarOrigin['horizontal'],
]; ]
return { vertical, horizontal }; return { vertical, horizontal }
}; }
const resolveNoticeMessage = ( const resolveNoticeMessage = (
notice: NoticeItem, notice: NoticeItem,
t: TranslationFn, t: TranslationFn,
): React.ReactNode => { ): React.ReactNode => {
const i18n = notice.i18n; const i18n = notice.i18n
if (!i18n) return notice.message; if (!i18n) return notice.message
const params = (i18n.params ?? {}) as Record<string, unknown>; const params = (i18n.params ?? {}) as Record<string, unknown>
const { prefixKey, prefixParams, prefix, message, ...restParams } = params; const { prefixKey, prefixParams, prefix, message, ...restParams } = params
const prefixKeyParams = const prefixKeyParams =
prefixParams && typeof prefixParams === "object" prefixParams && typeof prefixParams === 'object'
? (prefixParams as Record<string, unknown>) ? (prefixParams as Record<string, unknown>)
: undefined; : undefined
const resolvedPrefix = const resolvedPrefix =
typeof prefixKey === "string" typeof prefixKey === 'string'
? t(prefixKey as TranslationKey, { ? t(prefixKey as TranslationKey, {
defaultValue: prefixKey, defaultValue: prefixKey,
...(prefixKeyParams ?? {}), ...(prefixKeyParams ?? {}),
...restParams, ...restParams,
}) })
: typeof prefix === "string" : typeof prefix === 'string'
? prefix ? prefix
: undefined; : undefined
const messageStr = typeof message === "string" ? message : undefined; const messageStr = typeof message === 'string' ? message : undefined
const defaultValue = const defaultValue =
messageStr === undefined messageStr === undefined
? undefined ? undefined
: resolvedPrefix : resolvedPrefix
? `${resolvedPrefix} ${messageStr}` ? `${resolvedPrefix} ${messageStr}`
: messageStr; : messageStr
return t(i18n.key as TranslationKey, { return t(i18n.key as TranslationKey, {
defaultValue, defaultValue,
...restParams, ...restParams,
...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}), ...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}),
...(messageStr !== undefined ? { message: messageStr } : {}), ...(messageStr !== undefined ? { message: messageStr } : {}),
}); })
}; }
const extractNoticeCopyText = (input: unknown): string | undefined => { const extractNoticeCopyText = (input: unknown): string | undefined => {
if (input === null || input === undefined) return undefined; if (input === null || input === undefined) return undefined
if (typeof input === "string") return input; if (typeof input === 'string') return input
if (typeof input === "number" || typeof input === "boolean") { if (typeof input === 'number' || typeof input === 'boolean') {
return String(input); return String(input)
} }
if (input instanceof Error) { if (input instanceof Error) {
return input.message || input.name; return input.message || input.name
} }
if (React.isValidElement(input)) return undefined; if (React.isValidElement(input)) return undefined
if (typeof input === "object") { if (typeof input === 'object') {
const maybeMessage = (input as { message?: unknown }).message; const maybeMessage = (input as { message?: unknown }).message
if (typeof maybeMessage === "string") return maybeMessage; if (typeof maybeMessage === 'string') return maybeMessage
} }
try { try {
return JSON.stringify(input); return JSON.stringify(input)
} catch { } catch {
return String(input); return String(input)
} }
}; }
const resolveNoticeCopyText = ( const resolveNoticeCopyText = (
notice: NoticeItem, notice: NoticeItem,
t: TranslationFn, t: TranslationFn,
): string | undefined => { ): string | undefined => {
if ( if (
notice.i18n?.key === "shared.feedback.notices.prefixedRaw" || notice.i18n?.key === 'shared.feedback.notices.prefixedRaw' ||
notice.i18n?.key === "shared.feedback.notices.raw" notice.i18n?.key === 'shared.feedback.notices.raw'
) { ) {
const rawText = extractNoticeCopyText(notice.i18n?.params?.message); const rawText = extractNoticeCopyText(notice.i18n?.params?.message)
if (rawText) return rawText; if (rawText) return rawText
} }
return ( return (
extractNoticeCopyText(resolveNoticeMessage(notice, t)) ?? extractNoticeCopyText(resolveNoticeMessage(notice, t)) ??
extractNoticeCopyText(notice.message) extractNoticeCopyText(notice.message)
); )
}; }
interface NoticeManagerProps { interface NoticeManagerProps {
position?: NoticePosition | null; position?: NoticePosition | null
} }
export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => { export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => {
const { t } = useTranslation(); const { t } = useTranslation()
const resolvedPosition = useMemo(() => resolvePosition(position), [position]); const resolvedPosition = useMemo(() => resolvePosition(position), [position])
const anchorOrigin = useMemo( const anchorOrigin = useMemo(
() => getAnchorOrigin(resolvedPosition), () => getAnchorOrigin(resolvedPosition),
[resolvedPosition], [resolvedPosition],
); )
const currentNotices = useSyncExternalStore( const currentNotices = useSyncExternalStore(
subscribeNotices, subscribeNotices,
getSnapshotNotices, getSnapshotNotices,
); )
const handleClose = (id: number) => { const handleClose = (id: number) => {
hideNotice(id); hideNotice(id)
}; }
const handleNoticeCopy = useCallback( const handleNoticeCopy = useCallback(
async (notice: NoticeItem) => { async (notice: NoticeItem) => {
const text = resolveNoticeCopyText(notice, t); const text = resolveNoticeCopyText(notice, t)
if (!text) return; if (!text) return
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text)
showNotice.success( showNotice.success(
"shared.feedback.notifications.common.copySuccess", 'shared.feedback.notifications.common.copySuccess',
1000, 1000,
); )
} catch (error) { } catch (error) {
console.warn("[NoticeManager] copy to clipboard failed:", error); console.warn('[NoticeManager] copy to clipboard failed:', error)
} }
}, },
[t], [t],
); )
return ( return (
<Box <Box
sx={{ sx={{
position: "fixed", position: 'fixed',
top: anchorOrigin.vertical === "top" ? "20px" : "auto", top: anchorOrigin.vertical === 'top' ? '20px' : 'auto',
bottom: anchorOrigin.vertical === "bottom" ? "20px" : "auto", bottom: anchorOrigin.vertical === 'bottom' ? '20px' : 'auto',
left: anchorOrigin.horizontal === "left" ? "20px" : "auto", left: anchorOrigin.horizontal === 'left' ? '20px' : 'auto',
right: anchorOrigin.horizontal === "right" ? "20px" : "auto", right: anchorOrigin.horizontal === 'right' ? '20px' : 'auto',
zIndex: 1500, zIndex: 1500,
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
gap: "10px", gap: '10px',
maxWidth: "360px", maxWidth: '360px',
}} }}
> >
{currentNotices.map((notice) => ( {currentNotices.map((notice) => (
@@ -183,23 +183,23 @@ export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => {
open={true} open={true}
anchorOrigin={anchorOrigin} anchorOrigin={anchorOrigin}
sx={{ sx={{
position: "relative", position: 'relative',
transform: "none", transform: 'none',
top: "auto", top: 'auto',
right: "auto", right: 'auto',
bottom: "auto", bottom: 'auto',
left: "auto", left: 'auto',
width: "100%", width: '100%',
}} }}
> >
<Alert <Alert
severity={notice.type} severity={notice.type}
variant="filled" variant="filled"
sx={{ width: "100%" }} sx={{ width: '100%' }}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault(); event.preventDefault()
event.stopPropagation(); event.stopPropagation()
void handleNoticeCopy(notice); void handleNoticeCopy(notice)
}} }}
action={ action={
<IconButton <IconButton
@@ -216,5 +216,5 @@ export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => {
</Snackbar> </Snackbar>
))} ))}
</Box> </Box>
); )
}; }
+18 -18
View File
@@ -1,10 +1,10 @@
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'
import { IconButton, Fade, SxProps, Theme } from "@mui/material"; import { IconButton, Fade, SxProps, Theme } from '@mui/material'
interface Props { interface Props {
onClick: () => void; onClick: () => void
show: boolean; show: boolean
sx?: SxProps<Theme>; sx?: SxProps<Theme>
} }
export const ScrollTopButton = ({ onClick, show, sx }: Props) => { export const ScrollTopButton = ({ onClick, show, sx }: Props) => {
@@ -13,25 +13,25 @@ export const ScrollTopButton = ({ onClick, show, sx }: Props) => {
<IconButton <IconButton
onClick={onClick} onClick={onClick}
sx={{ sx={{
position: "absolute", position: 'absolute',
bottom: "20px", bottom: '20px',
right: "20px", right: '20px',
backgroundColor: (theme) => backgroundColor: (theme) =>
theme.palette.mode === "dark" theme.palette.mode === 'dark'
? "rgba(255,255,255,0.1)" ? 'rgba(255,255,255,0.1)'
: "rgba(0,0,0,0.1)", : 'rgba(0,0,0,0.1)',
"&:hover": { '&:hover': {
backgroundColor: (theme) => backgroundColor: (theme) =>
theme.palette.mode === "dark" theme.palette.mode === 'dark'
? "rgba(255,255,255,0.2)" ? 'rgba(255,255,255,0.2)'
: "rgba(0,0,0,0.2)", : 'rgba(0,0,0,0.2)',
}, },
visibility: show ? "visible" : "hidden", visibility: show ? 'visible' : 'hidden',
...sx, ...sx,
}} }}
> >
<KeyboardArrowUpIcon /> <KeyboardArrowUpIcon />
</IconButton> </IconButton>
</Fade> </Fade>
); )
}; }
+119 -119
View File
@@ -1,201 +1,201 @@
import { useTheme } from "@mui/material"; import { useTheme } from '@mui/material'
import { useEffect, useImperativeHandle, useRef, type Ref } from "react"; import { useEffect, useImperativeHandle, useRef, type Ref } from 'react'
import { Traffic } from "tauri-plugin-mihomo-api"; import { Traffic } from 'tauri-plugin-mihomo-api'
const maxPoint = 30; const maxPoint = 30
const refLineAlpha = 1; const refLineAlpha = 1
const refLineWidth = 2; const refLineWidth = 2
const upLineAlpha = 0.6; const upLineAlpha = 0.6
const upLineWidth = 4; const upLineWidth = 4
const downLineAlpha = 1; const downLineAlpha = 1
const downLineWidth = 4; 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 { export interface TrafficRef {
appendData: (data: Traffic) => void; appendData: (data: Traffic) => void
toggleStyle: () => void; toggleStyle: () => void
} }
/** /**
* draw the traffic graph * draw the traffic graph
*/ */
export function TrafficGraph({ ref }: { ref?: Ref<TrafficRef> }) { export function TrafficGraph({ ref }: { ref?: Ref<TrafficRef> }) {
const countRef = useRef(0); const countRef = useRef(0)
const styleRef = useRef(true); const styleRef = useRef(true)
const listRef = useRef<Traffic[]>(defaultList); const listRef = useRef<Traffic[]>(defaultList)
const canvasRef = useRef<HTMLCanvasElement>(null!); const canvasRef = useRef<HTMLCanvasElement>(null!)
const cacheRef = useRef<Traffic | null>(null); const cacheRef = useRef<Traffic | null>(null)
const { palette } = useTheme(); const { palette } = useTheme()
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
appendData: (data: Traffic) => { appendData: (data: Traffic) => {
cacheRef.current = data; cacheRef.current = data
}, },
toggleStyle: () => { toggleStyle: () => {
styleRef.current = !styleRef.current; styleRef.current = !styleRef.current
}, },
})); }))
useEffect(() => { useEffect(() => {
let timer: any; let timer: any
const zero = { up: 0, down: 0 }; const zero = { up: 0, down: 0 }
const handleData = () => { const handleData = () => {
const data = cacheRef.current ? cacheRef.current : zero; const data = cacheRef.current ? cacheRef.current : zero
cacheRef.current = null; cacheRef.current = null
const list = listRef.current; const list = listRef.current
if (list.length > maxPoint + 2) list.shift(); if (list.length > maxPoint + 2) list.shift()
list.push(data); list.push(data)
countRef.current = 0; countRef.current = 0
timer = setTimeout(handleData, 1000); timer = setTimeout(handleData, 1000)
}; }
handleData(); handleData()
return () => { return () => {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer)
}; }
}, []); }, [])
useEffect(() => { useEffect(() => {
let raf = 0; let raf = 0
const canvas = canvasRef.current!; 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 { primary, secondary, divider } = palette
const refLineColor = divider || "rgba(0, 0, 0, 0.12)"; const refLineColor = divider || 'rgba(0, 0, 0, 0.12)'
const upLineColor = secondary.main || "#9c27b0"; const upLineColor = secondary.main || '#9c27b0'
const downLineColor = primary.main || "#5b5c9d"; const downLineColor = primary.main || '#5b5c9d'
const width = canvas.width; const width = canvas.width
const height = canvas.height; const height = canvas.height
const dx = width / maxPoint; const dx = width / maxPoint
const dy = height / 7; const dy = height / 7
const l1 = dy; const l1 = dy
const l2 = dy * 4; const l2 = dy * 4
const countY = (v: number) => { const countY = (v: number) => {
const h = height; const h = height
if (v == 0) return h - 1; if (v == 0) return h - 1
if (v <= 10) return h - (v / 10) * dy; if (v <= 10) return h - (v / 10) * dy
if (v <= 100) return h - (v / 100 + 1) * dy; if (v <= 100) return h - (v / 100 + 1) * dy
if (v <= 1024) return h - (v / 1024 + 2) * dy; if (v <= 1024) return h - (v / 1024 + 2) * dy
if (v <= 10240) return h - (v / 10240 + 3) * dy; if (v <= 10240) return h - (v / 10240 + 3) * dy
if (v <= 102400) return h - (v / 102400 + 4) * dy; if (v <= 102400) return h - (v / 102400 + 4) * dy
if (v <= 1048576) return h - (v / 1048576 + 5) * dy; if (v <= 1048576) return h - (v / 1048576 + 5) * dy
if (v <= 10485760) return h - (v / 10485760 + 6) * dy; if (v <= 10485760) return h - (v / 10485760 + 6) * dy
return 1; return 1
}; }
const drawBezier = (list: number[], offset: number) => { const drawBezier = (list: number[], offset: number) => {
const points = list.map((y, i) => [ const points = list.map((y, i) => [
(dx * (i - 1) - offset + 3) | 0, (dx * (i - 1) - offset + 3) | 0,
countY(y), 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++) { for (let i = 1; i < points.length; i++) {
const p1 = points[i]; const p1 = points[i]
const p2 = points[i + 1] || p1; const p2 = points[i + 1] || p1
const x1 = (p1[0] + p2[0]) / 2; const x1 = (p1[0] + p2[0]) / 2
const y1 = (p1[1] + p2[1]) / 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 drawLine = (list: number[], offset: number) => {
const points = list.map((y, i) => [ const points = list.map((y, i) => [
(dx * (i - 1) - offset) | 0, (dx * (i - 1) - offset) | 0,
countY(y), 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++) { for (let i = 1; i < points.length; i++) {
const p = points[i]; const p = points[i]
context.lineTo(p[0], p[1]); context.lineTo(p[0], p[1])
}
} }
};
const drawGraph = (lastTime: number) => { const drawGraph = (lastTime: number) => {
const listUp = listRef.current.map((v) => v.up); const listUp = listRef.current.map((v) => v.up)
const listDown = listRef.current.map((v) => v.down); const listDown = listRef.current.map((v) => v.down)
const lineStyle = styleRef.current; const lineStyle = styleRef.current
const now = Date.now(); const now = Date.now()
const diff = now - lastTime; const diff = now - lastTime
if (diff < 33) { if (diff < 33) {
raf = requestAnimationFrame(() => drawGraph(lastTime)); raf = requestAnimationFrame(() => drawGraph(lastTime))
return; return
} }
const temp = Math.min((diff / 1000) * dx + countRef.current, dx); const temp = Math.min((diff / 1000) * dx + countRef.current, dx)
const offset = countRef.current === 0 ? 0 : temp; const offset = countRef.current === 0 ? 0 : temp
countRef.current = temp; countRef.current = temp
context.clearRect(0, 0, width, height); context.clearRect(0, 0, width, height)
// Reference lines // Reference lines
context.beginPath(); context.beginPath()
context.globalAlpha = refLineAlpha; context.globalAlpha = refLineAlpha
context.lineWidth = refLineWidth; context.lineWidth = refLineWidth
context.strokeStyle = refLineColor; context.strokeStyle = refLineColor
context.moveTo(0, l1); context.moveTo(0, l1)
context.lineTo(width, l1); context.lineTo(width, l1)
context.moveTo(0, l2); context.moveTo(0, l2)
context.lineTo(width, l2); context.lineTo(width, l2)
context.stroke(); context.stroke()
context.closePath(); context.closePath()
context.beginPath(); context.beginPath()
context.globalAlpha = upLineAlpha; context.globalAlpha = upLineAlpha
context.lineWidth = upLineWidth; context.lineWidth = upLineWidth
context.strokeStyle = upLineColor; context.strokeStyle = upLineColor
if (lineStyle) { if (lineStyle) {
drawBezier(listUp, offset); drawBezier(listUp, offset)
} else { } else {
drawLine(listUp, offset); drawLine(listUp, offset)
} }
context.stroke(); context.stroke()
context.closePath(); context.closePath()
context.beginPath(); context.beginPath()
context.globalAlpha = downLineAlpha; context.globalAlpha = downLineAlpha
context.lineWidth = downLineWidth; context.lineWidth = downLineWidth
context.strokeStyle = downLineColor; context.strokeStyle = downLineColor
if (lineStyle) { if (lineStyle) {
drawBezier(listDown, offset); drawBezier(listDown, offset)
} else { } else {
drawLine(listDown, offset); drawLine(listDown, offset)
} }
context.stroke(); context.stroke()
context.closePath(); context.closePath()
raf = requestAnimationFrame(() => drawGraph(now)); raf = requestAnimationFrame(() => drawGraph(now))
}; }
drawGraph(Date.now()); drawGraph(Date.now())
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf)
}; }
}, [palette]); }, [palette])
return <canvas ref={canvasRef} style={{ width: "100%", height: "100%" }} />; return <canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
} }
+12 -12
View File
@@ -1,22 +1,22 @@
import { Button } from "@mui/material"; import { Button } from '@mui/material'
import { useRef } from "react"; import { useRef } from 'react'
import { DialogRef } from "@/components/base"; import { DialogRef } from '@/components/base'
import { useUpdate } from "@/hooks/use-update"; import { useUpdate } from '@/hooks/use-update'
import { UpdateViewer } from "../setting/mods/update-viewer"; import { UpdateViewer } from '../setting/mods/update-viewer'
interface Props { interface Props {
className?: string; className?: string
} }
export const UpdateButton = (props: Props) => { export const UpdateButton = (props: Props) => {
const { className } = props; const { className } = props
const viewerRef = useRef<DialogRef>(null); const viewerRef = useRef<DialogRef>(null)
const { updateInfo } = useUpdate(); const { updateInfo } = useUpdate()
if (!updateInfo?.available) return null; if (!updateInfo?.available) return null
return ( return (
<> <>
@@ -32,5 +32,5 @@ export const UpdateButton = (props: Props) => {
New New
</Button> </Button>
</> </>
); )
}; }
+19 -19
View File
@@ -1,12 +1,12 @@
import { Close, CropSquare, FilterNone, Minimize } from "@mui/icons-material"; import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
import { Box, IconButton } from "@mui/material"; import { Box, IconButton } from '@mui/material'
import { forwardRef, useImperativeHandle } from "react"; import { forwardRef, useImperativeHandle } from 'react'
import { useWindowControls } from "@/hooks/use-window"; import { useWindowControls } from '@/hooks/use-window'
import getSystem from "@/utils/get-system"; import getSystem from '@/utils/get-system'
export const WindowControls = forwardRef(function WindowControls(props, ref) { export const WindowControls = forwardRef(function WindowControls(props, ref) {
const OS = getSystem(); const OS = getSystem()
const { const {
currentWindow, currentWindow,
maximized, maximized,
@@ -14,7 +14,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
close, close,
toggleFullscreen, toggleFullscreen,
toggleMaximize, toggleMaximize,
} = useWindowControls(); } = useWindowControls()
useImperativeHandle( useImperativeHandle(
ref, ref,
@@ -34,7 +34,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
toggleFullscreen, toggleFullscreen,
toggleMaximize, toggleMaximize,
], ],
); )
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。 // 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。 // 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
@@ -42,15 +42,15 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
return ( return (
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
gap: 1, gap: 1,
alignItems: "center", alignItems: 'center',
"> button": { '> button': {
cursor: "default", cursor: 'default',
}, },
}} }}
> >
{OS === "macos" && ( {OS === 'macos' && (
<> <>
{/* macOS 风格:关闭 → 最小化 → 全屏 */} {/* macOS 风格:关闭 → 最小化 → 全屏 */}
<IconButton size="small" sx={{ fontSize: 14 }} onClick={close}> <IconButton size="small" sx={{ fontSize: 14 }} onClick={close}>
@@ -73,7 +73,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
</> </>
)} )}
{OS === "windows" && ( {OS === 'windows' && (
<> <>
{/* Windows 风格:最小化 → 最大化 → 关闭 */} {/* Windows 风格:最小化 → 最大化 → 关闭 */}
<IconButton size="small" sx={{ fontSize: 16 }} onClick={minimize}> <IconButton size="small" sx={{ fontSize: 16 }} onClick={minimize}>
@@ -92,7 +92,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
</IconButton> </IconButton>
<IconButton <IconButton
size="small" size="small"
sx={{ fontSize: 16, ":hover": { bgcolor: "red", color: "white" } }} sx={{ fontSize: 16, ':hover': { bgcolor: 'red', color: 'white' } }}
onClick={close} onClick={close}
> >
<Close fontSize="inherit" color="inherit" /> <Close fontSize="inherit" color="inherit" />
@@ -100,7 +100,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
</> </>
)} )}
{OS === "linux" && ( {OS === 'linux' && (
<> <>
{/* Linux 桌面常见布局(GNOME/KDE 多为:最小化 → 最大化 → 关闭) */} {/* Linux 桌面常见布局(GNOME/KDE 多为:最小化 → 最大化 → 关闭) */}
<IconButton size="small" sx={{ fontSize: 16 }} onClick={minimize}> <IconButton size="small" sx={{ fontSize: 16 }} onClick={minimize}>
@@ -119,7 +119,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
</IconButton> </IconButton>
<IconButton <IconButton
size="small" size="small"
sx={{ fontSize: 16, ":hover": { bgcolor: "red", color: "white" } }} sx={{ fontSize: 16, ':hover': { bgcolor: 'red', color: 'white' } }}
onClick={close} onClick={close}
> >
<Close fontSize="inherit" color="inherit" /> <Close fontSize="inherit" color="inherit" />
@@ -127,5 +127,5 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
</> </>
)} )}
</Box> </Box>
); )
}); })
+50 -50
View File
@@ -1,26 +1,26 @@
import { styled, Box } from "@mui/material"; import { styled, Box } from '@mui/material'
import type { ReactNode } from "react"; import type { ReactNode } from 'react'
import type { SearchState } from "@/components/base"; import type { SearchState } from '@/components/base'
const Item = styled(Box)(({ theme: { palette, typography } }) => ({ const Item = styled(Box)(({ theme: { palette, typography } }) => ({
padding: "8px 0", padding: '8px 0',
margin: "0 12px", margin: '0 12px',
lineHeight: 1.35, lineHeight: 1.35,
borderBottom: `1px solid ${palette.divider}`, borderBottom: `1px solid ${palette.divider}`,
fontSize: "0.875rem", fontSize: '0.875rem',
fontFamily: typography.fontFamily, fontFamily: typography.fontFamily,
userSelect: "text", userSelect: 'text',
"& .time": { '& .time': {
color: palette.text.secondary, color: palette.text.secondary,
}, },
"& .type": { '& .type': {
display: "inline-block", display: 'inline-block',
marginLeft: 8, marginLeft: 8,
textAlign: "center", textAlign: 'center',
borderRadius: 2, borderRadius: 2,
textTransform: "uppercase", textTransform: 'uppercase',
fontWeight: "600", fontWeight: '600',
}, },
'& .type[data-type="error"], & .type[data-type="err"]': { '& .type[data-type="error"], & .type[data-type="err"]': {
color: palette.error.main, color: palette.error.main,
@@ -31,84 +31,84 @@ const Item = styled(Box)(({ theme: { palette, typography } }) => ({
'& .type[data-type="info"], & .type[data-type="inf"]': { '& .type[data-type="info"], & .type[data-type="inf"]': {
color: palette.info.main, color: palette.info.main,
}, },
"& .data": { '& .data': {
color: palette.text.primary, color: palette.text.primary,
overflowWrap: "anywhere", overflowWrap: 'anywhere',
}, },
"& .highlight": { '& .highlight': {
backgroundColor: palette.mode === "dark" ? "#ffeb3b40" : "#ffeb3b90", backgroundColor: palette.mode === 'dark' ? '#ffeb3b40' : '#ffeb3b90',
borderRadius: 2, borderRadius: 2,
padding: "0 2px", padding: '0 2px',
}, },
})); }))
interface Props { interface Props {
value: ILogItem; value: ILogItem
searchState?: SearchState; searchState?: SearchState
} }
const LogItem = ({ value, searchState }: Props) => { const LogItem = ({ value, searchState }: Props) => {
const renderHighlightText = (text: string) => { const renderHighlightText = (text: string) => {
if (!searchState?.text.trim()) return text; if (!searchState?.text.trim()) return text
try { try {
const searchText = searchState.text; const searchText = searchState.text
let pattern: string; let pattern: string
if (searchState.useRegularExpression) { if (searchState.useRegularExpression) {
try { try {
new RegExp(searchText); new RegExp(searchText)
pattern = searchText; pattern = searchText
} catch { } catch {
pattern = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); pattern = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
} }
} else { } else {
const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
pattern = searchState.matchWholeWord ? `\\b${escaped}\\b` : escaped; pattern = searchState.matchWholeWord ? `\\b${escaped}\\b` : escaped
} }
const flags = searchState.matchCase ? "g" : "gi"; const flags = searchState.matchCase ? 'g' : 'gi'
const regex = new RegExp(pattern, flags); const regex = new RegExp(pattern, flags)
const elements: ReactNode[] = []; const elements: ReactNode[] = []
let lastIndex = 0; let lastIndex = 0
let match: RegExpExecArray | null; let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) { while ((match = regex.exec(text)) !== null) {
const start = match.index; const start = match.index
const matchText = match[0]; const matchText = match[0]
if (matchText === "") { if (matchText === '') {
regex.lastIndex += 1; regex.lastIndex += 1
continue; continue
} }
if (start > lastIndex) { if (start > lastIndex) {
elements.push(text.slice(lastIndex, start)); elements.push(text.slice(lastIndex, start))
} }
elements.push( elements.push(
<span key={`highlight-${start}`} className="highlight"> <span key={`highlight-${start}`} className="highlight">
{matchText} {matchText}
</span>, </span>,
); )
lastIndex = start + matchText.length; lastIndex = start + matchText.length
} }
if (lastIndex < text.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 { } catch {
return text; return text
}
} }
};
return ( return (
<Item> <Item>
<div> <div>
<span className="time">{renderHighlightText(value.time || "")}</span> <span className="time">{renderHighlightText(value.time || '')}</span>
<span className="type" data-type={value.type.toLowerCase()}> <span className="type" data-type={value.type.toLowerCase()}>
{renderHighlightText(value.type)} {renderHighlightText(value.type)}
</span> </span>
@@ -117,7 +117,7 @@ const LogItem = ({ value, searchState }: Props) => {
<span className="data">{renderHighlightText(value.payload)}</span> <span className="data">{renderHighlightText(value.payload)}</span>
</div> </div>
</Item> </Item>
); )
}; }
export default LogItem; export default LogItem
+17 -17
View File
@@ -4,43 +4,43 @@ import {
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
} from "@mui/material"; } from '@mui/material'
import { useEffect } from "react"; import { useEffect } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
interface Props { interface Props {
open: boolean; open: boolean
title: string; title: string
message: string; message: string
onClose: () => void; onClose: () => void
onConfirm: () => void; onConfirm: () => void
} }
export const ConfirmViewer = (props: Props) => { 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(() => { useEffect(() => {
if (!open) return; if (!open) return
}, [open]); }, [open])
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth> <Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ pb: 1, userSelect: "text" }}> <DialogContent sx={{ pb: 1, userSelect: 'text' }}>
{message} {message}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} variant="outlined"> <Button onClick={onClose} variant="outlined">
{t("shared.actions.cancel")} {t('shared.actions.cancel')}
</Button> </Button>
<Button onClick={onConfirm} variant="contained"> <Button onClick={onConfirm} variant="contained">
{t("shared.actions.confirm")} {t('shared.actions.confirm')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); )
}; }
+106 -112
View File
@@ -1,10 +1,10 @@
import MonacoEditor from "@monaco-editor/react"; import MonacoEditor from '@monaco-editor/react'
import { import {
CloseFullscreenRounded, CloseFullscreenRounded,
ContentPasteRounded, ContentPasteRounded,
FormatPaintRounded, FormatPaintRounded,
OpenInFullRounded, OpenInFullRounded,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { import {
Button, Button,
ButtonGroup, ButtonGroup,
@@ -13,44 +13,38 @@ import {
DialogContent, DialogContent,
DialogTitle, DialogTitle,
IconButton, IconButton,
} from "@mui/material"; } from '@mui/material'
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import type { editor } from "monaco-editor"; import type { editor } from 'monaco-editor'
import { import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
type ReactNode, import { useTranslation } from 'react-i18next'
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { BaseLoadingOverlay } from "@/components/base"; import { BaseLoadingOverlay } from '@/components/base'
import { beforeEditorMount } from "@/services/monaco"; import { beforeEditorMount } from '@/services/monaco'
import { showNotice } from "@/services/notice-service"; import { showNotice } from '@/services/notice-service'
import { useThemeMode } from "@/services/states"; import { useThemeMode } from '@/services/states'
import debounce from "@/utils/debounce"; import debounce from '@/utils/debounce'
import getSystem from "@/utils/get-system"; 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 { export interface EditorViewerProps {
open: boolean; open: boolean
title?: string | ReactNode; title?: string | ReactNode
value: string; value: string
language: EditorLanguage; language: EditorLanguage
path: string; path: string
readOnly?: boolean; readOnly?: boolean
loading?: boolean; loading?: boolean
dirty?: boolean; dirty?: boolean
saveDisabled?: boolean; saveDisabled?: boolean
onChange?: (value: string) => void; onChange?: (value: string) => void
onSave?: () => void | Promise<void>; onSave?: () => void | Promise<void>
onClose: () => void; onClose: () => void
onValidate?: (markers: editor.IMarker[]) => void; onValidate?: (markers: editor.IMarker[]) => void
} }
export const EditorViewer = ({ export const EditorViewer = ({
@@ -68,111 +62,111 @@ export const EditorViewer = ({
onClose, onClose,
onValidate, onValidate,
}: EditorViewerProps) => { }: EditorViewerProps) => {
const { t } = useTranslation(); const { t } = useTranslation()
const themeMode = useThemeMode(); const themeMode = useThemeMode()
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false)
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null); const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const resolvedTitle = title ?? t("profiles.components.menu.editFile"); const resolvedTitle = title ?? t('profiles.components.menu.editFile')
const disableSave = loading || saveDisabled || dirty === false; const disableSave = loading || saveDisabled || dirty === false
const syncMaximizedState = useCallback(async () => { const syncMaximizedState = useCallback(async () => {
try { try {
setIsMaximized(await appWindow.isMaximized()); setIsMaximized(await appWindow.isMaximized())
} catch { } catch {
setIsMaximized(false); setIsMaximized(false)
} }
}, []); }, [])
const handleSave = useLockFn(async () => { const handleSave = useLockFn(async () => {
try { try {
if (!readOnly) { if (!readOnly) {
await onSave?.(); await onSave?.()
} }
onClose(); onClose()
} catch (error) { } catch (error) {
showNotice.error(error); showNotice.error(error)
} }
}); })
const handleClose = () => { const handleClose = () => {
try { try {
onClose(); onClose()
} catch (error) { } catch (error) {
showNotice.error(error); showNotice.error(error)
}
} }
};
const handlePaste = useLockFn(async () => { const handlePaste = useLockFn(async () => {
try { try {
if (readOnly || loading || !editorRef.current) return; if (readOnly || loading || !editorRef.current) return
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText()
if (!text) return; if (!text) return
const editorInstance = editorRef.current; const editorInstance = editorRef.current
const model = editorInstance.getModel(); const model = editorInstance.getModel()
const selections = editorInstance.getSelections(); const selections = editorInstance.getSelections()
if (!model || !selections || selections.length === 0) return; if (!model || !selections || selections.length === 0) return
editorInstance.pushUndoStop(); editorInstance.pushUndoStop()
editorInstance.executeEdits( editorInstance.executeEdits(
"explicit-paste", 'explicit-paste',
selections.map((selection) => ({ selections.map((selection) => ({
range: selection, range: selection,
text, text,
forceMoveMarkers: true, forceMoveMarkers: true,
})), })),
); )
editorInstance.pushUndoStop(); editorInstance.pushUndoStop()
editorInstance.focus(); editorInstance.focus()
} catch (error) { } catch (error) {
showNotice.error(error); showNotice.error(error)
} }
}); })
const handleFormat = useLockFn(async () => { const handleFormat = useLockFn(async () => {
try { try {
if (loading) return; if (loading) return
await editorRef.current?.getAction("editor.action.formatDocument")?.run(); await editorRef.current?.getAction('editor.action.formatDocument')?.run()
} catch (error) { } catch (error) {
showNotice.error(error); showNotice.error(error)
} }
}); })
const handleToggleMaximize = useLockFn(async () => { const handleToggleMaximize = useLockFn(async () => {
try { try {
await appWindow.toggleMaximize(); await appWindow.toggleMaximize()
await syncMaximizedState(); await syncMaximizedState()
editorRef.current?.layout(); editorRef.current?.layout()
} catch (error) { } catch (error) {
showNotice.error(error); showNotice.error(error)
} }
}); })
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return
void syncMaximizedState(); void syncMaximizedState()
}, [open, syncMaximizedState]); }, [open, syncMaximizedState])
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return
const onResized = debounce(() => { const onResized = debounce(() => {
void syncMaximizedState(); void syncMaximizedState()
try { try {
editorRef.current?.layout(); editorRef.current?.layout()
} catch { } catch {
// Ignore transient layout errors during window transitions. // Ignore transient layout errors during window transitions.
} }
}, 100); }, 100)
const unlistenResized = appWindow.onResized(onResized); const unlistenResized = appWindow.onResized(onResized)
return () => { return () => {
unlistenResized.then((unlisten) => unlisten()); unlistenResized.then((unlisten) => unlisten())
}; }
}, [open, syncMaximizedState]); }, [open, syncMaximizedState])
return ( return (
<Dialog <Dialog
@@ -186,14 +180,14 @@ export const EditorViewer = ({
<DialogContent <DialogContent
sx={{ sx={{
width: "auto", width: 'auto',
height: "calc(100vh - 185px)", height: 'calc(100vh - 185px)',
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
overflow: "hidden", overflow: 'hidden',
}} }}
> >
<div style={{ position: "relative", flex: "1 1 auto", minHeight: 0 }}> <div style={{ position: 'relative', flex: '1 1 auto', minHeight: 0 }}>
<BaseLoadingOverlay isLoading={loading} /> <BaseLoadingOverlay isLoading={loading} />
{!loading && ( {!loading && (
<MonacoEditor <MonacoEditor
@@ -201,30 +195,30 @@ export const EditorViewer = ({
path={path} path={path}
value={value} value={value}
language={language} language={language}
theme={themeMode === "light" ? "light" : "vs-dark"} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
loading={null} loading={null}
saveViewState saveViewState
keepCurrentModel={false} keepCurrentModel={false}
beforeMount={beforeEditorMount} beforeMount={beforeEditorMount}
onMount={(editorInstance) => { onMount={(editorInstance) => {
editorRef.current = editorInstance; editorRef.current = editorInstance
}} }}
onChange={(nextValue) => onChange?.(nextValue ?? "")} onChange={(nextValue) => onChange?.(nextValue ?? '')}
onValidate={onValidate} onValidate={onValidate}
options={{ options={{
automaticLayout: true, automaticLayout: true,
tabSize: 2, tabSize: 2,
minimap: { minimap: {
enabled: enabled:
typeof document !== "undefined" && typeof document !== 'undefined' &&
document.documentElement.clientWidth >= 1500, document.documentElement.clientWidth >= 1500,
}, },
mouseWheelZoom: true, mouseWheelZoom: true,
readOnly, readOnly,
readOnlyMessage: { readOnlyMessage: {
value: t("profiles.modals.editor.messages.readOnly"), value: t('profiles.modals.editor.messages.readOnly'),
}, },
renderValidationDecorations: "on", renderValidationDecorations: 'on',
quickSuggestions: { quickSuggestions: {
strings: true, strings: true,
comments: true, comments: true,
@@ -234,7 +228,7 @@ export const EditorViewer = ({
top: 33, top: 33,
}, },
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${ 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, fontLigatures: false,
smoothScrolling: true, smoothScrolling: true,
@@ -245,16 +239,16 @@ export const EditorViewer = ({
<ButtonGroup <ButtonGroup
variant="contained" variant="contained"
sx={{ position: "absolute", left: "14px", bottom: "8px" }} sx={{ position: 'absolute', left: '14px', bottom: '8px' }}
> >
<IconButton <IconButton
size="medium" size="medium"
color="inherit" color="inherit"
sx={{ display: readOnly ? "none" : "" }} sx={{ display: readOnly ? 'none' : '' }}
title={t("profiles.page.importForm.actions.paste")} title={t('profiles.page.importForm.actions.paste')}
disabled={loading} disabled={loading}
onClick={() => { onClick={() => {
void handlePaste(); void handlePaste()
}} }}
> >
<ContentPasteRounded fontSize="inherit" /> <ContentPasteRounded fontSize="inherit" />
@@ -262,11 +256,11 @@ export const EditorViewer = ({
<IconButton <IconButton
size="medium" size="medium"
color="inherit" color="inherit"
sx={{ display: readOnly ? "none" : "" }} sx={{ display: readOnly ? 'none' : '' }}
title={t("profiles.modals.editor.actions.format")} title={t('profiles.modals.editor.actions.format')}
disabled={loading} disabled={loading}
onClick={() => { onClick={() => {
void handleFormat(); void handleFormat()
}} }}
> >
<FormatPaintRounded fontSize="inherit" /> <FormatPaintRounded fontSize="inherit" />
@@ -275,10 +269,10 @@ export const EditorViewer = ({
size="medium" size="medium"
color="inherit" color="inherit"
title={t( title={t(
isMaximized ? "shared.window.minimize" : "shared.window.maximize", isMaximized ? 'shared.window.minimize' : 'shared.window.maximize',
)} )}
onClick={() => { onClick={() => {
void handleToggleMaximize(); void handleToggleMaximize()
}} }}
> >
{isMaximized ? <CloseFullscreenRounded /> : <OpenInFullRounded />} {isMaximized ? <CloseFullscreenRounded /> : <OpenInFullRounded />}
@@ -288,20 +282,20 @@ export const EditorViewer = ({
<DialogActions> <DialogActions>
<Button onClick={handleClose} variant="outlined"> <Button onClick={handleClose} variant="outlined">
{t(readOnly ? "shared.actions.close" : "shared.actions.cancel")} {t(readOnly ? 'shared.actions.close' : 'shared.actions.cancel')}
</Button> </Button>
{!readOnly && ( {!readOnly && (
<Button <Button
onClick={() => { onClick={() => {
void handleSave(); void handleSave()
}} }}
variant="contained" variant="contained"
disabled={disableSave} disabled={disableSave}
> >
{t("shared.actions.save")} {t('shared.actions.save')}
</Button> </Button>
)} )}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); )
}; }
+29 -29
View File
@@ -1,61 +1,61 @@
import { Box, Button, Typography } from "@mui/material"; import { Box, Button, Typography } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import { useRef, useState } from "react"; import { useRef, useState } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
interface Props { interface Props {
onChange: (file: File, value: string) => void; onChange: (file: File, value: string) => void
} }
export const FileInput = (props: Props) => { export const FileInput = (props: Props) => {
const { onChange } = props; const { onChange } = props
const { t } = useTranslation(); const { t } = useTranslation()
// file input // file input
const inputRef = useRef<any>(undefined); const inputRef = useRef<any>(undefined)
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const [fileName, setFileName] = useState(""); const [fileName, setFileName] = useState('')
const onFileInput = useLockFn(async (e: any) => { 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); setFileName(file.name)
setLoading(true); setLoading(true)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader()
reader.onload = (event) => { reader.onload = (event) => {
resolve(null); resolve(null)
onChange(file, event.target?.result as string); onChange(file, event.target?.result as string)
}; }
reader.onerror = reject; reader.onerror = reject
reader.readAsText(file); reader.readAsText(file)
}).finally(() => setLoading(false)); }).finally(() => setLoading(false))
}); })
return ( return (
<Box sx={{ mt: 2, mb: 1, display: "flex", alignItems: "center" }}> <Box sx={{ mt: 2, mb: 1, display: 'flex', alignItems: 'center' }}>
<Button <Button
variant="outlined" variant="outlined"
sx={{ flex: "none" }} sx={{ flex: 'none' }}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
{t("profiles.components.fileInput.chooseFile")} {t('profiles.components.fileInput.chooseFile')}
</Button> </Button>
<input <input
type="file" type="file"
accept=".yaml,.yml" accept=".yaml,.yml"
ref={inputRef} ref={inputRef}
style={{ display: "none" }} style={{ display: 'none' }}
onChange={onFileInput} onChange={onFileInput}
/> />
<Typography noWrap sx={{ ml: 1 }}> <Typography noWrap sx={{ ml: 1 }}>
{loading ? "Loading..." : fileName} {loading ? 'Loading...' : fileName}
</Typography> </Typography>
</Box> </Box>
); )
}; }
+57 -57
View File
@@ -1,6 +1,6 @@
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from '@dnd-kit/sortable'
import { CSS } from "@dnd-kit/utilities"; import { CSS } from '@dnd-kit/utilities'
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material"; import { DeleteForeverRounded, UndoRounded } from '@mui/icons-material'
import { import {
Box, Box,
IconButton, IconButton,
@@ -8,18 +8,18 @@ import {
ListItemText, ListItemText,
alpha, alpha,
styled, styled,
} from "@mui/material"; } from '@mui/material'
import { useIconCache } from "@/hooks/use-icon-cache"; import { useIconCache } from '@/hooks/use-icon-cache'
interface Props { interface Props {
type: "prepend" | "original" | "delete" | "append"; type: 'prepend' | 'original' | 'delete' | 'append'
group: IProxyGroupConfig; group: IProxyGroupConfig
onDelete: () => void; onDelete: () => void
} }
export const GroupItem = (props: Props) => { export const GroupItem = (props: Props) => {
const { type, group, onDelete } = props; const { type, group, onDelete } = props
const sortable = type === "prepend" || type === "append"; const sortable = type === 'prepend' || type === 'append'
const { const {
attributes: sortableAttributes, attributes: sortableAttributes,
@@ -31,64 +31,64 @@ export const GroupItem = (props: Props) => {
} = useSortable({ } = useSortable({
id: group.name, id: group.name,
disabled: !sortable, disabled: !sortable,
}); })
const dragAttributes = sortable ? sortableAttributes : undefined; const dragAttributes = sortable ? sortableAttributes : undefined
const dragListeners = sortable ? sortableListeners : undefined; const dragListeners = sortable ? sortableListeners : undefined
const dragNodeRef = sortable ? sortableSetNodeRef : undefined; const dragNodeRef = sortable ? sortableSetNodeRef : undefined
const iconCachePath = useIconCache({ const iconCachePath = useIconCache({
icon: group.icon, icon: group.icon,
cacheKey: group.name.replaceAll(" ", ""), cacheKey: group.name.replaceAll(' ', ''),
}); })
return ( return (
<ListItem <ListItem
dense dense
sx={({ palette }) => ({ sx={({ palette }) => ({
position: "relative", position: 'relative',
background: background:
type === "original" type === 'original'
? palette.mode === "dark" ? palette.mode === 'dark'
? alpha(palette.background.paper, 0.3) ? alpha(palette.background.paper, 0.3)
: alpha(palette.grey[400], 0.3) : alpha(palette.grey[400], 0.3)
: type === "delete" : type === 'delete'
? alpha(palette.error.main, 0.3) ? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3), : alpha(palette.success.main, 0.3),
height: "100%", height: '100%',
margin: "8px 0", margin: '8px 0',
borderRadius: "8px", borderRadius: '8px',
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, 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') && (
<img <img
src={iconCachePath === "" ? group.icon : iconCachePath} src={iconCachePath === '' ? group.icon : iconCachePath}
width="32px" width="32px"
style={{ style={{
marginRight: "12px", marginRight: '12px',
borderRadius: "6px", borderRadius: '6px',
}} }}
/> />
)} )}
{group.icon && group.icon?.trim().startsWith("data") && ( {group.icon && group.icon?.trim().startsWith('data') && (
<img <img
src={group.icon} src={group.icon}
width="32px" width="32px"
style={{ style={{
marginRight: "12px", marginRight: '12px',
borderRadius: "6px", borderRadius: '6px',
}} }}
/> />
)} )}
{group.icon && group.icon?.trim().startsWith("<svg") && ( {group.icon && group.icon?.trim().startsWith('<svg') && (
<img <img
src={`data:image/svg+xml;base64,${btoa(group.icon ?? "")}`} src={`data:image/svg+xml;base64,${btoa(group.icon ?? '')}`}
width="32px" width="32px"
style={{ style={{
marginRight: "12px", marginRight: '12px',
borderRadius: "6px", borderRadius: '6px',
}} }}
/> />
)} )}
@@ -96,10 +96,10 @@ export const GroupItem = (props: Props) => {
{...(dragAttributes ?? {})} {...(dragAttributes ?? {})}
{...(dragListeners ?? {})} {...(dragListeners ?? {})}
ref={dragNodeRef} ref={dragNodeRef}
sx={{ cursor: sortable ? "move" : "" }} sx={{ cursor: sortable ? 'move' : '' }}
primary={ primary={
<StyledPrimary <StyledPrimary
sx={{ textDecoration: type === "delete" ? "line-through" : "" }} sx={{ textDecoration: type === 'delete' ? 'line-through' : '' }}
> >
{group.name} {group.name}
</StyledPrimary> </StyledPrimary>
@@ -107,13 +107,13 @@ export const GroupItem = (props: Props) => {
secondary={ secondary={
<ListItemTextChild <ListItemTextChild
sx={{ sx={{
overflow: "hidden", overflow: 'hidden',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
pt: "2px", pt: '2px',
}} }}
> >
<Box sx={{ marginTop: "2px" }}> <Box sx={{ marginTop: '2px' }}>
<StyledTypeBox>{group.type}</StyledTypeBox> <StyledTypeBox>{group.type}</StyledTypeBox>
</Box> </Box>
</ListItemTextChild> </ListItemTextChild>
@@ -121,41 +121,41 @@ export const GroupItem = (props: Props) => {
slotProps={{ slotProps={{
secondary: { secondary: {
sx: { sx: {
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
color: "#ccc", color: '#ccc',
}, },
}, },
}} }}
/> />
<IconButton onClick={onDelete}> <IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />} {type === 'delete' ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton> </IconButton>
</ListItem> </ListItem>
); )
}; }
const StyledPrimary = styled("div")` const StyledPrimary = styled('div')`
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
line-height: 1.5; line-height: 1.5;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`; `
const ListItemTextChild = styled("span")` const ListItemTextChild = styled('span')`
display: block; display: block;
`; `
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({ const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block", display: 'inline-block',
border: "1px solid #ccc", border: '1px solid #ccc',
borderColor: alpha(theme.palette.primary.main, 0.5), borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8), color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4, borderRadius: 4,
fontSize: 10, fontSize: 10,
padding: "0 4px", padding: '0 4px',
lineHeight: 1.5, lineHeight: 1.5,
marginRight: "8px", marginRight: '8px',
})); }))
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -7,33 +7,33 @@ import {
DialogTitle, DialogTitle,
Divider, Divider,
Typography, Typography,
} from "@mui/material"; } from '@mui/material'
import { Fragment } from "react"; import { Fragment } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { BaseEmpty } from "@/components/base"; import { BaseEmpty } from '@/components/base'
interface Props { interface Props {
open: boolean; open: boolean
logInfo: [string, string][]; logInfo: [string, string][]
onClose: () => void; onClose: () => void
} }
export const LogViewer = (props: Props) => { export const LogViewer = (props: Props) => {
const { open, logInfo, onClose } = props; const { open, logInfo, onClose } = props
const { t } = useTranslation(); const { t } = useTranslation()
return ( return (
<Dialog open={open} onClose={onClose}> <Dialog open={open} onClose={onClose}>
<DialogTitle>{t("profiles.modals.logViewer.title")}</DialogTitle> <DialogTitle>{t('profiles.modals.logViewer.title')}</DialogTitle>
<DialogContent <DialogContent
sx={{ sx={{
width: 400, width: 400,
height: 300, height: 300,
overflowX: "hidden", overflowX: 'hidden',
userSelect: "text", userSelect: 'text',
pb: 1, pb: 1,
}} }}
> >
@@ -45,9 +45,9 @@ export const LogViewer = (props: Props) => {
size="small" size="small"
variant="outlined" variant="outlined"
color={ color={
level === "error" || level === "exception" level === 'error' || level === 'exception'
? "error" ? 'error'
: "default" : 'default'
} }
sx={{ mr: 1 }} sx={{ mr: 1 }}
/> />
@@ -62,9 +62,9 @@ export const LogViewer = (props: Props) => {
<DialogActions> <DialogActions>
<Button onClick={onClose} variant="outlined"> <Button onClick={onClose} variant="outlined">
{t("shared.actions.close")} {t('shared.actions.close')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); )
}; }
+32 -32
View File
@@ -1,58 +1,58 @@
import { alpha, Box, styled } from "@mui/material"; import { alpha, Box, styled } from '@mui/material'
export const ProfileBox = styled(Box)(({ export const ProfileBox = styled(Box)(({
theme, theme,
"aria-selected": selected, 'aria-selected': selected,
}) => { }) => {
const { mode, primary, text } = theme.palette; const { mode, primary, text } = theme.palette
const key = `${mode}-${!!selected}`; const key = `${mode}-${!!selected}`
const backgroundColor = mode === "light" ? "#ffffff" : "#282A36"; const backgroundColor = mode === 'light' ? '#ffffff' : '#282A36'
const color = { const color = {
"light-true": text.secondary, 'light-true': text.secondary,
"light-false": text.secondary, 'light-false': text.secondary,
"dark-true": alpha(text.secondary, 0.65), 'dark-true': alpha(text.secondary, 0.65),
"dark-false": alpha(text.secondary, 0.65), 'dark-false': alpha(text.secondary, 0.65),
}[key]!; }[key]!
const h2color = { const h2color = {
"light-true": primary.main, 'light-true': primary.main,
"light-false": text.primary, 'light-false': text.primary,
"dark-true": primary.main, 'dark-true': primary.main,
"dark-false": text.primary, 'dark-false': text.primary,
}[key]!; }[key]!
const borderSelect = { const borderSelect = {
"light-true": { 'light-true': {
borderLeft: `3px solid ${primary.main}`, borderLeft: `3px solid ${primary.main}`,
width: `calc(100% + 3px)`, width: `calc(100% + 3px)`,
marginLeft: `-3px`, marginLeft: `-3px`,
}, },
"light-false": { 'light-false': {
width: "100%", width: '100%',
}, },
"dark-true": { 'dark-true': {
borderLeft: `3px solid ${primary.main}`, borderLeft: `3px solid ${primary.main}`,
width: `calc(100% + 3px)`, width: `calc(100% + 3px)`,
marginLeft: `-3px`, marginLeft: `-3px`,
}, },
"dark-false": { 'dark-false': {
width: "100%", width: '100%',
}, },
}[key]; }[key]
return { return {
position: "relative", position: 'relative',
display: "block", display: 'block',
cursor: "pointer", cursor: 'pointer',
textAlign: "left", textAlign: 'left',
padding: "8px 16px", padding: '8px 16px',
boxSizing: "border-box", boxSizing: 'border-box',
backgroundColor, backgroundColor,
...borderSelect, ...borderSelect,
borderRadius: "8px", borderRadius: '8px',
color, color,
"& h2": { color: h2color }, '& h2': { color: h2color },
}; }
}); })
File diff suppressed because it is too large Load Diff
+68 -68
View File
@@ -1,4 +1,4 @@
import { FeaturedPlayListRounded } from "@mui/icons-material"; import { FeaturedPlayListRounded } from '@mui/icons-material'
import { import {
Box, Box,
Badge, Badge,
@@ -7,99 +7,99 @@ import {
Menu, Menu,
MenuItem, MenuItem,
Typography, Typography,
} from "@mui/material"; } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import { useCallback, useState } from "react"; import { useCallback, useState } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { EditorViewer } from "@/components/profile/editor-viewer"; import { EditorViewer } from '@/components/profile/editor-viewer'
import { useEditorDocument } from "@/hooks/use-editor-document"; import { useEditorDocument } from '@/hooks/use-editor-document'
import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds"; import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from "@/services/notice-service"; import { showNotice } from '@/services/notice-service'
import { LogViewer } from "./log-viewer"; import { LogViewer } from './log-viewer'
import { ProfileBox } from "./profile-box"; import { ProfileBox } from './profile-box'
interface Props { interface Props {
logInfo?: [string, string][]; logInfo?: [string, string][]
id: "Merge" | "Script"; id: 'Merge' | 'Script'
onSave?: (prev?: string, curr?: string) => void; onSave?: (prev?: string, curr?: string) => void
} }
const EMPTY_LOG_INFO: [string, string][] = []; const EMPTY_LOG_INFO: [string, string][] = []
// profile enhanced item // profile enhanced item
export const ProfileMore = (props: Props) => { export const ProfileMore = (props: Props) => {
const { id, logInfo, onSave } = props; const { id, logInfo, onSave } = props
const entries = logInfo ?? EMPTY_LOG_INFO; const entries = logInfo ?? EMPTY_LOG_INFO
const { t } = useTranslation(); const { t } = useTranslation()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [position, setPosition] = useState({ left: 0, top: 0 }); const [position, setPosition] = useState({ left: 0, top: 0 })
const [fileOpen, setFileOpen] = useState(false); const [fileOpen, setFileOpen] = useState(false)
const [logOpen, setLogOpen] = useState(false); const [logOpen, setLogOpen] = useState(false)
const loadDocument = useCallback(() => readProfileFile(id), [id]); const loadDocument = useCallback(() => readProfileFile(id), [id])
const document = useEditorDocument({ const document = useEditorDocument({
open: fileOpen, open: fileOpen,
load: loadDocument, load: loadDocument,
}); })
const onEditFile = () => { const onEditFile = () => {
setAnchorEl(null); setAnchorEl(null)
setFileOpen(true); setFileOpen(true)
}; }
const onOpenFile = useLockFn(async () => { const onOpenFile = useLockFn(async () => {
setAnchorEl(null); setAnchorEl(null)
try { try {
await viewProfile(id); await viewProfile(id)
} catch (err) { } 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<Props["id"], string> = { const globalTitles: Record<Props['id'], string> = {
Merge: "profiles.components.more.global.merge", Merge: 'profiles.components.more.global.merge',
Script: "profiles.components.more.global.script", Script: 'profiles.components.more.global.script',
}; }
const chipLabels: Record<Props["id"], string> = { const chipLabels: Record<Props['id'], string> = {
Merge: "profiles.components.more.chips.merge", Merge: 'profiles.components.more.chips.merge',
Script: "profiles.components.more.chips.script", Script: 'profiles.components.more.chips.script',
}; }
const itemMenu = [ const itemMenu = [
{ label: "profiles.components.menu.editFile", handler: onEditFile }, { label: 'profiles.components.menu.editFile', handler: onEditFile },
{ label: "profiles.components.menu.openFile", handler: onOpenFile }, { label: 'profiles.components.menu.openFile', handler: onOpenFile },
]; ]
const boxStyle = { const boxStyle = {
height: 26, height: 26,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
lineHeight: 1, lineHeight: 1,
}; }
const handleSave = useLockFn(async () => { const handleSave = useLockFn(async () => {
const currentValue = document.value; const currentValue = document.value
await saveProfileFile(id, currentValue); await saveProfileFile(id, currentValue)
onSave?.(document.savedValue, currentValue); onSave?.(document.savedValue, currentValue)
document.markSaved(currentValue); document.markSaved(currentValue)
}); })
return ( return (
<> <>
<ProfileBox <ProfileBox
onDoubleClick={onEditFile} onDoubleClick={onEditFile}
onContextMenu={(event) => { onContextMenu={(event) => {
const { clientX, clientY } = event; const { clientX, clientY } = event
setPosition({ top: clientY, left: clientX }); setPosition({ top: clientY, left: clientX })
setAnchorEl(event.currentTarget as HTMLElement); setAnchorEl(event.currentTarget as HTMLElement)
event.preventDefault(); event.preventDefault()
}} }}
> >
<Box <Box
@@ -123,19 +123,19 @@ export const ProfileMore = (props: Props) => {
color="primary" color="primary"
size="small" size="small"
variant="outlined" variant="outlined"
sx={{ height: 20, textTransform: "capitalize" }} sx={{ height: 20, textTransform: 'capitalize' }}
/> />
</Box> </Box>
<Box sx={boxStyle}> <Box sx={boxStyle}>
{id === "Script" && {id === 'Script' &&
(hasError ? ( (hasError ? (
<Badge color="error" variant="dot" overlap="circular"> <Badge color="error" variant="dot" overlap="circular">
<IconButton <IconButton
size="small" size="small"
edge="start" edge="start"
color="error" color="error"
title={t("profiles.modals.logViewer.title")} title={t('profiles.modals.logViewer.title')}
onClick={() => setLogOpen(true)} onClick={() => setLogOpen(true)}
> >
<FeaturedPlayListRounded fontSize="inherit" /> <FeaturedPlayListRounded fontSize="inherit" />
@@ -146,7 +146,7 @@ export const ProfileMore = (props: Props) => {
size="small" size="small"
edge="start" edge="start"
color="inherit" color="inherit"
title={t("profiles.modals.logViewer.title")} title={t('profiles.modals.logViewer.title')}
onClick={() => setLogOpen(true)} onClick={() => setLogOpen(true)}
> >
<FeaturedPlayListRounded fontSize="inherit" /> <FeaturedPlayListRounded fontSize="inherit" />
@@ -164,8 +164,8 @@ export const ProfileMore = (props: Props) => {
transitionDuration={225} transitionDuration={225}
MenuListProps={{ sx: { py: 0.5 } }} MenuListProps={{ sx: { py: 0.5 } }}
onContextMenu={(e) => { onContextMenu={(e) => {
setAnchorEl(null); setAnchorEl(null)
e.preventDefault(); e.preventDefault()
}} }}
> >
{itemMenu {itemMenu
@@ -179,10 +179,10 @@ export const ProfileMore = (props: Props) => {
(theme) => { (theme) => {
return { return {
color: color:
item.label === "Delete" item.label === 'Delete'
? theme.palette.error.main ? theme.palette.error.main
: undefined, : undefined,
}; }
}, },
]} ]}
dense dense
@@ -196,8 +196,8 @@ export const ProfileMore = (props: Props) => {
open={true} open={true}
title={t(globalTitles[id])} title={t(globalTitles[id])}
value={document.value} value={document.value}
language={id === "Merge" ? "yaml" : "javascript"} language={id === 'Merge' ? 'yaml' : 'javascript'}
path={`profile-more:${id}.${id === "Merge" ? "yaml" : "js"}`} path={`profile-more:${id}.${id === 'Merge' ? 'yaml' : 'js'}`}
loading={document.loading} loading={document.loading}
dirty={document.dirty} dirty={document.dirty}
onChange={document.setValue} onChange={document.setValue}
@@ -213,5 +213,5 @@ export const ProfileMore = (props: Props) => {
/> />
)} )}
</> </>
); )
}; }
+124 -124
View File
@@ -7,146 +7,146 @@ import {
Select, Select,
styled, styled,
TextField, TextField,
} from "@mui/material"; } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import type { Ref } from "react"; import type { Ref } from 'react'
import { useEffect, useImperativeHandle, useRef, useState } from "react"; import { useEffect, useImperativeHandle, useRef, useState } from 'react'
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { BaseDialog, Switch } from "@/components/base"; import { BaseDialog, Switch } from '@/components/base'
import { useProfiles } from "@/hooks/use-profiles"; import { useProfiles } from '@/hooks/use-profiles'
import { createProfile, patchProfile } from "@/services/cmds"; import { createProfile, patchProfile } from '@/services/cmds'
import { showNotice } from "@/services/notice-service"; import { showNotice } from '@/services/notice-service'
import { version } from "@root/package.json"; import { version } from '@root/package.json'
import { FileInput } from "./file-input"; import { FileInput } from './file-input'
interface Props { interface Props {
onChange: (isActivating?: boolean) => void; onChange: (isActivating?: boolean) => void
} }
export interface ProfileViewerRef { export interface ProfileViewerRef {
create: () => void; create: () => void
edit: (item: IProfileItem) => void; edit: (item: IProfileItem) => void
} }
// create or edit the profile // create or edit the profile
// remote / local // remote / local
type ProfileViewerProps = Props & { ref?: Ref<ProfileViewerRef> }; type ProfileViewerProps = Props & { ref?: Ref<ProfileViewerRef> }
export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
const { t } = useTranslation(); const { t } = useTranslation()
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
const [openType, setOpenType] = useState<"new" | "edit">("new"); const [openType, setOpenType] = useState<'new' | 'edit'>('new')
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const { profiles } = useProfiles(); const { profiles } = useProfiles()
// file input // file input
const fileDataRef = useRef<string | null>(null); const fileDataRef = useRef<string | null>(null)
const { control, watch, setValue, reset, handleSubmit, getValues } = const { control, watch, setValue, reset, handleSubmit, getValues } =
useForm<IProfileItem>({ useForm<IProfileItem>({
defaultValues: { defaultValues: {
type: "remote", type: 'remote',
name: "", name: '',
desc: "", desc: '',
url: "", url: '',
option: { option: {
with_proxy: false, with_proxy: false,
self_proxy: false, self_proxy: false,
}, },
}, },
}); })
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
create: () => { create: () => {
setOpenType("new"); setOpenType('new')
setOpen(true); setOpen(true)
}, },
edit: (item: IProfileItem) => { edit: (item: IProfileItem) => {
if (item) { if (item) {
Object.entries(item).forEach(([key, value]) => { Object.entries(item).forEach(([key, value]) => {
setValue(key as any, value); setValue(key as any, value)
}); })
} }
setOpenType("edit"); setOpenType('edit')
setOpen(true); setOpen(true)
}, },
})); }))
const selfProxy = watch("option.self_proxy"); const selfProxy = watch('option.self_proxy')
const withProxy = watch("option.with_proxy"); const withProxy = watch('option.with_proxy')
useEffect(() => { useEffect(() => {
if (selfProxy) setValue("option.with_proxy", false); if (selfProxy) setValue('option.with_proxy', false)
}, [selfProxy, setValue]); }, [selfProxy, setValue])
useEffect(() => { useEffect(() => {
if (withProxy) setValue("option.self_proxy", false); if (withProxy) setValue('option.self_proxy', false)
}, [setValue, withProxy]); }, [setValue, withProxy])
const handleOk = useLockFn( const handleOk = useLockFn(
handleSubmit(async (form) => { handleSubmit(async (form) => {
setLoading(true); setLoading(true)
try { try {
// 基本验证 // 基本验证
if (!form.type) throw new Error("`Type` should not be null"); if (!form.type) throw new Error('`Type` should not be null')
if (form.type === "remote" && !form.url) { if (form.type === 'remote' && !form.url) {
throw new Error("The URL should not be null"); 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) { if (option?.timeout_seconds) {
option.timeout_seconds = +option.timeout_seconds; option.timeout_seconds = +option.timeout_seconds
} }
if (option?.update_interval) { if (option?.update_interval) {
option.update_interval = +option.update_interval; option.update_interval = +option.update_interval
} else if (option) { } else if (option) {
option.update_interval = undefined; option.update_interval = undefined
} }
if (option?.user_agent === "") { if (option?.user_agent === '') {
option.user_agent = undefined; option.user_agent = undefined
} }
const name = form.name || `${form.type} file`; const name = form.name || `${form.type} file`
const item = { ...form, name, option }; const item = { ...form, name, option }
const isRemote = form.type === "remote"; const isRemote = form.type === 'remote'
const isUpdate = openType === "edit"; const isUpdate = openType === 'edit'
// 判断是否是当前激活的配置 // 判断是否是当前激活的配置
const isActivating = isUpdate && form.uid === (profiles?.current ?? ""); const isActivating = isUpdate && form.uid === (profiles?.current ?? '')
// 保存原始代理设置以便回退成功后恢复 // 保存原始代理设置以便回退成功后恢复
const originalOptions = { const originalOptions = {
with_proxy: form.option?.with_proxy, with_proxy: form.option?.with_proxy,
self_proxy: form.option?.self_proxy, self_proxy: form.option?.self_proxy,
}; }
// 执行创建或更新操作,本地配置不需要回退机制 // 执行创建或更新操作,本地配置不需要回退机制
if (!isRemote) { if (!isRemote) {
if (openType === "new") { if (openType === 'new') {
await createProfile(item, fileDataRef.current); await createProfile(item, fileDataRef.current)
} else { } else {
if (!form.uid) throw new Error("UID not found"); if (!form.uid) throw new Error('UID not found')
await patchProfile(form.uid, item); await patchProfile(form.uid, item)
} }
} else { } else {
// 远程配置使用回退机制 // 远程配置使用回退机制
try { try {
// 尝试正常操作 // 尝试正常操作
if (openType === "new") { if (openType === 'new') {
await createProfile(item, fileDataRef.current); await createProfile(item, fileDataRef.current)
} else { } else {
if (!form.uid) throw new Error("UID not found"); if (!form.uid) throw new Error('UID not found')
await patchProfile(form.uid, item); await patchProfile(form.uid, item)
} }
} catch { } catch {
// 首次创建/更新失败,尝试使用自身代理 // 首次创建/更新失败,尝试使用自身代理
showNotice.info( showNotice.info(
"profiles.modals.profileForm.feedback.notifications.creationRetry", 'profiles.modals.profileForm.feedback.notifications.creationRetry',
); )
// 使用自身代理的配置 // 使用自身代理的配置
const retryItem = { const retryItem = {
@@ -156,76 +156,76 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
with_proxy: false, with_proxy: false,
self_proxy: true, self_proxy: true,
}, },
}; }
// 使用自身代理再次尝试 // 使用自身代理再次尝试
if (openType === "new") { if (openType === 'new') {
await createProfile(retryItem, fileDataRef.current); await createProfile(retryItem, fileDataRef.current)
} else { } else {
if (!form.uid) throw new Error("UID not found"); if (!form.uid) throw new Error('UID not found')
await patchProfile(form.uid, retryItem); await patchProfile(form.uid, retryItem)
// 编辑模式下恢复原始代理设置 // 编辑模式下恢复原始代理设置
await patchProfile(form.uid, { option: originalOptions }); await patchProfile(form.uid, { option: originalOptions })
} }
showNotice.success( showNotice.success(
"profiles.modals.profileForm.feedback.notifications.creationSuccess", 'profiles.modals.profileForm.feedback.notifications.creationSuccess',
); )
} }
} }
// 成功后的操作 // 成功后的操作
setOpen(false); setOpen(false)
setTimeout(() => reset(), 500); setTimeout(() => reset(), 500)
fileDataRef.current = null; fileDataRef.current = null
// 优化:UI先关闭,异步通知父组件 // 优化:UI先关闭,异步通知父组件
setTimeout(() => { setTimeout(() => {
onChange(isActivating); onChange(isActivating)
}, 0); }, 0)
} catch (err) { } catch (err) {
showNotice.error(err); showNotice.error(err)
} finally { } finally {
setLoading(false); setLoading(false)
} }
}), }),
); )
const handleClose = () => { const handleClose = () => {
try { try {
setOpen(false); setOpen(false)
fileDataRef.current = null; fileDataRef.current = null
setTimeout(() => reset(), 500); setTimeout(() => reset(), 500)
} catch (e) { } catch (e) {
console.warn("[ProfileViewer] handleClose error:", e); console.warn('[ProfileViewer] handleClose error:', e)
}
} }
};
const text = { const text = {
fullWidth: true, fullWidth: true,
size: "small", size: 'small',
margin: "normal", margin: 'normal',
variant: "outlined", variant: 'outlined',
autoComplete: "off", autoComplete: 'off',
autoCorrect: "off", autoCorrect: 'off',
} as const; } as const
const formType = watch("type"); const formType = watch('type')
const isRemote = formType === "remote"; const isRemote = formType === 'remote'
const isLocal = formType === "local"; const isLocal = formType === 'local'
return ( return (
<BaseDialog <BaseDialog
open={open} open={open}
title={ title={
openType === "new" openType === 'new'
? t("profiles.modals.profileForm.title.create") ? t('profiles.modals.profileForm.title.create')
: t("profiles.modals.profileForm.title.edit") : t('profiles.modals.profileForm.title.edit')
} }
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }} contentSx={{ width: 375, pb: 0, maxHeight: '80%' }}
okBtn={t("shared.actions.save")} okBtn={t('shared.actions.save')}
cancelBtn={t("shared.actions.cancel")} cancelBtn={t('shared.actions.cancel')}
onClose={handleClose} onClose={handleClose}
onCancel={handleClose} onCancel={handleClose}
onOk={handleOk} onOk={handleOk}
@@ -237,12 +237,12 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => ( render={({ field }) => (
<FormControl size="small" fullWidth sx={{ mt: 1, mb: 1 }}> <FormControl size="small" fullWidth sx={{ mt: 1, mb: 1 }}>
<InputLabel> <InputLabel>
{t("profiles.modals.profileForm.fields.type")} {t('profiles.modals.profileForm.fields.type')}
</InputLabel> </InputLabel>
<Select <Select
{...field} {...field}
autoFocus autoFocus
label={t("profiles.modals.profileForm.fields.type")} label={t('profiles.modals.profileForm.fields.type')}
> >
<MenuItem value="remote">Remote</MenuItem> <MenuItem value="remote">Remote</MenuItem>
<MenuItem value="local">Local</MenuItem> <MenuItem value="local">Local</MenuItem>
@@ -255,7 +255,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
name="name" name="name"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<TextField {...text} {...field} label={t("shared.labels.name")} /> <TextField {...text} {...field} label={t('shared.labels.name')} />
)} )}
/> />
@@ -266,7 +266,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
<TextField <TextField
{...text} {...text}
{...field} {...field}
label={t("profiles.modals.profileForm.fields.description")} label={t('profiles.modals.profileForm.fields.description')}
/> />
)} )}
/> />
@@ -281,7 +281,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
{...text} {...text}
{...field} {...field}
multiline multiline
label={t("profiles.modals.profileForm.fields.subscriptionUrl")} label={t('profiles.modals.profileForm.fields.subscriptionUrl')}
/> />
)} )}
/> />
@@ -308,12 +308,12 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
{...field} {...field}
type="number" type="number"
placeholder="60" placeholder="60"
label={t("profiles.modals.profileForm.fields.httpTimeout")} label={t('profiles.modals.profileForm.fields.httpTimeout')}
slotProps={{ slotProps={{
input: { input: {
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
{t("shared.units.seconds")} {t('shared.units.seconds')}
</InputAdornment> </InputAdornment>
), ),
}, },
@@ -333,12 +333,12 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
{...text} {...text}
{...field} {...field}
type="number" type="number"
label={t("profiles.modals.profileForm.fields.updateInterval")} label={t('profiles.modals.profileForm.fields.updateInterval')}
slotProps={{ slotProps={{
input: { input: {
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
{t("shared.units.minutes")} {t('shared.units.minutes')}
</InputAdornment> </InputAdornment>
), ),
}, },
@@ -348,11 +348,11 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
/> />
)} )}
{isLocal && openType === "new" && ( {isLocal && openType === 'new' && (
<FileInput <FileInput
onChange={(file, val) => { onChange={(file, val) => {
setValue("name", getValues("name") || file.name); setValue('name', getValues('name') || file.name)
fileDataRef.current = val; fileDataRef.current = val
}} }}
/> />
)} )}
@@ -365,7 +365,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => ( render={({ field }) => (
<StyledBox> <StyledBox>
<InputLabel> <InputLabel>
{t("profiles.modals.profileForm.fields.useSystemProxy")} {t('profiles.modals.profileForm.fields.useSystemProxy')}
</InputLabel> </InputLabel>
<Switch checked={field.value} {...field} color="primary" /> <Switch checked={field.value} {...field} color="primary" />
</StyledBox> </StyledBox>
@@ -378,7 +378,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => ( render={({ field }) => (
<StyledBox> <StyledBox>
<InputLabel> <InputLabel>
{t("profiles.modals.profileForm.fields.useClashProxy")} {t('profiles.modals.profileForm.fields.useClashProxy')}
</InputLabel> </InputLabel>
<Switch checked={field.value} {...field} color="primary" /> <Switch checked={field.value} {...field} color="primary" />
</StyledBox> </StyledBox>
@@ -391,7 +391,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => ( render={({ field }) => (
<StyledBox> <StyledBox>
<InputLabel> <InputLabel>
{t("profiles.modals.profileForm.fields.acceptInvalidCerts")} {t('profiles.modals.profileForm.fields.acceptInvalidCerts')}
</InputLabel> </InputLabel>
<Switch checked={field.value} {...field} color="primary" /> <Switch checked={field.value} {...field} color="primary" />
</StyledBox> </StyledBox>
@@ -404,7 +404,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => ( render={({ field }) => (
<StyledBox> <StyledBox>
<InputLabel> <InputLabel>
{t("profiles.modals.profileForm.fields.allowAutoUpdate")} {t('profiles.modals.profileForm.fields.allowAutoUpdate')}
</InputLabel> </InputLabel>
<Switch checked={field.value} {...field} color="primary" /> <Switch checked={field.value} {...field} color="primary" />
</StyledBox> </StyledBox>
@@ -413,12 +413,12 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
</> </>
)} )}
</BaseDialog> </BaseDialog>
); )
} }
const StyledBox = styled(Box)(() => ({ const StyledBox = styled(Box)(() => ({
margin: "8px 0 8px 8px", margin: '8px 0 8px 8px',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
})); }))
+168 -171
View File
@@ -6,16 +6,13 @@ import {
closestCenter, closestCenter,
useSensor, useSensor,
useSensors, useSensors,
} from "@dnd-kit/core"; } from '@dnd-kit/core'
import { import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
SortableContext, import MonacoEditor from '@monaco-editor/react'
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import MonacoEditor from "@monaco-editor/react";
import { import {
VerticalAlignBottomRounded, VerticalAlignBottomRounded,
VerticalAlignTopRounded, VerticalAlignTopRounded,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { import {
Box, Box,
Button, Button,
@@ -27,62 +24,62 @@ import {
ListItem, ListItem,
TextField, TextField,
styled, styled,
} from "@mui/material"; } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import yaml from "js-yaml"; import yaml from 'js-yaml'
import { import {
startTransition, startTransition,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
} from "react"; } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from 'react-virtuoso'
import { BaseSearchBox } from "@/components/base"; import { BaseSearchBox } from '@/components/base'
import { ProxyItem } from "@/components/profile/proxy-item"; import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from "@/services/cmds"; import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from "@/services/notice-service"; import { showNotice } from '@/services/notice-service'
import { useThemeMode } from "@/services/states"; import { useThemeMode } from '@/services/states'
import getSystem from "@/utils/get-system"; import getSystem from '@/utils/get-system'
import parseUri from "@/utils/uri-parser"; import parseUri from '@/utils/uri-parser'
interface Props { interface Props {
profileUid: string; profileUid: string
property: string; property: string
open: boolean; open: boolean
onClose: () => void; onClose: () => void
onSave?: (prev?: string, curr?: string) => void; onSave?: (prev?: string, curr?: string) => void
} }
export const ProxiesEditorViewer = (props: Props) => { export const ProxiesEditorViewer = (props: Props) => {
const { profileUid, property, open, onClose, onSave } = props; const { profileUid, property, open, onClose, onSave } = props
const { t } = useTranslation(); const { t } = useTranslation()
const themeMode = useThemeMode(); const themeMode = useThemeMode()
const [prevData, setPrevData] = useState(""); const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState(""); const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true); const [visualization, setVisualization] = useState(true)
const [match, setMatch] = useState(() => (_: string) => true); const [match, setMatch] = useState(() => (_: string) => true)
const [proxyUri, setProxyUri] = useState<string>(""); const [proxyUri, setProxyUri] = useState<string>('')
const [proxyList, setProxyList] = useState<IProxyConfig[]>([]); const [proxyList, setProxyList] = useState<IProxyConfig[]>([])
const [prependSeq, setPrependSeq] = useState<IProxyConfig[]>([]); const [prependSeq, setPrependSeq] = useState<IProxyConfig[]>([])
const [appendSeq, setAppendSeq] = useState<IProxyConfig[]>([]); const [appendSeq, setAppendSeq] = useState<IProxyConfig[]>([])
const [deleteSeq, setDeleteSeq] = useState<string[]>([]); const [deleteSeq, setDeleteSeq] = useState<string[]>([])
const filteredPrependSeq = useMemo( const filteredPrependSeq = useMemo(
() => prependSeq.filter((proxy) => match(proxy.name)), () => prependSeq.filter((proxy) => match(proxy.name)),
[prependSeq, match], [prependSeq, match],
); )
const filteredProxyList = useMemo( const filteredProxyList = useMemo(
() => proxyList.filter((proxy) => match(proxy.name)), () => proxyList.filter((proxy) => match(proxy.name)),
[proxyList, match], [proxyList, match],
); )
const filteredAppendSeq = useMemo( const filteredAppendSeq = useMemo(
() => appendSeq.filter((proxy) => match(proxy.name)), () => appendSeq.filter((proxy) => match(proxy.name)),
[appendSeq, match], [appendSeq, match],
); )
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -91,138 +88,138 @@ export const ProxiesEditorViewer = (props: Props) => {
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}), }),
); )
const reorder = ( const reorder = (
list: IProxyConfig[], list: IProxyConfig[],
startIndex: number, startIndex: number,
endIndex: number, endIndex: number,
) => { ) => {
const result = Array.from(list); const result = Array.from(list)
const [removed] = result.splice(startIndex, 1); const [removed] = result.splice(startIndex, 1)
result.splice(endIndex, 0, removed); result.splice(endIndex, 0, removed)
return result; return result
}; }
const onPrependDragEnd = async (event: DragEndEvent) => { const onPrependDragEnd = async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event
if (over) { if (over) {
if (active.id !== over.id) { if (active.id !== over.id) {
let activeIndex = 0; let activeIndex = 0
let overIndex = 0; let overIndex = 0
prependSeq.forEach((item, index) => { prependSeq.forEach((item, index) => {
if (item.name === active.id) { if (item.name === active.id) {
activeIndex = index; activeIndex = index
} }
if (item.name === over.id) { 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 onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event
if (over) { if (over) {
if (active.id !== over.id) { if (active.id !== over.id) {
let activeIndex = 0; let activeIndex = 0
let overIndex = 0; let overIndex = 0
appendSeq.forEach((item, index) => { appendSeq.forEach((item, index) => {
if (item.name === active.id) { if (item.name === active.id) {
activeIndex = index; activeIndex = index
} }
if (item.name === over.id) { if (item.name === over.id) {
overIndex = index; overIndex = index
}
})
setAppendSeq(reorder(appendSeq, activeIndex, overIndex))
} }
});
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
} }
} }
};
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState // 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => { const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
const proxies: IProxyConfig[] = []; const proxies: IProxyConfig[] = []
const names: string[] = []; const names: string[] = []
let uris: string; let uris: string
try { try {
uris = atob(proxyUri); uris = atob(proxyUri)
} catch { } catch {
uris = proxyUri; uris = proxyUri
} }
const lines = uris.trim().split("\n"); const lines = uris.trim().split('\n')
let idx = 0; let idx = 0
const batchSize = 50; const batchSize = 50
let parseTimer: number | undefined; let parseTimer: number | undefined
const parseBatch = () => { const parseBatch = () => {
const end = Math.min(idx + batchSize, lines.length); const end = Math.min(idx + batchSize, lines.length)
for (; idx < end; idx++) { for (; idx < end; idx++) {
const uri = lines[idx]; const uri = lines[idx]
try { try {
const proxy = parseUri(uri.trim()); const proxy = parseUri(uri.trim())
if (!names.includes(proxy.name)) { if (!names.includes(proxy.name)) {
proxies.push(proxy); proxies.push(proxy)
names.push(proxy.name); names.push(proxy.name)
} }
} catch (err) { } catch (err) {
console.warn( console.warn(
"[ProxiesEditorViewer] parseUri failed for line:", '[ProxiesEditorViewer] parseUri failed for line:',
uri, uri,
err, err,
); )
// 不阻塞主流程 // 不阻塞主流程
} }
} }
if (idx < lines.length) { if (idx < lines.length) {
parseTimer = window.setTimeout(parseBatch, 0); parseTimer = window.setTimeout(parseBatch, 0)
} else { } else {
if (parseTimer !== undefined) { if (parseTimer !== undefined) {
clearTimeout(parseTimer); clearTimeout(parseTimer)
parseTimer = undefined; parseTimer = undefined
} }
cb(proxies); cb(proxies)
}
}
parseBatch()
} }
};
parseBatch();
};
const fetchProfile = useCallback(async () => { const fetchProfile = useCallback(async () => {
const data = await readProfileFile(profileUid); const data = await readProfileFile(profileUid)
const originProxiesObj = yaml.load(data) as { const originProxiesObj = yaml.load(data) as {
proxies: IProxyConfig[]; proxies: IProxyConfig[]
} | null; } | null
setProxyList(originProxiesObj?.proxies || []); setProxyList(originProxiesObj?.proxies || [])
}, [profileUid]); }, [profileUid])
const fetchContent = useCallback(async () => { const fetchContent = useCallback(async () => {
const data = await readProfileFile(property); const data = await readProfileFile(property)
const obj = yaml.load(data) as ISeqProfileConfig | null; const obj = yaml.load(data) as ISeqProfileConfig | null
setPrependSeq(obj?.prepend || []); setPrependSeq(obj?.prepend || [])
setAppendSeq(obj?.append || []); setAppendSeq(obj?.append || [])
setDeleteSeq(obj?.delete || []); setDeleteSeq(obj?.delete || [])
setPrevData(data); setPrevData(data)
setCurrData(data); setCurrData(data)
}, [property]); }, [property])
useEffect(() => { useEffect(() => {
if (currData === "" || visualization !== true) { if (currData === '' || visualization !== true) {
return; return
} }
const obj = yaml.load(currData) as ISeqProfileConfig | null; const obj = yaml.load(currData) as ISeqProfileConfig | null
startTransition(() => { startTransition(() => {
setPrependSeq(obj?.prepend ?? []); setPrependSeq(obj?.prepend ?? [])
setAppendSeq(obj?.append ?? []); setAppendSeq(obj?.append ?? [])
setDeleteSeq(obj?.delete ?? []); setDeleteSeq(obj?.delete ?? [])
}); })
}, [currData, visualization]); }, [currData, visualization])
useEffect(() => { useEffect(() => {
if (!(prependSeq && appendSeq && deleteSeq)) { if (!(prependSeq && appendSeq && deleteSeq)) {
return; return
} }
const serialize = () => { const serialize = () => {
@@ -232,45 +229,45 @@ export const ProxiesEditorViewer = (props: Props) => {
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq }, { prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true }, { forceQuotes: true },
), ),
); )
} catch (e) { } catch (e) {
console.warn("[ProxiesEditorViewer] yaml.dump failed:", e); console.warn('[ProxiesEditorViewer] yaml.dump failed:', e)
// 防止异常导致UI卡死 // 防止异常导致UI卡死
} }
}; }
let idleId: number | undefined; let idleId: number | undefined
let timeoutId: number | undefined; let timeoutId: number | undefined
if (window.requestIdleCallback) { if (window.requestIdleCallback) {
idleId = window.requestIdleCallback(serialize); idleId = window.requestIdleCallback(serialize)
} else { } else {
timeoutId = window.setTimeout(serialize, 0); timeoutId = window.setTimeout(serialize, 0)
} }
return () => { return () => {
if (idleId !== undefined && window.cancelIdleCallback) { if (idleId !== undefined && window.cancelIdleCallback) {
window.cancelIdleCallback(idleId); window.cancelIdleCallback(idleId)
} }
if (timeoutId !== undefined) { if (timeoutId !== undefined) {
clearTimeout(timeoutId); clearTimeout(timeoutId)
} }
}; }
}, [prependSeq, appendSeq, deleteSeq]); }, [prependSeq, appendSeq, deleteSeq])
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return
fetchContent(); fetchContent()
fetchProfile(); fetchProfile()
}, [fetchContent, fetchProfile, open]); }, [fetchContent, fetchProfile, open])
const handleSave = useLockFn(async () => { const handleSave = useLockFn(async () => {
try { try {
await saveProfileFile(property, currData); await saveProfileFile(property, currData)
showNotice.success("shared.feedback.notifications.saved"); showNotice.success('shared.feedback.notifications.saved')
onSave?.(prevData, currData); onSave?.(prevData, currData)
onClose(); onClose()
} catch (err) { } catch (err) {
showNotice.error(err); showNotice.error(err)
} }
}); })
return ( return (
<Dialog <Dialog
@@ -283,18 +280,18 @@ export const ProxiesEditorViewer = (props: Props) => {
<DialogTitle> <DialogTitle>
{ {
<Box display="flex" justifyContent="space-between"> <Box display="flex" justifyContent="space-between">
{t("profiles.modals.proxiesEditor.title")} {t('profiles.modals.proxiesEditor.title')}
<Box> <Box>
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
onClick={() => { onClick={() => {
setVisualization((prev) => !prev); setVisualization((prev) => !prev)
}} }}
> >
{visualization {visualization
? t("shared.editorModes.advanced") ? t('shared.editorModes.advanced')
: t("shared.editorModes.visualization")} : t('shared.editorModes.visualization')}
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -302,27 +299,27 @@ export const ProxiesEditorViewer = (props: Props) => {
</DialogTitle> </DialogTitle>
<DialogContent <DialogContent
sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }} sx={{ display: 'flex', width: 'auto', height: 'calc(100vh - 185px)' }}
> >
{visualization ? ( {visualization ? (
<> <>
<List <List
sx={{ sx={{
width: "50%", width: '50%',
padding: "0 10px", padding: '0 10px',
}} }}
> >
<Box <Box
sx={{ sx={{
height: "calc(100% - 80px)", height: 'calc(100% - 80px)',
overflowY: "auto", overflowY: 'auto',
}} }}
> >
<Item> <Item>
<TextField <TextField
autoComplete="new-password" autoComplete="new-password"
placeholder={t( placeholder={t(
"profiles.modals.proxiesEditor.placeholders.multiUri", 'profiles.modals.proxiesEditor.placeholders.multiUri',
)} )}
fullWidth fullWidth
rows={9} rows={9}
@@ -339,11 +336,11 @@ export const ProxiesEditorViewer = (props: Props) => {
startIcon={<VerticalAlignTopRounded />} startIcon={<VerticalAlignTopRounded />}
onClick={() => { onClick={() => {
handleParseAsync((proxies) => { handleParseAsync((proxies) => {
setPrependSeq((prev) => [...proxies, ...prev]); setPrependSeq((prev) => [...proxies, ...prev])
}); })
}} }}
> >
{t("profiles.modals.proxiesEditor.actions.prepend")} {t('profiles.modals.proxiesEditor.actions.prepend')}
</Button> </Button>
</Item> </Item>
<Item> <Item>
@@ -353,24 +350,24 @@ export const ProxiesEditorViewer = (props: Props) => {
startIcon={<VerticalAlignBottomRounded />} startIcon={<VerticalAlignBottomRounded />}
onClick={() => { onClick={() => {
handleParseAsync((proxies) => { handleParseAsync((proxies) => {
setAppendSeq((prev) => [...prev, ...proxies]); setAppendSeq((prev) => [...prev, ...proxies])
}); })
}} }}
> >
{t("profiles.modals.proxiesEditor.actions.append")} {t('profiles.modals.proxiesEditor.actions.append')}
</Button> </Button>
</Item> </Item>
</List> </List>
<List <List
sx={{ sx={{
width: "50%", width: '50%',
padding: "0 10px", padding: '0 10px',
}} }}
> >
<BaseSearchBox onSearch={(match) => setMatch(() => match)} /> <BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso <Virtuoso
style={{ height: "calc(100% - 24px)", marginTop: "8px" }} style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
totalCount={ totalCount={
filteredProxyList.length + filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) + (filteredPrependSeq.length > 0 ? 1 : 0) +
@@ -378,7 +375,7 @@ export const ProxiesEditorViewer = (props: Props) => {
} }
increaseViewportBy={256} increaseViewportBy={256}
itemContent={(index) => { itemContent={(index) => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0; const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) { if (filteredPrependSeq.length > 0 && index === 0) {
return ( return (
<DndContext <DndContext
@@ -388,7 +385,7 @@ export const ProxiesEditorViewer = (props: Props) => {
> >
<SortableContext <SortableContext
items={filteredPrependSeq.map((x) => { items={filteredPrependSeq.map((x) => {
return x.name; return x.name
})} })}
> >
{filteredPrependSeq.map((item) => { {filteredPrependSeq.map((item) => {
@@ -402,23 +399,23 @@ export const ProxiesEditorViewer = (props: Props) => {
prependSeq.filter( prependSeq.filter(
(v) => v.name !== item.name, (v) => v.name !== item.name,
), ),
); )
}} }}
/> />
); )
})} })}
</SortableContext> </SortableContext>
</DndContext> </DndContext>
); )
} else if (index < filteredProxyList.length + shift) { } else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift; const newIndex = index - shift
return ( return (
<ProxyItem <ProxyItem
key={filteredProxyList[newIndex].name} key={filteredProxyList[newIndex].name}
type={ type={
deleteSeq.includes(filteredProxyList[newIndex].name) deleteSeq.includes(filteredProxyList[newIndex].name)
? "delete" ? 'delete'
: "original" : 'original'
} }
proxy={filteredProxyList[newIndex]} proxy={filteredProxyList[newIndex]}
onDelete={() => { onDelete={() => {
@@ -429,16 +426,16 @@ export const ProxiesEditorViewer = (props: Props) => {
deleteSeq.filter( deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name, (v) => v !== filteredProxyList[newIndex].name,
), ),
); )
} else { } else {
setDeleteSeq((prev) => [ setDeleteSeq((prev) => [
...prev, ...prev,
filteredProxyList[newIndex].name, filteredProxyList[newIndex].name,
]); ])
} }
}} }}
/> />
); )
} else { } else {
return ( return (
<DndContext <DndContext
@@ -448,7 +445,7 @@ export const ProxiesEditorViewer = (props: Props) => {
> >
<SortableContext <SortableContext
items={filteredAppendSeq.map((x) => { items={filteredAppendSeq.map((x) => {
return x.name; return x.name
})} })}
> >
{filteredAppendSeq.map((item) => { {filteredAppendSeq.map((item) => {
@@ -462,14 +459,14 @@ export const ProxiesEditorViewer = (props: Props) => {
appendSeq.filter( appendSeq.filter(
(v) => v.name !== item.name, (v) => v.name !== item.name,
), ),
); )
}} }}
/> />
); )
})} })}
</SortableContext> </SortableContext>
</DndContext> </DndContext>
); )
} }
}} }}
/> />
@@ -480,7 +477,7 @@ export const ProxiesEditorViewer = (props: Props) => {
height="100%" height="100%"
language="yaml" language="yaml"
value={currData} value={currData}
theme={themeMode === "light" ? "light" : "vs-dark"} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
options={{ options={{
tabSize: 2, // 根据语言类型设置缩进大小 tabSize: 2, // 根据语言类型设置缩进大小
minimap: { minimap: {
@@ -496,29 +493,29 @@ export const ProxiesEditorViewer = (props: Props) => {
top: 33, // 顶部padding防止遮挡snippets top: 33, // 顶部padding防止遮挡snippets
}, },
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${ 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, // 连字符 fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动 smoothScrolling: true, // 平滑滚动
}} }}
onChange={(value) => setCurrData(value ?? "")} onChange={(value) => setCurrData(value ?? '')}
/> />
)} )}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onClose} variant="outlined"> <Button onClick={onClose} variant="outlined">
{t("shared.actions.cancel")} {t('shared.actions.cancel')}
</Button> </Button>
<Button onClick={handleSave} variant="contained"> <Button onClick={handleSave} variant="contained">
{t("shared.actions.save")} {t('shared.actions.save')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
); )
}; }
const Item = styled(ListItem)(() => ({ const Item = styled(ListItem)(() => ({
padding: "5px 2px", padding: '5px 2px',
})); }))
+43 -43
View File
@@ -1,6 +1,6 @@
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from '@dnd-kit/sortable'
import { CSS } from "@dnd-kit/utilities"; import { CSS } from '@dnd-kit/utilities'
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material"; import { DeleteForeverRounded, UndoRounded } from '@mui/icons-material'
import { import {
Box, Box,
IconButton, IconButton,
@@ -8,17 +8,17 @@ import {
ListItemText, ListItemText,
alpha, alpha,
styled, styled,
} from "@mui/material"; } from '@mui/material'
interface Props { interface Props {
type: "prepend" | "original" | "delete" | "append"; type: 'prepend' | 'original' | 'delete' | 'append'
proxy: IProxyConfig; proxy: IProxyConfig
onDelete: () => void; onDelete: () => void
} }
export const ProxyItem = (props: Props) => { export const ProxyItem = (props: Props) => {
const { type, proxy, onDelete } = props; const { type, proxy, onDelete } = props
const sortable = type === "prepend" || type === "append"; const sortable = type === 'prepend' || type === 'append'
const { const {
attributes: sortableAttributes, attributes: sortableAttributes,
@@ -30,41 +30,41 @@ export const ProxyItem = (props: Props) => {
} = useSortable({ } = useSortable({
id: proxy.name, id: proxy.name,
disabled: !sortable, disabled: !sortable,
}); })
const dragAttributes = sortable ? sortableAttributes : undefined; const dragAttributes = sortable ? sortableAttributes : undefined
const dragListeners = sortable ? sortableListeners : undefined; const dragListeners = sortable ? sortableListeners : undefined
const dragNodeRef = sortable ? sortableSetNodeRef : undefined; const dragNodeRef = sortable ? sortableSetNodeRef : undefined
return ( return (
<ListItem <ListItem
dense dense
sx={({ palette }) => ({ sx={({ palette }) => ({
position: "relative", position: 'relative',
background: background:
type === "original" type === 'original'
? palette.mode === "dark" ? palette.mode === 'dark'
? alpha(palette.background.paper, 0.3) ? alpha(palette.background.paper, 0.3)
: alpha(palette.grey[400], 0.3) : alpha(palette.grey[400], 0.3)
: type === "delete" : type === 'delete'
? alpha(palette.error.main, 0.3) ? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3), : alpha(palette.success.main, 0.3),
height: "100%", height: '100%',
margin: "8px 0", margin: '8px 0',
borderRadius: "8px", borderRadius: '8px',
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
zIndex: isDragging ? "calc(infinity)" : undefined, zIndex: isDragging ? 'calc(infinity)' : undefined,
})} })}
> >
<ListItemText <ListItemText
{...(dragAttributes ?? {})} {...(dragAttributes ?? {})}
{...(dragListeners ?? {})} {...(dragListeners ?? {})}
ref={dragNodeRef} ref={dragNodeRef}
sx={{ cursor: sortable ? "move" : "" }} sx={{ cursor: sortable ? 'move' : '' }}
primary={ primary={
<StyledPrimary <StyledPrimary
title={proxy.name} title={proxy.name}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }} sx={{ textDecoration: type === 'delete' ? 'line-through' : '' }}
> >
{proxy.name} {proxy.name}
</StyledPrimary> </StyledPrimary>
@@ -72,13 +72,13 @@ export const ProxyItem = (props: Props) => {
secondary={ secondary={
<ListItemTextChild <ListItemTextChild
sx={{ sx={{
overflow: "hidden", overflow: 'hidden',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
pt: "2px", pt: '2px',
}} }}
> >
<Box sx={{ marginTop: "2px" }}> <Box sx={{ marginTop: '2px' }}>
<StyledTypeBox>{proxy.type}</StyledTypeBox> <StyledTypeBox>{proxy.type}</StyledTypeBox>
</Box> </Box>
</ListItemTextChild> </ListItemTextChild>
@@ -86,41 +86,41 @@ export const ProxyItem = (props: Props) => {
slotProps={{ slotProps={{
secondary: { secondary: {
sx: { sx: {
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
color: "#ccc", color: '#ccc',
}, },
}, },
}} }}
/> />
<IconButton onClick={onDelete}> <IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />} {type === 'delete' ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton> </IconButton>
</ListItem> </ListItem>
); )
}; }
const StyledPrimary = styled("div")` const StyledPrimary = styled('div')`
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
line-height: 1.5; line-height: 1.5;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`; `
const ListItemTextChild = styled("span")` const ListItemTextChild = styled('span')`
display: block; display: block;
`; `
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({ const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block", display: 'inline-block',
border: "1px solid #ccc", border: '1px solid #ccc',
borderColor: alpha(theme.palette.primary.main, 0.5), borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8), color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4, borderRadius: 4,
fontSize: 10, fontSize: 10,
padding: "0 4px", padding: '0 4px',
lineHeight: 1.5, lineHeight: 1.5,
marginRight: "8px", marginRight: '8px',
})); }))
+51 -51
View File
@@ -1,6 +1,6 @@
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from '@dnd-kit/sortable'
import { CSS } from "@dnd-kit/utilities"; import { CSS } from '@dnd-kit/utilities'
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material"; import { DeleteForeverRounded, UndoRounded } from '@mui/icons-material'
import { import {
Box, Box,
IconButton, IconButton,
@@ -8,23 +8,23 @@ import {
ListItemText, ListItemText,
alpha, alpha,
styled, styled,
} from "@mui/material"; } from '@mui/material'
interface Props { interface Props {
type: "prepend" | "original" | "delete" | "append"; type: 'prepend' | 'original' | 'delete' | 'append'
ruleRaw: string; ruleRaw: string
onDelete: () => void; onDelete: () => void
} }
export const RuleItem = (props: Props) => { export const RuleItem = (props: Props) => {
const { type, ruleRaw, onDelete } = props; const { type, ruleRaw, onDelete } = props
const sortable = type === "prepend" || type === "append"; const sortable = type === 'prepend' || type === 'append'
const rule = ruleRaw.replace(",no-resolve", ""); const rule = ruleRaw.replace(',no-resolve', '')
const ruleType = rule.match(/^[^,]+/)?.[0] ?? ""; const ruleType = rule.match(/^[^,]+/)?.[0] ?? ''
const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? ""; const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? ''
const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1); const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1)
const $sortable = useSortable({ id: ruleRaw }); const $sortable = useSortable({ id: ruleRaw })
const { const {
attributes, attributes,
@@ -42,55 +42,55 @@ export const RuleItem = (props: Props) => {
transform: null, transform: null,
transition: null, transition: null,
isDragging: false, isDragging: false,
}; }
return ( return (
<ListItem <ListItem
dense dense
sx={({ palette }) => ({ sx={({ palette }) => ({
position: "relative", position: 'relative',
background: background:
type === "original" type === 'original'
? palette.mode === "dark" ? palette.mode === 'dark'
? alpha(palette.background.paper, 0.3) ? alpha(palette.background.paper, 0.3)
: alpha(palette.grey[400], 0.3) : alpha(palette.grey[400], 0.3)
: type === "delete" : type === 'delete'
? alpha(palette.error.main, 0.3) ? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3), : alpha(palette.success.main, 0.3),
height: "100%", height: '100%',
margin: "8px 0", margin: '8px 0',
borderRadius: "8px", borderRadius: '8px',
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
zIndex: isDragging ? "calc(infinity)" : undefined, zIndex: isDragging ? 'calc(infinity)' : undefined,
})} })}
> >
<ListItemText <ListItemText
{...attributes} {...attributes}
{...listeners} {...listeners}
ref={setNodeRef} ref={setNodeRef}
sx={{ cursor: sortable ? "move" : "" }} sx={{ cursor: sortable ? 'move' : '' }}
primary={ primary={
<StyledPrimary <StyledPrimary
title={ruleContent || "-"} title={ruleContent || '-'}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }} sx={{ textDecoration: type === 'delete' ? 'line-through' : '' }}
> >
{ruleContent || "-"} {ruleContent || '-'}
</StyledPrimary> </StyledPrimary>
} }
secondary={ secondary={
<ListItemTextChild <ListItemTextChild
sx={{ sx={{
width: "62%", width: '62%',
overflow: "hidden", overflow: 'hidden',
display: "flex", display: 'flex',
justifyContent: "space-between", justifyContent: 'space-between',
pt: "2px", pt: '2px',
}} }}
> >
<Box sx={{ marginTop: "2px" }}> <Box sx={{ marginTop: '2px' }}>
<StyledTypeBox>{ruleType}</StyledTypeBox> <StyledTypeBox>{ruleType}</StyledTypeBox>
</Box> </Box>
<StyledSubtitle sx={{ color: "text.secondary" }}> <StyledSubtitle sx={{ color: 'text.secondary' }}>
{proxyPolicy} {proxyPolicy}
</StyledSubtitle> </StyledSubtitle>
</ListItemTextChild> </ListItemTextChild>
@@ -98,49 +98,49 @@ export const RuleItem = (props: Props) => {
slotProps={{ slotProps={{
secondary: { secondary: {
sx: { sx: {
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
color: "#ccc", color: '#ccc',
}, },
}, },
}} }}
/> />
<IconButton onClick={onDelete}> <IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />} {type === 'delete' ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton> </IconButton>
</ListItem> </ListItem>
); )
}; }
const StyledPrimary = styled("div")` const StyledPrimary = styled('div')`
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
line-height: 1.5; line-height: 1.5;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`; `
const StyledSubtitle = styled("span")` const StyledSubtitle = styled('span')`
font-size: 13px; font-size: 13px;
overflow: hidden; overflow: hidden;
color: text.secondary; color: text.secondary;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`; `
const ListItemTextChild = styled("span")` const ListItemTextChild = styled('span')`
display: block; display: block;
`; `
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({ const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block", display: 'inline-block',
border: "1px solid #ccc", border: '1px solid #ccc',
borderColor: alpha(theme.palette.primary.main, 0.5), borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8), color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4, borderRadius: 4,
fontSize: 10, fontSize: 10,
padding: "0 4px", padding: '0 4px',
lineHeight: 1.5, lineHeight: 1.5,
marginRight: "8px", marginRight: '8px',
})); }))
File diff suppressed because it is too large Load Diff
+102 -103
View File
@@ -1,4 +1,4 @@
import { RefreshRounded, StorageOutlined } from "@mui/icons-material"; import { RefreshRounded, StorageOutlined } from '@mui/icons-material'
import { import {
Box, Box,
Button, Button,
@@ -15,126 +15,126 @@ import {
Typography, Typography,
alpha, alpha,
styled, styled,
} from "@mui/material"; } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import dayjs from "dayjs"; import dayjs from 'dayjs'
import { useState } from "react"; import { useState } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { updateProxyProvider } from "tauri-plugin-mihomo-api"; import { updateProxyProvider } from 'tauri-plugin-mihomo-api'
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from '@/providers/app-data-context'
import { showNotice } from "@/services/notice-service"; import { showNotice } from '@/services/notice-service'
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from '@/utils/parse-traffic'
// 样式化组件 - 类型框 // 样式化组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({ const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block", display: 'inline-block',
border: "1px solid #ccc", border: '1px solid #ccc',
borderColor: alpha(theme.palette.secondary.main, 0.5), borderColor: alpha(theme.palette.secondary.main, 0.5),
color: alpha(theme.palette.secondary.main, 0.8), color: alpha(theme.palette.secondary.main, 0.8),
borderRadius: 4, borderRadius: 4,
fontSize: 10, fontSize: 10,
marginRight: "4px", marginRight: '4px',
padding: "0 2px", padding: '0 2px',
lineHeight: 1.25, lineHeight: 1.25,
})); }))
// 解析过期时间 // 解析过期时间
const parseExpire = (expire?: number) => { const parseExpire = (expire?: number) => {
if (!expire) return "-"; if (!expire) return '-'
return dayjs(expire * 1000).format("YYYY-MM-DD"); return dayjs(expire * 1000).format('YYYY-MM-DD')
}; }
export const ProviderButton = () => { export const ProviderButton = () => {
const { t } = useTranslation(); const { t } = useTranslation()
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData(); const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData()
const [updating, setUpdating] = useState<Record<string, boolean>>({}); const [updating, setUpdating] = useState<Record<string, boolean>>({})
// 检查是否有提供者 // 检查是否有提供者
const hasProviders = Object.keys(proxyProviders || {}).length > 0; const hasProviders = Object.keys(proxyProviders || {}).length > 0
// 更新单个代理提供者 // 更新单个代理提供者
const updateProvider = useLockFn(async (name: string) => { const updateProvider = useLockFn(async (name: string) => {
try { try {
// 设置更新状态 // 设置更新状态
setUpdating((prev) => ({ ...prev, [name]: true })); setUpdating((prev) => ({ ...prev, [name]: true }))
await updateProxyProvider(name); await updateProxyProvider(name)
// 刷新数据 // 刷新数据
await refreshProxy(); await refreshProxy()
await refreshProxyProviders(); await refreshProxyProviders()
showNotice.success( showNotice.success(
"proxies.feedback.notifications.provider.updateSuccess", 'proxies.feedback.notifications.provider.updateSuccess',
{ {
name, name,
}, },
); )
} catch (err) { } catch (err) {
showNotice.error("proxies.feedback.notifications.provider.updateFailed", { showNotice.error('proxies.feedback.notifications.provider.updateFailed', {
name, name,
message: String(err), message: String(err),
}); })
} finally { } finally {
// 清除更新状态 // 清除更新状态
setUpdating((prev) => ({ ...prev, [name]: false })); setUpdating((prev) => ({ ...prev, [name]: false }))
} }
}); })
// 更新所有代理提供者 // 更新所有代理提供者
const updateAllProviders = useLockFn(async () => { const updateAllProviders = useLockFn(async () => {
try { try {
// 获取所有provider的名称 // 获取所有provider的名称
const allProviders = Object.keys(proxyProviders || {}); const allProviders = Object.keys(proxyProviders || {})
if (allProviders.length === 0) { if (allProviders.length === 0) {
showNotice.info("proxies.feedback.notifications.provider.none"); showNotice.info('proxies.feedback.notifications.provider.none')
return; return
} }
// 设置所有provider为更新中状态 // 设置所有provider为更新中状态
const newUpdating = allProviders.reduce( const newUpdating = allProviders.reduce(
(acc, key) => { (acc, key) => {
acc[key] = true; acc[key] = true
return acc; return acc
}, },
{} as Record<string, boolean>, {} as Record<string, boolean>,
); )
setUpdating(newUpdating); setUpdating(newUpdating)
// 改为串行逐个更新所有provider // 改为串行逐个更新所有provider
for (const name of allProviders) { for (const name of allProviders) {
try { try {
await updateProxyProvider(name); await updateProxyProvider(name)
// 每个更新完成后更新状态 // 每个更新完成后更新状态
setUpdating((prev) => ({ ...prev, [name]: false })); setUpdating((prev) => ({ ...prev, [name]: false }))
} catch (err) { } catch (err) {
console.error(`更新 ${name} 失败`, err); console.error(`更新 ${name} 失败`, err)
// 继续执行下一个,不中断整体流程 // 继续执行下一个,不中断整体流程
} }
} }
// 刷新数据 // 刷新数据
await refreshProxy(); await refreshProxy()
await refreshProxyProviders(); await refreshProxyProviders()
showNotice.success("proxies.feedback.notifications.provider.allUpdated"); showNotice.success('proxies.feedback.notifications.provider.allUpdated')
} catch (err) { } catch (err) {
showNotice.error("proxies.feedback.notifications.provider.genericError", { showNotice.error('proxies.feedback.notifications.provider.genericError', {
message: String(err), message: String(err),
}); })
} finally { } finally {
// 清除所有更新状态 // 清除所有更新状态
setUpdating({}); setUpdating({})
} }
}); })
const handleClose = () => { const handleClose = () => {
setOpen(false); setOpen(false)
}; }
if (!hasProviders) return null; if (!hasProviders) return null
return ( return (
<> <>
@@ -145,7 +145,7 @@ export const ProviderButton = () => {
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
sx={{ mr: 1 }} sx={{ mr: 1 }}
> >
{t("proxies.page.provider.title")} {t('proxies.page.provider.title')}
</Button> </Button>
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
@@ -156,16 +156,16 @@ export const ProviderButton = () => {
alignItems="center" alignItems="center"
> >
<Typography variant="h6"> <Typography variant="h6">
{t("proxies.page.provider.title")} {t('proxies.page.provider.title')}
</Typography> </Typography>
<Box> <Box>
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
onClick={updateAllProviders} onClick={updateAllProviders}
aria-label={t("proxies.page.provider.actions.updateAll")} aria-label={t('proxies.page.provider.actions.updateAll')}
> >
{t("proxies.page.provider.actions.updateAll")} {t('proxies.page.provider.actions.updateAll')}
</Button> </Button>
</Box> </Box>
</Box> </Box>
@@ -176,17 +176,17 @@ export const ProviderButton = () => {
{Object.entries(proxyProviders || {}) {Object.entries(proxyProviders || {})
.sort() .sort()
.map(([key, item]) => { .map(([key, item]) => {
const provider = item; const provider = item
const time = dayjs(provider.updatedAt); const time = dayjs(provider.updatedAt)
const isUpdating = updating[key]; const isUpdating = updating[key]
// 订阅信息 // 订阅信息
const sub = provider.subscriptionInfo; const sub = provider.subscriptionInfo
const hasSubInfo = !!sub; const hasSubInfo = !!sub
const upload = sub?.Upload || 0; const upload = sub?.Upload || 0
const download = sub?.Download || 0; const download = sub?.Download || 0
const total = sub?.Total || 0; const total = sub?.Total || 0
const expire = sub?.Expire || 0; const expire = sub?.Expire || 0
// 流量使用进度 // 流量使用进度
const progress = const progress =
@@ -195,7 +195,7 @@ export const ProviderButton = () => {
Math.round(((download + upload) * 100) / total) + 1, Math.round(((download + upload) * 100) / total) + 1,
100, 100,
) )
: 0; : 0
return ( return (
<ListItem <ListItem
@@ -203,25 +203,24 @@ export const ProviderButton = () => {
sx={[ sx={[
{ {
p: 0, p: 0,
mb: "8px", mb: '8px',
borderRadius: 2, borderRadius: 2,
overflow: "hidden", overflow: 'hidden',
transition: "all 0.2s", transition: 'all 0.2s',
}, },
({ palette: { mode, primary } }) => { ({ palette: { mode, primary } }) => {
const bgcolor = const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
mode === "light" ? "#ffffff" : "#24252f";
const hoverColor = const hoverColor =
mode === "light" mode === 'light'
? alpha(primary.main, 0.1) ? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2); : alpha(primary.main, 0.2)
return { return {
backgroundColor: bgcolor, backgroundColor: bgcolor,
"&:hover": { '&:hover': {
backgroundColor: hoverColor, backgroundColor: hoverColor,
}, },
}; }
}, },
]} ]}
> >
@@ -230,9 +229,9 @@ export const ProviderButton = () => {
primary={ primary={
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
justifyContent: "space-between", justifyContent: 'space-between',
alignItems: "center", alignItems: 'center',
}} }}
> >
<Typography <Typography
@@ -240,9 +239,9 @@ export const ProviderButton = () => {
component="div" component="div"
noWrap noWrap
title={key} title={key}
sx={{ display: "flex", alignItems: "center" }} sx={{ display: 'flex', alignItems: 'center' }}
> >
<span style={{ marginRight: "8px" }}>{key}</span> <span style={{ marginRight: '8px' }}>{key}</span>
<TypeBox component="span"> <TypeBox component="span">
{provider.proxies.length} {provider.proxies.length}
</TypeBox> </TypeBox>
@@ -256,7 +255,7 @@ export const ProviderButton = () => {
color="text.secondary" color="text.secondary"
noWrap noWrap
> >
<small>{t("shared.labels.updateAt")}: </small> <small>{t('shared.labels.updateAt')}: </small>
{time.fromNow()} {time.fromNow()}
</Typography> </Typography>
</Box> </Box>
@@ -269,20 +268,20 @@ export const ProviderButton = () => {
<Box <Box
sx={{ sx={{
mb: 1, mb: 1,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
}} }}
> >
<span <span
title={t("shared.labels.usedTotal") as string} title={t('shared.labels.usedTotal') as string}
> >
{parseTraffic(upload + download)} /{" "} {parseTraffic(upload + download)} /{' '}
{parseTraffic(total)} {parseTraffic(total)}
</span> </span>
<span <span
title={ title={
t("shared.labels.expireTime") as string t('shared.labels.expireTime') as string
} }
> >
{parseExpire(expire)} {parseExpire(expire)}
@@ -308,45 +307,45 @@ export const ProviderButton = () => {
<Box <Box
sx={{ sx={{
width: 40, width: 40,
display: "flex", display: 'flex',
justifyContent: "center", justifyContent: 'center',
alignItems: "center", alignItems: 'center',
}} }}
> >
<IconButton <IconButton
size="small" size="small"
color="primary" color="primary"
onClick={() => { onClick={() => {
updateProvider(key); updateProvider(key)
}} }}
disabled={isUpdating} disabled={isUpdating}
sx={{ sx={{
animation: isUpdating animation: isUpdating
? "spin 1s linear infinite" ? 'spin 1s linear infinite'
: "none", : 'none',
"@keyframes spin": { '@keyframes spin': {
"0%": { transform: "rotate(0deg)" }, '0%': { transform: 'rotate(0deg)' },
"100%": { transform: "rotate(360deg)" }, '100%': { transform: 'rotate(360deg)' },
}, },
}} }}
title={t("proxies.page.provider.actions.update")} title={t('proxies.page.provider.actions.update')}
aria-label={t("proxies.page.provider.actions.update")} aria-label={t('proxies.page.provider.actions.update')}
> >
<RefreshRounded /> <RefreshRounded />
</IconButton> </IconButton>
</Box> </Box>
</ListItem> </ListItem>
); )
})} })}
</List> </List>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleClose} variant="outlined"> <Button onClick={handleClose} variant="outlined">
{t("shared.actions.close")} {t('shared.actions.close')}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</> </>
); )
}; }
+193 -193
View File
@@ -6,22 +6,22 @@ import {
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
} from "@dnd-kit/core"; } from '@dnd-kit/core'
import { import {
arrayMove, arrayMove,
SortableContext, SortableContext,
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
useSortable, useSortable,
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from '@dnd-kit/sortable'
import { CSS } from "@dnd-kit/utilities"; import { CSS } from '@dnd-kit/utilities'
import { import {
ArrowDownward, ArrowDownward,
Delete as DeleteIcon, Delete as DeleteIcon,
DragIndicator, DragIndicator,
Link, Link,
LinkOff, LinkOff,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { import {
Alert, Alert,
Box, Box,
@@ -31,55 +31,55 @@ import {
Paper, Paper,
Typography, Typography,
useTheme, useTheme,
} from "@mui/material"; } from '@mui/material'
import yaml from "js-yaml"; import yaml from 'js-yaml'
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { import {
closeAllConnections, closeAllConnections,
selectNodeForGroup, selectNodeForGroup,
} from "tauri-plugin-mihomo-api"; } from 'tauri-plugin-mihomo-api'
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from "@/services/cmds"; import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import { debugLog } from "@/utils/debug"; import { debugLog } from '@/utils/debug'
interface ProxyChainItem { interface ProxyChainItem {
id: string; id: string
name: string; name: string
type?: string; type?: string
delay?: number; delay?: number
} }
interface ParsedChainConfig { interface ParsedChainConfig {
proxies?: Array<{ proxies?: Array<{
name: string; name: string
type: string; type: string
[key: string]: any; [key: string]: any
}>; }>
} }
interface ProxyChainProps { interface ProxyChainProps {
proxyChain: ProxyChainItem[]; proxyChain: ProxyChainItem[]
onUpdateChain: (chain: ProxyChainItem[]) => void; onUpdateChain: (chain: ProxyChainItem[]) => void
chainConfigData?: string | null; chainConfigData?: string | null
onMarkUnsavedChanges?: () => void; onMarkUnsavedChanges?: () => void
mode?: string; mode?: string
selectedGroup?: string | null; selectedGroup?: string | null
} }
interface SortableItemProps { interface SortableItemProps {
proxy: ProxyChainItem; proxy: ProxyChainItem
index: number; index: number
isFirst: boolean; isFirst: boolean
isLast: boolean; isLast: boolean
onRemove: (id: string) => void; onRemove: (id: string) => void
} }
const toChainItems = ( const toChainItems = (
parsedConfig: ParsedChainConfig | null | undefined, parsedConfig: ParsedChainConfig | null | undefined,
): ProxyChainItem[] => { ): ProxyChainItem[] => {
const timestamp = Date.now(); const timestamp = Date.now()
return ( return (
parsedConfig?.proxies?.map((proxy, index) => ({ parsedConfig?.proxies?.map((proxy, index) => ({
@@ -88,8 +88,8 @@ const toChainItems = (
type: proxy.type, type: proxy.type,
delay: undefined, delay: undefined,
})) || [] })) || []
); )
}; }
const SortableItem = ({ const SortableItem = ({
proxy, proxy,
@@ -98,8 +98,8 @@ const SortableItem = ({
isLast, isLast,
onRemove, onRemove,
}: SortableItemProps) => { }: SortableItemProps) => {
const theme = useTheme(); const theme = useTheme()
const { t } = useTranslation(); const { t } = useTranslation()
const { const {
attributes, attributes,
listeners, listeners,
@@ -107,25 +107,25 @@ const SortableItem = ({
transform, transform,
transition, transition,
isDragging, isDragging,
} = useSortable({ id: proxy.id }); } = useSortable({ id: proxy.id })
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
}; }
const roleLabel = isFirst const roleLabel = isFirst
? t("proxies.page.chain.entryNode") ? t('proxies.page.chain.entryNode')
: isLast : isLast
? t("proxies.page.chain.exitNode") ? t('proxies.page.chain.exitNode')
: undefined; : undefined
const roleColor = isFirst const roleColor = isFirst
? theme.palette.success.main ? theme.palette.success.main
: isLast : isLast
? theme.palette.warning.main ? theme.palette.warning.main
: undefined; : undefined
return ( return (
<Box <Box
@@ -133,8 +133,8 @@ const SortableItem = ({
style={style} style={style}
sx={{ sx={{
mb: 0, mb: 0,
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
p: 1, p: 1,
backgroundColor: isDragging backgroundColor: isDragging
? theme.palette.action.selected ? theme.palette.action.selected
@@ -144,20 +144,20 @@ const SortableItem = ({
? `1.5px solid ${roleColor}` ? `1.5px solid ${roleColor}`
: `1px solid ${theme.palette.divider}`, : `1px solid ${theme.palette.divider}`,
boxShadow: isDragging ? theme.shadows[4] : theme.shadows[1], boxShadow: isDragging ? theme.shadows[4] : theme.shadows[1],
transition: "box-shadow 0.2s, background-color 0.2s", transition: 'box-shadow 0.2s, background-color 0.2s',
}} }}
> >
<Box <Box
{...attributes} {...attributes}
{...listeners} {...listeners}
sx={{ sx={{
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
mr: 1, mr: 1,
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
cursor: "grab", cursor: 'grab',
"&:active": { '&:active': {
cursor: "grabbing", cursor: 'grabbing',
}, },
}} }}
> >
@@ -171,7 +171,7 @@ const SortableItem = ({
sx={{ sx={{
mr: 1, mr: 1,
fontWeight: 700, fontWeight: 700,
color: "#fff", color: '#fff',
backgroundColor: roleColor, backgroundColor: roleColor,
}} }}
/> />
@@ -189,9 +189,9 @@ const SortableItem = ({
sx={{ sx={{
flex: 1, flex: 1,
fontWeight: 500, fontWeight: 500,
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
}} }}
> >
{proxy.name} {proxy.name}
@@ -211,17 +211,17 @@ const SortableItem = ({
label={ label={
proxy.delay > 0 proxy.delay > 0
? `${proxy.delay}ms` ? `${proxy.delay}ms`
: t("shared.labels.timeout") || "超时" : t('shared.labels.timeout') || '超时'
} }
size="small" size="small"
color={ color={
proxy.delay > 0 && proxy.delay < 200 proxy.delay > 0 && proxy.delay < 200
? "success" ? 'success'
: proxy.delay > 0 && proxy.delay < 800 : proxy.delay > 0 && proxy.delay < 800
? "warning" ? 'warning'
: "error" : 'error'
} }
sx={{ mr: 1, fontSize: "0.7rem", minWidth: 50 }} sx={{ mr: 1, fontSize: '0.7rem', minWidth: 50 }}
/> />
)} )}
@@ -230,16 +230,16 @@ const SortableItem = ({
onClick={() => onRemove(proxy.id)} onClick={() => onRemove(proxy.id)}
sx={{ sx={{
color: theme.palette.error.main, color: theme.palette.error.main,
"&:hover": { '&:hover': {
backgroundColor: theme.palette.error.light + "20", backgroundColor: theme.palette.error.light + '20',
}, },
}} }}
> >
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>
); )
}; }
export const ProxyChain = ({ export const ProxyChain = ({
proxyChain, proxyChain,
@@ -249,48 +249,48 @@ export const ProxyChain = ({
mode, mode,
selectedGroup, selectedGroup,
}: ProxyChainProps) => { }: ProxyChainProps) => {
const theme = useTheme(); const theme = useTheme()
const { t } = useTranslation(); const { t } = useTranslation()
const { proxies, refreshProxy } = useAppData(); const { proxies, refreshProxy } = useAppData()
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false)
const markUnsavedChanges = useCallback(() => { const markUnsavedChanges = useCallback(() => {
onMarkUnsavedChanges?.(); onMarkUnsavedChanges?.()
}, [onMarkUnsavedChanges]); }, [onMarkUnsavedChanges])
const isConnected = useMemo(() => { const isConnected = useMemo(() => {
if (!proxies || proxyChain.length < 2) { if (!proxies || proxyChain.length < 2) {
return false; return false
} }
const lastNode = proxyChain[proxyChain.length - 1]; const lastNode = proxyChain[proxyChain.length - 1]
if (mode === "global") { if (mode === 'global') {
return proxies.global?.now === lastNode.name; return proxies.global?.now === lastNode.name
} }
if (!selectedGroup || !Array.isArray(proxies.groups)) { if (!selectedGroup || !Array.isArray(proxies.groups)) {
return false; return false
} }
const proxyChainGroup = proxies.groups.find( const proxyChainGroup = proxies.groups.find(
(group: { name: string }) => group.name === selectedGroup, (group: { name: string }) => group.name === selectedGroup,
); )
return proxyChainGroup?.now === lastNode.name; return proxyChainGroup?.now === lastNode.name
}, [proxies, proxyChain, mode, selectedGroup]); }, [proxies, proxyChain, mode, selectedGroup])
// 监听链的变化,但排除从配置加载的情况 // 监听链的变化,但排除从配置加载的情况
const chainLengthRef = useRef(proxyChain.length); const chainLengthRef = useRef(proxyChain.length)
useEffect(() => { useEffect(() => {
// 只有当链长度发生变化且不是初始加载时,才标记为未保存 // 只有当链长度发生变化且不是初始加载时,才标记为未保存
if ( if (
chainLengthRef.current !== proxyChain.length && chainLengthRef.current !== proxyChain.length &&
chainLengthRef.current !== 0 chainLengthRef.current !== 0
) { ) {
markUnsavedChanges(); markUnsavedChanges()
} }
chainLengthRef.current = proxyChain.length; chainLengthRef.current = proxyChain.length
}, [proxyChain.length, markUnsavedChanges]); }, [proxyChain.length, markUnsavedChanges])
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -299,50 +299,50 @@ export const ProxyChain = ({
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}), }),
); )
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event
if (active.id !== over?.id) { if (active.id !== over?.id) {
const oldIndex = proxyChain.findIndex((item) => item.id === active.id); const oldIndex = proxyChain.findIndex((item) => item.id === active.id)
const newIndex = proxyChain.findIndex((item) => item.id === over?.id); const newIndex = proxyChain.findIndex((item) => item.id === over?.id)
onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex)); onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex))
markUnsavedChanges(); markUnsavedChanges()
} }
}, },
[proxyChain, onUpdateChain, markUnsavedChanges], [proxyChain, onUpdateChain, markUnsavedChanges],
); )
const handleRemoveProxy = useCallback( const handleRemoveProxy = useCallback(
(id: string) => { (id: string) => {
const newChain = proxyChain.filter((item) => item.id !== id); const newChain = proxyChain.filter((item) => item.id !== id)
onUpdateChain(newChain); onUpdateChain(newChain)
markUnsavedChanges(); markUnsavedChanges()
}, },
[proxyChain, onUpdateChain, markUnsavedChanges], [proxyChain, onUpdateChain, markUnsavedChanges],
); )
const handleConnect = useCallback(async () => { const handleConnect = useCallback(async () => {
if (isConnected) { if (isConnected) {
setIsConnecting(true); setIsConnecting(true)
try { try {
await updateProxyChainConfigInRuntime(null); await updateProxyChainConfigInRuntime(null)
const targetGroup = const targetGroup =
mode === "global" mode === 'global'
? "GLOBAL" ? 'GLOBAL'
: selectedGroup || localStorage.getItem("proxy-chain-group"); : selectedGroup || localStorage.getItem('proxy-chain-group')
if (targetGroup) { if (targetGroup) {
try { try {
await selectNodeForGroup(targetGroup, "DIRECT"); await selectNodeForGroup(targetGroup, 'DIRECT')
} catch { } catch {
if (proxyChain.length >= 1) { if (proxyChain.length >= 1) {
try { try {
await selectNodeForGroup(targetGroup, proxyChain[0].name); await selectNodeForGroup(targetGroup, proxyChain[0].name)
} catch { } catch {
// ignore // ignore
} }
@@ -350,59 +350,59 @@ export const ProxyChain = ({
} }
} }
localStorage.removeItem("proxy-chain-group"); localStorage.removeItem('proxy-chain-group')
localStorage.removeItem("proxy-chain-exit-node"); localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem("proxy-chain-items"); localStorage.removeItem('proxy-chain-items')
await closeAllConnections(); await closeAllConnections()
await refreshProxy(); await refreshProxy()
onUpdateChain([]); onUpdateChain([])
} catch (error) { } catch (error) {
console.error("Failed to disconnect from proxy chain:", error); console.error('Failed to disconnect from proxy chain:', error)
alert(t("proxies.page.chain.disconnectFailed") || "断开链式代理失败"); alert(t('proxies.page.chain.disconnectFailed') || '断开链式代理失败')
} finally { } finally {
setIsConnecting(false); setIsConnecting(false)
} }
return; return
} }
if (proxyChain.length < 2) { if (proxyChain.length < 2) {
alert(t("proxies.page.chain.minimumNodes") || "链式代理至少需要2个节点"); alert(t('proxies.page.chain.minimumNodes') || '链式代理至少需要2个节点')
return; return
} }
setIsConnecting(true); setIsConnecting(true)
try { try {
// 第一步:保存链式代理配置 // 第一步:保存链式代理配置
const chainProxies = proxyChain.map((node) => node.name); const chainProxies = proxyChain.map((node) => node.name)
debugLog("Saving chain config:", chainProxies); debugLog('Saving chain config:', chainProxies)
await updateProxyChainConfigInRuntime(chainProxies); await updateProxyChainConfigInRuntime(chainProxies)
debugLog("Chain configuration saved successfully"); debugLog('Chain configuration saved successfully')
// 第二步:连接到代理链的最后一个节点 // 第二步:连接到代理链的最后一个节点
const lastNode = proxyChain[proxyChain.length - 1]; const lastNode = proxyChain[proxyChain.length - 1]
debugLog(`Connecting to proxy chain, last node: ${lastNode.name}`); debugLog(`Connecting to proxy chain, last node: ${lastNode.name}`)
// 根据模式确定使用的代理组名称 // 根据模式确定使用的代理组名称
if (mode !== "global" && !selectedGroup) { if (mode !== 'global' && !selectedGroup) {
throw new Error("规则模式下必须选择代理组"); throw new Error('规则模式下必须选择代理组')
} }
const targetGroup = mode === "global" ? "GLOBAL" : selectedGroup; const targetGroup = mode === 'global' ? 'GLOBAL' : selectedGroup
await selectNodeForGroup(targetGroup || "GLOBAL", lastNode.name); await selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name)
localStorage.setItem("proxy-chain-group", targetGroup || "GLOBAL"); localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL')
localStorage.setItem("proxy-chain-exit-node", lastNode.name); localStorage.setItem('proxy-chain-exit-node', lastNode.name)
// 刷新代理信息以更新连接状态 // 刷新代理信息以更新连接状态
refreshProxy(); refreshProxy()
debugLog("Successfully connected to proxy chain"); debugLog('Successfully connected to proxy chain')
} catch (error) { } catch (error) {
console.error("Failed to connect to proxy chain:", error); console.error('Failed to connect to proxy chain:', error)
alert(t("proxies.page.chain.connectFailed") || "连接链式代理失败"); alert(t('proxies.page.chain.connectFailed') || '连接链式代理失败')
} finally { } finally {
setIsConnecting(false); setIsConnecting(false)
} }
}, [ }, [
proxyChain, proxyChain,
@@ -412,112 +412,112 @@ export const ProxyChain = ({
mode, mode,
selectedGroup, selectedGroup,
onUpdateChain, onUpdateChain,
]); ])
const proxyChainRef = useRef(proxyChain); const proxyChainRef = useRef(proxyChain)
const onUpdateChainRef = useRef(onUpdateChain); const onUpdateChainRef = useRef(onUpdateChain)
useEffect(() => { useEffect(() => {
proxyChainRef.current = proxyChain; proxyChainRef.current = proxyChain
onUpdateChainRef.current = onUpdateChain; onUpdateChainRef.current = onUpdateChain
}, [proxyChain, onUpdateChain]); }, [proxyChain, onUpdateChain])
// 处理链式代理配置数据 // 处理链式代理配置数据
useEffect(() => { useEffect(() => {
if (chainConfigData) { if (chainConfigData) {
try { try {
// JSON is valid YAML, so one parser covers both persisted formats. // JSON is valid YAML, so one parser covers both persisted formats.
const parsedConfig = yaml.load(chainConfigData) as ParsedChainConfig; const parsedConfig = yaml.load(chainConfigData) as ParsedChainConfig
const chainItems = toChainItems(parsedConfig); const chainItems = toChainItems(parsedConfig)
if (chainItems.length > 0) { if (chainItems.length > 0) {
onUpdateChain(chainItems); onUpdateChain(chainItems)
} }
} catch (error) { } catch (error) {
console.error("Failed to process chain config data:", error); console.error('Failed to process chain config data:', error)
} }
} }
}, [chainConfigData, onUpdateChain]); }, [chainConfigData, onUpdateChain])
// 定时更新延迟数据 // 定时更新延迟数据
useEffect(() => { useEffect(() => {
if (!proxies?.records) return; if (!proxies?.records) return
const updateDelays = () => { const updateDelays = () => {
const currentChain = proxyChainRef.current; const currentChain = proxyChainRef.current
if (currentChain.length === 0) return; if (currentChain.length === 0) return
const updatedChain = currentChain.map((item) => { const updatedChain = currentChain.map((item) => {
const proxyRecord = proxies.records[item.name]; const proxyRecord = proxies.records[item.name]
if ( if (
proxyRecord && proxyRecord &&
proxyRecord.history && proxyRecord.history &&
proxyRecord.history.length > 0 proxyRecord.history.length > 0
) { ) {
const latestDelay = const latestDelay =
proxyRecord.history[proxyRecord.history.length - 1].delay; proxyRecord.history[proxyRecord.history.length - 1].delay
return { ...item, delay: latestDelay }; return { ...item, delay: latestDelay }
} }
return item; return item
}); })
// 只有在延迟数据确实发生变化时才更新 // 只有在延迟数据确实发生变化时才更新
const hasChanged = updatedChain.some( const hasChanged = updatedChain.some(
(item, index) => item.delay !== currentChain[index]?.delay, (item, index) => item.delay !== currentChain[index]?.delay,
); )
if (hasChanged) { if (hasChanged) {
onUpdateChainRef.current(updatedChain); onUpdateChainRef.current(updatedChain)
}
} }
};
// 立即更新一次延迟 // 立即更新一次延迟
updateDelays(); updateDelays()
// 设置定时器,每5秒更新一次延迟 // 设置定时器,每5秒更新一次延迟
const interval = setInterval(updateDelays, 5000); const interval = setInterval(updateDelays, 5000)
return () => clearInterval(interval); return () => clearInterval(interval)
}, [proxies?.records]); // 只依赖proxies.records }, [proxies?.records]) // 只依赖proxies.records
return ( return (
<Paper <Paper
elevation={1} elevation={1}
sx={{ sx={{
height: "100%", height: '100%',
p: 2, p: 2,
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
}} }}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
mb: 2, mb: 2,
}} }}
> >
<Typography variant="h6">{t("proxies.page.chain.header")}</Typography> <Typography variant="h6">{t('proxies.page.chain.header')}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{proxyChain.length > 0 && ( {proxyChain.length > 0 && (
<IconButton <IconButton
size="small" size="small"
onClick={() => { onClick={() => {
updateProxyChainConfigInRuntime(null); updateProxyChainConfigInRuntime(null)
localStorage.removeItem("proxy-chain-group"); localStorage.removeItem('proxy-chain-group')
localStorage.removeItem("proxy-chain-exit-node"); localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem("proxy-chain-items"); localStorage.removeItem('proxy-chain-items')
onUpdateChain([]); onUpdateChain([])
}} }}
sx={{ sx={{
color: theme.palette.error.main, color: theme.palette.error.main,
"&:hover": { '&:hover': {
backgroundColor: theme.palette.error.light + "20", backgroundColor: theme.palette.error.light + '20',
}, },
}} }}
title={ title={
t("proxies.page.actions.clearChainConfig") || "删除链式配置" t('proxies.page.actions.clearChainConfig') || '删除链式配置'
} }
> >
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
@@ -531,51 +531,51 @@ export const ProxyChain = ({
disabled={ disabled={
isConnecting || isConnecting ||
proxyChain.length < 2 || proxyChain.length < 2 ||
(mode !== "global" && !selectedGroup) (mode !== 'global' && !selectedGroup)
} }
color={isConnected ? "error" : "success"} color={isConnected ? 'error' : 'success'}
sx={{ sx={{
minWidth: 90, minWidth: 90,
}} }}
title={ title={
proxyChain.length < 2 proxyChain.length < 2
? t("proxies.page.chain.minimumNodes") || ? t('proxies.page.chain.minimumNodes') ||
"链式代理至少需要2个节点" '链式代理至少需要2个节点'
: undefined : undefined
} }
> >
{isConnecting {isConnecting
? t("proxies.page.actions.connecting") || "连接中..." ? t('proxies.page.actions.connecting') || '连接中...'
: isConnected : isConnected
? t("proxies.page.actions.disconnect") || "断开" ? t('proxies.page.actions.disconnect') || '断开'
: t("proxies.page.actions.connect") || "连接"} : t('proxies.page.actions.connect') || '连接'}
</Button> </Button>
</Box> </Box>
</Box> </Box>
<Alert <Alert
severity={proxyChain.length === 1 ? "warning" : "info"} severity={proxyChain.length === 1 ? 'warning' : 'info'}
sx={{ mb: 2 }} sx={{ mb: 2 }}
> >
{proxyChain.length === 1 {proxyChain.length === 1
? t("proxies.page.chain.minimumNodesHint") || ? t('proxies.page.chain.minimumNodesHint') ||
"链式代理至少需要2个节点,请再添加一个节点。" '链式代理至少需要2个节点,请再添加一个节点。'
: t("proxies.page.chain.instruction") || : t('proxies.page.chain.instruction') ||
"按顺序点击节点添加到代理链中"} '按顺序点击节点添加到代理链中'}
</Alert> </Alert>
<Box sx={{ flex: 1, overflow: "auto" }}> <Box sx={{ flex: 1, overflow: 'auto' }}>
{proxyChain.length === 0 ? ( {proxyChain.length === 0 ? (
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
height: "100%", height: '100%',
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
}} }}
> >
<Typography>{t("proxies.page.chain.empty")}</Typography> <Typography>{t('proxies.page.chain.empty')}</Typography>
</Box> </Box>
) : ( ) : (
<DndContext <DndContext
@@ -608,8 +608,8 @@ export const ProxyChain = ({
{index < proxyChain.length - 1 && ( {index < proxyChain.length - 1 && (
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
justifyContent: "center", justifyContent: 'center',
py: 0.25, py: 0.25,
}} }}
> >
@@ -630,5 +630,5 @@ export const ProxyChain = ({
)} )}
</Box> </Box>
</Paper> </Paper>
); )
}; }
+61 -61
View File
@@ -1,23 +1,23 @@
import { Box, Button, Tooltip } from "@mui/material"; import { Box, Button, Tooltip } from '@mui/material'
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from 'react'
interface ProxyGroupNavigatorProps { interface ProxyGroupNavigatorProps {
proxyGroupNames: string[]; proxyGroupNames: string[]
onGroupLocation: (groupName: string) => void; onGroupLocation: (groupName: string) => void
enableHoverJump?: boolean; enableHoverJump?: boolean
hoverDelay?: number; hoverDelay?: number
} }
export const DEFAULT_HOVER_DELAY = 280; export const DEFAULT_HOVER_DELAY = 280
// 提取代理组名的第一个字符 // 提取代理组名的第一个字符
const getGroupDisplayChar = (groupName: string): string => { const getGroupDisplayChar = (groupName: string): string => {
if (!groupName) return "?"; if (!groupName) return '?'
// 直接返回第一个字符,支持表情符号 // 直接返回第一个字符,支持表情符号
const firstChar = Array.from(groupName)[0]; const firstChar = Array.from(groupName)[0]
return firstChar || "?"; return firstChar || '?'
}; }
export const ProxyGroupNavigator = ({ export const ProxyGroupNavigator = ({
proxyGroupNames, proxyGroupNames,
@@ -25,55 +25,55 @@ export const ProxyGroupNavigator = ({
enableHoverJump = true, enableHoverJump = true,
hoverDelay = DEFAULT_HOVER_DELAY, hoverDelay = DEFAULT_HOVER_DELAY,
}: ProxyGroupNavigatorProps) => { }: ProxyGroupNavigatorProps) => {
const lastHoveredRef = useRef<string | null>(null); const lastHoveredRef = useRef<string | null>(null)
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hoverDelayMs = hoverDelay >= 0 ? hoverDelay : 0; const hoverDelayMs = hoverDelay >= 0 ? hoverDelay : 0
const clearHoverTimer = useCallback(() => { const clearHoverTimer = useCallback(() => {
if (hoverTimerRef.current) { if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current); clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null; hoverTimerRef.current = null
} }
}, []); }, [])
useEffect(() => { useEffect(() => {
if (!enableHoverJump) { if (!enableHoverJump) {
clearHoverTimer(); clearHoverTimer()
lastHoveredRef.current = null; lastHoveredRef.current = null
} }
return () => { return () => {
clearHoverTimer(); clearHoverTimer()
}; }
}, [clearHoverTimer, enableHoverJump]); }, [clearHoverTimer, enableHoverJump])
const handleGroupClick = useCallback( const handleGroupClick = useCallback(
(groupName: string) => { (groupName: string) => {
clearHoverTimer(); clearHoverTimer()
lastHoveredRef.current = groupName; lastHoveredRef.current = groupName
onGroupLocation(groupName); onGroupLocation(groupName)
}, },
[clearHoverTimer, onGroupLocation], [clearHoverTimer, onGroupLocation],
); )
const handleGroupHover = useCallback( const handleGroupHover = useCallback(
(groupName: string) => { (groupName: string) => {
if (!enableHoverJump) return; if (!enableHoverJump) return
if (lastHoveredRef.current === groupName) return; if (lastHoveredRef.current === groupName) return
clearHoverTimer(); clearHoverTimer()
hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = setTimeout(() => {
hoverTimerRef.current = null; hoverTimerRef.current = null
lastHoveredRef.current = groupName; lastHoveredRef.current = groupName
onGroupLocation(groupName); onGroupLocation(groupName)
}, hoverDelayMs); }, hoverDelayMs)
}, },
[clearHoverTimer, enableHoverJump, hoverDelayMs, onGroupLocation], [clearHoverTimer, enableHoverJump, hoverDelayMs, onGroupLocation],
); )
const handleButtonLeave = useCallback(() => { const handleButtonLeave = useCallback(() => {
clearHoverTimer(); clearHoverTimer()
lastHoveredRef.current = null; lastHoveredRef.current = null
}, [clearHoverTimer]); }, [clearHoverTimer])
// 处理代理组数据,去重和排序 // 处理代理组数据,去重和排序
const processedGroups = useMemo(() => { const processedGroups = useMemo(() => {
@@ -82,36 +82,36 @@ export const ProxyGroupNavigator = ({
.map((name) => ({ .map((name) => ({
name, name,
displayChar: getGroupDisplayChar(name), displayChar: getGroupDisplayChar(name),
})); }))
}, [proxyGroupNames]); }, [proxyGroupNames])
if (processedGroups.length === 0) { if (processedGroups.length === 0) {
return null; return null
} }
return ( return (
<Box <Box
sx={{ sx={{
position: "absolute", position: 'absolute',
right: 2, right: 2,
top: "50%", top: '50%',
transform: "translateY(-50%)", transform: 'translateY(-50%)',
zIndex: 10, zIndex: 10,
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
gap: 0.25, gap: 0.25,
bgcolor: "transparent", bgcolor: 'transparent',
borderRadius: 0.5, borderRadius: 0.5,
boxShadow: 0, boxShadow: 0,
p: 0.25, p: 0.25,
maxHeight: "70vh", maxHeight: '70vh',
overflowY: "auto", overflowY: 'auto',
scrollbarWidth: "none", scrollbarWidth: 'none',
"&::-webkit-scrollbar": { '&::-webkit-scrollbar': {
width: 0, width: 0,
height: 0, height: 0,
}, },
minWidth: "auto", minWidth: 'auto',
}} }}
> >
{processedGroups.map(({ name, displayChar }) => ( {processedGroups.map(({ name, displayChar }) => (
@@ -129,17 +129,17 @@ export const ProxyGroupNavigator = ({
minHeight: 28, minHeight: 28,
width: 28, width: 28,
height: 28, height: 28,
fontSize: "12px", fontSize: '12px',
fontWeight: 600, fontWeight: 600,
padding: 0, padding: 0,
borderRadius: 0.25, borderRadius: 0.25,
color: "text.secondary", color: 'text.secondary',
textAlign: "center", textAlign: 'center',
justifyContent: "center", justifyContent: 'center',
textTransform: "none", textTransform: 'none',
"&:hover": { '&:hover': {
bgcolor: "primary.light", bgcolor: 'primary.light',
color: "primary.contrastText", color: 'primary.contrastText',
}, },
}} }}
> >
@@ -148,5 +148,5 @@ export const ProxyGroupNavigator = ({
</Tooltip> </Tooltip>
))} ))}
</Box> </Box>
); )
}; }
+226 -228
View File
@@ -1,4 +1,4 @@
import { ExpandMoreRounded } from "@mui/icons-material"; import { ExpandMoreRounded } from '@mui/icons-material'
import { import {
Alert, Alert,
Box, Box,
@@ -8,255 +8,253 @@ import {
MenuItem, MenuItem,
Snackbar, Snackbar,
Typography, Typography,
} from "@mui/material"; } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api"; import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api'
import { BaseEmpty } from "@/components/base"; import { BaseEmpty } from '@/components/base'
import { useProxySelection } from "@/hooks/use-proxy-selection"; import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import { useAppData } from "@/providers/app-data-context"; import { useAppData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from "@/services/cmds"; import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import delayManager from "@/services/delay"; import delayManager from '@/services/delay'
import { debugLog } from "@/utils/debug"; import { debugLog } from '@/utils/debug'
import { ScrollTopButton } from "../layout/scroll-top-button"; import { ScrollTopButton } from '../layout/scroll-top-button'
import { ProxyChain } from "./proxy-chain"; import { ProxyChain } from './proxy-chain'
import { import {
DEFAULT_HOVER_DELAY, DEFAULT_HOVER_DELAY,
ProxyGroupNavigator, ProxyGroupNavigator,
} from "./proxy-group-navigator"; } from './proxy-group-navigator'
import { ProxyRender } from "./proxy-render"; import { ProxyRender } from './proxy-render'
import { useRenderList } from "./use-render-list"; import { useRenderList } from './use-render-list'
interface Props { interface Props {
mode: string; mode: string
isChainMode?: boolean; isChainMode?: boolean
chainConfigData?: string | null; chainConfigData?: string | null
} }
interface ProxyChainItem { interface ProxyChainItem {
id: string; id: string
name: string; name: string
type?: string; type?: string
delay?: number; delay?: number
} }
const VirtuosoFooter = () => <div style={{ height: "8px" }} />; const VirtuosoFooter = () => <div style={{ height: '8px' }} />
export const ProxyGroups = (props: Props) => { export const ProxyGroups = (props: Props) => {
const { t } = useTranslation(); const { t } = useTranslation()
const { mode, isChainMode = false, chainConfigData } = props; const { mode, isChainMode = false, chainConfigData } = props
const [proxyChain, setProxyChain] = useState<ProxyChainItem[]>(() => { const [proxyChain, setProxyChain] = useState<ProxyChainItem[]>(() => {
try { try {
const saved = localStorage.getItem("proxy-chain-items"); const saved = localStorage.getItem('proxy-chain-items')
if (saved) { if (saved) {
return JSON.parse(saved); return JSON.parse(saved)
} }
} catch { } catch {
// ignore // ignore
} }
return []; return []
}); })
const [selectedGroup, setSelectedGroup] = useState<string | null>(null); const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (proxyChain.length > 0) { if (proxyChain.length > 0) {
localStorage.setItem("proxy-chain-items", JSON.stringify(proxyChain)); localStorage.setItem('proxy-chain-items', JSON.stringify(proxyChain))
} else { } else {
localStorage.removeItem("proxy-chain-items"); localStorage.removeItem('proxy-chain-items')
} }
}, [proxyChain]); }, [proxyChain])
const [ruleMenuAnchor, setRuleMenuAnchor] = useState<null | HTMLElement>( const [ruleMenuAnchor, setRuleMenuAnchor] = useState<null | HTMLElement>(null)
null,
);
const [duplicateWarning, setDuplicateWarning] = useState<{ const [duplicateWarning, setDuplicateWarning] = useState<{
open: boolean; open: boolean
message: string; message: string
}>({ open: false, message: "" }); }>({ open: false, message: '' })
const { verge } = useVerge(); const { verge } = useVerge()
const { proxies: proxiesData } = useAppData(); const { proxies: proxiesData } = useAppData()
const groups = proxiesData?.groups; const groups = proxiesData?.groups
const availableGroups = useMemo(() => { const availableGroups = useMemo(() => {
if (!groups) return []; if (!groups) return []
// 在链式代理模式下,仅显示支持选择节点的 Selector 代理组 // 在链式代理模式下,仅显示支持选择节点的 Selector 代理组
return isChainMode return isChainMode
? groups.filter((g: any) => g.type === "Selector") ? groups.filter((g: any) => g.type === 'Selector')
: groups; : groups
}, [groups, isChainMode]); }, [groups, isChainMode])
const defaultRuleGroup = useMemo(() => { const defaultRuleGroup = useMemo(() => {
if (isChainMode && mode === "rule" && availableGroups.length > 0) { if (isChainMode && mode === 'rule' && availableGroups.length > 0) {
return availableGroups[0].name; return availableGroups[0].name
} }
return null; return null
}, [availableGroups, isChainMode, mode]); }, [availableGroups, isChainMode, mode])
const activeSelectedGroup = useMemo( const activeSelectedGroup = useMemo(
() => selectedGroup ?? defaultRuleGroup, () => selectedGroup ?? defaultRuleGroup,
[selectedGroup, defaultRuleGroup], [selectedGroup, defaultRuleGroup],
); )
const { renderList, onProxies, onHeadState } = useRenderList( const { renderList, onProxies, onHeadState } = useRenderList(
mode, mode,
isChainMode, isChainMode,
activeSelectedGroup, activeSelectedGroup,
); )
const getGroupHeadState = useCallback( const getGroupHeadState = useCallback(
(groupName: string) => { (groupName: string) => {
const headItem = renderList.find( const headItem = renderList.find(
(item) => item.type === 1 && item.group?.name === groupName, (item) => item.type === 1 && item.group?.name === groupName,
); )
return headItem?.headState; return headItem?.headState
}, },
[renderList], [renderList],
); )
// 统代理选择 // 统代理选择
const { handleProxyGroupChange } = useProxySelection({ const { handleProxyGroupChange } = useProxySelection({
onSuccess: () => { onSuccess: () => {
onProxies(); onProxies()
}, },
onError: (error) => { onError: (error) => {
console.error("代理切换失败", error); console.error('代理切换失败', error)
onProxies(); onProxies()
}, },
}); })
const timeout = verge?.default_latency_timeout || 10000; const timeout = verge?.default_latency_timeout || 10000
const virtuosoRef = useRef<VirtuosoHandle>(null); const virtuosoRef = useRef<VirtuosoHandle>(null)
const scrollPositionRef = useRef<Record<string, number>>({}); const scrollPositionRef = useRef<Record<string, number>>({})
const [showScrollTop, setShowScrollTop] = useState(false); const [showScrollTop, setShowScrollTop] = useState(false)
const scrollerRef = useRef<Element | null>(null); const scrollerRef = useRef<Element | null>(null)
// 从 localStorage 恢复滚动位置 // 从 localStorage 恢复滚动位置
useEffect(() => { useEffect(() => {
if (renderList.length === 0) return; if (renderList.length === 0) return
let restoreTimer: ReturnType<typeof setTimeout> | null = null; let restoreTimer: ReturnType<typeof setTimeout> | null = null
try { try {
const savedPositions = localStorage.getItem("proxy-scroll-positions"); const savedPositions = localStorage.getItem('proxy-scroll-positions')
if (savedPositions) { if (savedPositions) {
const positions = JSON.parse(savedPositions); const positions = JSON.parse(savedPositions)
scrollPositionRef.current = positions; scrollPositionRef.current = positions
const savedPosition = positions[mode]; const savedPosition = positions[mode]
if (savedPosition !== undefined) { if (savedPosition !== undefined) {
restoreTimer = setTimeout(() => { restoreTimer = setTimeout(() => {
virtuosoRef.current?.scrollTo({ virtuosoRef.current?.scrollTo({
top: savedPosition, top: savedPosition,
behavior: "auto", behavior: 'auto',
}); })
}, 100); }, 100)
} }
} }
} catch (e) { } catch (e) {
console.error("Error restoring scroll position:", e); console.error('Error restoring scroll position:', e)
} }
return () => { return () => {
if (restoreTimer) { if (restoreTimer) {
clearTimeout(restoreTimer); clearTimeout(restoreTimer)
} }
}; }
}, [mode, renderList.length]); }, [mode, renderList.length])
// 改为使用节流函数保存滚动位置 // 改为使用节流函数保存滚动位置
const saveScrollPosition = useCallback( const saveScrollPosition = useCallback(
(scrollTop: number) => { (scrollTop: number) => {
try { try {
scrollPositionRef.current[mode] = scrollTop; scrollPositionRef.current[mode] = scrollTop
localStorage.setItem( localStorage.setItem(
"proxy-scroll-positions", 'proxy-scroll-positions',
JSON.stringify(scrollPositionRef.current), JSON.stringify(scrollPositionRef.current),
); )
} catch (e) { } catch (e) {
console.error("Error saving scroll position:", e); console.error('Error saving scroll position:', e)
} }
}, },
[mode], [mode],
); )
// 使用改进的滚动处理 // 使用改进的滚动处理
const handleScroll = useMemo( const handleScroll = useMemo(
() => () =>
throttle((event: Event) => { throttle((event: Event) => {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null
const scrollTop = target?.scrollTop ?? 0; const scrollTop = target?.scrollTop ?? 0
setShowScrollTop(scrollTop > 100); setShowScrollTop(scrollTop > 100)
// 使用稳定的节流来保存位置,而不是setTimeout // 使用稳定的节流来保存位置,而不是setTimeout
saveScrollPosition(scrollTop); saveScrollPosition(scrollTop)
}, 500), // 增加到500ms以确保平滑滚动 }, 500), // 增加到500ms以确保平滑滚动
[saveScrollPosition], [saveScrollPosition],
); )
// 添加和清理滚动事件监听器 // 添加和清理滚动事件监听器
useEffect(() => { useEffect(() => {
const node = scrollerRef.current; const node = scrollerRef.current
if (!node) return; if (!node) return
const listener = handleScroll as EventListener; const listener = handleScroll as EventListener
const options: AddEventListenerOptions = { passive: true }; const options: AddEventListenerOptions = { passive: true }
node.addEventListener("scroll", listener, options); node.addEventListener('scroll', listener, options)
return () => { return () => {
node.removeEventListener("scroll", listener, options); node.removeEventListener('scroll', listener, options)
}; }
}, [handleScroll]); }, [handleScroll])
// 滚动到顶部 // 滚动到顶部
const scrollToTop = useCallback(() => { const scrollToTop = useCallback(() => {
virtuosoRef.current?.scrollTo?.({ virtuosoRef.current?.scrollTo?.({
top: 0, top: 0,
behavior: "smooth", behavior: 'smooth',
}); })
saveScrollPosition(0); saveScrollPosition(0)
}, [saveScrollPosition]); }, [saveScrollPosition])
// 关闭重复节点警告 // 关闭重复节点警告
const handleCloseDuplicateWarning = useCallback(() => { const handleCloseDuplicateWarning = useCallback(() => {
setDuplicateWarning({ open: false, message: "" }); setDuplicateWarning({ open: false, message: '' })
}, []); }, [])
const currentGroup = useMemo(() => { const currentGroup = useMemo(() => {
if (!activeSelectedGroup) return null; if (!activeSelectedGroup) return null
return ( return (
availableGroups.find( availableGroups.find(
(group: any) => group.name === activeSelectedGroup, (group: any) => group.name === activeSelectedGroup,
) ?? null ) ?? null
); )
}, [activeSelectedGroup, availableGroups]); }, [activeSelectedGroup, availableGroups])
// 处理代理组选择菜单 // 处理代理组选择菜单
const handleGroupMenuOpen = (event: React.MouseEvent<HTMLElement>) => { const handleGroupMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setRuleMenuAnchor(event.currentTarget); setRuleMenuAnchor(event.currentTarget)
}; }
const handleGroupMenuClose = () => { const handleGroupMenuClose = () => {
setRuleMenuAnchor(null); setRuleMenuAnchor(null)
}; }
const handleGroupSelect = (groupName: string) => { const handleGroupSelect = (groupName: string) => {
setSelectedGroup(groupName); setSelectedGroup(groupName)
handleGroupMenuClose(); handleGroupMenuClose()
if (isChainMode && mode === "rule") { if (isChainMode && mode === 'rule') {
updateProxyChainConfigInRuntime(null); updateProxyChainConfigInRuntime(null)
localStorage.removeItem("proxy-chain-group"); localStorage.removeItem('proxy-chain-group')
localStorage.removeItem("proxy-chain-exit-node"); localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem("proxy-chain-items"); localStorage.removeItem('proxy-chain-items')
setProxyChain([]); setProxyChain([])
}
} }
};
const handleChangeProxy = useCallback( const handleChangeProxy = useCallback(
(group: IProxyGroupItem, proxy: IProxyItem) => { (group: IProxyGroupItem, proxy: IProxyItem) => {
@@ -265,69 +263,69 @@ export const ProxyGroups = (props: Props) => {
setProxyChain((prev) => { setProxyChain((prev) => {
// 检查是否已经存在相同名称的代理,防止重复添加 // 检查是否已经存在相同名称的代理,防止重复添加
if (prev.some((item) => item.name === proxy.name)) { if (prev.some((item) => item.name === proxy.name)) {
const warningMessage = t("proxies.page.chain.duplicateNode"); const warningMessage = t('proxies.page.chain.duplicateNode')
setDuplicateWarning({ setDuplicateWarning({
open: true, open: true,
message: warningMessage, message: warningMessage,
}); })
return prev; // 返回原来的状态,不做任何更改 return prev // 返回原来的状态,不做任何更改
} }
// 安全获取延迟数据,如果没有延迟数据则设为 undefined // 安全获取延迟数据,如果没有延迟数据则设为 undefined
const delay = const delay =
proxy.history && proxy.history.length > 0 proxy.history && proxy.history.length > 0
? proxy.history[proxy.history.length - 1].delay ? proxy.history[proxy.history.length - 1].delay
: undefined; : undefined
const chainItem: ProxyChainItem = { const chainItem: ProxyChainItem = {
id: `${proxy.name}_${Date.now()}`, id: `${proxy.name}_${Date.now()}`,
name: proxy.name, name: proxy.name,
type: proxy.type, type: proxy.type,
delay: delay, delay: delay,
};
return [...prev, chainItem];
});
return;
} }
if (!["Selector", "URLTest", "Fallback"].includes(group.type)) return; return [...prev, chainItem]
})
return
}
handleProxyGroupChange(group, proxy); if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
handleProxyGroupChange(group, proxy)
}, },
[handleProxyGroupChange, isChainMode, t], [handleProxyGroupChange, isChainMode, t],
); )
// 测全部延迟 // 测全部延迟
const handleCheckAll = useLockFn(async (groupName: string) => { const handleCheckAll = useLockFn(async (groupName: string) => {
debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`); debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`)
const proxies = renderList const proxies = renderList
.filter( .filter(
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4), (e) => e.group?.name === groupName && (e.type === 2 || e.type === 4),
) )
.flatMap((e) => e.proxyCol || e.proxy!) .flatMap((e) => e.proxyCol || e.proxy!)
.filter(Boolean); .filter(Boolean)
debugLog(`[ProxyGroups] 找到代理数量: ${proxies.length}`); debugLog(`[ProxyGroups] 找到代理数量: ${proxies.length}`)
const providers = new Set(proxies.map((p) => p!.provider!).filter(Boolean)); const providers = new Set(proxies.map((p) => p!.provider!).filter(Boolean))
if (providers.size) { if (providers.size) {
debugLog(`[ProxyGroups] 发现提供者,数量: ${providers.size}`); debugLog(`[ProxyGroups] 发现提供者,数量: ${providers.size}`)
Promise.allSettled( Promise.allSettled(
[...providers].map((p) => healthcheckProxyProvider(p)), [...providers].map((p) => healthcheckProxyProvider(p)),
).then(() => { ).then(() => {
debugLog(`[ProxyGroups] 提供者健康检查完成`); debugLog(`[ProxyGroups] 提供者健康检查完成`)
onProxies(); onProxies()
}); })
} }
const names = proxies.filter((p) => !p!.provider).map((p) => p!.name); const names = proxies.filter((p) => !p!.provider).map((p) => p!.name)
debugLog(`[ProxyGroups] 过滤后需要测试的代理数量: ${names.length}`); debugLog(`[ProxyGroups] 过滤后需要测试的代理数量: ${names.length}`)
const url = delayManager.getUrl(groupName); const url = delayManager.getUrl(groupName)
debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`); debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`)
try { try {
await Promise.race([ await Promise.race([
@@ -336,116 +334,116 @@ export const ProxyGroups = (props: Props) => {
debugLog( debugLog(
`[ProxyGroups] getGroupProxyDelays返回结果数量:`, `[ProxyGroups] getGroupProxyDelays返回结果数量:`,
Object.keys(result || {}).length, Object.keys(result || {}).length,
); )
}), // 查询group delays 将清除fixed(不关注调用结果) }), // 查询group delays 将清除fixed(不关注调用结果)
]); ])
debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`); debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`)
} catch (error) { } catch (error) {
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error); console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
} finally { } finally {
const headState = getGroupHeadState(groupName); const headState = getGroupHeadState(groupName)
if (headState?.sortType === 1) { if (headState?.sortType === 1) {
onHeadState(groupName, { sortType: headState.sortType }); onHeadState(groupName, { sortType: headState.sortType })
} }
onProxies(); onProxies()
} }
}); })
// 滚到对应的节点 // 滚到对应的节点
const handleLocation = (group: IProxyGroupItem) => { const handleLocation = (group: IProxyGroupItem) => {
if (!group) return; if (!group) return
const { name, now } = group; const { name, now } = group
const index = renderList.findIndex( const index = renderList.findIndex(
(e) => (e) =>
e.group?.name === name && e.group?.name === name &&
((e.type === 2 && e.proxy?.name === now) || ((e.type === 2 && e.proxy?.name === now) ||
(e.type === 4 && e.proxyCol?.some((p) => p.name === now))), (e.type === 4 && e.proxyCol?.some((p) => p.name === now))),
); )
if (index >= 0) { if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({ virtuosoRef.current?.scrollToIndex?.({
index, index,
align: "center", align: 'center',
behavior: "smooth", behavior: 'smooth',
}); })
}
} }
};
// 定位到指定的代理组 // 定位到指定的代理组
const handleGroupLocationByName = useCallback( const handleGroupLocationByName = useCallback(
(groupName: string) => { (groupName: string) => {
const index = renderList.findIndex( const index = renderList.findIndex(
(item) => item.type === 0 && item.group?.name === groupName, (item) => item.type === 0 && item.group?.name === groupName,
); )
if (index >= 0) { if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({ virtuosoRef.current?.scrollToIndex?.({
index, index,
align: "start", align: 'start',
behavior: "smooth", behavior: 'smooth',
}); })
} }
}, },
[renderList], [renderList],
); )
const proxyGroupNames = useMemo(() => { const proxyGroupNames = useMemo(() => {
const names = renderList const names = renderList
.filter((item) => item.type === 0 && item.group?.name) .filter((item) => item.type === 0 && item.group?.name)
.map((item) => item.group!.name); .map((item) => item.group!.name)
return Array.from(new Set(names)); return Array.from(new Set(names))
}, [renderList]); }, [renderList])
if (mode === "direct") { if (mode === 'direct') {
return <BaseEmpty textKey="proxies.page.messages.directMode" />; return <BaseEmpty textKey="proxies.page.messages.directMode" />
} }
if (isChainMode) { if (isChainMode) {
// 获取所有代理组 // 获取所有代理组
const proxyGroups = proxiesData?.groups || []; const proxyGroups = proxiesData?.groups || []
return ( return (
<> <>
<Box sx={{ display: "flex", height: "100%", gap: 2 }}> <Box sx={{ display: 'flex', height: '100%', gap: 2 }}>
<Box sx={{ flex: 1, position: "relative" }}> <Box sx={{ flex: 1, position: 'relative' }}>
{/* 代理规则标题和代理组按钮栏 */} {/* 代理规则标题和代理组按钮栏 */}
{mode === "rule" && proxyGroups.length > 0 && ( {mode === 'rule' && proxyGroups.length > 0 && (
<Box sx={{ borderBottom: "1px solid", borderColor: "divider" }}> <Box sx={{ borderBottom: '1px solid', borderColor: 'divider' }}>
{/* 代理规则标题 */} {/* 代理规则标题 */}
<Box <Box
sx={{ sx={{
px: 2, px: 2,
py: 1.5, py: 1.5,
borderBottom: "1px solid", borderBottom: '1px solid',
borderColor: "divider", borderColor: 'divider',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography <Typography
variant="h6" variant="h6"
sx={{ fontWeight: 600, fontSize: "16px" }} sx={{ fontWeight: 600, fontSize: '16px' }}
> >
{t("proxies.page.rules.title")} {t('proxies.page.rules.title')}
</Typography> </Typography>
{currentGroup && ( {currentGroup && (
<Box <Box
sx={{ display: "flex", alignItems: "center", gap: 1 }} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
> >
<Chip <Chip
size="small" size="small"
label={`${currentGroup.name} (${currentGroup.type})`} label={`${currentGroup.name} (${currentGroup.type})`}
variant="outlined" variant="outlined"
sx={{ sx={{
fontSize: "12px", fontSize: '12px',
maxWidth: "200px", maxWidth: '200px',
"& .MuiChip-label": { '& .MuiChip-label': {
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
}, },
}} }}
/> />
@@ -458,17 +456,17 @@ export const ProxyGroups = (props: Props) => {
size="small" size="small"
onClick={handleGroupMenuOpen} onClick={handleGroupMenuOpen}
sx={{ sx={{
border: "1px solid", border: '1px solid',
borderColor: "divider", borderColor: 'divider',
borderRadius: "4px", borderRadius: '4px',
padding: "4px 8px", padding: '4px 8px',
}} }}
> >
<Typography <Typography
variant="body2" variant="body2"
sx={{ mr: 0.5, fontSize: "12px" }} sx={{ mr: 0.5, fontSize: '12px' }}
> >
{t("proxies.page.rules.select")} {t('proxies.page.rules.select')}
</Typography> </Typography>
<ExpandMoreRounded fontSize="small" /> <ExpandMoreRounded fontSize="small" />
</IconButton> </IconButton>
@@ -481,16 +479,16 @@ export const ProxyGroups = (props: Props) => {
ref={virtuosoRef} ref={virtuosoRef}
style={{ style={{
height: height:
mode === "rule" && proxyGroups.length > 0 mode === 'rule' && proxyGroups.length > 0
? "calc(100% - 80px)" // 只有标题的高度 ? 'calc(100% - 80px)' // 只有标题的高度
: "calc(100% - 14px)", : 'calc(100% - 14px)',
}} }}
totalCount={renderList.length} totalCount={renderList.length}
increaseViewportBy={{ top: 200, bottom: 200 }} increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150} overscan={150}
defaultItemHeight={56} defaultItemHeight={56}
scrollerRef={(ref) => { scrollerRef={(ref) => {
scrollerRef.current = ref as Element; scrollerRef.current = ref as Element
}} }}
components={{ components={{
Footer: VirtuosoFooter, Footer: VirtuosoFooter,
@@ -501,7 +499,7 @@ export const ProxyGroups = (props: Props) => {
<ProxyRender <ProxyRender
key={renderList[index].key} key={renderList[index].key}
item={renderList[index]} item={renderList[index]}
indent={mode === "rule" || mode === "script"} indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation} onLocation={handleLocation}
onCheckAll={handleCheckAll} onCheckAll={handleCheckAll}
onHeadState={onHeadState} onHeadState={onHeadState}
@@ -513,7 +511,7 @@ export const ProxyGroups = (props: Props) => {
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} /> <ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</Box> </Box>
<Box sx={{ width: "400px", minWidth: "300px" }}> <Box sx={{ width: '400px', minWidth: '300px' }}>
<ProxyChain <ProxyChain
proxyChain={proxyChain} proxyChain={proxyChain}
onUpdateChain={setProxyChain} onUpdateChain={setProxyChain}
@@ -528,7 +526,7 @@ export const ProxyGroups = (props: Props) => {
open={duplicateWarning.open} open={duplicateWarning.open}
autoHideDuration={3000} autoHideDuration={3000}
onClose={handleCloseDuplicateWarning} onClose={handleCloseDuplicateWarning}
anchorOrigin={{ vertical: "top", horizontal: "center" }} anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
> >
<Alert <Alert
onClose={handleCloseDuplicateWarning} onClose={handleCloseDuplicateWarning}
@@ -559,15 +557,15 @@ export const ProxyGroups = (props: Props) => {
onClick={() => handleGroupSelect(group.name)} onClick={() => handleGroupSelect(group.name)}
selected={activeSelectedGroup === group.name} selected={activeSelectedGroup === group.name}
sx={{ sx={{
fontSize: "14px", fontSize: '14px',
py: 1, py: 1,
}} }}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "flex-start", alignItems: 'flex-start',
}} }}
> >
<Typography variant="body2" sx={{ fontWeight: 500 }}> <Typography variant="body2" sx={{ fontWeight: 500 }}>
@@ -588,15 +586,15 @@ export const ProxyGroups = (props: Props) => {
)} )}
</Menu> </Menu>
</> </>
); )
} }
return ( return (
<div <div
style={{ position: "relative", height: "100%", willChange: "transform" }} style={{ position: 'relative', height: '100%', willChange: 'transform' }}
> >
{/* 代理组导航栏 */} {/* 代理组导航栏 */}
{mode === "rule" && ( {mode === 'rule' && (
<ProxyGroupNavigator <ProxyGroupNavigator
proxyGroupNames={proxyGroupNames} proxyGroupNames={proxyGroupNames}
onGroupLocation={handleGroupLocationByName} onGroupLocation={handleGroupLocationByName}
@@ -607,13 +605,13 @@ export const ProxyGroups = (props: Props) => {
<Virtuoso <Virtuoso
ref={virtuosoRef} ref={virtuosoRef}
style={{ height: "calc(100% - 14px)" }} style={{ height: 'calc(100% - 14px)' }}
totalCount={renderList.length} totalCount={renderList.length}
increaseViewportBy={{ top: 200, bottom: 200 }} increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150} overscan={150}
defaultItemHeight={56} defaultItemHeight={56}
scrollerRef={(ref) => { scrollerRef={(ref) => {
scrollerRef.current = ref as Element; scrollerRef.current = ref as Element
}} }}
components={{ components={{
Footer: VirtuosoFooter, Footer: VirtuosoFooter,
@@ -625,7 +623,7 @@ export const ProxyGroups = (props: Props) => {
<ProxyRender <ProxyRender
key={renderList[index].key} key={renderList[index].key}
item={renderList[index]} item={renderList[index]}
indent={mode === "rule" || mode === "script"} indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation} onLocation={handleLocation}
onCheckAll={handleCheckAll} onCheckAll={handleCheckAll}
onHeadState={onHeadState} onHeadState={onHeadState}
@@ -635,34 +633,34 @@ export const ProxyGroups = (props: Props) => {
/> />
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} /> <ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</div> </div>
); )
}; }
// 替换简单防抖函数为更优的节流函数 // 替换简单防抖函数为更优的节流函数
function throttle<T extends (...args: any[]) => any>( function throttle<T extends (...args: any[]) => any>(
func: T, func: T,
wait: number, wait: number,
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null
let previous = 0; let previous = 0
return function (...args: Parameters<T>) { return function (...args: Parameters<T>) {
const now = Date.now(); const now = Date.now()
const remaining = wait - (now - previous); const remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) { if (remaining <= 0 || remaining > wait) {
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer)
timer = null; timer = null
} }
previous = now; previous = now
func(...args); func(...args)
} else if (!timer) { } else if (!timer) {
timer = setTimeout(() => { timer = setTimeout(() => {
previous = Date.now(); previous = Date.now()
timer = null; timer = null
func(...args); func(...args)
}, remaining); }, remaining)
}
} }
};
} }
+54 -54
View File
@@ -10,30 +10,30 @@ import {
WifiTetheringOffRounded, WifiTetheringOffRounded,
SortByAlphaRounded, SortByAlphaRounded,
SortRounded, SortRounded,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { Box, IconButton, TextField, SxProps } from "@mui/material"; import { Box, IconButton, TextField, SxProps } from '@mui/material'
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { BaseSearchBox } from "@/components/base"; import { BaseSearchBox } from '@/components/base'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import delayManager from "@/services/delay"; import delayManager from '@/services/delay'
import { debugLog } from "@/utils/debug"; import { debugLog } from '@/utils/debug'
import type { ProxySortType } from "./use-filter-sort"; import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from "./use-head-state"; import type { HeadState } from './use-head-state'
interface Props { interface Props {
sx?: SxProps; sx?: SxProps
url?: string; url?: string
groupName: string; groupName: string
headState: HeadState; headState: HeadState
onLocation: () => void; onLocation: () => void
onCheckDelay: () => void; onCheckDelay: () => void
onHeadState: (val: Partial<HeadState>) => void; onHeadState: (val: Partial<HeadState>) => void
} }
const defaultSx: SxProps = {}; const defaultSx: SxProps = {}
export const ProxyHead = ({ export const ProxyHead = ({
sx = defaultSx, sx = defaultSx,
@@ -53,31 +53,31 @@ export const ProxyHead = ({
filterMatchCase, filterMatchCase,
filterMatchWholeWord, filterMatchWholeWord,
filterUseRegularExpression, filterUseRegularExpression,
} = headState; } = headState
const { t } = useTranslation(); const { t } = useTranslation()
const [autoFocus, setAutoFocus] = useState(false); const [autoFocus, setAutoFocus] = useState(false)
useEffect(() => { useEffect(() => {
// fix the focus conflict // fix the focus conflict
const timer = setTimeout(() => setAutoFocus(true), 100); const timer = setTimeout(() => setAutoFocus(true), 100)
return () => clearTimeout(timer); return () => clearTimeout(timer)
}, []); }, [])
const { verge } = useVerge(); const { verge } = useVerge()
const defaultLatencyUrl = const defaultLatencyUrl =
verge?.default_latency_test?.trim() || "http://cp.cloudflare.com"; verge?.default_latency_test?.trim() || 'http://cp.cloudflare.com'
useEffect(() => { useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl); delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
}, [groupName, testUrl, defaultLatencyUrl, url]); }, [groupName, testUrl, defaultLatencyUrl, url])
return ( return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, ...sx }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ...sx }}>
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
title={t("proxies.page.tooltips.locate")} title={t('proxies.page.tooltips.locate')}
onClick={onLocation} onClick={onLocation}
> >
<MyLocationRounded /> <MyLocationRounded />
@@ -86,15 +86,15 @@ export const ProxyHead = ({
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
title={t("proxies.page.tooltips.delayCheck")} title={t('proxies.page.tooltips.delayCheck')}
onClick={() => { onClick={() => {
debugLog(`[ProxyHead] 点击延迟测试按钮,组: ${groupName}`); debugLog(`[ProxyHead] 点击延迟测试按钮,组: ${groupName}`)
// Remind the user that it is custom test url // Remind the user that it is custom test url
if (testUrl?.trim() && textState !== "filter") { if (testUrl?.trim() && textState !== 'filter') {
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`); debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: "url" }); onHeadState({ textState: 'url' })
} }
onCheckDelay(); onCheckDelay()
}} }}
> >
<NetworkCheckRounded /> <NetworkCheckRounded />
@@ -105,9 +105,9 @@ export const ProxyHead = ({
color="inherit" color="inherit"
title={ title={
[ [
t("proxies.page.tooltips.sortDefault"), t('proxies.page.tooltips.sortDefault'),
t("proxies.page.tooltips.sortDelay"), t('proxies.page.tooltips.sortDelay'),
t("proxies.page.tooltips.sortName"), t('proxies.page.tooltips.sortName'),
][sortType] ][sortType]
} }
onClick={() => onClick={() =>
@@ -122,12 +122,12 @@ export const ProxyHead = ({
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
title={t("proxies.page.tooltips.delayCheckUrl")} title={t('proxies.page.tooltips.delayCheckUrl')}
onClick={() => onClick={() =>
onHeadState({ textState: textState === "url" ? null : "url" }) onHeadState({ textState: textState === 'url' ? null : 'url' })
} }
> >
{textState === "url" ? ( {textState === 'url' ? (
<WifiTetheringRounded /> <WifiTetheringRounded />
) : ( ) : (
<WifiTetheringOffRounded /> <WifiTetheringOffRounded />
@@ -139,8 +139,8 @@ export const ProxyHead = ({
color="inherit" color="inherit"
title={ title={
showType showType
? t("proxies.page.tooltips.showBasic") ? t('proxies.page.tooltips.showBasic')
: t("proxies.page.tooltips.showDetail") : t('proxies.page.tooltips.showDetail')
} }
onClick={() => onHeadState({ showType: !showType })} onClick={() => onHeadState({ showType: !showType })}
> >
@@ -150,20 +150,20 @@ export const ProxyHead = ({
<IconButton <IconButton
size="small" size="small"
color="inherit" color="inherit"
title={t("proxies.page.tooltips.filter")} title={t('proxies.page.tooltips.filter')}
onClick={() => onClick={() =>
onHeadState({ textState: textState === "filter" ? null : "filter" }) onHeadState({ textState: textState === 'filter' ? null : 'filter' })
} }
> >
{textState === "filter" ? ( {textState === 'filter' ? (
<FilterAltRounded /> <FilterAltRounded />
) : ( ) : (
<FilterAltOffRounded /> <FilterAltOffRounded />
)} )}
</IconButton> </IconButton>
{textState === "filter" && ( {textState === 'filter' && (
<Box sx={{ ml: 0.5, flex: "1 1 auto" }}> <Box sx={{ ml: 0.5, flex: '1 1 auto' }}>
<BaseSearchBox <BaseSearchBox
autoFocus={autoFocus} autoFocus={autoFocus}
value={filterText} value={filterText}
@@ -184,7 +184,7 @@ export const ProxyHead = ({
</Box> </Box>
)} )}
{textState === "url" && ( {textState === 'url' && (
<TextField <TextField
autoComplete="new-password" autoComplete="new-password"
autoFocus={autoFocus} autoFocus={autoFocus}
@@ -193,11 +193,11 @@ export const ProxyHead = ({
value={testUrl} value={testUrl}
size="small" size="small"
variant="outlined" variant="outlined"
placeholder={t("proxies.page.placeholders.delayCheckUrl")} placeholder={t('proxies.page.placeholders.delayCheckUrl')}
onChange={(e) => onHeadState({ testUrl: e.target.value })} onChange={(e) => onHeadState({ testUrl: e.target.value })}
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }} sx={{ ml: 0.5, flex: '1 1 auto', input: { py: 0.65, px: 1 } }}
/> />
)} )}
</Box> </Box>
); )
}; }
+114 -111
View File
@@ -1,88 +1,91 @@
import { CheckCircleOutlineRounded } from "@mui/icons-material"; import { CheckCircleOutlineRounded } from '@mui/icons-material'
import { alpha, Box, ListItemButton, styled, Typography } from "@mui/material"; import { alpha, Box, ListItemButton, styled, Typography } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useReducer } from "react"; import { useCallback, useEffect, useReducer } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { BaseLoading } from "@/components/base"; import { BaseLoading } from '@/components/base'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import delayManager, { DelayUpdate } from "@/services/delay"; import delayManager, { DelayUpdate } from '@/services/delay'
interface Props { interface Props {
group: IProxyGroupItem; group: IProxyGroupItem
proxy: IProxyItem; proxy: IProxyItem
selected: boolean; selected: boolean
showType?: boolean; showType?: boolean
onClick?: (name: string) => void; onClick?: (name: string) => void
} }
// 多列布局 // 多列布局
export const ProxyItemMini = (props: Props) => { export const ProxyItemMini = (props: Props) => {
const { group, proxy, selected, showType = true, onClick } = props; const { group, proxy, selected, showType = true, onClick } = props
const { t } = useTranslation(); const { t } = useTranslation()
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"]; const presetList = ['DIRECT', 'REJECT', 'REJECT-DROP', 'PASS', 'COMPATIBLE']
const isPreset = presetList.includes(proxy.name); const isPreset = presetList.includes(proxy.name)
// -1/<=0 为不显示,-2 为 loading // -1/<=0 为不显示,-2 为 loading
const [delayState, setDelayState] = useReducer( const [delayState, setDelayState] = useReducer(
(_: DelayUpdate, next: DelayUpdate) => next, (_: DelayUpdate, next: DelayUpdate) => next,
{ delay: -1, updatedAt: 0 }, {
); delay: -1,
const { verge } = useVerge(); updatedAt: 0,
const timeout = verge?.default_latency_timeout || 10000; },
)
const { verge } = useVerge()
const timeout = verge?.default_latency_timeout || 10000
useEffect(() => { useEffect(() => {
if (isPreset) return; if (isPreset) return
delayManager.setListener(proxy.name, group.name, setDelayState); delayManager.setListener(proxy.name, group.name, setDelayState)
return () => { return () => {
delayManager.removeListener(proxy.name, group.name); delayManager.removeListener(proxy.name, group.name)
}; }
}, [isPreset, proxy.name, group.name]); }, [isPreset, proxy.name, group.name])
const updateDelay = useCallback(() => { const updateDelay = useCallback(() => {
if (!proxy) return; if (!proxy) return
const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name); const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name)
if (cachedUpdate) { if (cachedUpdate) {
setDelayState({ ...cachedUpdate }); setDelayState({ ...cachedUpdate })
return; return
} }
const fallbackDelay = delayManager.getDelayFix(proxy, group.name); const fallbackDelay = delayManager.getDelayFix(proxy, group.name)
if (fallbackDelay === -1) { if (fallbackDelay === -1) {
setDelayState({ delay: -1, updatedAt: 0 }); setDelayState({ delay: -1, updatedAt: 0 })
return; return
} }
let updatedAt = 0; let updatedAt = 0
const history = proxy.history; const history = proxy.history
if (history && history.length > 0) { if (history && history.length > 0) {
const lastRecord = history[history.length - 1]; const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time); const parsed = Date.parse(lastRecord.time)
if (!Number.isNaN(parsed)) { if (!Number.isNaN(parsed)) {
updatedAt = parsed; updatedAt = parsed
} }
} }
setDelayState({ setDelayState({
delay: fallbackDelay, delay: fallbackDelay,
updatedAt, updatedAt,
}); })
}, [proxy, group.name]); }, [proxy, group.name])
useEffect(() => { useEffect(() => {
updateDelay(); updateDelay()
}, [updateDelay]); }, [updateDelay])
const onDelay = useLockFn(async () => { const onDelay = useLockFn(async () => {
setDelayState({ delay: -2, updatedAt: Date.now() }); setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState( setDelayState(
await delayManager.checkDelay(proxy.name, group.name, timeout), await delayManager.checkDelay(proxy.name, group.name, timeout),
); )
}); })
const delayValue = delayState.delay; const delayValue = delayState.delay
return ( return (
<ListItemButton <ListItemButton
@@ -95,53 +98,53 @@ export const ProxyItemMini = (props: Props) => {
borderRadius: 1.5, borderRadius: 1.5,
pl: 1.5, pl: 1.5,
pr: 1, pr: 1,
justifyContent: "space-between", justifyContent: 'space-between',
alignItems: "center", alignItems: 'center',
}, },
({ palette: { mode, primary } }) => { ({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f"; const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
const showDelay = delayValue > 0; const showDelay = delayValue > 0
const selectColor = mode === "light" ? primary.main : primary.light; const selectColor = mode === 'light' ? primary.main : primary.light
return { return {
"&:hover .the-check": { display: !showDelay ? "block" : "none" }, '&:hover .the-check': { display: !showDelay ? 'block' : 'none' },
"&:hover .the-delay": { display: showDelay ? "block" : "none" }, '&:hover .the-delay': { display: showDelay ? 'block' : 'none' },
"&:hover .the-icon": { display: "none" }, '&:hover .the-icon': { display: 'none' },
"& .the-pin, & .the-unpin": { '& .the-pin, & .the-unpin': {
position: "absolute", position: 'absolute',
fontSize: "12px", fontSize: '12px',
top: "-5px", top: '-5px',
right: "-5px", right: '-5px',
}, },
"& .the-unpin": { filter: "grayscale(1)" }, '& .the-unpin': { filter: 'grayscale(1)' },
"&.Mui-selected": { '&.Mui-selected': {
width: `calc(100% + 3px)`, width: `calc(100% + 3px)`,
marginLeft: `-3px`, marginLeft: `-3px`,
borderLeft: `3px solid ${selectColor}`, borderLeft: `3px solid ${selectColor}`,
bgcolor: bgcolor:
mode === "light" mode === 'light'
? alpha(primary.main, 0.15) ? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35), : alpha(primary.main, 0.35),
}, },
backgroundColor: bgcolor, backgroundColor: bgcolor,
}; }
}, },
]} ]}
> >
<Box <Box
title={`${proxy.name}\n${proxy.now ?? ""}`} title={`${proxy.name}\n${proxy.now ?? ''}`}
sx={{ overflow: "hidden" }} sx={{ overflow: 'hidden' }}
> >
<Typography <Typography
variant="body2" variant="body2"
component="div" component="div"
color="text.primary" color="text.primary"
sx={{ sx={{
display: "block", display: 'block',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
wordBreak: "break-all", wordBreak: 'break-all',
overflow: "hidden", overflow: 'hidden',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
}} }}
> >
{proxy.name} {proxy.name}
@@ -150,10 +153,10 @@ export const ProxyItemMini = (props: Props) => {
{showType && ( {showType && (
<Box <Box
sx={{ sx={{
display: "flex", display: 'flex',
flexWrap: "nowrap", flexWrap: 'nowrap',
flex: "none", flex: 'none',
marginTop: "4px", marginTop: '4px',
}} }}
> >
{proxy.now && ( {proxy.now && (
@@ -162,12 +165,12 @@ export const ProxyItemMini = (props: Props) => {
component="div" component="div"
color="text.secondary" color="text.secondary"
sx={{ sx={{
display: "block", display: 'block',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
wordBreak: "break-all", wordBreak: 'break-all',
overflow: "hidden", overflow: 'hidden',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
marginRight: "8px", marginRight: '8px',
}} }}
> >
{proxy.now} {proxy.now}
@@ -210,7 +213,7 @@ export const ProxyItemMini = (props: Props) => {
)} )}
</Box> </Box>
<Box <Box
sx={{ ml: 0.5, color: "primary.main", display: isPreset ? "none" : "" }} sx={{ ml: 0.5, color: 'primary.main', display: isPreset ? 'none' : '' }}
> >
{delayValue === -2 && ( {delayValue === -2 && (
<Widget> <Widget>
@@ -222,13 +225,13 @@ export const ProxyItemMini = (props: Props) => {
<Widget <Widget
className="the-check" className="the-check"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
onDelay(); onDelay()
}} }}
sx={({ palette }) => ({ sx={({ palette }) => ({
display: "none", // hover 时显示 display: 'none', // hover 时显示
":hover": { bgcolor: alpha(palette.primary.main, 0.15) }, ':hover': { bgcolor: alpha(palette.primary.main, 0.15) },
})} })}
> >
Check Check
@@ -240,68 +243,68 @@ export const ProxyItemMini = (props: Props) => {
<Widget <Widget
className="the-delay" className="the-delay"
onClick={(e) => { onClick={(e) => {
if (proxy.provider) return; if (proxy.provider) return
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
onDelay(); onDelay()
}} }}
color={delayManager.formatDelayColor(delayValue, timeout)} color={delayManager.formatDelayColor(delayValue, timeout)}
sx={({ palette }) => sx={({ palette }) =>
!proxy.provider !proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } } ? { ':hover': { bgcolor: alpha(palette.primary.main, 0.15) } }
: {} : {}
} }
> >
{delayManager.formatDelay(delayValue, timeout)} {delayManager.formatDelay(delayValue, timeout)}
</Widget> </Widget>
)} )}
{proxy.type !== "Direct" && {proxy.type !== 'Direct' &&
delayValue !== -2 && delayValue !== -2 &&
delayValue < 0 && delayValue < 0 &&
selected && ( selected && (
// 展示已选择的 icon // 展示已选择的 icon
<CheckCircleOutlineRounded <CheckCircleOutlineRounded
className="the-icon" className="the-icon"
sx={{ fontSize: 16, mr: 0.5, display: "block" }} sx={{ fontSize: 16, mr: 0.5, display: 'block' }}
/> />
)} )}
</Box> </Box>
{group.fixed && group.fixed === proxy.name && ( {group.fixed && group.fixed === proxy.name && (
// 展示 fixed 状态 // 展示 fixed 状态
<span <span
className={proxy.name === group.now ? "the-pin" : "the-unpin"} className={proxy.name === group.now ? 'the-pin' : 'the-unpin'}
title={ title={
group.type === "URLTest" group.type === 'URLTest'
? t("proxies.page.labels.delayCheckReset") ? t('proxies.page.labels.delayCheckReset')
: "" : ''
} }
> >
📌 📌
</span> </span>
)} )}
</ListItemButton> </ListItemButton>
); )
}; }
const Widget = styled(Box)(({ theme: { typography } }) => ({ const Widget = styled(Box)(({ theme: { typography } }) => ({
padding: "2px 4px", padding: '2px 4px',
fontSize: 14, fontSize: 14,
fontFamily: typography.fontFamily, fontFamily: typography.fontFamily,
borderRadius: "4px", borderRadius: '4px',
})); }))
const TypeBox = styled(Box, { const TypeBox = styled(Box, {
shouldForwardProp: (prop) => prop !== "component", shouldForwardProp: (prop) => prop !== 'component',
})<{ component?: React.ElementType }>(({ theme: { typography } }) => ({ })<{ component?: React.ElementType }>(({ theme: { typography } }) => ({
display: "inline-block", display: 'inline-block',
border: "1px solid #ccc", border: '1px solid #ccc',
borderColor: "text.secondary", borderColor: 'text.secondary',
color: "text.secondary", color: 'text.secondary',
borderRadius: 4, borderRadius: 4,
fontSize: 10, fontSize: 10,
fontFamily: typography.fontFamily, fontFamily: typography.fontFamily,
marginRight: "4px", marginRight: '4px',
marginTop: "auto", marginTop: 'auto',
padding: "0 4px", padding: '0 4px',
lineHeight: 1.5, lineHeight: 1.5,
})); }))
+87 -84
View File
@@ -1,4 +1,4 @@
import { CheckCircleOutlineRounded } from "@mui/icons-material"; import { CheckCircleOutlineRounded } from '@mui/icons-material'
import { import {
alpha, alpha,
Box, Box,
@@ -9,105 +9,108 @@ import {
styled, styled,
SxProps, SxProps,
Theme, Theme,
} from "@mui/material"; } from '@mui/material'
import { useLockFn } from "ahooks"; import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useReducer } from "react"; import { useCallback, useEffect, useReducer } from 'react'
import { BaseLoading } from "@/components/base"; import { BaseLoading } from '@/components/base'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import delayManager, { DelayUpdate } from "@/services/delay"; import delayManager, { DelayUpdate } from '@/services/delay'
interface Props { interface Props {
group: IProxyGroupItem; group: IProxyGroupItem
proxy: IProxyItem; proxy: IProxyItem
selected: boolean; selected: boolean
showType?: boolean; showType?: boolean
sx?: SxProps<Theme>; sx?: SxProps<Theme>
onClick?: (name: string) => void; onClick?: (name: string) => void
} }
const Widget = styled(Box)(() => ({ const Widget = styled(Box)(() => ({
padding: "3px 6px", padding: '3px 6px',
fontSize: 14, fontSize: 14,
borderRadius: "4px", borderRadius: '4px',
})); }))
const TypeBox = styled("span")(({ theme }) => ({ const TypeBox = styled('span')(({ theme }) => ({
display: "inline-block", display: 'inline-block',
border: "1px solid #ccc", border: '1px solid #ccc',
borderColor: alpha(theme.palette.text.secondary, 0.36), borderColor: alpha(theme.palette.text.secondary, 0.36),
color: alpha(theme.palette.text.secondary, 0.42), color: alpha(theme.palette.text.secondary, 0.42),
borderRadius: 4, borderRadius: 4,
fontSize: 10, fontSize: 10,
marginRight: "4px", marginRight: '4px',
padding: "0 2px", padding: '0 2px',
lineHeight: 1.25, lineHeight: 1.25,
})); }))
export const ProxyItem = (props: Props) => { export const ProxyItem = (props: Props) => {
const { group, proxy, selected, showType = true, sx, onClick } = props; const { group, proxy, selected, showType = true, sx, onClick } = props
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"]; const presetList = ['DIRECT', 'REJECT', 'REJECT-DROP', 'PASS', 'COMPATIBLE']
const isPreset = presetList.includes(proxy.name); const isPreset = presetList.includes(proxy.name)
// -1/<=0 为不显示,-2 为 loading // -1/<=0 为不显示,-2 为 loading
const [delayState, setDelayState] = useReducer( const [delayState, setDelayState] = useReducer(
(_: DelayUpdate, next: DelayUpdate) => next, (_: DelayUpdate, next: DelayUpdate) => next,
{ delay: -1, updatedAt: 0 }, {
); delay: -1,
const { verge } = useVerge(); updatedAt: 0,
const timeout = verge?.default_latency_timeout || 10000; },
)
const { verge } = useVerge()
const timeout = verge?.default_latency_timeout || 10000
useEffect(() => { useEffect(() => {
if (isPreset) return; if (isPreset) return
delayManager.setListener(proxy.name, group.name, setDelayState); delayManager.setListener(proxy.name, group.name, setDelayState)
return () => { return () => {
delayManager.removeListener(proxy.name, group.name); delayManager.removeListener(proxy.name, group.name)
}; }
}, [proxy.name, group.name, isPreset]); }, [proxy.name, group.name, isPreset])
const updateDelay = useCallback(() => { const updateDelay = useCallback(() => {
if (!proxy) return; if (!proxy) return
const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name); const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name)
if (cachedUpdate) { if (cachedUpdate) {
setDelayState({ ...cachedUpdate }); setDelayState({ ...cachedUpdate })
return; return
} }
const fallbackDelay = delayManager.getDelayFix(proxy, group.name); const fallbackDelay = delayManager.getDelayFix(proxy, group.name)
if (fallbackDelay === -1) { if (fallbackDelay === -1) {
setDelayState({ delay: -1, updatedAt: 0 }); setDelayState({ delay: -1, updatedAt: 0 })
return; return
} }
let updatedAt = 0; let updatedAt = 0
const history = proxy.history; const history = proxy.history
if (history && history.length > 0) { if (history && history.length > 0) {
const lastRecord = history[history.length - 1]; const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time); const parsed = Date.parse(lastRecord.time)
if (!Number.isNaN(parsed)) { if (!Number.isNaN(parsed)) {
updatedAt = parsed; updatedAt = parsed
} }
} }
setDelayState({ setDelayState({
delay: fallbackDelay, delay: fallbackDelay,
updatedAt, updatedAt,
}); })
}, [proxy, group.name]); }, [proxy, group.name])
useEffect(() => { useEffect(() => {
updateDelay(); updateDelay()
}, [updateDelay]); }, [updateDelay])
const onDelay = useLockFn(async () => { const onDelay = useLockFn(async () => {
setDelayState({ delay: -2, updatedAt: Date.now() }); setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState( setDelayState(
await delayManager.checkDelay(proxy.name, group.name, timeout), await delayManager.checkDelay(proxy.name, group.name, timeout),
); )
}); })
const delayValue = delayState.delay; const delayValue = delayState.delay
return ( return (
<ListItem sx={sx}> <ListItem sx={sx}>
@@ -118,27 +121,27 @@ export const ProxyItem = (props: Props) => {
sx={[ sx={[
{ borderRadius: 1 }, { borderRadius: 1 },
({ palette: { mode, primary } }) => { ({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f"; const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
const selectColor = mode === "light" ? primary.main : primary.light; const selectColor = mode === 'light' ? primary.main : primary.light
const showDelay = delayValue > 0; const showDelay = delayValue > 0
return { return {
"&:hover .the-check": { display: !showDelay ? "block" : "none" }, '&:hover .the-check': { display: !showDelay ? 'block' : 'none' },
"&:hover .the-delay": { display: showDelay ? "block" : "none" }, '&:hover .the-delay': { display: showDelay ? 'block' : 'none' },
"&:hover .the-icon": { display: "none" }, '&:hover .the-icon': { display: 'none' },
"&.Mui-selected": { '&.Mui-selected': {
width: `calc(100% + 3px)`, width: `calc(100% + 3px)`,
marginLeft: `-3px`, marginLeft: `-3px`,
borderLeft: `3px solid ${selectColor}`, borderLeft: `3px solid ${selectColor}`,
bgcolor: bgcolor:
mode === "light" mode === 'light'
? alpha(primary.main, 0.15) ? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35), : alpha(primary.main, 0.35),
}, },
backgroundColor: bgcolor, backgroundColor: bgcolor,
marginBottom: "8px", marginBottom: '8px',
height: "40px", height: '40px',
}; }
}, },
]} ]}
> >
@@ -148,10 +151,10 @@ export const ProxyItem = (props: Props) => {
<> <>
<Box <Box
sx={{ sx={{
display: "inline-block", display: 'inline-block',
marginRight: "8px", marginRight: '8px',
fontSize: "14px", fontSize: '14px',
color: "text.primary", color: 'text.primary',
}} }}
> >
{proxy.name} {proxy.name}
@@ -172,9 +175,9 @@ export const ProxyItem = (props: Props) => {
<ListItemIcon <ListItemIcon
sx={{ sx={{
justifyContent: "flex-end", justifyContent: 'flex-end',
color: "primary.main", color: 'primary.main',
display: isPreset ? "none" : "", display: isPreset ? 'none' : '',
}} }}
> >
{delayValue === -2 && ( {delayValue === -2 && (
@@ -188,13 +191,13 @@ export const ProxyItem = (props: Props) => {
<Widget <Widget
className="the-check" className="the-check"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
onDelay(); onDelay()
}} }}
sx={({ palette }) => ({ sx={({ palette }) => ({
display: "none", // hover 时显示 display: 'none', // hover 时显示
":hover": { bgcolor: alpha(palette.primary.main, 0.15) }, ':hover': { bgcolor: alpha(palette.primary.main, 0.15) },
})} })}
> >
Check Check
@@ -206,15 +209,15 @@ export const ProxyItem = (props: Props) => {
<Widget <Widget
className="the-delay" className="the-delay"
onClick={(e) => { onClick={(e) => {
if (proxy.provider) return; if (proxy.provider) return
e.preventDefault(); e.preventDefault()
e.stopPropagation(); e.stopPropagation()
onDelay(); onDelay()
}} }}
color={delayManager.formatDelayColor(delayValue, timeout)} color={delayManager.formatDelayColor(delayValue, timeout)}
sx={({ palette }) => sx={({ palette }) =>
!proxy.provider !proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } } ? { ':hover': { bgcolor: alpha(palette.primary.main, 0.15) } }
: {} : {}
} }
> >
@@ -232,5 +235,5 @@ export const ProxyItem = (props: Props) => {
</ListItemIcon> </ListItemIcon>
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
); )
}; }
+77 -77
View File
@@ -2,7 +2,7 @@ import {
ExpandLessRounded, ExpandLessRounded,
ExpandMoreRounded, ExpandMoreRounded,
InboxRounded, InboxRounded,
} from "@mui/icons-material"; } from '@mui/icons-material'
import { import {
alpha, alpha,
Box, Box,
@@ -12,35 +12,35 @@ import {
styled, styled,
Chip, Chip,
Tooltip, Tooltip,
} from "@mui/material"; } from '@mui/material'
import { useMemo } from "react"; import { useMemo } from 'react'
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next'
import { useIconCache } from "@/hooks/use-icon-cache"; import { useIconCache } from '@/hooks/use-icon-cache'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import { useThemeMode } from "@/services/states"; import { useThemeMode } from '@/services/states'
import { ProxyHead } from "./proxy-head"; import { ProxyHead } from './proxy-head'
import { ProxyItem } from "./proxy-item"; import { ProxyItem } from './proxy-item'
import { ProxyItemMini } from "./proxy-item-mini"; import { ProxyItemMini } from './proxy-item-mini'
import { HeadState } from "./use-head-state"; import { HeadState } from './use-head-state'
import type { IRenderItem } from "./use-render-list"; import type { IRenderItem } from './use-render-list'
interface RenderProps { interface RenderProps {
item: IRenderItem; item: IRenderItem
indent: boolean; indent: boolean
isChainMode?: boolean; isChainMode?: boolean
onLocation: (group: IRenderItem["group"]) => void; onLocation: (group: IRenderItem['group']) => void
onCheckAll: (groupName: string) => void; onCheckAll: (groupName: string) => void
onHeadState: (groupName: string, patch: Partial<HeadState>) => void; onHeadState: (groupName: string, patch: Partial<HeadState>) => void
onChangeProxy: ( onChangeProxy: (
group: IRenderItem["group"], group: IRenderItem['group'],
proxy: IRenderItem["proxy"] & { name: string }, proxy: IRenderItem['proxy'] & { name: string },
) => void; ) => void
} }
export const ProxyRender = (props: RenderProps) => { export const ProxyRender = (props: RenderProps) => {
const { t } = useTranslation(); const { t } = useTranslation()
const { const {
indent, indent,
item, item,
@@ -49,35 +49,35 @@ export const ProxyRender = (props: RenderProps) => {
onHeadState, onHeadState,
onChangeProxy, onChangeProxy,
isChainMode: _ = false, isChainMode: _ = false,
} = props; } = props
const { type, group, headState, proxy, proxyCol } = item; const { type, group, headState, proxy, proxyCol } = item
const { verge } = useVerge(); const { verge } = useVerge()
const enable_group_icon = verge?.enable_group_icon ?? true; const enable_group_icon = verge?.enable_group_icon ?? true
const mode = useThemeMode(); const mode = useThemeMode()
const isDark = mode === "light" ? false : true; const isDark = mode === 'light' ? false : true
const itembackgroundcolor = isDark ? "#282A36" : "#ffffff"; const itembackgroundcolor = isDark ? '#282A36' : '#ffffff'
const iconCachePath = useIconCache({ const iconCachePath = useIconCache({
icon: group.icon, icon: group.icon,
cacheKey: group.name.replaceAll(" ", ""), cacheKey: group.name.replaceAll(' ', ''),
enabled: enable_group_icon, enabled: enable_group_icon,
}); })
const proxyColItemsMemo = useMemo(() => { const proxyColItemsMemo = useMemo(() => {
if (type !== 4 || !proxyCol) { if (type !== 4 || !proxyCol) {
return null; return null
} }
return proxyCol.map((proxyItem) => ( return proxyCol.map((proxyItem) => (
<ProxyItemMini <ProxyItemMini
key={`${item.key}-${proxyItem?.name ?? "unknown"}`} key={`${item.key}-${proxyItem?.name ?? 'unknown'}`}
group={group} group={group}
proxy={proxyItem!} proxy={proxyItem!}
selected={group.now === proxyItem?.name} selected={group.now === proxyItem?.name}
showType={headState?.showType} showType={headState?.showType}
onClick={() => onChangeProxy(group, proxyItem!)} onClick={() => onChangeProxy(group, proxyItem!)}
/> />
)); ))
}, [type, proxyCol, item.key, group, headState, onChangeProxy]); }, [type, proxyCol, item.key, group, headState, onChangeProxy])
if (type === 0) { if (type === 0) {
return ( return (
@@ -85,33 +85,33 @@ export const ProxyRender = (props: RenderProps) => {
dense dense
style={{ style={{
background: itembackgroundcolor, background: itembackgroundcolor,
height: "100%", height: '100%',
margin: "8px 8px", margin: '8px 8px',
borderRadius: "8px", borderRadius: '8px',
}} }}
onClick={() => onHeadState(group.name, { open: !headState?.open })} onClick={() => onHeadState(group.name, { open: !headState?.open })}
> >
{enable_group_icon && {enable_group_icon &&
group.icon && group.icon &&
group.icon.trim().startsWith("http") && ( group.icon.trim().startsWith('http') && (
<img <img
src={iconCachePath === "" ? group.icon : iconCachePath} src={iconCachePath === '' ? group.icon : iconCachePath}
width="32px" width="32px"
style={{ marginRight: "12px", borderRadius: "6px" }} style={{ marginRight: '12px', borderRadius: '6px' }}
/> />
)} )}
{enable_group_icon && {enable_group_icon &&
group.icon && group.icon &&
group.icon.trim().startsWith("data") && ( group.icon.trim().startsWith('data') && (
<img <img
src={group.icon} src={group.icon}
width="32px" width="32px"
style={{ marginRight: "12px", borderRadius: "6px" }} style={{ marginRight: '12px', borderRadius: '6px' }}
/> />
)} )}
{enable_group_icon && {enable_group_icon &&
group.icon && group.icon &&
group.icon.trim().startsWith("<svg") && ( group.icon.trim().startsWith('<svg') && (
<img <img
src={`data:image/svg+xml;base64,${btoa(group.icon)}`} src={`data:image/svg+xml;base64,${btoa(group.icon)}`}
width="32px" width="32px"
@@ -122,15 +122,15 @@ export const ProxyRender = (props: RenderProps) => {
secondary={ secondary={
<Box <Box
sx={{ sx={{
overflow: "hidden", overflow: 'hidden',
display: "flex", display: 'flex',
alignItems: "center", alignItems: 'center',
pt: "2px", pt: '2px',
}} }}
> >
<Box component="span" sx={{ marginTop: "2px" }}> <Box component="span" sx={{ marginTop: '2px' }}>
<StyledTypeBox>{group.type}</StyledTypeBox> <StyledTypeBox>{group.type}</StyledTypeBox>
<StyledSubtitle sx={{ color: "text.secondary" }}> <StyledSubtitle sx={{ color: 'text.secondary' }}>
{group.now} {group.now}
</StyledSubtitle> </StyledSubtitle>
</Box> </Box>
@@ -138,13 +138,13 @@ export const ProxyRender = (props: RenderProps) => {
} }
slotProps={{ slotProps={{
secondary: { secondary: {
component: "div", component: 'div',
sx: { display: "flex", alignItems: "center", color: "#ccc" }, sx: { display: 'flex', alignItems: 'center', color: '#ccc' },
}, },
}} }}
/> />
<Box sx={{ display: "flex", alignItems: "center" }}> <Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title={t("proxies.page.labels.proxyCount")} arrow> <Tooltip title={t('proxies.page.labels.proxyCount')} arrow>
<Chip <Chip
size="small" size="small"
label={`${group.all.length}`} label={`${group.all.length}`}
@@ -159,7 +159,7 @@ export const ProxyRender = (props: RenderProps) => {
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />} {headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
</Box> </Box>
</ListItemButton> </ListItemButton>
); )
} }
if (type === 1) { if (type === 1) {
@@ -173,7 +173,7 @@ export const ProxyRender = (props: RenderProps) => {
onCheckDelay={() => onCheckAll(group.name)} onCheckDelay={() => onCheckAll(group.name)}
onHeadState={(p) => onHeadState(group.name, p)} onHeadState={(p) => onHeadState(group.name, p)}
/> />
); )
} }
if (type === 2) { if (type === 2) {
@@ -186,7 +186,7 @@ export const ProxyRender = (props: RenderProps) => {
sx={{ py: 0, pl: 2 }} sx={{ py: 0, pl: 2 }}
onClick={() => onChangeProxy(group, proxy!)} onClick={() => onChangeProxy(group, proxy!)}
/> />
); )
} }
if (type === 3) { if (type === 3) {
@@ -195,16 +195,16 @@ export const ProxyRender = (props: RenderProps) => {
sx={{ sx={{
py: 2, py: 2,
pl: 0, pl: 0,
display: "flex", display: 'flex',
flexDirection: "column", flexDirection: 'column',
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
}} }}
> >
<InboxRounded sx={{ fontSize: "2.5em", color: "inherit" }} /> <InboxRounded sx={{ fontSize: '2.5em', color: 'inherit' }} />
<Typography sx={{ color: "inherit" }}>No Proxies</Typography> <Typography sx={{ color: 'inherit' }}>No Proxies</Typography>
</Box> </Box>
); )
} }
if (type === 4) { if (type === 4) {
@@ -212,7 +212,7 @@ export const ProxyRender = (props: RenderProps) => {
<Box <Box
sx={{ sx={{
height: 56, height: 56,
display: "grid", display: 'grid',
gap: 1, gap: 1,
pl: 2, pl: 2,
pr: 2, pr: 2,
@@ -222,36 +222,36 @@ export const ProxyRender = (props: RenderProps) => {
> >
{proxyColItemsMemo} {proxyColItemsMemo}
</Box> </Box>
); )
} }
return null; return null
}; }
const StyledPrimary = styled("span")` const StyledPrimary = styled('span')`
font-size: 16px; font-size: 16px;
font-weight: 700; font-weight: 700;
line-height: 1.5; line-height: 1.5;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`; `
const StyledSubtitle = styled("span")` const StyledSubtitle = styled('span')`
font-size: 13px; font-size: 13px;
overflow: hidden; overflow: hidden;
color: text.secondary; color: text.secondary;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`; `
const StyledTypeBox = styled(Box)(({ theme }) => ({ const StyledTypeBox = styled(Box)(({ theme }) => ({
display: "inline-block", display: 'inline-block',
border: "1px solid #ccc", border: '1px solid #ccc',
borderColor: alpha(theme.palette.primary.main, 0.5), borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8), color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4, borderRadius: 4,
fontSize: 10, fontSize: 10,
padding: "0 4px", padding: '0 4px',
lineHeight: 1.5, lineHeight: 1.5,
marginRight: "8px", marginRight: '8px',
})); }))
+88 -88
View File
@@ -1,17 +1,17 @@
import { useEffect, useMemo, useReducer, useRef } from "react"; import { useEffect, useMemo, useReducer, useRef } from 'react'
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from '@/hooks/use-verge'
import delayManager from "@/services/delay"; import delayManager from '@/services/delay'
import { compileStringMatcher } from "@/utils/search-matcher"; import { compileStringMatcher } from '@/utils/search-matcher'
// default | delay | alphabet // default | delay | alphabet
export type ProxySortType = 0 | 1 | 2; export type ProxySortType = 0 | 1 | 2
export type ProxySearchState = { export type ProxySearchState = {
matchCase?: boolean; matchCase?: boolean
matchWholeWord?: boolean; matchWholeWord?: boolean
useRegularExpression?: boolean; useRegularExpression?: boolean
}; }
export default function useFilterSort( export default function useFilterSort(
proxies: IProxyItem[], proxies: IProxyItem[],
@@ -20,39 +20,39 @@ export default function useFilterSort(
sortType: ProxySortType, sortType: ProxySortType,
searchState?: ProxySearchState, searchState?: ProxySearchState,
) { ) {
const { verge } = useVerge(); const { verge } = useVerge()
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0); const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0)
const lastInputRef = useRef<{ text: string; sort: ProxySortType } | null>( const lastInputRef = useRef<{ text: string; sort: ProxySortType } | null>(
null, null,
); )
const debounceTimerRef = useRef<number | null>(null); const debounceTimerRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
let last = 0; let last = 0
delayManager.setGroupListener(groupName, () => { delayManager.setGroupListener(groupName, () => {
// 简单节流 // 简单节流
const now = Date.now(); const now = Date.now()
if (now - last > 666) { if (now - last > 666) {
last = now; last = now
bumpRefresh(); bumpRefresh()
} }
}); })
return () => { return () => {
delayManager.removeGroupListener(groupName); delayManager.removeGroupListener(groupName)
}; }
}, [groupName]); }, [groupName])
const compute = useMemo(() => { const compute = useMemo(() => {
const fp = filterProxies(proxies, groupName, filterText, searchState); const fp = filterProxies(proxies, groupName, filterText, searchState)
const sp = sortProxies( const sp = sortProxies(
fp, fp,
groupName, groupName,
sortType, sortType,
verge?.default_latency_timeout, verge?.default_latency_timeout,
); )
return sp; return sp
}, [ }, [
proxies, proxies,
groupName, groupName,
@@ -60,40 +60,40 @@ export default function useFilterSort(
sortType, sortType,
searchState, searchState,
verge?.default_latency_timeout, verge?.default_latency_timeout,
]); ])
const [result, setResult] = useReducer( const [result, setResult] = useReducer(
(_prev: IProxyItem[], next: IProxyItem[]) => next, (_prev: IProxyItem[], next: IProxyItem[]) => next,
compute, compute,
); )
useEffect(() => { useEffect(() => {
if (debounceTimerRef.current !== null) { if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current); window.clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null; debounceTimerRef.current = null
} }
const prev = lastInputRef.current; const prev = lastInputRef.current
const stableInputs = const stableInputs =
prev && prev.text === filterText && prev.sort === sortType; prev && prev.text === filterText && prev.sort === sortType
lastInputRef.current = { text: filterText, sort: sortType }; lastInputRef.current = { text: filterText, sort: sortType }
const delay = stableInputs ? 0 : 150; const delay = stableInputs ? 0 : 150
debounceTimerRef.current = window.setTimeout(() => { debounceTimerRef.current = window.setTimeout(() => {
setResult(compute); setResult(compute)
debounceTimerRef.current = null; debounceTimerRef.current = null
}, delay); }, delay)
return () => { return () => {
if (debounceTimerRef.current !== null) { if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current); window.clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null; debounceTimerRef.current = null
} }
}; }
}, [compute, filterText, sortType]); }, [compute, filterText, sortType])
return result; return result
} }
export function filterSort( export function filterSort(
@@ -104,16 +104,16 @@ export function filterSort(
latencyTimeout?: number, latencyTimeout?: number,
searchState?: ProxySearchState, searchState?: ProxySearchState,
) { ) {
const fp = filterProxies(proxies, groupName, filterText, searchState); const fp = filterProxies(proxies, groupName, filterText, searchState)
const sp = sortProxies(fp, groupName, sortType, latencyTimeout); const sp = sortProxies(fp, groupName, sortType, latencyTimeout)
return sp; return sp
} }
/** /**
* 可以通过延迟数/节点类型 过滤 * 可以通过延迟数/节点类型 过滤
*/ */
const regex1 = /delay([=<>])(\d+|timeout|error)/i; const regex1 = /delay([=<>])(\d+|timeout|error)/i
const regex2 = /type=(.*)/i; const regex2 = /type=(.*)/i
/** /**
* filter the proxy * filter the proxy
@@ -125,49 +125,49 @@ function filterProxies(
filterText: string, filterText: string,
searchState?: ProxySearchState, searchState?: ProxySearchState,
) { ) {
const query = filterText.trim(); const query = filterText.trim()
if (!query) return proxies; if (!query) return proxies
const res1 = regex1.exec(query); const res1 = regex1.exec(query)
if (res1) { if (res1) {
const symbol = res1[1]; const symbol = res1[1]
const symbol2 = res1[2].toLowerCase(); const symbol2 = res1[2].toLowerCase()
const value = const value =
symbol2 === "error" ? 1e5 : symbol2 === "timeout" ? 3000 : +symbol2; symbol2 === 'error' ? 1e5 : symbol2 === 'timeout' ? 3000 : +symbol2
return proxies.filter((p) => { return proxies.filter((p) => {
const delay = delayManager.getDelayFix(p, groupName); const delay = delayManager.getDelayFix(p, groupName)
if (delay < 0) return false; if (delay < 0) return false
if (symbol === "=" && symbol2 === "error") return delay >= 1e5; if (symbol === '=' && symbol2 === 'error') return delay >= 1e5
if (symbol === "=" && symbol2 === "timeout") if (symbol === '=' && symbol2 === 'timeout')
return delay < 1e5 && delay >= 3000; return delay < 1e5 && delay >= 3000
if (symbol === "=") return delay == value; if (symbol === '=') return delay == value
if (symbol === "<") return delay <= value; if (symbol === '<') return delay <= value
if (symbol === ">") return delay >= value; if (symbol === '>') return delay >= value
return false; return false
}); })
} }
const res2 = regex2.exec(query); const res2 = regex2.exec(query)
if (res2) { if (res2) {
const type = res2[1].toLowerCase(); const type = res2[1].toLowerCase()
return proxies.filter((p) => p.type.toLowerCase().includes(type)); return proxies.filter((p) => p.type.toLowerCase().includes(type))
} }
const { const {
matchCase = false, matchCase = false,
matchWholeWord = false, matchWholeWord = false,
useRegularExpression = false, useRegularExpression = false,
} = searchState ?? {}; } = searchState ?? {}
const compiled = compileStringMatcher(query, { const compiled = compileStringMatcher(query, {
matchCase, matchCase,
matchWholeWord, matchWholeWord,
useRegularExpression, useRegularExpression,
}); })
if (!compiled.isValid) return []; if (!compiled.isValid) return []
return proxies.filter((p) => compiled.matcher(p.name)); return proxies.filter((p) => compiled.matcher(p.name))
} }
/** /**
@@ -179,41 +179,41 @@ function sortProxies(
sortType: ProxySortType, sortType: ProxySortType,
latencyTimeout?: number, latencyTimeout?: number,
) { ) {
if (!proxies) return []; if (!proxies) return []
if (sortType === 0) return proxies; if (sortType === 0) return proxies
const list = proxies.slice(); const list = proxies.slice()
const effectiveTimeout = const effectiveTimeout =
typeof latencyTimeout === "number" && latencyTimeout > 0 typeof latencyTimeout === 'number' && latencyTimeout > 0
? latencyTimeout ? latencyTimeout
: 10000; : 10000
if (sortType === 1) { if (sortType === 1) {
const categorizeDelay = (delay: number): [number, number] => { const categorizeDelay = (delay: number): [number, number] => {
if (!Number.isFinite(delay)) return [3, Number.MAX_SAFE_INTEGER]; if (!Number.isFinite(delay)) return [3, Number.MAX_SAFE_INTEGER]
if (delay > 1e5) return [4, delay]; if (delay > 1e5) return [4, delay]
if (delay === 0 || (delay >= effectiveTimeout && delay <= 1e5)) { if (delay === 0 || (delay >= effectiveTimeout && delay <= 1e5)) {
return [3, delay || effectiveTimeout]; return [3, delay || effectiveTimeout]
} }
if (delay < 0) { if (delay < 0) {
// sentinel delays (-1, -2, etc.) should always sort after real measurements // sentinel delays (-1, -2, etc.) should always sort after real measurements
return [5, Number.MAX_SAFE_INTEGER]; return [5, Number.MAX_SAFE_INTEGER]
}
return [0, delay]
} }
return [0, delay];
};
list.sort((a, b) => { list.sort((a, b) => {
const ad = delayManager.getDelayFix(a, groupName); const ad = delayManager.getDelayFix(a, groupName)
const bd = delayManager.getDelayFix(b, groupName); const bd = delayManager.getDelayFix(b, groupName)
const [ar, av] = categorizeDelay(ad); const [ar, av] = categorizeDelay(ad)
const [br, bv] = categorizeDelay(bd); const [br, bv] = categorizeDelay(bd)
if (ar !== br) return ar - br; if (ar !== br) return ar - br
return av - bv; return av - bv
}); })
} else { } else {
list.sort((a, b) => a.name.localeCompare(b.name)); list.sort((a, b) => a.name.localeCompare(b.name))
} }
return list; return list
} }

Some files were not shown because too many files have changed in this diff Show More