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
title: "[BUG] "
title: '[BUG] '
description: 反馈你遇到的问题 / Report the issue you are experiencing
labels: ["bug"]
type: "Bug"
labels: ['bug']
type: 'Bug'
body:
- type: markdown
+3 -3
View File
@@ -1,8 +1,8 @@
name: 功能请求 / Feature request
title: "[Feature] "
title: '[Feature] '
description: 提出你的功能请求 / Propose your feature request
labels: ["enhancement"]
type: "Feature"
labels: ['enhancement']
type: 'Feature'
body:
- type: markdown
+3 -3
View File
@@ -1,8 +1,8 @@
name: I18N / 多语言相关
title: "[I18N] "
title: '[I18N] '
description: 用于多语言翻译、国际化相关问题或建议 / For issues or suggestions related to translations and internationalization
labels: ["I18n"]
type: "Task"
labels: ['I18n']
type: 'Task'
body:
- type: markdown
+12 -12
View File
@@ -27,7 +27,7 @@ env:
RUST_BACKTRACE: short
HUSKY: 0
concurrency:
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
jobs:
check_alpha_tag:
@@ -249,7 +249,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
body_path: release.txt
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
@@ -307,7 +307,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -325,7 +325,7 @@ jobs:
- name: Tauri build
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -337,8 +337,8 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: ${{ env.TAG_NAME }}
releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
releaseBody: "More new features are now supported."
releaseName: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
releaseBody: 'More new features are now supported.'
releaseDraft: false
prerelease: true
tauriScript: pnpm
@@ -377,7 +377,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -453,7 +453,7 @@ jobs:
fi
pnpm build --target ${{ matrix.target }}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -468,7 +468,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
files: |
@@ -505,7 +505,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -531,7 +531,7 @@ jobs:
id: build
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -563,7 +563,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
files: src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*setup*
+30 -30
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
schedule:
# UTC+8 12:00, 18:00 -> UTC 4:00, 10:00
- cron: "0 4,10 * * *"
- cron: '0 4,10 * * *'
permissions: write-all
env:
TAG_NAME: autobuild
@@ -13,7 +13,7 @@ env:
RUST_BACKTRACE: short
HUSKY: 0
concurrency:
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
@@ -46,7 +46,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -105,7 +105,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
body_path: release.txt
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
@@ -147,7 +147,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.91.0"
toolchain: '1.91.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -157,8 +157,8 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust"
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -196,14 +196,14 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
cache: "pnpm"
node-version: '24.14.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
@@ -225,7 +225,7 @@ jobs:
- name: Tauri build for Windows-macOS-Linux
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -237,8 +237,8 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: ${{ env.TAG_NAME }}
releaseName: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
releaseBody: "More new features are now supported."
releaseName: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
releaseBody: 'More new features are now supported.'
releaseDraft: false
prerelease: true
tauriScript: pnpm
@@ -269,7 +269,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.91.0"
toolchain: '1.91.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -279,8 +279,8 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust"
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -294,14 +294,14 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
cache: "pnpm"
node-version: '24.14.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
@@ -313,7 +313,7 @@ jobs:
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest
- name: "Setup for linux"
- name: 'Setup for linux'
run: |-
sudo ls -lR /etc/apt/
@@ -376,7 +376,7 @@ jobs:
fi
pnpm build --target ${{ matrix.target }}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -391,7 +391,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
files: |
@@ -424,8 +424,8 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust"
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -439,14 +439,14 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
cache: "pnpm"
node-version: '24.14.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
@@ -476,7 +476,7 @@ jobs:
id: build
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -509,7 +509,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_CHANNEL }}"
name: 'Clash Verge Rev ${{ env.TAG_CHANNEL }}'
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
files: target/${{ matrix.target }}/release/bundle/nsis/*setup*
@@ -541,7 +541,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4.4.0
name: Install pnpm
@@ -4,36 +4,36 @@ on:
workflow_dispatch:
inputs:
tag_name:
description: "Release tag name to check against (default: autobuild)"
description: 'Release tag name to check against (default: autobuild)'
required: false
default: "autobuild"
default: 'autobuild'
type: string
force_build:
description: "Force build regardless of checks"
description: 'Force build regardless of checks'
required: false
default: false
type: boolean
workflow_call:
inputs:
tag_name:
description: "Release tag name to check against (default: autobuild)"
description: 'Release tag name to check against (default: autobuild)'
required: false
default: "autobuild"
default: 'autobuild'
type: string
force_build:
description: "Force build regardless of checks"
description: 'Force build regardless of checks'
required: false
default: false
type: boolean
outputs:
should_run:
description: "Whether the build should run"
description: 'Whether the build should run'
value: ${{ jobs.check_commit.outputs.should_run }}
last_tauri_commit:
description: "The last commit hash with Tauri-related changes"
description: 'The last commit hash with Tauri-related changes'
value: ${{ jobs.check_commit.outputs.last_tauri_commit }}
autobuild_version:
description: "The generated autobuild version string"
description: 'The generated autobuild version string'
value: ${{ jobs.check_commit.outputs.autobuild_version }}
permissions:
+6 -6
View File
@@ -4,24 +4,24 @@ on:
workflow_dispatch:
inputs:
tag_name:
description: "Release tag name to clean (default: autobuild)"
description: 'Release tag name to clean (default: autobuild)'
required: false
default: "autobuild"
default: 'autobuild'
type: string
dry_run:
description: "Dry run mode (only show what would be deleted)"
description: 'Dry run mode (only show what would be deleted)'
required: false
default: false
type: boolean
workflow_call:
inputs:
tag_name:
description: "Release tag name to clean (default: autobuild)"
description: 'Release tag name to clean (default: autobuild)'
required: false
default: "autobuild"
default: 'autobuild'
type: string
dry_run:
description: "Dry run mode (only show what would be deleted)"
description: 'Dry run mode (only show what would be deleted)'
required: false
default: false
type: boolean
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
cargo-check:
# Treat all Rust compiler warnings as errors
env:
RUSTFLAGS: "-D warnings"
RUSTFLAGS: '-D warnings'
strategy:
fail-fast: false
matrix:
@@ -43,7 +43,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
+11 -11
View File
@@ -4,22 +4,22 @@ on:
workflow_dispatch:
inputs:
run_windows:
description: "运行 Windows"
description: '运行 Windows'
required: false
type: boolean
default: true
run_macos_aarch64:
description: "运行 macOS aarch64"
description: '运行 macOS aarch64'
required: false
type: boolean
default: true
run_windows_arm64:
description: "运行 Windows ARM64"
description: '运行 Windows ARM64'
required: false
type: boolean
default: true
run_linux_amd64:
description: "运行 Linux amd64"
description: '运行 Linux amd64'
required: false
type: boolean
default: true
@@ -32,7 +32,7 @@ env:
RUST_BACKTRACE: short
HUSKY: 0
concurrency:
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
@@ -80,8 +80,8 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust"
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -103,14 +103,14 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
cache: "pnpm"
node-version: '24.14.0'
cache: 'pnpm'
- name: Pnpm Cache
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: "pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
key: 'pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
restore-keys: |
pnpm-shared-stable-${{ matrix.os }}-${{ matrix.target }}
lookup-only: true
@@ -137,7 +137,7 @@ jobs:
if: github.event.inputs[matrix.input] == 'true'
uses: tauri-apps/tauri-action@v0
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
+2 -2
View File
@@ -47,8 +47,8 @@ jobs:
- uses: actions/setup-node@v6
if: steps.check_frontend.outputs.frontend == 'true'
with:
node-version: "24.14.0"
cache: "pnpm"
node-version: '24.14.0'
cache: 'pnpm'
- name: Restore pnpm cache
if: steps.check_frontend.outputs.frontend == 'true'
+2 -2
View File
@@ -59,8 +59,8 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust"
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
+30 -30
View File
@@ -7,7 +7,7 @@ on:
push:
# -rc tag 时预览发布, 跳过 telegram 通知、跳过 winget 提交、跳过 latest.json 文件更新
tags:
- "v*.*.*"
- 'v*.*.*'
permissions: write-all
env:
CARGO_INCREMENTAL: 0
@@ -15,7 +15,7 @@ env:
HUSKY: 0
concurrency:
# only allow per workflow per commit (and not pr) to run at a time
group: "${{ github.workflow }} - ${{ github.head_ref || github.ref }}"
group: '${{ github.workflow }} - ${{ github.head_ref || github.ref }}'
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
@@ -129,7 +129,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG_NAME }}
name: "Clash Verge Rev ${{ env.TAG_NAME }}"
name: 'Clash Verge Rev ${{ env.TAG_NAME }}'
body_path: release.txt
draft: false
prerelease: ${{ contains(github.ref_name, '-rc') }}
@@ -162,7 +162,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.91.0"
toolchain: '1.91.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -172,8 +172,8 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust"
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -197,7 +197,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -220,7 +220,7 @@ jobs:
# 上游 5.24 修改了 latest.json 的生成逻辑,且依赖 tauri-plugin-update 2.10.0 暂未发布,故锁定在 0.5.23 版本
uses: tauri-apps/tauri-action@v0.6.2
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -232,8 +232,8 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
with:
tagName: ${{ github.ref_name }}
releaseName: "Clash Verge Rev ${{ github.ref_name }}"
releaseBody: "Draft release, will be updated later."
releaseName: 'Clash Verge Rev ${{ github.ref_name }}'
releaseBody: 'Draft release, will be updated later.'
releaseDraft: true
prerelease: ${{ contains(github.ref_name, '-rc') }}
tauriScript: pnpm
@@ -261,7 +261,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.91.0"
toolchain: '1.91.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -271,8 +271,8 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust"
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -281,7 +281,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -293,7 +293,7 @@ jobs:
pnpm i
pnpm run prebuild ${{ matrix.target }}
- name: "Setup for linux"
- name: 'Setup for linux'
run: |-
sudo ls -lR /etc/apt/
@@ -323,14 +323,14 @@ jobs:
patchelf:${{ matrix.arch }} \
librsvg2-dev:${{ matrix.arch }}
- name: "Install aarch64 tools"
- name: 'Install aarch64 tools'
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt install -y \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu
- name: "Install armv7 tools"
- name: 'Install armv7 tools'
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: |
sudo apt install -y \
@@ -356,7 +356,7 @@ jobs:
fi
pnpm build --target ${{ matrix.target }}
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -371,8 +371,8 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: v${{env.VERSION}}
name: "Clash Verge Rev v${{env.VERSION}}"
body: "See release notes for detailed changelog."
name: 'Clash Verge Rev v${{env.VERSION}}'
body: 'See release notes for detailed changelog.'
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: ${{ contains(github.ref_name, '-rc') }}
files: |
@@ -400,7 +400,7 @@ jobs:
- name: Install Rust Stable
uses: dtolnay/rust-toolchain@master
with:
toolchain: "1.91.0"
toolchain: '1.91.0'
targets: ${{ matrix.target }}
- name: Add Rust Target
@@ -410,8 +410,8 @@ jobs:
uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/dev' }}
prefix-key: "v1-rust"
key: "rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}"
prefix-key: 'v1-rust'
key: 'rust-shared-stable-${{ matrix.os }}-${{ matrix.target }}'
workspaces: |
. -> target
cache-all-crates: true
@@ -420,7 +420,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -450,7 +450,7 @@ jobs:
id: build
uses: tauri-apps/tauri-action@v0.6.2
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -482,8 +482,8 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: v${{steps.build.outputs.appVersion}}
name: "Clash Verge Rev v${{steps.build.outputs.appVersion}}"
body: "See release notes for detailed changelog."
name: 'Clash Verge Rev v${{steps.build.outputs.appVersion}}'
body: 'See release notes for detailed changelog.'
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: ${{ contains(github.ref_name, '-rc') }}
files: target/${{ matrix.target }}/release/bundle/nsis/*setup*
@@ -505,7 +505,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -531,7 +531,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -593,7 +593,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
@@ -39,7 +39,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@v6
with:
node-version: "24.14.0"
node-version: '24.14.0'
- uses: pnpm/action-setup@v4
name: Install pnpm
+1
View File
@@ -9,3 +9,4 @@ src-tauri/target/
src-tauri/gen/
target
Cargo.lock
+3 -14
View File
@@ -1,16 +1,5 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css",
"endOfLine": "auto",
"embeddedLanguageFormatting": "auto"
"semi": false,
"singleQuote": true,
"plugins": ["prettier-plugin-toml"]
}
-1
View File
@@ -10,7 +10,6 @@ members = [
]
resolver = "2"
[profile.release]
panic = "abort"
codegen-units = 1
+1 -1
View File
@@ -5,7 +5,7 @@ notifications:
body: ダッシュボードの表示状態が更新されました。
clashModeChanged:
title: モード切り替え
body: "{mode} に切り替えました。"
body: '{mode} に切り替えました。'
systemProxyToggled:
title: システムプロキシ
body: システムプロキシの状態が更新されました。
+1 -1
View File
@@ -5,7 +5,7 @@ notifications:
body: 대시보드 표시 상태가 업데이트되었습니다.
clashModeChanged:
title: 모드 전환
body: "{mode}(으)로 전환되었습니다."
body: '{mode}(으)로 전환되었습니다.'
systemProxyToggled:
title: 시스템 프록시
body: 시스템 프록시 상태가 업데이트되었습니다.
+1 -1
View File
@@ -5,7 +5,7 @@ notifications:
body: Gösterge panelinin görünürlüğü güncellendi.
clashModeChanged:
title: Mod Değişimi
body: "{mode} moduna geçildi."
body: '{mode} moduna geçildi.'
systemProxyToggled:
title: Sistem Vekil'i
body: Sistem vekil'i durumu güncellendi.
+1 -1
View File
@@ -5,7 +5,7 @@ notifications:
body: Идарә панеленең күренеше яңартылды.
clashModeChanged:
title: Режим алыштыру
body: "{mode} режимына күчтел."
body: '{mode} режимына күчтел.'
systemProxyToggled:
title: Системалы прокси
body: Системалы прокси хәле яңартылды.
+83 -86
View File
@@ -1,148 +1,145 @@
import eslintJS from "@eslint/js";
import eslintReact from "@eslint-react/eslint-plugin";
import { defineConfig } from "eslint/config";
import configPrettier from "eslint-config-prettier";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import pluginImportX from "eslint-plugin-import-x";
import pluginPrettier from "eslint-plugin-prettier";
import pluginReactCompiler from "eslint-plugin-react-compiler";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginReactRefresh from "eslint-plugin-react-refresh";
import pluginUnusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import tseslint from "typescript-eslint";
import eslintJS from '@eslint/js'
import eslintReact from '@eslint-react/eslint-plugin'
import { defineConfig } from 'eslint/config'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import pluginImportX from 'eslint-plugin-import-x'
import pluginReactCompiler from 'eslint-plugin-react-compiler'
import pluginReactHooks from 'eslint-plugin-react-hooks'
import pluginReactRefresh from 'eslint-plugin-react-refresh'
import pluginUnusedImports from 'eslint-plugin-unused-imports'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default defineConfig([
{
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
plugins: {
js: eslintJS,
// @ts-expect-error -- https://github.com/typescript-eslint/typescript-eslint/issues/11543
"react-hooks": pluginReactHooks,
"react-compiler": pluginReactCompiler,
'react-hooks': pluginReactHooks,
'react-compiler': pluginReactCompiler,
// @ts-expect-error -- https://github.com/un-ts/eslint-plugin-import-x/issues/421
"import-x": pluginImportX,
"react-refresh": pluginReactRefresh,
"unused-imports": pluginUnusedImports,
prettier: pluginPrettier,
'import-x': pluginImportX,
'react-refresh': pluginReactRefresh,
'unused-imports': pluginUnusedImports,
},
extends: [
eslintJS.configs.recommended,
tseslint.configs.recommended,
eslintReact.configs["recommended-typescript"],
configPrettier,
eslintReact.configs['recommended-typescript'],
],
languageOptions: {
globals: globals.browser,
parserOptions: {
projectService: {
allowDefaultProject: ["src/polyfills/*.js"],
allowDefaultProject: [
'eslint.config.ts',
'src/polyfills/*.js',
'src-tauri/src/enhance/builtin/*.js',
],
},
},
},
settings: {
react: {
version: "detect",
version: 'detect',
},
"import-x/resolver-next": [
'import-x/resolver-next': [
createTypeScriptImportResolver({
project: "./tsconfig.json",
project: './tsconfig.json',
}),
],
},
rules: {
// React
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"react-compiler/react-compiler": "error",
"react-refresh/only-export-components": [
"warn",
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
'react-compiler/react-compiler': 'error',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
"@eslint-react/no-forward-ref": "off",
'@eslint-react/no-forward-ref': 'off',
// React performance and production quality rules
"@eslint-react/no-array-index-key": "warn",
"@eslint-react/no-children-count": "error",
"@eslint-react/no-children-for-each": "error",
"@eslint-react/no-children-map": "error",
"@eslint-react/no-children-only": "error",
"@eslint-react/no-children-prop": "error",
"@eslint-react/no-children-to-array": "error",
"@eslint-react/no-class-component": "error",
"@eslint-react/no-clone-element": "error",
"@eslint-react/no-create-ref": "error",
"@eslint-react/no-default-props": "error",
"@eslint-react/no-direct-mutation-state": "error",
"@eslint-react/no-implicit-key": "error",
"@eslint-react/no-prop-types": "error",
"@eslint-react/no-set-state-in-component-did-mount": "error",
"@eslint-react/no-set-state-in-component-did-update": "error",
"@eslint-react/no-set-state-in-component-will-update": "error",
"@eslint-react/no-string-refs": "error",
"@eslint-react/no-unstable-context-value": "warn",
"@eslint-react/no-unstable-default-props": "warn",
"@eslint-react/no-unused-class-component-members": "error",
"@eslint-react/no-unused-state": "error",
"@eslint-react/no-useless-fragment": "warn",
"@eslint-react/prefer-destructuring-assignment": "warn",
'@eslint-react/no-array-index-key': 'warn',
'@eslint-react/no-children-count': 'error',
'@eslint-react/no-children-for-each': 'error',
'@eslint-react/no-children-map': 'error',
'@eslint-react/no-children-only': 'error',
'@eslint-react/no-children-prop': 'error',
'@eslint-react/no-children-to-array': 'error',
'@eslint-react/no-class-component': 'error',
'@eslint-react/no-clone-element': 'error',
'@eslint-react/no-create-ref': 'error',
'@eslint-react/no-default-props': 'error',
'@eslint-react/no-direct-mutation-state': 'error',
'@eslint-react/no-implicit-key': 'error',
'@eslint-react/no-prop-types': 'error',
'@eslint-react/no-set-state-in-component-did-mount': 'error',
'@eslint-react/no-set-state-in-component-did-update': 'error',
'@eslint-react/no-set-state-in-component-will-update': 'error',
'@eslint-react/no-string-refs': 'error',
'@eslint-react/no-unstable-context-value': 'warn',
'@eslint-react/no-unstable-default-props': 'warn',
'@eslint-react/no-unused-class-component-members': 'error',
'@eslint-react/no-unused-state': 'error',
'@eslint-react/no-useless-fragment': 'warn',
'@eslint-react/prefer-destructuring-assignment': 'warn',
// TypeScript
"@typescript-eslint/no-explicit-any": "off",
'@typescript-eslint/no-explicit-any': 'off',
// unused-imports 代替 no-unused-vars
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
'@typescript-eslint/no-unused-vars': 'off',
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^ignore",
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^ignore',
},
],
// Import
"import-x/no-unresolved": "error",
"import-x/order": [
"warn",
'import-x/no-unresolved': 'error',
'import-x/order': [
'warn',
{
groups: [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
'builtin',
'external',
'internal',
'parent',
'sibling',
'index',
],
"newlines-between": "always",
'newlines-between': 'always',
alphabetize: {
order: "asc",
order: 'asc',
caseInsensitive: true,
},
},
],
// 其他常见
"prefer-const": "warn",
"no-case-declarations": "error",
"no-fallthrough": "error",
"no-empty": ["warn", { allowEmptyCatch: true }],
// Prettier 格式化问题
"prettier/prettier": "warn",
'prefer-const': 'warn',
'no-case-declarations': 'error',
'no-fallthrough': 'error',
'no-empty': ['warn', { allowEmptyCatch: true }],
},
},
{
files: ["scripts/**/*.{js,mjs,cjs}", "scripts-workflow/**/*.{js,mjs,cjs}"],
files: ['scripts/**/*.{js,mjs,cjs}'],
languageOptions: {
globals: {
@@ -151,4 +148,4 @@ export default defineConfig([
},
},
},
]);
])
+1 -2
View File
@@ -99,10 +99,8 @@
"commander": "^14.0.3",
"cross-env": "^10.1.0",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
@@ -115,6 +113,7 @@
"lint-staged": "^16.3.2",
"node-fetch": "^3.3.2",
"prettier": "^3.8.1",
"prettier-plugin-toml": "^2.0.6",
"sass": "^1.97.3",
"tar": "^7.5.10",
"terser": "^5.46.0",
+23 -63
View File
@@ -198,18 +198,12 @@ importers:
eslint:
specifier: ^10.0.3
version: 10.0.3(jiti@2.6.1)
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@10.0.3(jiti@2.6.1))
eslint-import-resolver-typescript:
specifier: ^4.4.4
version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1))
eslint-plugin-import-x:
specifier: ^4.16.1
version: 4.16.1(@typescript-eslint/utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))
eslint-plugin-prettier:
specifier: ^5.5.5
version: 5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1))(prettier@3.8.1)
eslint-plugin-react-compiler:
specifier: 19.1.0-rc.2
version: 19.1.0-rc.2(eslint@10.0.3(jiti@2.6.1))
@@ -246,6 +240,9 @@ importers:
prettier:
specifier: ^3.8.1
version: 3.8.1
prettier-plugin-toml:
specifier: ^2.0.6
version: 2.0.6(prettier@3.8.1)
sass:
specifier: ^1.97.3
version: 1.97.3
@@ -1407,10 +1404,6 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@pkgr/core@0.2.9':
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
@@ -1750,6 +1743,12 @@ packages:
'@tanstack/virtual-core@3.13.21':
resolution: {integrity: sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==}
'@taplo/core@0.2.0':
resolution: {integrity: sha512-r8bl54Zj1In3QLkiW/ex694bVzpPJ9EhwqT9xkcUVODnVUGirdB1JTsmiIv0o1uwqZiwhi8xNnTOQBRQCpizrQ==}
'@taplo/lib@0.5.0':
resolution: {integrity: sha512-+xIqpQXJco3T+VGaTTwmhxLa51qpkQxCjRwezjFZgr+l21ExlywJFcDfTrNmL6lG6tqb0h8GyJKO3UPGPtSCWg==}
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
@@ -2460,12 +2459,6 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-prettier@10.1.8:
resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-import-context@0.1.9:
resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -2501,20 +2494,6 @@ packages:
eslint-import-resolver-node:
optional: true
eslint-plugin-prettier@5.5.5:
resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '>=8.0.0'
eslint: '>=8.0.0'
eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
prettier: '>=3.0.0'
peerDependenciesMeta:
'@types/eslint':
optional: true
eslint-config-prettier:
optional: true
eslint-plugin-react-compiler@19.1.0-rc.2:
resolution: {integrity: sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==}
engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0}
@@ -2656,9 +2635,6 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -3373,9 +3349,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier-linter-helpers@1.0.1:
resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==}
engines: {node: '>=6.0.0'}
prettier-plugin-toml@2.0.6:
resolution: {integrity: sha512-12N/wBuHa9jd/KVy9pRP20NMKxQfQLMseQCt66lIbLaPLItvGUcSIryE1eZZMJ7loSws6Ig3M2Elc2EreNh76w==}
engines: {node: '>=16.0.0'}
peerDependencies:
prettier: ^3.0.3
prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
@@ -3652,10 +3630,6 @@ packages:
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
synckit@0.11.12:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
systemjs@6.15.1:
resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==}
@@ -5217,8 +5191,6 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6
optional: true
'@pkgr/core@0.2.9': {}
'@popperjs/core@2.11.8': {}
'@rolldown/binding-android-arm64@1.0.0-rc.9':
@@ -5441,6 +5413,12 @@ snapshots:
'@tanstack/virtual-core@3.13.21': {}
'@taplo/core@0.2.0': {}
'@taplo/lib@0.5.0':
dependencies:
'@taplo/core': 0.2.0
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/cli-darwin-arm64@2.10.1':
@@ -6122,10 +6100,6 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)):
dependencies:
eslint: 10.0.3(jiti@2.6.1)
eslint-import-context@0.1.9(unrs-resolver@1.11.1):
dependencies:
get-tsconfig: 4.13.6
@@ -6165,15 +6139,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.3(jiti@2.6.1)))(eslint@10.0.3(jiti@2.6.1))(prettier@3.8.1):
dependencies:
eslint: 10.0.3(jiti@2.6.1)
prettier: 3.8.1
prettier-linter-helpers: 1.0.1
synckit: 0.11.12
optionalDependencies:
eslint-config-prettier: 10.1.8(eslint@10.0.3(jiti@2.6.1))
eslint-plugin-react-compiler@19.1.0-rc.2(eslint@10.0.3(jiti@2.6.1)):
dependencies:
'@babel/core': 7.29.0
@@ -6406,8 +6371,6 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
@@ -7248,9 +7211,10 @@ snapshots:
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1:
prettier-plugin-toml@2.0.6(prettier@3.8.1):
dependencies:
fast-diff: 1.3.0
'@taplo/lib': 0.5.0
prettier: 3.8.1
prettier@3.8.1: {}
@@ -7562,10 +7526,6 @@ snapshots:
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
synckit@0.11.12:
dependencies:
'@pkgr/core': 0.2.9
systemjs@6.15.1: {}
tar@7.5.10:
File diff suppressed because it is too large Load Diff
+26 -29
View File
@@ -1,26 +1,26 @@
import { exec } from "child_process";
import fs from "fs/promises";
import path from "path";
import { promisify } from "util";
import { exec } from 'child_process'
import fs from 'fs/promises'
import path from 'path'
import { promisify } from 'util'
/**
* 为Alpha版本重命名版本号
*/
const execPromise = promisify(exec);
const execPromise = promisify(exec)
/**
* 标准输出HEAD hash
*/
async function getLatestCommitHash() {
try {
const { stdout } = await execPromise("git rev-parse HEAD");
const commitHash = stdout.trim();
const { stdout } = await execPromise('git rev-parse HEAD')
const commitHash = stdout.trim()
// 格式化,只截取前7位字符
const formathash = commitHash.substring(0, 7);
console.log(`Found the latest commit hash code: ${commitHash}`);
return formathash;
const formathash = commitHash.substring(0, 7)
console.log(`Found the latest commit hash code: ${commitHash}`)
return formathash
} catch (error) {
console.error("pnpm run fix-alpha-version ERROR", error);
console.error('pnpm run fix-alpha-version ERROR', error)
}
}
@@ -30,38 +30,35 @@ async function getLatestCommitHash() {
*/
async function updatePackageVersion(newVersion) {
// 获取内容根目录
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
const _dirname = process.cwd()
const packageJsonPath = path.join(_dirname, 'package.json')
try {
// 读取文件
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
const data = await fs.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(data)
// 获取键值替换
let result = packageJson.version.replace("alpha", newVersion);
let result = packageJson.version.replace('alpha', newVersion)
// 检查当前版本号是否已经包含了 alpha- 后缀
if (!packageJson.version.includes(`alpha-`)) {
// 如果只有 alpha 而没有 alpha-,则替换为 alpha-newVersion
result = packageJson.version.replace("alpha", `alpha-${newVersion}`);
result = packageJson.version.replace('alpha', `alpha-${newVersion}`)
} else {
// 如果已经是 alpha-xxx 格式,则更新 xxx 部分
result = packageJson.version.replace(
/alpha-[^-]*/,
`alpha-${newVersion}`,
);
result = packageJson.version.replace(/alpha-[^-]*/, `alpha-${newVersion}`)
}
console.log("[INFO]: Current version is: ", result);
packageJson.version = result;
console.log('[INFO]: Current version is: ', result)
packageJson.version = result
// 写入版本号
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
"utf8",
);
console.log(`[INFO]: Alpha version update to: ${newVersion}`);
'utf8',
)
console.log(`[INFO]: Alpha version update to: ${newVersion}`)
} catch (error) {
console.error("pnpm run fix-alpha-version ERROR", error);
console.error('pnpm run fix-alpha-version ERROR', error)
}
}
const newVersion = await getLatestCommitHash();
updatePackageVersion(newVersion).catch(console.error);
const newVersion = await getLatestCommitHash()
updatePackageVersion(newVersion).catch(console.error)
+57 -57
View File
@@ -1,98 +1,98 @@
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, "..");
const LOCALE_DIR = path.resolve(ROOT_DIR, "src/locales/en");
const KEY_OUTPUT = path.resolve(ROOT_DIR, "src/types/generated/i18n-keys.ts");
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT_DIR = path.resolve(__dirname, '..')
const LOCALE_DIR = path.resolve(ROOT_DIR, 'src/locales/en')
const KEY_OUTPUT = path.resolve(ROOT_DIR, 'src/types/generated/i18n-keys.ts')
const RESOURCE_OUTPUT = path.resolve(
ROOT_DIR,
"src/types/generated/i18n-resources.ts",
);
'src/types/generated/i18n-resources.ts',
)
const isPlainObject = (value) =>
typeof value === "object" && value !== null && !Array.isArray(value);
typeof value === 'object' && value !== null && !Array.isArray(value)
const flattenKeys = (data, prefix = "") => {
const keys = [];
const flattenKeys = (data, prefix = '') => {
const keys = []
for (const [key, value] of Object.entries(data)) {
const nextPrefix = prefix ? `${prefix}.${key}` : key;
const nextPrefix = prefix ? `${prefix}.${key}` : key
if (isPlainObject(value)) {
keys.push(...flattenKeys(value, nextPrefix));
keys.push(...flattenKeys(value, nextPrefix))
} else {
keys.push(nextPrefix);
keys.push(nextPrefix)
}
}
return keys;
};
return keys
}
const buildType = (data, indent = 0) => {
if (!isPlainObject(data)) {
return "string";
return 'string'
}
const entries = Object.entries(data).sort(([a], [b]) => a.localeCompare(b));
const pad = " ".repeat(indent);
const entries = Object.entries(data).sort(([a], [b]) => a.localeCompare(b))
const pad = ' '.repeat(indent)
const inner = entries
.map(([key, value]) => {
const typeStr = buildType(value, indent + 2);
return `${" ".repeat(indent + 2)}${JSON.stringify(key)}: ${typeStr};`;
const typeStr = buildType(value, indent + 2)
return `${' '.repeat(indent + 2)}${JSON.stringify(key)}: ${typeStr};`
})
.join("\n");
.join('\n')
return entries.length
? `{
${inner}
${pad}}`
: "{}";
};
: '{}'
}
const loadNamespaceJson = async () => {
const dirents = await fs.readdir(LOCALE_DIR, { withFileTypes: true });
const namespaces = [];
const dirents = await fs.readdir(LOCALE_DIR, { withFileTypes: true })
const namespaces = []
for (const dirent of dirents) {
if (!dirent.isFile() || !dirent.name.endsWith(".json")) continue;
const name = dirent.name.replace(/\.json$/, "");
const filePath = path.join(LOCALE_DIR, dirent.name);
const raw = await fs.readFile(filePath, "utf8");
const json = JSON.parse(raw);
namespaces.push({ name, json });
if (!dirent.isFile() || !dirent.name.endsWith('.json')) continue
const name = dirent.name.replace(/\.json$/, '')
const filePath = path.join(LOCALE_DIR, dirent.name)
const raw = await fs.readFile(filePath, 'utf8')
const json = JSON.parse(raw)
namespaces.push({ name, json })
}
namespaces.sort((a, b) => a.name.localeCompare(b.name));
return namespaces;
};
namespaces.sort((a, b) => a.name.localeCompare(b.name))
return namespaces
}
const buildKeysFile = (keys) => {
const arrayLiteral = keys.map((key) => ` "${key}"`).join(",\n");
return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport const translationKeys = [\n${arrayLiteral}\n] as const;\n\nexport type TranslationKey = typeof translationKeys[number];\n`;
};
const arrayLiteral = keys.map((key) => ` "${key}"`).join(',\n')
return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport const translationKeys = [\n${arrayLiteral}\n] as const;\n\nexport type TranslationKey = typeof translationKeys[number];\n`
}
const buildResourcesFile = (namespaces) => {
const namespaceEntries = namespaces
.map(({ name, json }) => {
const typeStr = buildType(json, 4);
return ` ${JSON.stringify(name)}: ${typeStr};`;
const typeStr = buildType(json, 4)
return ` ${JSON.stringify(name)}: ${typeStr};`
})
.join("\n");
.join('\n')
return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport interface TranslationResources {\n translation: {\n${namespaceEntries}\n };\n}\n`;
};
return `// This file is auto-generated by scripts/generate-i18n-keys.mjs\n// Do not edit this file manually.\n\nexport interface TranslationResources {\n translation: {\n${namespaceEntries}\n };\n}\n`
}
const main = async () => {
const namespaces = await loadNamespaceJson();
const keys = namespaces.flatMap(({ name, json }) => flattenKeys(json, name));
const keysContent = buildKeysFile(keys);
const resourcesContent = buildResourcesFile(namespaces);
await fs.mkdir(path.dirname(KEY_OUTPUT), { recursive: true });
await fs.writeFile(KEY_OUTPUT, keysContent, "utf8");
await fs.writeFile(RESOURCE_OUTPUT, resourcesContent, "utf8");
console.log(`Generated ${keys.length} translation keys.`);
};
const namespaces = await loadNamespaceJson()
const keys = namespaces.flatMap(({ name, json }) => flattenKeys(json, name))
const keysContent = buildKeysFile(keys)
const resourcesContent = buildResourcesFile(namespaces)
await fs.mkdir(path.dirname(KEY_OUTPUT), { recursive: true })
await fs.writeFile(KEY_OUTPUT, keysContent, 'utf8')
await fs.writeFile(RESOURCE_OUTPUT, resourcesContent, 'utf8')
console.log(`Generated ${keys.length} translation keys.`)
}
main().catch((error) => {
console.error("Failed to generate i18n metadata:", error);
process.exitCode = 1;
});
console.error('Failed to generate i18n metadata:', error)
process.exitCode = 1
})
+50 -50
View File
@@ -1,104 +1,104 @@
import fs from "fs";
import fsp from "fs/promises";
import { createRequire } from "module";
import path from "path";
import fs from 'fs'
import fsp from 'fs/promises'
import { createRequire } from 'module'
import path from 'path'
import { context, getOctokit } from "@actions/github";
import AdmZip from "adm-zip";
import { context, getOctokit } from '@actions/github'
import AdmZip from 'adm-zip'
const target = process.argv.slice(2)[0];
const alpha = process.argv.slice(2)[1];
const target = process.argv.slice(2)[0]
const alpha = process.argv.slice(2)[1]
const ARCH_MAP = {
"x86_64-pc-windows-msvc": "x64",
"i686-pc-windows-msvc": "x86",
"aarch64-pc-windows-msvc": "arm64",
};
'x86_64-pc-windows-msvc': 'x64',
'i686-pc-windows-msvc': 'x86',
'aarch64-pc-windows-msvc': 'arm64',
}
const PROCESS_MAP = {
x64: "x64",
ia32: "x86",
arm64: "arm64",
};
const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch];
x64: 'x64',
ia32: 'x86',
arm64: 'arm64',
}
const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch]
/// Script for ci
/// 打包绿色版/便携版 (only Windows)
async function resolvePortable() {
if (process.platform !== "win32") return;
if (process.platform !== 'win32') return
const releaseDir = target
? `./src-tauri/target/${target}/release`
: `./src-tauri/target/release`;
: `./src-tauri/target/release`
const configDir = path.join(releaseDir, ".config");
const configDir = path.join(releaseDir, '.config')
if (!fs.existsSync(releaseDir)) {
throw new Error("could not found the release dir");
throw new Error('could not found the release dir')
}
await fsp.mkdir(configDir, { recursive: true });
if (!fs.existsSync(path.join(configDir, "PORTABLE"))) {
await fsp.writeFile(path.join(configDir, "PORTABLE"), "");
await fsp.mkdir(configDir, { recursive: true })
if (!fs.existsSync(path.join(configDir, 'PORTABLE'))) {
await fsp.writeFile(path.join(configDir, 'PORTABLE'), '')
}
const zip = new AdmZip();
const zip = new AdmZip()
zip.addLocalFile(path.join(releaseDir, "Clash Verge.exe"));
zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe"));
zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe"));
zip.addLocalFolder(path.join(releaseDir, "resources"), "resources");
zip.addLocalFile(path.join(releaseDir, 'Clash Verge.exe'))
zip.addLocalFile(path.join(releaseDir, 'verge-mihomo.exe'))
zip.addLocalFile(path.join(releaseDir, 'verge-mihomo-alpha.exe'))
zip.addLocalFolder(path.join(releaseDir, 'resources'), 'resources')
zip.addLocalFolder(
path.join(
releaseDir,
`Microsoft.WebView2.FixedVersionRuntime.133.0.3065.92.${arch}`,
),
`Microsoft.WebView2.FixedVersionRuntime.133.0.3065.92.${arch}`,
);
zip.addLocalFolder(configDir, ".config");
)
zip.addLocalFolder(configDir, '.config')
const require = createRequire(import.meta.url);
const packageJson = require("../package.json");
const { version } = packageJson;
const require = createRequire(import.meta.url)
const packageJson = require('../package.json')
const { version } = packageJson
const zipFile = `Clash.Verge_${version}_${arch}_fixed_webview2_portable.zip`;
zip.writeZip(zipFile);
const zipFile = `Clash.Verge_${version}_${arch}_fixed_webview2_portable.zip`
zip.writeZip(zipFile)
console.log("[INFO]: create portable zip successfully");
console.log('[INFO]: create portable zip successfully')
// push release assets
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error("GITHUB_TOKEN is required");
throw new Error('GITHUB_TOKEN is required')
}
const options = { owner: context.repo.owner, repo: context.repo.repo };
const github = getOctokit(process.env.GITHUB_TOKEN);
const tag = alpha ? "alpha" : process.env.TAG_NAME || `v${version}`;
console.log("[INFO]: upload to ", tag);
const options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN)
const tag = alpha ? 'alpha' : process.env.TAG_NAME || `v${version}`
console.log('[INFO]: upload to ', tag)
const { data: release } = await github.rest.repos.getReleaseByTag({
...options,
tag,
});
})
const assets = release.assets.filter((x) => {
return x.name === zipFile;
});
return x.name === zipFile
})
if (assets.length > 0) {
const id = assets[0].id;
const id = assets[0].id
await github.rest.repos.deleteReleaseAsset({
...options,
asset_id: id,
});
})
}
console.log(release.name);
console.log(release.name)
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: release.id,
name: zipFile,
data: zip.toBuffer(),
});
})
}
resolvePortable().catch(console.error);
resolvePortable().catch(console.error)
+33 -33
View File
@@ -1,53 +1,53 @@
import fs from "fs";
import fsp from "fs/promises";
import { createRequire } from "module";
import path from "path";
import fs from 'fs'
import fsp from 'fs/promises'
import { createRequire } from 'module'
import path from 'path'
import AdmZip from "adm-zip";
import AdmZip from 'adm-zip'
const target = process.argv.slice(2)[0];
const target = process.argv.slice(2)[0]
const ARCH_MAP = {
"x86_64-pc-windows-msvc": "x64",
"aarch64-pc-windows-msvc": "arm64",
};
'x86_64-pc-windows-msvc': 'x64',
'aarch64-pc-windows-msvc': 'arm64',
}
const PROCESS_MAP = {
x64: "x64",
arm64: "arm64",
};
const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch];
x64: 'x64',
arm64: 'arm64',
}
const arch = target ? ARCH_MAP[target] : PROCESS_MAP[process.arch]
/// Script for ci
/// 打包绿色版/便携版 (only Windows)
async function resolvePortable() {
if (process.platform !== "win32") return;
if (process.platform !== 'win32') return
const releaseDir = target
? `./src-tauri/target/${target}/release`
: `./src-tauri/target/release`;
const configDir = path.join(releaseDir, ".config");
: `./src-tauri/target/release`
const configDir = path.join(releaseDir, '.config')
if (!fs.existsSync(releaseDir)) {
throw new Error("could not found the release dir");
throw new Error('could not found the release dir')
}
await fsp.mkdir(configDir, { recursive: true });
if (!fs.existsSync(path.join(configDir, "PORTABLE"))) {
await fsp.writeFile(path.join(configDir, "PORTABLE"), "");
await fsp.mkdir(configDir, { recursive: true })
if (!fs.existsSync(path.join(configDir, 'PORTABLE'))) {
await fsp.writeFile(path.join(configDir, 'PORTABLE'), '')
}
const zip = new AdmZip();
const zip = new AdmZip()
zip.addLocalFile(path.join(releaseDir, "clash-verge.exe"));
zip.addLocalFile(path.join(releaseDir, "verge-mihomo.exe"));
zip.addLocalFile(path.join(releaseDir, "verge-mihomo-alpha.exe"));
zip.addLocalFolder(path.join(releaseDir, "resources"), "resources");
zip.addLocalFolder(configDir, ".config");
zip.addLocalFile(path.join(releaseDir, 'clash-verge.exe'))
zip.addLocalFile(path.join(releaseDir, 'verge-mihomo.exe'))
zip.addLocalFile(path.join(releaseDir, 'verge-mihomo-alpha.exe'))
zip.addLocalFolder(path.join(releaseDir, 'resources'), 'resources')
zip.addLocalFolder(configDir, '.config')
const require = createRequire(import.meta.url);
const packageJson = require("../package.json");
const { version } = packageJson;
const zipFile = `Clash.Verge_${version}_${arch}_portable.zip`;
zip.writeZip(zipFile);
console.log("[INFO]: create portable zip successfully");
const require = createRequire(import.meta.url)
const packageJson = require('../package.json')
const { version } = packageJson
const zipFile = `Clash.Verge_${version}_${arch}_portable.zip`
zip.writeZip(zipFile)
console.log('[INFO]: create portable zip successfully')
}
resolvePortable().catch(console.error);
resolvePortable().catch(console.error)
+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
import { spawn } from "child_process";
import { existsSync } from "fs";
import path from "path";
import { spawn } from 'child_process'
import { existsSync } from 'fs'
import path from 'path'
const rootDir = process.cwd();
const scriptPath = path.join(rootDir, "scripts", "release-version.mjs");
const rootDir = process.cwd()
const scriptPath = path.join(rootDir, 'scripts', 'release-version.mjs')
if (!existsSync(scriptPath)) {
console.error("release-version.mjs not found!");
process.exit(1);
console.error('release-version.mjs not found!')
process.exit(1)
}
const versionArg = process.argv[2];
const versionArg = process.argv[2]
if (!versionArg) {
console.error("Usage: pnpm publish-version <version>");
process.exit(1);
console.error('Usage: pnpm publish-version <version>')
process.exit(1)
}
// 1. 调用 release-version.mjs
const runRelease = () =>
new Promise((resolve, reject) => {
const child = spawn("node", [scriptPath, versionArg], { stdio: "inherit" });
child.on("exit", (code) => {
if (code === 0) resolve();
else reject(new Error("release-version failed"));
});
});
const child = spawn('node', [scriptPath, versionArg], { stdio: 'inherit' })
child.on('exit', (code) => {
if (code === 0) resolve()
else reject(new Error('release-version failed'))
})
})
// 2. 判断是否需要打 tag
function isSemver(version) {
return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version);
return /^v?\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?$/.test(version)
}
async function run() {
await runRelease();
await runRelease()
let tag = null;
if (versionArg === "alpha") {
let tag = null
if (versionArg === 'alpha') {
// 读取 package.json 里的主版本
const pkg = await import(path.join(rootDir, "package.json"), {
assert: { type: "json" },
});
tag = `v${pkg.default.version}-alpha`;
const pkg = await import(path.join(rootDir, 'package.json'), {
assert: { type: 'json' },
})
tag = `v${pkg.default.version}-alpha`
} else if (isSemver(versionArg)) {
// 1.2.3 或 v1.2.3
tag = versionArg.startsWith("v") ? versionArg : `v${versionArg}`;
tag = versionArg.startsWith('v') ? versionArg : `v${versionArg}`
}
if (tag) {
// 打 tag 并推送
const { execSync } = await import("child_process");
const { execSync } = await import('child_process')
try {
execSync(`git tag ${tag}`, { stdio: "inherit" });
execSync(`git push origin ${tag}`, { stdio: "inherit" });
console.log(`[INFO]: Git tag ${tag} created and pushed.`);
execSync(`git tag ${tag}`, { stdio: 'inherit' })
execSync(`git push origin ${tag}`, { stdio: 'inherit' })
console.log(`[INFO]: Git tag ${tag} created and pushed.`)
} catch {
console.error(`[ERROR]: Failed to create or push git tag: ${tag}`);
process.exit(1);
console.error(`[ERROR]: Failed to create or push git tag: ${tag}`)
process.exit(1)
}
} else {
console.log("[INFO]: No git tag created for this version.");
console.log('[INFO]: No git tag created for this version.')
}
}
run();
run()
+115 -115
View File
@@ -29,11 +29,11 @@
* Errors are logged and the process exits with code 1 on failure.
*/
import { execSync } from "child_process";
import fs from "fs/promises";
import path from "path";
import { execSync } from 'child_process'
import fs from 'fs/promises'
import path from 'path'
import { program } from "commander";
import { program } from 'commander'
/**
* 获取当前 git 短 commit hash
@@ -41,10 +41,10 @@ import { program } from "commander";
*/
function getGitShortCommit() {
try {
return execSync("git rev-parse --short HEAD").toString().trim();
return execSync('git rev-parse --short HEAD').toString().trim()
} catch {
console.warn("[WARN]: Failed to get git short commit, fallback to 'nogit'");
return "nogit";
console.warn("[WARN]: Failed to get git short commit, fallback to 'nogit'")
return 'nogit'
}
}
@@ -55,21 +55,21 @@ function getGitShortCommit() {
function getLatestTauriCommit() {
try {
const fullHash = execSync(
"bash ./scripts-workflow/get_latest_tauri_commit.bash",
'bash ./scripts-workflow/get_latest_tauri_commit.bash',
)
.toString()
.trim();
.trim()
const shortHash = execSync(`git rev-parse --short ${fullHash}`)
.toString()
.trim();
console.log(`[INFO]: Latest Tauri-related commit: ${shortHash}`);
return shortHash;
.trim()
console.log(`[INFO]: Latest Tauri-related commit: ${shortHash}`)
return shortHash
} catch (error) {
console.warn(
"[WARN]: Failed to get latest Tauri commit, fallback to current git short commit",
);
console.warn(`[WARN]: Error details: ${error.message}`);
return getGitShortCommit();
'[WARN]: Failed to get latest Tauri commit, fallback to current git short commit',
)
console.warn(`[WARN]: Error details: ${error.message}`)
return getGitShortCommit()
}
}
@@ -81,25 +81,25 @@ function getLatestTauriCommit() {
* @returns {string}
*/
function generateShortTimestamp(withCommit = false, useTauriCommit = false) {
const now = new Date();
const now = new Date()
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
});
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
month: '2-digit',
day: '2-digit',
})
const parts = formatter.formatToParts(now);
const month = parts.find((part) => part.type === "month").value;
const day = parts.find((part) => part.type === "day").value;
const parts = formatter.formatToParts(now)
const month = parts.find((part) => part.type === 'month').value
const day = parts.find((part) => part.type === 'day').value
if (withCommit) {
const gitShort = useTauriCommit
? getLatestTauriCommit()
: getGitShortCommit();
return `${month}${day}.${gitShort}`;
: getGitShortCommit()
return `${month}${day}.${gitShort}`
}
return `${month}${day}`;
return `${month}${day}`
}
/**
@@ -110,7 +110,7 @@ function generateShortTimestamp(withCommit = false, useTauriCommit = false) {
function isValidVersion(version) {
return /^v?\d+\.\d+\.\d+(-(alpha|beta|rc)(\.\d+)?)?(\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*)?$/i.test(
version,
);
)
}
/**
@@ -119,7 +119,7 @@ function isValidVersion(version) {
* @returns {string}
*/
function normalizeVersion(version) {
return version.startsWith("v") ? version : `v${version}`;
return version.startsWith('v') ? version : `v${version}`
}
/**
@@ -128,9 +128,9 @@ function normalizeVersion(version) {
* @returns {string}
*/
function getBaseVersion(version) {
let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, "");
base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, "");
return base;
let base = version.replace(/-(alpha|beta|rc)(\.\d+)?/i, '')
base = base.replace(/\+[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*/g, '')
return base
}
/**
@@ -138,30 +138,30 @@ function getBaseVersion(version) {
* @param {string} newVersion
*/
async function updatePackageVersion(newVersion) {
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
const _dirname = process.cwd()
const packageJsonPath = path.join(_dirname, 'package.json')
try {
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
const data = await fs.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(data)
console.log(
"[INFO]: Current package.json version is: ",
'[INFO]: Current package.json version is: ',
packageJson.version,
);
packageJson.version = newVersion.startsWith("v")
)
packageJson.version = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion;
: newVersion
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2),
"utf8",
);
'utf8',
)
console.log(
`[INFO]: package.json version updated to: ${packageJson.version}`,
);
)
} catch (error) {
console.error("Error updating package.json version:", error);
throw error;
console.error('Error updating package.json version:', error)
throw error
}
}
@@ -170,30 +170,30 @@ async function updatePackageVersion(newVersion) {
* @param {string} newVersion
*/
async function updateCargoVersion(newVersion) {
const _dirname = process.cwd();
const cargoTomlPath = path.join(_dirname, "src-tauri", "Cargo.toml");
const _dirname = process.cwd()
const cargoTomlPath = path.join(_dirname, 'src-tauri', 'Cargo.toml')
try {
const data = await fs.readFile(cargoTomlPath, "utf8");
const lines = data.split("\n");
const versionWithoutV = newVersion.startsWith("v")
const data = await fs.readFile(cargoTomlPath, 'utf8')
const lines = data.split('\n')
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion;
: newVersion
const updatedLines = lines.map((line) => {
if (line.trim().startsWith("version =")) {
if (line.trim().startsWith('version =')) {
return line.replace(
/version\s*=\s*"[^"]+"/,
`version = "${versionWithoutV}"`,
);
)
}
return line;
});
return line
})
await fs.writeFile(cargoTomlPath, updatedLines.join("\n"), "utf8");
console.log(`[INFO]: Cargo.toml version updated to: ${versionWithoutV}`);
await fs.writeFile(cargoTomlPath, updatedLines.join('\n'), 'utf8')
console.log(`[INFO]: Cargo.toml version updated to: ${versionWithoutV}`)
} catch (error) {
console.error("Error updating Cargo.toml version:", error);
throw error;
console.error('Error updating Cargo.toml version:', error)
throw error
}
}
@@ -202,34 +202,34 @@ async function updateCargoVersion(newVersion) {
* @param {string} newVersion
*/
async function updateTauriConfigVersion(newVersion) {
const _dirname = process.cwd();
const tauriConfigPath = path.join(_dirname, "src-tauri", "tauri.conf.json");
const _dirname = process.cwd()
const tauriConfigPath = path.join(_dirname, 'src-tauri', 'tauri.conf.json')
try {
const data = await fs.readFile(tauriConfigPath, "utf8");
const tauriConfig = JSON.parse(data);
const versionWithoutV = newVersion.startsWith("v")
const data = await fs.readFile(tauriConfigPath, 'utf8')
const tauriConfig = JSON.parse(data)
const versionWithoutV = newVersion.startsWith('v')
? newVersion.slice(1)
: newVersion;
: newVersion
console.log(
"[INFO]: Current tauri.conf.json version is: ",
'[INFO]: Current tauri.conf.json version is: ',
tauriConfig.version,
);
)
// 使用完整版本信息,包含build metadata
tauriConfig.version = versionWithoutV;
tauriConfig.version = versionWithoutV
await fs.writeFile(
tauriConfigPath,
JSON.stringify(tauriConfig, null, 2),
"utf8",
);
'utf8',
)
console.log(
`[INFO]: tauri.conf.json version updated to: ${versionWithoutV}`,
);
)
} catch (error) {
console.error("Error updating tauri.conf.json version:", error);
throw error;
console.error('Error updating tauri.conf.json version:', error)
throw error
}
}
@@ -237,15 +237,15 @@ async function updateTauriConfigVersion(newVersion) {
* 获取当前版本号
*/
async function getCurrentVersion() {
const _dirname = process.cwd();
const packageJsonPath = path.join(_dirname, "package.json");
const _dirname = process.cwd()
const packageJsonPath = path.join(_dirname, 'package.json')
try {
const data = await fs.readFile(packageJsonPath, "utf8");
const packageJson = JSON.parse(data);
return packageJson.version;
const data = await fs.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(data)
return packageJson.version
} catch (error) {
console.error("Error getting current version:", error);
throw error;
console.error('Error getting current version:', error)
throw error
}
}
@@ -254,62 +254,62 @@ async function getCurrentVersion() {
*/
async function main(versionArg) {
if (!versionArg) {
console.error("Error: Version argument is required");
process.exit(1);
console.error('Error: Version argument is required')
process.exit(1)
}
try {
let newVersion;
let newVersion
const validTags = [
"alpha",
"beta",
"rc",
"autobuild",
"autobuild-latest",
"deploytest",
];
'alpha',
'beta',
'rc',
'autobuild',
'autobuild-latest',
'deploytest',
]
if (validTags.includes(versionArg.toLowerCase())) {
const currentVersion = await getCurrentVersion();
const baseVersion = getBaseVersion(currentVersion);
const currentVersion = await getCurrentVersion()
const baseVersion = getBaseVersion(currentVersion)
if (versionArg.toLowerCase() === "autobuild") {
if (versionArg.toLowerCase() === 'autobuild') {
// 格式: 2.3.0+autobuild.1004.cc39b27
// 使用 Tauri 相关的最新 commit hash
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}`;
} else if (versionArg.toLowerCase() === "autobuild-latest") {
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp(true, true)}`
} else if (versionArg.toLowerCase() === 'autobuild-latest') {
// 格式: 2.3.0+autobuild.1004.a1b2c3d (使用最新 Tauri 提交)
const latestTauriCommit = getLatestTauriCommit();
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}`;
} else if (versionArg.toLowerCase() === "deploytest") {
const latestTauriCommit = getLatestTauriCommit()
newVersion = `${baseVersion}+autobuild.${generateShortTimestamp()}.${latestTauriCommit}`
} else if (versionArg.toLowerCase() === 'deploytest') {
// 格式: 2.3.0+deploytest.1004.cc39b27
// 使用 Tauri 相关的最新 commit hash
newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}`;
newVersion = `${baseVersion}+deploytest.${generateShortTimestamp(true, true)}`
} else {
newVersion = `${baseVersion}-${versionArg.toLowerCase()}`;
newVersion = `${baseVersion}-${versionArg.toLowerCase()}`
}
} else {
if (!isValidVersion(versionArg)) {
console.error("Error: Invalid version format");
process.exit(1);
console.error('Error: Invalid version format')
process.exit(1)
}
newVersion = normalizeVersion(versionArg);
newVersion = normalizeVersion(versionArg)
}
console.log(`[INFO]: Updating versions to: ${newVersion}`);
await updatePackageVersion(newVersion);
await updateCargoVersion(newVersion);
await updateTauriConfigVersion(newVersion);
console.log("[SUCCESS]: All version updates completed successfully!");
console.log(`[INFO]: Updating versions to: ${newVersion}`)
await updatePackageVersion(newVersion)
await updateCargoVersion(newVersion)
await updateTauriConfigVersion(newVersion)
console.log('[SUCCESS]: All version updates completed successfully!')
} catch (error) {
console.error("[ERROR]: Failed to update versions:", error);
process.exit(1);
console.error('[ERROR]: Failed to update versions:', error)
process.exit(1)
}
}
program
.name("pnpm release-version")
.description("Update project version numbers")
.argument("<version>", "version tag or full version")
.name('pnpm release-version')
.description('Update project version numbers')
.argument('<version>', 'version tag or full version')
.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_TEST = "@vergetest"; // 测试频道
const CHAT_ID_RELEASE = '@clash_verge_re' // 正式发布频道
const CHAT_ID_TEST = '@vergetest' // 测试频道
async function sendTelegramNotification() {
if (!process.env.TELEGRAM_BOT_TOKEN) {
throw new Error("TELEGRAM_BOT_TOKEN is required");
throw new Error('TELEGRAM_BOT_TOKEN is required')
}
const version =
process.env.VERSION ||
(() => {
const pkg = readFileSync("package.json", "utf-8");
return JSON.parse(pkg).version;
})();
const pkg = readFileSync('package.json', 'utf-8')
return JSON.parse(pkg).version
})()
const downloadUrl =
process.env.DOWNLOAD_URL ||
`https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v${version}`;
`https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v${version}`
const isAutobuild =
process.env.BUILD_TYPE === "autobuild" || version.includes("autobuild");
const chatId = isAutobuild ? CHAT_ID_TEST : CHAT_ID_RELEASE;
const buildType = isAutobuild ? "滚动更新版" : "正式版";
process.env.BUILD_TYPE === 'autobuild' || version.includes('autobuild')
const chatId = isAutobuild ? CHAT_ID_TEST : CHAT_ID_RELEASE
const buildType = isAutobuild ? '滚动更新版' : '正式版'
log_info(`Preparing Telegram notification for ${buildType} ${version}`);
log_info(`Target channel: ${chatId}`);
log_info(`Download URL: ${downloadUrl}`);
log_info(`Preparing Telegram notification for ${buildType} ${version}`)
log_info(`Target channel: ${chatId}`)
log_info(`Download URL: ${downloadUrl}`)
// 读取发布说明和下载地址
let releaseContent = "";
let releaseContent = ''
try {
releaseContent = readFileSync("release.txt", "utf-8");
log_info("成功读取 release.txt 文件");
releaseContent = readFileSync('release.txt', 'utf-8')
log_info('成功读取 release.txt 文件')
} catch (error) {
log_error("无法读取 release.txt,使用默认发布说明", error);
releaseContent = "更多新功能现已支持,详细更新日志请查看发布页面。";
log_error('无法读取 release.txt,使用默认发布说明', error)
releaseContent = '更多新功能现已支持,详细更新日志请查看发布页面。'
}
// Markdown 转换为 HTML
function convertMarkdownToTelegramHTML(content) {
return content
.split("\n")
.split('\n')
.map((line) => {
if (line.trim().length === 0) {
return "";
} else if (line.startsWith("## ")) {
return `<b>${line.replace("## ", "")}</b>`;
} else if (line.startsWith("### ")) {
return `<b>${line.replace("### ", "")}</b>`;
} else if (line.startsWith("#### ")) {
return `<b>${line.replace("#### ", "")}</b>`;
return ''
} else if (line.startsWith('## ')) {
return `<b>${line.replace('## ', '')}</b>`
} else if (line.startsWith('### ')) {
return `<b>${line.replace('### ', '')}</b>`
} else if (line.startsWith('#### ')) {
return `<b>${line.replace('#### ', '')}</b>`
} else {
let processedLine = line.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(match, text, url) => {
const encodedUrl = encodeURI(url);
return `<a href="${encodedUrl}">${text}</a>`;
const encodedUrl = encodeURI(url)
return `<a href="${encodedUrl}">${text}</a>`
},
);
processedLine = processedLine.replace(
/\*\*([^*]+)\*\*/g,
"<b>$1</b>",
);
return processedLine;
)
processedLine = processedLine.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
return processedLine
}
})
.join("\n");
.join('\n')
}
function normalizeDetailsTags(content) {
return content
.replace(
/<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(/<\/?details>/g, "")
.replace(/<\/?strong>/g, (m) => (m === "</strong>" ? "</b>" : "<b>"))
.replace(/<br\s*\/?>/g, "\n");
.replace(/<summary>\s*(.*?)\s*<\/summary>/g, '\n<b>$1</b>\n')
.replace(/<\/?details>/g, '')
.replace(/<\/?strong>/g, (m) => (m === '</strong>' ? '</b>' : '<b>'))
.replace(/<br\s*\/?>/g, '\n')
}
releaseContent = normalizeDetailsTags(releaseContent);
const formattedContent = convertMarkdownToTelegramHTML(releaseContent);
releaseContent = normalizeDetailsTags(releaseContent)
const formattedContent = convertMarkdownToTelegramHTML(releaseContent)
const releaseTitle = isAutobuild ? "滚动更新版发布" : "正式发布";
const encodedVersion = encodeURIComponent(version);
const releaseTag = isAutobuild ? "autobuild" : `v${version}`;
const content = `<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 releaseTitle = isAutobuild ? '滚动更新版发布' : '正式发布'
const encodedVersion = encodeURIComponent(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}`
// 发送到 Telegram
try {
@@ -105,22 +102,22 @@ async function sendTelegramNotification() {
url: `https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${encodedVersion}`,
prefer_large_media: true,
},
parse_mode: "HTML",
parse_mode: 'HTML',
},
);
log_success(`✅ Telegram 通知发送成功到 ${chatId}`);
)
log_success(`✅ Telegram 通知发送成功到 ${chatId}`)
} catch (error) {
log_error(
`❌ Telegram 通知发送失败到 ${chatId}:`,
error.response?.data || error.message,
error,
);
process.exit(1);
)
process.exit(1)
}
}
// 执行函数
sendTelegramNotification().catch((error) => {
log_error("脚本执行失败:", error);
process.exit(1);
});
log_error('脚本执行失败:', error)
process.exit(1)
})
+38 -38
View File
@@ -1,84 +1,84 @@
import fs from "fs";
import fsp from "fs/promises";
import path from "path";
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
const UPDATE_LOG = "Changelog.md";
const UPDATE_LOG = 'Changelog.md'
// parse the Changelog.md
export async function resolveUpdateLog(tag) {
const cwd = process.cwd();
const cwd = process.cwd()
const reTitle = /^## v[\d.]+/;
const reEnd = /^---/;
const reTitle = /^## v[\d.]+/
const reEnd = /^---/
const file = path.join(cwd, UPDATE_LOG);
const file = path.join(cwd, UPDATE_LOG)
if (!fs.existsSync(file)) {
throw new Error("could not found Changelog.md");
throw new Error('could not found Changelog.md')
}
const data = await fsp.readFile(file, "utf-8");
const data = await fsp.readFile(file, 'utf-8')
const map = {};
let p = "";
const map = {}
let p = ''
data.split("\n").forEach((line) => {
data.split('\n').forEach((line) => {
if (reTitle.test(line)) {
p = line.slice(3).trim();
p = line.slice(3).trim()
if (!map[p]) {
map[p] = [];
map[p] = []
} else {
throw new Error(`Tag ${p} dup`);
throw new Error(`Tag ${p} dup`)
}
} else if (reEnd.test(line)) {
p = "";
p = ''
} else if (p) {
map[p].push(line);
map[p].push(line)
}
});
})
if (!map[tag]) {
throw new Error(`could not found "${tag}" in Changelog.md`);
throw new Error(`could not found "${tag}" in Changelog.md`)
}
return map[tag].join("\n").trim();
return map[tag].join('\n').trim()
}
export async function resolveUpdateLogDefault() {
const cwd = process.cwd();
const file = path.join(cwd, UPDATE_LOG);
const cwd = process.cwd()
const file = path.join(cwd, UPDATE_LOG)
if (!fs.existsSync(file)) {
throw new Error("could not found Changelog.md");
throw new Error('could not found Changelog.md')
}
const data = await fsp.readFile(file, "utf-8");
const data = await fsp.readFile(file, 'utf-8')
const reTitle = /^## v[\d.]+/;
const reEnd = /^---/;
const reTitle = /^## v[\d.]+/
const reEnd = /^---/
let isCapturing = false;
const content = [];
let firstTag = "";
let isCapturing = false
const content = []
let firstTag = ''
for (const line of data.split("\n")) {
for (const line of data.split('\n')) {
if (reTitle.test(line) && !isCapturing) {
isCapturing = true;
firstTag = line.slice(3).trim();
continue;
isCapturing = true
firstTag = line.slice(3).trim()
continue
}
if (isCapturing) {
if (reEnd.test(line)) {
break;
break
}
content.push(line);
content.push(line)
}
}
if (!firstTag) {
throw new Error("could not found any version tag in Changelog.md");
throw new Error('could not found any version tag in Changelog.md')
}
return content.join("\n").trim();
return content.join('\n').trim()
}
+57 -57
View File
@@ -1,117 +1,117 @@
import { context, getOctokit } from "@actions/github";
import fetch from "node-fetch";
import { context, getOctokit } from '@actions/github'
import fetch from 'node-fetch'
import { resolveUpdateLog } from "./updatelog.mjs";
import { resolveUpdateLog } from './updatelog.mjs'
const UPDATE_TAG_NAME = "updater";
const UPDATE_JSON_FILE = "update-fixed-webview2.json";
const UPDATE_JSON_PROXY = "update-fixed-webview2-proxy.json";
const UPDATE_TAG_NAME = 'updater'
const UPDATE_JSON_FILE = 'update-fixed-webview2.json'
const UPDATE_JSON_PROXY = 'update-fixed-webview2-proxy.json'
/// generate update.json
/// upload to update tag's release asset
async function resolveUpdater() {
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error("GITHUB_TOKEN is required");
throw new Error('GITHUB_TOKEN is required')
}
const options = { owner: context.repo.owner, repo: context.repo.repo };
const github = getOctokit(process.env.GITHUB_TOKEN);
const options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN)
const { data: tags } = await github.rest.repos.listTags({
...options,
per_page: 10,
page: 1,
});
})
// get the latest publish tag
const tag = tags.find((t) => t.name.startsWith("v"));
const tag = tags.find((t) => t.name.startsWith('v'))
console.log(tag);
console.log();
console.log(tag)
console.log()
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
...options,
tag: tag.name,
});
})
const updateData = {
name: tag.name,
notes: await resolveUpdateLog(tag.name), // use Changelog.md
pub_date: new Date().toISOString(),
platforms: {
"windows-x86_64": { signature: "", url: "" },
"windows-aarch64": { signature: "", url: "" },
"windows-x86": { signature: "", url: "" },
"windows-i686": { signature: "", url: "" },
'windows-x86_64': { signature: '', url: '' },
'windows-aarch64': { signature: '', url: '' },
'windows-x86': { signature: '', url: '' },
'windows-i686': { signature: '', url: '' },
},
};
}
const promises = latestRelease.assets.map(async (asset) => {
const { name, browser_download_url } = asset;
const { name, browser_download_url } = asset
// win64 url
if (name.endsWith("x64_fixed_webview2-setup.exe")) {
updateData.platforms["windows-x86_64"].url = browser_download_url;
if (name.endsWith('x64_fixed_webview2-setup.exe')) {
updateData.platforms['windows-x86_64'].url = browser_download_url
}
// win64 signature
if (name.endsWith("x64_fixed_webview2-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["windows-x86_64"].signature = sig;
if (name.endsWith('x64_fixed_webview2-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-x86_64'].signature = sig
}
// win32 url
if (name.endsWith("x86_fixed_webview2-setup.exe")) {
updateData.platforms["windows-x86"].url = browser_download_url;
updateData.platforms["windows-i686"].url = browser_download_url;
if (name.endsWith('x86_fixed_webview2-setup.exe')) {
updateData.platforms['windows-x86'].url = browser_download_url
updateData.platforms['windows-i686'].url = browser_download_url
}
// win32 signature
if (name.endsWith("x86_fixed_webview2-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["windows-x86"].signature = sig;
updateData.platforms["windows-i686"].signature = sig;
if (name.endsWith('x86_fixed_webview2-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-x86'].signature = sig
updateData.platforms['windows-i686'].signature = sig
}
// win arm url
if (name.endsWith("arm64_fixed_webview2-setup.exe")) {
updateData.platforms["windows-aarch64"].url = browser_download_url;
if (name.endsWith('arm64_fixed_webview2-setup.exe')) {
updateData.platforms['windows-aarch64'].url = browser_download_url
}
// win arm signature
if (name.endsWith("arm64_fixed_webview2-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["windows-aarch64"].signature = sig;
if (name.endsWith('arm64_fixed_webview2-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-aarch64'].signature = sig
}
});
})
await Promise.allSettled(promises);
console.log(updateData);
await Promise.allSettled(promises)
console.log(updateData)
// maybe should test the signature as well
// delete the null field
Object.entries(updateData.platforms).forEach(([key, value]) => {
if (!value.url) {
console.log(`[Error]: failed to parse release for "${key}"`);
delete updateData.platforms[key];
console.log(`[Error]: failed to parse release for "${key}"`)
delete updateData.platforms[key]
}
});
})
// 生成一个代理github的更新文件
// 使用 https://hub.fastgit.xyz/ 做github资源的加速
const updateDataNew = JSON.parse(JSON.stringify(updateData));
const updateDataNew = JSON.parse(JSON.stringify(updateData))
Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
if (value.url) {
updateDataNew.platforms[key].url =
"https://download.clashverge.dev/" + value.url;
'https://download.clashverge.dev/' + value.url
} else {
console.log(`[Error]: updateDataNew.platforms.${key} is null`);
console.log(`[Error]: updateDataNew.platforms.${key} is null`)
}
});
})
// update the update.json
const { data: updateRelease } = await github.rest.repos.getReleaseByTag({
...options,
tag: UPDATE_TAG_NAME,
});
})
// delete the old assets
for (const asset of updateRelease.assets) {
@@ -119,13 +119,13 @@ async function resolveUpdater() {
await github.rest.repos.deleteReleaseAsset({
...options,
asset_id: asset.id,
});
})
}
if (asset.name === UPDATE_JSON_PROXY) {
await github.rest.repos
.deleteReleaseAsset({ ...options, asset_id: asset.id })
.catch(console.error); // do not break the pipeline
.catch(console.error) // do not break the pipeline
}
}
@@ -135,24 +135,24 @@ async function resolveUpdater() {
release_id: updateRelease.id,
name: UPDATE_JSON_FILE,
data: JSON.stringify(updateData, null, 2),
});
})
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
name: UPDATE_JSON_PROXY,
data: JSON.stringify(updateDataNew, null, 2),
});
})
}
// get the signature file content
async function getSignature(url) {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/octet-stream" },
});
method: 'GET',
headers: { 'Content-Type': 'application/octet-stream' },
})
return response.text();
return response.text()
}
resolveUpdater().catch(console.error);
resolveUpdater().catch(console.error)
+140 -143
View File
@@ -1,263 +1,263 @@
import { getOctokit, context } from "@actions/github";
import fetch from "node-fetch";
import { getOctokit, context } from '@actions/github'
import fetch from 'node-fetch'
import { resolveUpdateLog, resolveUpdateLogDefault } from "./updatelog.mjs";
import { resolveUpdateLog, resolveUpdateLogDefault } from './updatelog.mjs'
// Add stable update JSON filenames
const UPDATE_TAG_NAME = "updater";
const UPDATE_JSON_FILE = "update.json";
const UPDATE_JSON_PROXY = "update-proxy.json";
const UPDATE_TAG_NAME = 'updater'
const UPDATE_JSON_FILE = 'update.json'
const UPDATE_JSON_PROXY = 'update-proxy.json'
// Add alpha update JSON filenames
const ALPHA_TAG_NAME = "updater-alpha";
const ALPHA_UPDATE_JSON_FILE = "update.json";
const ALPHA_UPDATE_JSON_PROXY = "update-proxy.json";
const ALPHA_TAG_NAME = 'updater-alpha'
const ALPHA_UPDATE_JSON_FILE = 'update.json'
const ALPHA_UPDATE_JSON_PROXY = 'update-proxy.json'
/// generate update.json
/// upload to update tag's release asset
async function resolveUpdater() {
if (process.env.GITHUB_TOKEN === undefined) {
throw new Error("GITHUB_TOKEN is required");
throw new Error('GITHUB_TOKEN is required')
}
const options = { owner: context.repo.owner, repo: context.repo.repo };
const github = getOctokit(process.env.GITHUB_TOKEN);
const options = { owner: context.repo.owner, repo: context.repo.repo }
const github = getOctokit(process.env.GITHUB_TOKEN)
// Fetch all tags using pagination
let allTags = [];
let page = 1;
const perPage = 100;
let allTags = []
let page = 1
const perPage = 100
while (true) {
const { data: pageTags } = await github.rest.repos.listTags({
...options,
per_page: perPage,
page: page,
});
})
allTags = allTags.concat(pageTags);
allTags = allTags.concat(pageTags)
// Break if we received fewer tags than requested (last page)
if (pageTags.length < perPage) {
break;
break
}
page++;
page++
}
const tags = allTags;
console.log(`Retrieved ${tags.length} tags in total`);
const tags = allTags
console.log(`Retrieved ${tags.length} tags in total`)
// More flexible tag detection with regex patterns
const stableTagRegex = /^v\d+\.\d+\.\d+$/; // Matches vX.Y.Z format
const stableTagRegex = /^v\d+\.\d+\.\d+$/ // Matches vX.Y.Z format
// const preReleaseRegex = /^v\d+\.\d+\.\d+-(alpha|beta|rc|pre)/i; // Matches vX.Y.Z-alpha/beta/rc format
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i; // Matches exact alpha/beta/rc/pre tags
const preReleaseRegex = /^(alpha|beta|rc|pre)$/i // Matches exact alpha/beta/rc/pre tags
// Get the latest stable tag and pre-release tag
const stableTag = tags.find((t) => stableTagRegex.test(t.name));
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name));
const stableTag = tags.find((t) => stableTagRegex.test(t.name))
const preReleaseTag = tags.find((t) => preReleaseRegex.test(t.name))
console.log("All tags:", tags.map((t) => t.name).join(", "));
console.log("Stable tag:", stableTag ? stableTag.name : "None found");
console.log('All tags:', tags.map((t) => t.name).join(', '))
console.log('Stable tag:', stableTag ? stableTag.name : 'None found')
console.log(
"Pre-release tag:",
preReleaseTag ? preReleaseTag.name : "None found",
);
console.log();
'Pre-release tag:',
preReleaseTag ? preReleaseTag.name : 'None found',
)
console.log()
// Process stable release
if (stableTag) {
await processRelease(github, options, stableTag, false);
await processRelease(github, options, stableTag, false)
}
// Process pre-release if found
if (preReleaseTag) {
await processRelease(github, options, preReleaseTag, true);
await processRelease(github, options, preReleaseTag, true)
}
}
// Process a release (stable or alpha) and generate update files
async function processRelease(github, options, tag, isAlpha) {
if (!tag) return;
if (!tag) return
try {
const { data: release } = await github.rest.repos.getReleaseByTag({
...options,
tag: tag.name,
});
})
const updateData = {
name: tag.name,
notes: await resolveUpdateLog(tag.name).catch(() =>
resolveUpdateLogDefault().catch(() => "No changelog available"),
resolveUpdateLogDefault().catch(() => 'No changelog available'),
),
pub_date: new Date().toISOString(),
platforms: {
win64: { signature: "", url: "" }, // compatible with older formats
linux: { signature: "", url: "" }, // compatible with older formats
darwin: { signature: "", url: "" }, // compatible with older formats
"darwin-aarch64": { signature: "", url: "" },
"darwin-intel": { signature: "", url: "" },
"darwin-x86_64": { signature: "", url: "" },
"linux-x86_64": { signature: "", url: "" },
"linux-x86": { signature: "", url: "" },
"linux-i686": { signature: "", url: "" },
"linux-aarch64": { signature: "", url: "" },
"linux-armv7": { signature: "", url: "" },
"windows-x86_64": { signature: "", url: "" },
"windows-aarch64": { signature: "", url: "" },
"windows-x86": { signature: "", url: "" },
"windows-i686": { signature: "", url: "" },
win64: { signature: '', url: '' }, // compatible with older formats
linux: { signature: '', url: '' }, // compatible with older formats
darwin: { signature: '', url: '' }, // compatible with older formats
'darwin-aarch64': { signature: '', url: '' },
'darwin-intel': { signature: '', url: '' },
'darwin-x86_64': { signature: '', url: '' },
'linux-x86_64': { signature: '', url: '' },
'linux-x86': { signature: '', url: '' },
'linux-i686': { signature: '', url: '' },
'linux-aarch64': { signature: '', url: '' },
'linux-armv7': { signature: '', url: '' },
'windows-x86_64': { signature: '', url: '' },
'windows-aarch64': { signature: '', url: '' },
'windows-x86': { signature: '', url: '' },
'windows-i686': { signature: '', url: '' },
},
};
}
const promises = release.assets.map(async (asset) => {
const { name, browser_download_url } = asset;
const { name, browser_download_url } = asset
// Process all the platform URL and signature data
// win64 url
if (name.endsWith("x64-setup.exe")) {
updateData.platforms.win64.url = browser_download_url;
updateData.platforms["windows-x86_64"].url = browser_download_url;
if (name.endsWith('x64-setup.exe')) {
updateData.platforms.win64.url = browser_download_url
updateData.platforms['windows-x86_64'].url = browser_download_url
}
// win64 signature
if (name.endsWith("x64-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms.win64.signature = sig;
updateData.platforms["windows-x86_64"].signature = sig;
if (name.endsWith('x64-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms.win64.signature = sig
updateData.platforms['windows-x86_64'].signature = sig
}
// win32 url
if (name.endsWith("x86-setup.exe")) {
updateData.platforms["windows-x86"].url = browser_download_url;
updateData.platforms["windows-i686"].url = browser_download_url;
if (name.endsWith('x86-setup.exe')) {
updateData.platforms['windows-x86'].url = browser_download_url
updateData.platforms['windows-i686'].url = browser_download_url
}
// win32 signature
if (name.endsWith("x86-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["windows-x86"].signature = sig;
updateData.platforms["windows-i686"].signature = sig;
if (name.endsWith('x86-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-x86'].signature = sig
updateData.platforms['windows-i686'].signature = sig
}
// win arm url
if (name.endsWith("arm64-setup.exe")) {
updateData.platforms["windows-aarch64"].url = browser_download_url;
if (name.endsWith('arm64-setup.exe')) {
updateData.platforms['windows-aarch64'].url = browser_download_url
}
// win arm signature
if (name.endsWith("arm64-setup.exe.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["windows-aarch64"].signature = sig;
if (name.endsWith('arm64-setup.exe.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['windows-aarch64'].signature = sig
}
// darwin url (intel)
if (name.endsWith(".app.tar.gz") && !name.includes("aarch")) {
updateData.platforms.darwin.url = browser_download_url;
updateData.platforms["darwin-intel"].url = browser_download_url;
updateData.platforms["darwin-x86_64"].url = browser_download_url;
if (name.endsWith('.app.tar.gz') && !name.includes('aarch')) {
updateData.platforms.darwin.url = browser_download_url
updateData.platforms['darwin-intel'].url = browser_download_url
updateData.platforms['darwin-x86_64'].url = browser_download_url
}
// darwin signature (intel)
if (name.endsWith(".app.tar.gz.sig") && !name.includes("aarch")) {
const sig = await getSignature(browser_download_url);
updateData.platforms.darwin.signature = sig;
updateData.platforms["darwin-intel"].signature = sig;
updateData.platforms["darwin-x86_64"].signature = sig;
if (name.endsWith('.app.tar.gz.sig') && !name.includes('aarch')) {
const sig = await getSignature(browser_download_url)
updateData.platforms.darwin.signature = sig
updateData.platforms['darwin-intel'].signature = sig
updateData.platforms['darwin-x86_64'].signature = sig
}
// darwin url (aarch)
if (name.endsWith("aarch64.app.tar.gz")) {
updateData.platforms["darwin-aarch64"].url = browser_download_url;
if (name.endsWith('aarch64.app.tar.gz')) {
updateData.platforms['darwin-aarch64'].url = browser_download_url
// 使linux可以检查更新
updateData.platforms.linux.url = browser_download_url;
updateData.platforms["linux-x86_64"].url = browser_download_url;
updateData.platforms["linux-x86"].url = browser_download_url;
updateData.platforms["linux-i686"].url = browser_download_url;
updateData.platforms["linux-aarch64"].url = browser_download_url;
updateData.platforms["linux-armv7"].url = browser_download_url;
updateData.platforms.linux.url = browser_download_url
updateData.platforms['linux-x86_64'].url = browser_download_url
updateData.platforms['linux-x86'].url = browser_download_url
updateData.platforms['linux-i686'].url = browser_download_url
updateData.platforms['linux-aarch64'].url = browser_download_url
updateData.platforms['linux-armv7'].url = browser_download_url
}
// darwin signature (aarch)
if (name.endsWith("aarch64.app.tar.gz.sig")) {
const sig = await getSignature(browser_download_url);
updateData.platforms["darwin-aarch64"].signature = sig;
updateData.platforms.linux.signature = sig;
updateData.platforms["linux-x86_64"].signature = sig;
updateData.platforms["linux-x86"].url = browser_download_url;
updateData.platforms["linux-i686"].url = browser_download_url;
updateData.platforms["linux-aarch64"].signature = sig;
updateData.platforms["linux-armv7"].signature = sig;
if (name.endsWith('aarch64.app.tar.gz.sig')) {
const sig = await getSignature(browser_download_url)
updateData.platforms['darwin-aarch64'].signature = sig
updateData.platforms.linux.signature = sig
updateData.platforms['linux-x86_64'].signature = sig
updateData.platforms['linux-x86'].url = browser_download_url
updateData.platforms['linux-i686'].url = browser_download_url
updateData.platforms['linux-aarch64'].signature = sig
updateData.platforms['linux-armv7'].signature = sig
}
});
})
await Promise.allSettled(promises);
console.log(updateData);
await Promise.allSettled(promises)
console.log(updateData)
// maybe should test the signature as well
// delete the null field
Object.entries(updateData.platforms).forEach(([key, value]) => {
if (!value.url) {
console.log(`[Error]: failed to parse release for "${key}"`);
delete updateData.platforms[key];
console.log(`[Error]: failed to parse release for "${key}"`)
delete updateData.platforms[key]
}
});
})
// Generate a proxy update file for accelerated GitHub resources
const updateDataNew = JSON.parse(JSON.stringify(updateData));
const updateDataNew = JSON.parse(JSON.stringify(updateData))
Object.entries(updateDataNew.platforms).forEach(([key, value]) => {
if (value.url) {
updateDataNew.platforms[key].url =
"https://download.clashverge.dev/" + value.url;
'https://download.clashverge.dev/' + value.url
} else {
console.log(`[Error]: updateDataNew.platforms.${key} is null`);
console.log(`[Error]: updateDataNew.platforms.${key} is null`)
}
});
})
// Get the appropriate updater release based on isAlpha flag
const releaseTag = isAlpha ? ALPHA_TAG_NAME : UPDATE_TAG_NAME;
const releaseTag = isAlpha ? ALPHA_TAG_NAME : UPDATE_TAG_NAME
console.log(
`Processing ${isAlpha ? "alpha" : "stable"} release:`,
`Processing ${isAlpha ? 'alpha' : 'stable'} release:`,
releaseTag,
);
)
try {
let updateRelease;
let updateRelease
try {
// Try to get the existing release
const response = await github.rest.repos.getReleaseByTag({
...options,
tag: releaseTag,
});
updateRelease = response.data;
})
updateRelease = response.data
console.log(
`Found existing ${releaseTag} release with ID: ${updateRelease.id}`,
);
)
} catch (error) {
// If release doesn't exist, create it
if (error.status === 404) {
console.log(
`Release with tag ${releaseTag} not found, creating new release...`,
);
)
const createResponse = await github.rest.repos.createRelease({
...options,
tag_name: releaseTag,
name: isAlpha
? "Auto-update Alpha Channel"
: "Auto-update Stable Channel",
body: `This release contains the update information for ${isAlpha ? "alpha" : "stable"} channel.`,
? 'Auto-update Alpha Channel'
: 'Auto-update Stable Channel',
body: `This release contains the update information for ${isAlpha ? 'alpha' : 'stable'} channel.`,
prerelease: isAlpha,
});
updateRelease = createResponse.data;
})
updateRelease = createResponse.data
console.log(
`Created new ${releaseTag} release with ID: ${updateRelease.id}`,
);
)
} else {
// If it's another error, throw it
throw error;
throw error
}
}
// File names based on release type
const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE;
const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY;
const jsonFile = isAlpha ? ALPHA_UPDATE_JSON_FILE : UPDATE_JSON_FILE
const proxyFile = isAlpha ? ALPHA_UPDATE_JSON_PROXY : UPDATE_JSON_PROXY
// Delete existing assets with these names
for (const asset of updateRelease.assets) {
@@ -265,13 +265,13 @@ async function processRelease(github, options, tag, isAlpha) {
await github.rest.repos.deleteReleaseAsset({
...options,
asset_id: asset.id,
});
})
}
if (asset.name === proxyFile) {
await github.rest.repos
.deleteReleaseAsset({ ...options, asset_id: asset.id })
.catch(console.error); // do not break the pipeline
.catch(console.error) // do not break the pipeline
}
}
@@ -281,32 +281,29 @@ async function processRelease(github, options, tag, isAlpha) {
release_id: updateRelease.id,
name: jsonFile,
data: JSON.stringify(updateData, null, 2),
});
})
await github.rest.repos.uploadReleaseAsset({
...options,
release_id: updateRelease.id,
name: proxyFile,
data: JSON.stringify(updateDataNew, null, 2),
});
})
console.log(
`Successfully uploaded ${isAlpha ? "alpha" : "stable"} update files to ${releaseTag}`,
);
`Successfully uploaded ${isAlpha ? 'alpha' : 'stable'} update files to ${releaseTag}`,
)
} catch (error) {
console.error(
`Failed to process ${isAlpha ? "alpha" : "stable"} release:`,
`Failed to process ${isAlpha ? 'alpha' : 'stable'} release:`,
error.message,
);
)
}
} catch (error) {
if (error.status === 404) {
console.log(`Release not found for tag: ${tag.name}, skipping...`);
console.log(`Release not found for tag: ${tag.name}, skipping...`)
} else {
console.error(
`Failed to get release for tag: ${tag.name}`,
error.message,
);
console.error(`Failed to get release for tag: ${tag.name}`, error.message)
}
}
}
@@ -314,11 +311,11 @@ async function processRelease(github, options, tag, isAlpha) {
// get the signature file content
async function getSignature(url) {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/octet-stream" },
});
method: 'GET',
headers: { 'Content-Type': 'application/octet-stream' },
})
return response.text();
return response.text()
}
resolveUpdater().catch(console.error);
resolveUpdater().catch(console.error)
+6 -6
View File
@@ -1,11 +1,11 @@
import clc from "cli-color";
import clc from 'cli-color'
export const log_success = (msg, ...optionalParams) =>
console.log(clc.green(msg), ...optionalParams);
console.log(clc.green(msg), ...optionalParams)
export const log_error = (msg, ...optionalParams) =>
console.log(clc.red(msg), ...optionalParams);
console.log(clc.red(msg), ...optionalParams)
export const log_info = (msg, ...optionalParams) =>
console.log(clc.bgBlue(msg), ...optionalParams);
var debugMsg = clc.xterm(245);
console.log(clc.bgBlue(msg), ...optionalParams)
var debugMsg = clc.xterm(245)
export const log_debug = (msg, ...optionalParams) =>
console.log(debugMsg(msg), ...optionalParams);
console.log(debugMsg(msg), ...optionalParams)
+4 -4
View File
@@ -1,8 +1,8 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (config.mode === "script") {
config.mode = "rule";
if (config.mode === 'script') {
config.mode = 'rule'
}
return config;
return config
}
@@ -1,12 +1,12 @@
// This function is exported for use by the Clash core
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line unused-imports/no-unused-vars
function main(config, _name) {
if (Array.isArray(config.proxies)) {
config.proxies.forEach((p, i) => {
if (p.type === "hysteria" && typeof p.alpn === "string") {
config.proxies[i].alpn = [p.alpn];
if (p.type === 'hysteria' && typeof p.alpn === 'string') {
config.proxies[i].alpn = [p.alpn]
}
});
})
}
return config;
return config
}
+2 -2
View File
@@ -1,4 +1,4 @@
@font-face {
font-family: "twemoji mozilla";
src: url("../fonts/Twemoji.Mozilla.ttf");
font-family: 'twemoji mozilla';
src: url('../fonts/Twemoji.Mozilla.ttf');
}
+5 -5
View File
@@ -1,12 +1,12 @@
@use "./layout.scss";
@use "./page.scss";
@use "./font.scss";
@use './layout.scss';
@use './page.scss';
@use './font.scss';
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
+21 -21
View File
@@ -1,4 +1,4 @@
import { LoadingButton } from "@mui/lab";
import { LoadingButton } from '@mui/lab'
import {
Button,
Dialog,
@@ -7,29 +7,29 @@ import {
DialogTitle,
type SxProps,
type Theme,
} from "@mui/material";
import { ReactNode } from "react";
} from '@mui/material'
import { ReactNode } from 'react'
interface Props {
title: ReactNode;
open: boolean;
okBtn?: ReactNode;
cancelBtn?: ReactNode;
disableEnforceFocus?: boolean;
disableOk?: boolean;
disableCancel?: boolean;
disableFooter?: boolean;
contentSx?: SxProps<Theme>;
children?: ReactNode;
loading?: boolean;
onOk?: () => void;
onCancel?: () => void;
onClose?: () => void;
title: ReactNode
open: boolean
okBtn?: ReactNode
cancelBtn?: ReactNode
disableEnforceFocus?: boolean
disableOk?: boolean
disableCancel?: boolean
disableFooter?: boolean
contentSx?: SxProps<Theme>
children?: ReactNode
loading?: boolean
onOk?: () => void
onCancel?: () => void
onClose?: () => void
}
export interface DialogRef {
open: () => void;
close: () => void;
open: () => void
close: () => void
}
export const BaseDialog: React.FC<Props> = ({
@@ -73,5 +73,5 @@ export const BaseDialog: React.FC<Props> = ({
</DialogActions>
)}
</Dialog>
);
};
)
}
+21 -21
View File
@@ -1,40 +1,40 @@
import { InboxRounded } from "@mui/icons-material";
import { alpha, Box, Typography } from "@mui/material";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { InboxRounded } from '@mui/icons-material'
import { alpha, Box, Typography } from '@mui/material'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import type { TranslationKey } from "@/types/generated/i18n-keys";
import type { TranslationKey } from '@/types/generated/i18n-keys'
interface Props {
text?: ReactNode;
textKey?: TranslationKey;
extra?: ReactNode;
text?: ReactNode
textKey?: TranslationKey
extra?: ReactNode
}
export const BaseEmpty = ({
text,
textKey = "shared.statuses.empty",
textKey = 'shared.statuses.empty',
extra,
}: Props) => {
const { t } = useTranslation();
const { t } = useTranslation()
const resolvedText: ReactNode = text !== undefined ? text : t(textKey);
const resolvedText: ReactNode = text !== undefined ? text : t(textKey)
return (
<Box
sx={({ palette }) => ({
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: alpha(palette.text.secondary, 0.75),
})}
>
<InboxRounded sx={{ fontSize: "4em" }} />
<Typography sx={{ fontSize: "1.25em" }}>{resolvedText}</Typography>
<InboxRounded sx={{ fontSize: '4em' }} />
<Typography sx={{ fontSize: '1.25em' }}>{resolvedText}</Typography>
{extra}
</Box>
);
};
)
}
+8 -8
View File
@@ -1,9 +1,9 @@
import { ReactNode } from "react";
import { ErrorBoundary, FallbackProps } from "react-error-boundary";
import { ReactNode } from 'react'
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
function ErrorFallback({ error }: FallbackProps) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
const errorMessage = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : undefined
return (
<div role="alert" style={{ padding: 16 }}>
@@ -16,15 +16,15 @@ function ErrorFallback({ error }: FallbackProps) {
<pre>{errorStack}</pre>
</details>
</div>
);
)
}
interface Props {
children?: ReactNode;
children?: ReactNode
}
export const BaseErrorBoundary = ({ children }: Props) => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>{children}</ErrorBoundary>
);
};
)
}
+23 -23
View File
@@ -1,13 +1,13 @@
import { Box, styled } from "@mui/material";
import React from "react";
import { Box, styled } from '@mui/material'
import React from 'react'
type Props = {
label: string;
fontSize?: string;
width?: string;
padding?: string;
children?: React.ReactNode;
};
label: string
fontSize?: string
width?: string
padding?: string
children?: React.ReactNode
}
export const BaseFieldset: React.FC<Props> = ({
label,
@@ -17,28 +17,28 @@ export const BaseFieldset: React.FC<Props> = ({
children,
}: Props) => {
const Fieldset = styled(Box)<{ component?: string }>(() => ({
position: "relative",
border: "1px solid #bbb",
borderRadius: "5px",
width: width ?? "auto",
padding: padding ?? "15px",
}));
position: 'relative',
border: '1px solid #bbb',
borderRadius: '5px',
width: width ?? 'auto',
padding: padding ?? '15px',
}))
const Label = styled("legend")(({ theme }) => ({
position: "absolute",
top: "-10px",
left: padding ?? "15px",
const Label = styled('legend')(({ theme }) => ({
position: 'absolute',
top: '-10px',
left: padding ?? '15px',
backgroundColor: theme.palette.background.paper,
backgroundImage:
"linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))",
'linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))',
color: theme.palette.text.primary,
fontSize: fontSize ?? "1em",
}));
fontSize: fontSize ?? '1em',
}))
return (
<Fieldset component="fieldset">
<Label>{label}</Label>
{children}
</Fieldset>
);
};
)
}
+13 -13
View File
@@ -1,35 +1,35 @@
import { Box, CircularProgress } from "@mui/material";
import React from "react";
import { Box, CircularProgress } from '@mui/material'
import React from 'react'
interface BaseLoadingOverlayProps {
isLoading: boolean;
isLoading: boolean
}
export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({
isLoading,
}) => {
if (!isLoading) return null;
if (!isLoading) return null
return (
<Box
sx={{
position: "absolute",
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
// Respect current theme; avoid bright flash in dark mode
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? "rgba(0, 0, 0, 0.5)"
: "rgba(255, 255, 255, 0.7)",
theme.palette.mode === 'dark'
? 'rgba(0, 0, 0, 0.5)'
: 'rgba(255, 255, 255, 0.7)',
zIndex: 1000,
}}
>
<CircularProgress />
</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;
display: flex;
height: 100%;
@@ -31,11 +31,11 @@ const Loading = styled("div")`
transform: scale(1);
}
}
`;
`
const LoadingItem = styled("div")(({ theme }) => ({
const LoadingItem = styled('div')(({ theme }) => ({
background: theme.palette.text.secondary,
}));
}))
export const BaseLoading = () => {
return (
@@ -44,5 +44,5 @@ export const BaseLoading = () => {
<LoadingItem />
<LoadingItem />
</Loading>
);
};
)
}
+19 -19
View File
@@ -1,29 +1,29 @@
import { Typography } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import React, { ReactNode } from "react";
import { Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import React, { ReactNode } from 'react'
import { BaseErrorBoundary } from "./base-error-boundary";
import { BaseErrorBoundary } from './base-error-boundary'
interface Props {
title?: React.ReactNode; // the page title
header?: React.ReactNode; // something behind title
contentStyle?: React.CSSProperties;
children?: ReactNode;
full?: boolean;
title?: React.ReactNode // the page title
header?: React.ReactNode // something behind title
contentStyle?: React.CSSProperties
children?: ReactNode
full?: boolean
}
export const BasePage: React.FC<Props> = (props) => {
const { title, header, contentStyle, full, children } = props;
const theme = useTheme();
const { title, header, contentStyle, full, children } = props
const theme = useTheme()
const isDark = theme.palette.mode === "dark";
const isDark = theme.palette.mode === 'dark'
return (
<BaseErrorBoundary>
<div className="base-page">
<header data-tauri-drag-region="true" style={{ userSelect: "none" }}>
<header data-tauri-drag-region="true" style={{ userSelect: 'none' }}>
<Typography
sx={{ fontSize: "20px", fontWeight: "700 " }}
sx={{ fontSize: '20px', fontWeight: '700 ' }}
data-tauri-drag-region="true"
>
{title}
@@ -33,12 +33,12 @@ export const BasePage: React.FC<Props> = (props) => {
</header>
<div
className={full ? "base-container no-padding" : "base-container"}
style={{ backgroundColor: isDark ? "#1e1f27" : "#ffffff" }}
className={full ? 'base-container no-padding' : 'base-container'}
style={{ backgroundColor: isDark ? '#1e1f27' : '#ffffff' }}
>
<section
style={{
backgroundColor: isDark ? "#1e1f27" : "var(--background-color)",
backgroundColor: isDark ? '#1e1f27' : 'var(--background-color)',
}}
>
<div className="base-content" style={contentStyle}>
@@ -48,5 +48,5 @@ export const BasePage: React.FC<Props> = (props) => {
</div>
</div>
</BaseErrorBoundary>
);
};
)
}
+97 -97
View File
@@ -1,6 +1,6 @@
import { ClearRounded } from "@mui/icons-material";
import { Box, SvgIcon, TextField, styled, IconButton } from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import { ClearRounded } from '@mui/icons-material'
import { Box, SvgIcon, TextField, styled, IconButton } from '@mui/material'
import Tooltip from '@mui/material/Tooltip'
import {
ChangeEvent,
useCallback,
@@ -8,67 +8,67 @@ import {
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
} from 'react'
import { useTranslation } from 'react-i18next'
import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react";
import UseRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react";
import { buildRegex, compileStringMatcher } from "@/utils/search-matcher";
import matchCaseIcon from '@/assets/image/component/match_case.svg?react'
import matchWholeWordIcon from '@/assets/image/component/match_whole_word.svg?react'
import UseRegularExpressionIcon from '@/assets/image/component/use_regular_expression.svg?react'
import { buildRegex, compileStringMatcher } from '@/utils/search-matcher'
export type SearchState = {
text: string;
matchCase: boolean;
matchWholeWord: boolean;
useRegularExpression: boolean;
};
text: string
matchCase: boolean
matchWholeWord: boolean
useRegularExpression: boolean
}
type SearchOptionState = Omit<SearchState, "text">;
type SearchOptionState = Omit<SearchState, 'text'>
type SearchProps = {
value?: string;
defaultValue?: string;
autoFocus?: boolean;
placeholder?: string;
matchCase?: boolean;
matchWholeWord?: boolean;
useRegularExpression?: boolean;
searchState?: Partial<SearchOptionState>;
onSearch: (match: (content: string) => boolean, state: SearchState) => void;
};
value?: string
defaultValue?: string
autoFocus?: boolean
placeholder?: string
matchCase?: boolean
matchWholeWord?: boolean
useRegularExpression?: boolean
searchState?: Partial<SearchOptionState>
onSearch: (match: (content: string) => boolean, state: SearchState) => void
}
const StyledTextField = styled(TextField)(({ theme }) => ({
"& .MuiInputBase-root": {
background: theme.palette.mode === "light" ? "#fff" : undefined,
paddingRight: "4px",
'& .MuiInputBase-root': {
background: theme.palette.mode === 'light' ? '#fff' : undefined,
paddingRight: '4px',
},
"& .MuiInputBase-root svg[aria-label='active'] path": {
fill: theme.palette.primary.light,
},
"& .MuiInputBase-root svg[aria-label='inactive'] path": {
fill: "#A7A7A7",
fill: '#A7A7A7',
},
}));
}))
const useControllableState = <T,>(options: {
controlled: T | undefined;
defaultValue: T;
controlled: T | undefined
defaultValue: T
}) => {
const { controlled, defaultValue } = options;
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const isControlled = controlled !== undefined;
const { controlled, defaultValue } = options
const [uncontrolled, setUncontrolled] = useState(defaultValue)
const isControlled = controlled !== undefined
const value = isControlled ? controlled : uncontrolled;
const value = isControlled ? controlled : uncontrolled
const setValue = useCallback(
(next: T) => {
if (!isControlled) setUncontrolled(next);
if (!isControlled) setUncontrolled(next)
},
[isControlled],
);
)
return [value, setValue] as const;
};
return [value, setValue] as const
}
export const BaseSearchBox = ({
value,
@@ -81,113 +81,113 @@ export const BaseSearchBox = ({
useRegularExpression: defaultUseRegularExpression = false,
onSearch,
}: SearchProps) => {
const { t } = useTranslation();
const onSearchRef = useRef(onSearch);
const lastSearchStateRef = useRef<SearchState | null>(null);
const { t } = useTranslation()
const onSearchRef = useRef(onSearch)
const lastSearchStateRef = useRef<SearchState | null>(null)
const [text, setText] = useControllableState<string>({
controlled: value,
defaultValue: defaultValue ?? "",
});
defaultValue: defaultValue ?? '',
})
const [matchCase, setMatchCase] = useControllableState<boolean>({
controlled: searchState?.matchCase,
defaultValue: defaultMatchCase,
});
})
const [matchWholeWord, setMatchWholeWord] = useControllableState<boolean>({
controlled: searchState?.matchWholeWord,
defaultValue: defaultMatchWholeWord,
});
})
const [useRegularExpression, setUseRegularExpression] =
useControllableState<boolean>({
controlled: searchState?.useRegularExpression,
defaultValue: defaultUseRegularExpression,
});
})
const iconStyle = {
style: {
height: "24px",
width: "24px",
cursor: "pointer",
height: '24px',
width: '24px',
cursor: 'pointer',
} as React.CSSProperties,
inheritViewBox: true,
};
}
useEffect(() => {
onSearchRef.current = onSearch;
}, [onSearch]);
onSearchRef.current = onSearch
}, [onSearch])
const emitSearch = useCallback((nextState: SearchState) => {
const prevState = lastSearchStateRef.current;
const prevState = lastSearchStateRef.current
const isSameState =
!!prevState &&
prevState.text === nextState.text &&
prevState.matchCase === nextState.matchCase &&
prevState.matchWholeWord === nextState.matchWholeWord &&
prevState.useRegularExpression === nextState.useRegularExpression;
if (isSameState) return;
prevState.useRegularExpression === nextState.useRegularExpression
if (isSameState) return
const compiled = compileStringMatcher(nextState.text, nextState);
onSearchRef.current(compiled.matcher, nextState);
const compiled = compileStringMatcher(nextState.text, nextState)
onSearchRef.current(compiled.matcher, nextState)
lastSearchStateRef.current = nextState;
}, []);
lastSearchStateRef.current = nextState
}, [])
useEffect(() => {
emitSearch({ text, matchCase, matchWholeWord, useRegularExpression });
}, [emitSearch, matchCase, matchWholeWord, text, useRegularExpression]);
emitSearch({ text, matchCase, matchWholeWord, useRegularExpression })
}, [emitSearch, matchCase, matchWholeWord, text, useRegularExpression])
const effectiveErrorMessage = useMemo(() => {
if (!useRegularExpression || !text) return "";
const flags = matchCase ? "" : "i";
return buildRegex(text, flags) ? "" : t("shared.validation.invalidRegex");
}, [matchCase, t, text, useRegularExpression]);
if (!useRegularExpression || !text) return ''
const flags = matchCase ? '' : 'i'
return buildRegex(text, flags) ? '' : t('shared.validation.invalidRegex')
}, [matchCase, t, text, useRegularExpression])
const handleChangeText = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const nextText = e.target?.value ?? "";
setText(nextText);
const nextText = e.target?.value ?? ''
setText(nextText)
emitSearch({
text: nextText,
matchCase,
matchWholeWord,
useRegularExpression,
});
};
})
}
const handleToggleUseRegularExpression = () => {
const next = !useRegularExpression;
setUseRegularExpression(next);
const next = !useRegularExpression
setUseRegularExpression(next)
emitSearch({
text,
matchCase,
matchWholeWord,
useRegularExpression: next,
});
};
})
}
const handleClearInput = () => {
setText("");
emitSearch({ text: "", matchCase, matchWholeWord, useRegularExpression });
};
setText('')
emitSearch({ text: '', matchCase, matchWholeWord, useRegularExpression })
}
const handleToggleMatchCase = () => {
const next = !matchCase;
setMatchCase(next);
emitSearch({ text, matchCase: next, matchWholeWord, useRegularExpression });
};
const next = !matchCase
setMatchCase(next)
emitSearch({ text, matchCase: next, matchWholeWord, useRegularExpression })
}
const handleToggleMatchWholeWord = () => {
const next = !matchWholeWord;
setMatchWholeWord(next);
emitSearch({ text, matchCase, matchWholeWord: next, useRegularExpression });
};
const next = !matchWholeWord
setMatchWholeWord(next)
emitSearch({ text, matchCase, matchWholeWord: next, useRegularExpression })
}
return (
<Tooltip title={effectiveErrorMessage || ""} placement="bottom-start">
<Tooltip title={effectiveErrorMessage || ''} placement="bottom-start">
<StyledTextField
autoComplete="new-password"
hiddenLabel
@@ -196,7 +196,7 @@ export const BaseSearchBox = ({
variant="outlined"
autoFocus={autoFocus}
spellCheck="false"
placeholder={placeholder ?? t("shared.placeholders.filter")}
placeholder={placeholder ?? t('shared.placeholders.filter')}
sx={{ input: { py: 0.65, px: 1.25 } }}
value={text}
onChange={handleChangeText}
@@ -207,7 +207,7 @@ export const BaseSearchBox = ({
endAdornment: (
<Box display="flex">
{!!text && (
<Tooltip title={t("shared.placeholders.resetInput")}>
<Tooltip title={t('shared.placeholders.resetInput')}>
<IconButton
size="small"
{...iconStyle}
@@ -217,26 +217,26 @@ export const BaseSearchBox = ({
</IconButton>
</Tooltip>
)}
<Tooltip title={t("shared.placeholders.matchCase")}>
<Tooltip title={t('shared.placeholders.matchCase')}>
<SvgIcon
component={matchCaseIcon}
{...iconStyle}
aria-label={matchCase ? "active" : "inactive"}
aria-label={matchCase ? 'active' : 'inactive'}
onClick={handleToggleMatchCase}
/>
</Tooltip>
<Tooltip title={t("shared.placeholders.matchWholeWord")}>
<Tooltip title={t('shared.placeholders.matchWholeWord')}>
<SvgIcon
component={matchWholeWordIcon}
{...iconStyle}
aria-label={matchWholeWord ? "active" : "inactive"}
aria-label={matchWholeWord ? 'active' : 'inactive'}
onClick={handleToggleMatchWholeWord}
/>
</Tooltip>
<Tooltip title={t("shared.placeholders.useRegex")}>
<Tooltip title={t('shared.placeholders.useRegex')}>
<SvgIcon
component={UseRegularExpressionIcon}
aria-label={useRegularExpression ? "active" : "inactive"}
aria-label={useRegularExpression ? 'active' : 'inactive'}
{...iconStyle}
onClick={handleToggleUseRegularExpression}
/>
@@ -247,5 +247,5 @@ export const BaseSearchBox = ({
}}
/>
</Tooltip>
);
};
)
}
+74 -74
View File
@@ -1,4 +1,4 @@
import { CodeRounded, ViewModuleRounded } from "@mui/icons-material";
import { CodeRounded, ViewModuleRounded } from '@mui/icons-material'
import {
Box,
Button,
@@ -8,51 +8,51 @@ import {
TextField,
Tooltip,
Typography,
} from "@mui/material";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type BaseSplitChipEditorMode = "visual" | "advanced";
export type BaseSplitChipEditorMode = 'visual' | 'advanced'
interface BaseSplitChipEditorProps {
value?: string;
onChange: (value: string) => void;
disabled?: boolean;
error?: boolean;
helperText?: ReactNode;
placeholder?: string;
rows?: number;
separator?: string;
splitPattern?: RegExp;
defaultMode?: BaseSplitChipEditorMode;
showModeToggle?: boolean;
ariaLabel?: string;
addLabel?: ReactNode;
emptyLabel?: ReactNode;
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>;
renderHeader?: (modeToggle: ReactNode) => ReactNode;
value?: string
onChange: (value: string) => void
disabled?: boolean
error?: boolean
helperText?: ReactNode
placeholder?: string
rows?: number
separator?: string
splitPattern?: RegExp
defaultMode?: BaseSplitChipEditorMode
showModeToggle?: boolean
ariaLabel?: string
addLabel?: ReactNode
emptyLabel?: ReactNode
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
renderHeader?: (modeToggle: ReactNode) => ReactNode
}
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/;
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
const splitValue = (value: string, splitPattern: RegExp) =>
value
.split(splitPattern)
.map((item) => item.trim())
.filter(Boolean);
.filter(Boolean)
export const BaseSplitChipEditor = ({
value = "",
value = '',
onChange,
disabled = false,
error = false,
helperText,
placeholder,
rows = 4,
separator = ",",
separator = ',',
splitPattern = DEFAULT_SPLIT_PATTERN,
defaultMode = "visual",
defaultMode = 'visual',
showModeToggle = true,
ariaLabel,
addLabel,
@@ -60,58 +60,58 @@ export const BaseSplitChipEditor = ({
modeLabels,
renderHeader,
}: BaseSplitChipEditorProps) => {
const { t } = useTranslation();
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode);
const [draft, setDraft] = useState("");
const { t } = useTranslation()
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
const [draft, setDraft] = useState('')
const resolvedLabels = useMemo(
() => ({
visual: modeLabels?.visual ?? t("shared.editorModes.visualization"),
advanced: modeLabels?.advanced ?? t("shared.editorModes.advanced"),
add: addLabel ?? t("shared.actions.new"),
empty: emptyLabel ?? t("shared.statuses.empty"),
visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
add: addLabel ?? t('shared.actions.new'),
empty: emptyLabel ?? t('shared.statuses.empty'),
}),
[t, modeLabels, addLabel, emptyLabel],
);
)
const values = useMemo(
() => splitValue(value, splitPattern),
[value, splitPattern],
);
)
const items = useMemo(() => {
const counts = new Map<string, number>();
const counts = new Map<string, number>()
return values.map((item) => {
const nextCount = (counts.get(item) ?? 0) + 1;
counts.set(item, nextCount);
const nextCount = (counts.get(item) ?? 0) + 1
counts.set(item, nextCount)
return {
key: `${item}-${nextCount}`,
value: item,
};
});
}, [values]);
}
})
}, [values])
const handleAddDraft = () => {
const nextValues = splitValue(draft, splitPattern);
const nextValues = splitValue(draft, splitPattern)
if (!nextValues.length) {
return;
return
}
const nextValue = [...values, ...nextValues].join(separator)
onChange(nextValue)
setDraft('')
}
const nextValue = [...values, ...nextValues].join(separator);
onChange(nextValue);
setDraft("");
};
const handleRemoveItem = (index: number) => {
const nextValue = values.filter((_, itemIndex) => itemIndex !== index);
onChange(nextValue.join(separator));
};
const nextValue = values.filter((_, itemIndex) => itemIndex !== index)
onChange(nextValue.join(separator))
}
const nextMode = mode === "visual" ? "advanced" : "visual";
const nextMode = mode === 'visual' ? 'advanced' : 'visual'
const toggleLabel =
nextMode === "visual" ? resolvedLabels.visual : resolvedLabels.advanced;
const ToggleIcon = nextMode === "visual" ? ViewModuleRounded : CodeRounded;
nextMode === 'visual' ? resolvedLabels.visual : resolvedLabels.advanced
const ToggleIcon = nextMode === 'visual' ? ViewModuleRounded : CodeRounded
const resolvedAriaLabel =
ariaLabel ?? (typeof toggleLabel === "string" ? toggleLabel : undefined);
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
const modeToggle = showModeToggle ? (
<Tooltip title={toggleLabel}>
@@ -119,26 +119,26 @@ export const BaseSplitChipEditor = ({
size="small"
aria-label={resolvedAriaLabel}
onClick={() => {
setMode(nextMode);
if (nextMode === "visual") {
setDraft("");
setMode(nextMode)
if (nextMode === 'visual') {
setDraft('')
}
}}
>
<ToggleIcon fontSize="small" />
</IconButton>
</Tooltip>
) : null;
) : null
return (
<>
{renderHeader ? renderHeader(modeToggle) : modeToggle}
{mode === "visual" ? (
<Box sx={{ padding: "0 2px 5px" }}>
{mode === 'visual' ? (
<Box sx={{ padding: '0 2px 5px' }}>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
display: 'flex',
flexWrap: 'wrap',
gap: 1,
minHeight: 32,
}}
@@ -161,7 +161,7 @@ export const BaseSplitChipEditor = ({
)}
</Box>
<Box
sx={{ display: "flex", gap: 1, marginTop: 1, alignItems: "center" }}
sx={{ display: 'flex', gap: 1, marginTop: 1, alignItems: 'center' }}
>
<TextField
disabled={disabled}
@@ -171,14 +171,14 @@ export const BaseSplitChipEditor = ({
placeholder={placeholder}
error={error}
sx={{
"& .MuiInputBase-root": { minHeight: 32 },
"& .MuiInputBase-input": { padding: "4px 8px" },
'& .MuiInputBase-root': { minHeight: 32 },
'& .MuiInputBase-input': { padding: '4px 8px' },
}}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleAddDraft();
if (event.key === 'Enter') {
event.preventDefault()
handleAddDraft()
}
}}
/>
@@ -187,7 +187,7 @@ export const BaseSplitChipEditor = ({
size="small"
onClick={handleAddDraft}
disabled={disabled || !draft.trim()}
sx={{ minHeight: 32, padding: "2px 8px" }}
sx={{ minHeight: 32, padding: '2px 8px' }}
>
{resolvedLabels.add}
</Button>
@@ -203,14 +203,14 @@ export const BaseSplitChipEditor = ({
size="small"
multiline
rows={rows}
sx={{ width: "100%" }}
sx={{ width: '100%' }}
value={value}
helperText={helperText}
onChange={(event) => {
onChange(event.target.value);
onChange(event.target.value)
}}
/>
)}
</>
);
};
)
}
+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>) => {
return (
@@ -13,7 +13,7 @@ export const BaseStyledSelect = styled((props: SelectProps<string>) => {
}}
{...props}
/>
);
)
})(({ 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 { useTranslation } from "react-i18next";
import { TextField, type TextFieldProps, styled } from '@mui/material'
import { useTranslation } from 'react-i18next'
export const BaseStyledTextField = styled((props: TextFieldProps) => {
const { t } = useTranslation();
const { t } = useTranslation()
return (
<TextField
@@ -12,13 +12,13 @@ export const BaseStyledTextField = styled((props: TextFieldProps) => {
size="small"
variant="outlined"
spellCheck="false"
placeholder={t("shared.placeholders.filter")}
placeholder={t('shared.placeholders.filter')}
sx={{ input: { py: 0.65, px: 1.25 } }}
{...props}
/>
);
)
})(({ theme }) => ({
"& .MuiInputBase-root": {
background: theme.palette.mode === "light" ? "#fff" : undefined,
'& .MuiInputBase-root': {
background: theme.palette.mode === 'light' ? '#fff' : undefined,
},
}));
}))
+22 -22
View File
@@ -1,5 +1,5 @@
import { styled } from "@mui/material/styles";
import { default as MuiSwitch, SwitchProps } from "@mui/material/Switch";
import { styled } from '@mui/material/styles'
import { default as MuiSwitch, SwitchProps } from '@mui/material/Switch'
export const Switch = styled((props: SwitchProps) => (
<MuiSwitch
@@ -12,47 +12,47 @@ export const Switch = styled((props: SwitchProps) => (
height: 26,
padding: 0,
marginRight: 1,
"& .MuiSwitch-switchBase": {
'& .MuiSwitch-switchBase': {
padding: 0,
margin: 2,
transitionDuration: "300ms",
"&.Mui-checked": {
transform: "translateX(16px)",
color: "#fff",
"& + .MuiSwitch-track": {
transitionDuration: '300ms',
'&.Mui-checked': {
transform: 'translateX(16px)',
color: '#fff',
'& + .MuiSwitch-track': {
backgroundColor: theme.palette.primary.main,
opacity: 1,
border: 0,
},
"&.Mui-disabled + .MuiSwitch-track": {
'&.Mui-disabled + .MuiSwitch-track': {
opacity: 0.5,
},
},
"&.Mui-focusVisible .MuiSwitch-thumb": {
color: "#33cf4d",
border: "6px solid #fff",
'&.Mui-focusVisible .MuiSwitch-thumb': {
color: '#33cf4d',
border: '6px solid #fff',
},
"&.Mui-disabled .MuiSwitch-thumb": {
'&.Mui-disabled .MuiSwitch-thumb': {
color:
theme.palette.mode === "light"
theme.palette.mode === 'light'
? theme.palette.grey[100]
: theme.palette.grey[600],
},
"&.Mui-disabled + .MuiSwitch-track": {
opacity: theme.palette.mode === "light" ? 0.7 : 0.3,
'&.Mui-disabled + .MuiSwitch-track': {
opacity: theme.palette.mode === 'light' ? 0.7 : 0.3,
},
},
"& .MuiSwitch-thumb": {
boxSizing: "border-box",
'& .MuiSwitch-thumb': {
boxSizing: 'border-box',
width: 22,
height: 22,
},
"& .MuiSwitch-track": {
'& .MuiSwitch-track': {
borderRadius: 26 / 2,
backgroundColor: theme.palette.mode === "light" ? "#BBBBBB" : "#39393D",
backgroundColor: theme.palette.mode === 'light' ? '#BBBBBB' : '#39393D',
opacity: 1,
transition: theme.transitions.create(["background-color"], {
transition: theme.transitions.create(['background-color'], {
duration: 500,
}),
},
}));
}))
+8 -8
View File
@@ -1,24 +1,24 @@
import { InfoRounded } from "@mui/icons-material";
import { InfoRounded } from '@mui/icons-material'
import {
Tooltip,
IconButton,
IconButtonProps,
SvgIconProps,
} from "@mui/material";
} from '@mui/material'
interface Props extends IconButtonProps {
title?: string;
icon?: React.ElementType<SvgIconProps>;
title?: string
icon?: React.ElementType<SvgIconProps>
}
export const TooltipIcon: React.FC<Props> = (props: Props) => {
const { title = "", icon: Icon = InfoRounded, ...restProps } = props;
const { title = '', icon: Icon = InfoRounded, ...restProps } = props
return (
<Tooltip title={title} placement="top">
<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>
</Tooltip>
);
};
)
}
+13 -13
View File
@@ -1,16 +1,16 @@
export { BaseDialog, type DialogRef } from "./base-dialog";
export { BaseEmpty } from "./base-empty";
export { BaseErrorBoundary } from "./base-error-boundary";
export { BaseFieldset } from "./base-fieldset";
export { BaseLoading } from "./base-loading";
export { BaseLoadingOverlay } from "./base-loading-overlay";
export { BasePage } from "./base-page";
export { BaseSearchBox, type SearchState } from "./base-search-box";
export { BaseDialog, type DialogRef } from './base-dialog'
export { BaseEmpty } from './base-empty'
export { BaseErrorBoundary } from './base-error-boundary'
export { BaseFieldset } from './base-fieldset'
export { BaseLoading } from './base-loading'
export { BaseLoadingOverlay } from './base-loading-overlay'
export { BasePage } from './base-page'
export { BaseSearchBox, type SearchState } from './base-search-box'
export {
BaseSplitChipEditor,
type BaseSplitChipEditorMode,
} from "./base-split-chip-editor";
export { BaseStyledSelect } from "./base-styled-select";
export { BaseStyledTextField } from "./base-styled-text-field";
export { Switch } from "./base-switch";
export { TooltipIcon } from "./base-tooltip-icon";
} from './base-split-chip-editor'
export { BaseStyledSelect } from './base-styled-select'
export { BaseStyledTextField } from './base-styled-text-field'
export { Switch } from './base-switch'
export { TooltipIcon } from './base-tooltip-icon'
@@ -5,10 +5,10 @@ import {
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { DragIndicatorRounded } from "@mui/icons-material";
} from '@dnd-kit/core'
import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { DragIndicatorRounded } from '@mui/icons-material'
import {
Button,
Checkbox,
@@ -20,17 +20,17 @@ import {
List,
ListItem,
ListItemText,
} from "@mui/material";
import type { Column } from "@tanstack/react-table";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import type { Column } from '@tanstack/react-table'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean;
columns: Column<IConnectionsItem, unknown>[];
onClose: () => void;
onOrderChange: (order: string[]) => void;
onReset: () => void;
open: boolean
columns: Column<IConnectionsItem, unknown>[]
onClose: () => void
onOrderChange: (order: string[]) => void
onReset: () => void
}
export const ConnectionColumnManager = ({
@@ -44,34 +44,34 @@ export const ConnectionColumnManager = ({
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
}),
);
const { t } = useTranslation();
)
const { t } = useTranslation()
const items = useMemo(() => columns.map((column) => column.id), [columns]);
const items = useMemo(() => columns.map((column) => column.id), [columns])
const visibleCount = useMemo(
() => columns.filter((column) => column.getIsVisible()).length,
[columns],
);
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const { active, over } = event
if (!over || active.id === over.id) return
const order = columns.map((column) => column.id);
const oldIndex = order.indexOf(active.id as string);
const newIndex = order.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
const order = columns.map((column) => column.id)
const oldIndex = order.indexOf(active.id as string)
const newIndex = order.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return
onOrderChange(arrayMove(order, oldIndex, newIndex));
onOrderChange(arrayMove(order, oldIndex, newIndex))
},
[columns, onOrderChange],
);
)
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>
{t("connections.components.columnManager.title")}
{t('connections.components.columnManager.title')}
</DialogTitle>
<DialogContent sx={{ pt: 1 }}>
<DndContext
@@ -83,7 +83,7 @@ export const ConnectionColumnManager = ({
<List
dense
disablePadding
sx={{ display: "flex", flexDirection: "column", gap: 1 }}
sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}
>
{columns.map((column) => (
<SortableColumnItem
@@ -91,7 +91,7 @@ export const ConnectionColumnManager = ({
column={column}
label={getColumnLabel(column)}
dragHandleLabel={t(
"connections.components.columnManager.dragHandle",
'connections.components.columnManager.dragHandle',
)}
disableToggle={
!column.getCanHide() ||
@@ -105,21 +105,21 @@ export const ConnectionColumnManager = ({
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="text" onClick={onReset}>
{t("shared.actions.resetToDefault")}
{t('shared.actions.resetToDefault')}
</Button>
<Button variant="contained" onClick={onClose}>
{t("shared.actions.close")}
{t('shared.actions.close')}
</Button>
</DialogActions>
</Dialog>
);
};
)
}
interface SortableColumnItemProps {
column: Column<IConnectionsItem, unknown>;
label: string;
dragHandleLabel: string;
disableToggle?: boolean;
column: Column<IConnectionsItem, unknown>
label: string
dragHandleLabel: string
disableToggle?: boolean
}
const SortableColumnItem = ({
@@ -135,7 +135,7 @@ const SortableColumnItem = ({
transform,
transition,
isDragging,
} = useSortable({ id: column.id });
} = useSortable({ id: column.id })
const style = useMemo(
() => ({
@@ -143,7 +143,7 @@ const SortableColumnItem = ({
transition,
}),
[transform, transition],
);
)
return (
<ListItem
@@ -154,9 +154,9 @@ const SortableColumnItem = ({
py: 0.5,
borderRadius: 1,
border: (theme) => `1px solid ${theme.palette.divider}`,
backgroundColor: isDragging ? "action.hover" : "transparent",
display: "flex",
alignItems: "center",
backgroundColor: isDragging ? 'action.hover' : 'transparent',
display: 'flex',
alignItems: 'center',
gap: 1,
}}
style={style}
@@ -169,13 +169,13 @@ const SortableColumnItem = ({
/>
<ListItemText
primary={label}
slotProps={{ primary: { variant: "body2" } }}
slotProps={{ primary: { variant: 'body2' } }}
sx={{ mr: 1 }}
/>
<IconButton
edge="end"
size="small"
sx={{ cursor: isDragging ? "grabbing" : "grab" }}
sx={{ cursor: isDragging ? 'grabbing' : 'grab' }}
aria-label={dragHandleLabel}
{...attributes}
{...listeners}
@@ -183,13 +183,13 @@ const SortableColumnItem = ({
<DragIndicatorRounded fontSize="small" />
</IconButton>
</ListItem>
);
};
)
}
const getColumnLabel = (column: Column<IConnectionsItem, unknown>) => {
const meta = column.columnDef.meta as { label?: string } | undefined;
if (meta?.label) return meta.label;
const meta = column.columnDef.meta as { label?: string } | undefined
if (meta?.label) return meta.label
const header = column.columnDef.header;
return typeof header === "string" ? header : column.id;
};
const header = column.columnDef.header
return typeof header === 'string' ? header : column.id
}
+63 -63
View File
@@ -1,43 +1,43 @@
import { Box, Button, Snackbar, useTheme } from "@mui/material";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { useImperativeHandle, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { closeConnection } from "tauri-plugin-mihomo-api";
import { Box, Button, Snackbar, useTheme } from '@mui/material'
import { useLockFn } from 'ahooks'
import dayjs from 'dayjs'
import { useImperativeHandle, useState, type Ref } from 'react'
import { useTranslation } from 'react-i18next'
import { closeConnection } from 'tauri-plugin-mihomo-api'
import parseTraffic from "@/utils/parse-traffic";
import parseTraffic from '@/utils/parse-traffic'
export interface ConnectionDetailRef {
open: (detail: IConnectionsItem, closed: boolean) => void;
open: (detail: IConnectionsItem, closed: boolean) => void
}
export function ConnectionDetail({ ref }: { ref?: Ref<ConnectionDetailRef> }) {
const [open, setOpen] = useState(false);
const [detail, setDetail] = useState<IConnectionsItem>(null!);
const [closed, setClosed] = useState(false);
const theme = useTheme();
const [open, setOpen] = useState(false)
const [detail, setDetail] = useState<IConnectionsItem>(null!)
const [closed, setClosed] = useState(false)
const theme = useTheme()
useImperativeHandle(ref, () => ({
open: (detail: IConnectionsItem, closed: boolean) => {
if (open) return;
setOpen(true);
setDetail(detail);
setClosed(closed);
if (open) return
setOpen(true)
setDetail(detail)
setClosed(closed)
},
}));
}))
const onClose = () => setOpen(false);
const onClose = () => setOpen(false)
return (
<Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
open={open}
onClose={onClose}
sx={{
".MuiSnackbarContent-root": {
maxWidth: "520px",
maxHeight: "480px",
overflowY: "auto",
'.MuiSnackbarContent-root': {
maxWidth: '520px',
maxHeight: '480px',
overflowY: 'auto',
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
},
@@ -52,87 +52,87 @@ export function ConnectionDetail({ ref }: { ref?: Ref<ConnectionDetailRef> }) {
) : null
}
/>
);
)
}
interface InnerProps {
data: IConnectionsItem;
closed: boolean;
onClose?: () => void;
data: IConnectionsItem
closed: boolean
onClose?: () => void
}
const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => {
const { t } = useTranslation();
const { metadata, rulePayload } = data;
const theme = useTheme();
const chains = [...data.chains].reverse().join(" / ");
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule;
const { t } = useTranslation()
const { metadata, rulePayload } = data
const theme = useTheme()
const chains = [...data.chains].reverse().join(' / ')
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule
const host = metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
: `${metadata.remoteDestination}:${metadata.destinationPort}`
const Destination = metadata.destinationIP
? metadata.destinationIP
: metadata.remoteDestination;
: metadata.remoteDestination
const information = [
{ label: t("connections.components.fields.host"), value: host },
{ label: t('connections.components.fields.host'), value: host },
{
label: t("shared.labels.downloaded"),
value: parseTraffic(data.download).join(" "),
label: t('shared.labels.downloaded'),
value: parseTraffic(data.download).join(' '),
},
{
label: t("shared.labels.uploaded"),
value: parseTraffic(data.upload).join(" "),
label: t('shared.labels.uploaded'),
value: parseTraffic(data.upload).join(' '),
},
{
label: t("connections.components.fields.dlSpeed"),
value: parseTraffic(data.curDownload ?? -1).join(" ") + "/s",
label: t('connections.components.fields.dlSpeed'),
value: parseTraffic(data.curDownload ?? -1).join(' ') + '/s',
},
{
label: t("connections.components.fields.ulSpeed"),
value: parseTraffic(data.curUpload ?? -1).join(" ") + "/s",
label: t('connections.components.fields.ulSpeed'),
value: parseTraffic(data.curUpload ?? -1).join(' ') + '/s',
},
{
label: t("connections.components.fields.chains"),
label: t('connections.components.fields.chains'),
value: chains,
},
{ label: t("connections.components.fields.rule"), value: rule },
{ label: t('connections.components.fields.rule'), value: rule },
{
label: t("connections.components.fields.process"),
value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ""}`,
label: t('connections.components.fields.process'),
value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ''}`,
},
{
label: t("connections.components.fields.time"),
label: t('connections.components.fields.time'),
value: dayjs(data.start).fromNow(),
},
{
label: t("connections.components.fields.source"),
label: t('connections.components.fields.source'),
value: `${metadata.sourceIP}:${metadata.sourcePort}`,
},
{
label: t("connections.components.fields.destination"),
label: t('connections.components.fields.destination'),
value: Destination,
},
{
label: t("connections.components.fields.destinationPort"),
label: t('connections.components.fields.destinationPort'),
value: `${metadata.destinationPort}`,
},
{
label: t("connections.components.fields.type"),
label: t('connections.components.fields.type'),
value: `${metadata.type}(${metadata.network})`,
},
];
]
const onDelete = useLockFn(async () => closeConnection(data.id));
const onDelete = useLockFn(async () => closeConnection(data.id))
return (
<Box sx={{ userSelect: "text", color: theme.palette.text.secondary }}>
<Box sx={{ userSelect: 'text', color: theme.palette.text.secondary }}>
{information.map((each) => (
<div key={each.label}>
<b>{each.label}</b>
<span
style={{
wordBreak: "break-all",
wordBreak: 'break-all',
color: theme.palette.text.primary,
}}
>
@@ -142,19 +142,19 @@ const InnerConnectionDetail = ({ data, closed, onClose }: InnerProps) => {
))}
{!closed && (
<Box sx={{ textAlign: "right" }}>
<Box sx={{ textAlign: 'right' }}>
<Button
variant="contained"
title={t("connections.components.actions.closeConnection")}
title={t('connections.components.actions.closeConnection')}
onClick={() => {
onDelete();
onClose?.();
onDelete()
onClose?.()
}}
>
{t("connections.components.actions.closeConnection")}
{t('connections.components.actions.closeConnection')}
</Button>
</Box>
)}
</Box>
);
};
)
}
+31 -31
View File
@@ -1,4 +1,4 @@
import { CloseRounded } from "@mui/icons-material";
import { CloseRounded } from '@mui/icons-material'
import {
styled,
ListItem,
@@ -6,52 +6,52 @@ import {
ListItemText,
Box,
alpha,
} from "@mui/material";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { closeConnection } from "tauri-plugin-mihomo-api";
} from '@mui/material'
import { useLockFn } from 'ahooks'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import { closeConnection } from 'tauri-plugin-mihomo-api'
import parseTraffic from "@/utils/parse-traffic";
import parseTraffic from '@/utils/parse-traffic'
const Tag = styled("span")(({ theme }) => ({
fontSize: "10px",
padding: "0 4px",
const Tag = styled('span')(({ theme }) => ({
fontSize: '10px',
padding: '0 4px',
lineHeight: 1.375,
border: "1px solid",
border: '1px solid',
borderRadius: 4,
borderColor: alpha(theme.palette.text.secondary, 0.35),
marginTop: "4px",
marginRight: "4px",
}));
marginTop: '4px',
marginRight: '4px',
}))
interface Props {
value: IConnectionsItem;
closed: boolean;
onShowDetail?: () => void;
value: IConnectionsItem
closed: boolean
onShowDetail?: () => void
}
export const ConnectionItem = (props: Props) => {
const { value, closed, onShowDetail } = props;
const { value, closed, onShowDetail } = props
const { id, metadata, chains, start, curUpload, curDownload } = value;
const { t } = useTranslation();
const { id, metadata, chains, start, curUpload, curDownload } = value
const { t } = useTranslation()
const onDelete = useLockFn(async () => closeConnection(id));
const showTraffic = curUpload! >= 100 || curDownload! >= 100;
const onDelete = useLockFn(async () => closeConnection(id))
const showTraffic = curUpload! >= 100 || curDownload! >= 100
return (
<ListItem
dense
sx={{ borderBottom: "1px solid var(--divider-color)" }}
sx={{ borderBottom: '1px solid var(--divider-color)' }}
secondaryAction={
!closed && (
<IconButton
edge="end"
color="inherit"
onClick={onDelete}
title={t("connections.components.actions.closeConnection")}
aria-label={t("connections.components.actions.closeConnection")}
title={t('connections.components.actions.closeConnection')}
aria-label={t('connections.components.actions.closeConnection')}
>
<CloseRounded />
</IconButton>
@@ -59,12 +59,12 @@ export const ConnectionItem = (props: Props) => {
}
>
<ListItemText
sx={{ userSelect: "text", cursor: "pointer" }}
sx={{ userSelect: 'text', cursor: 'pointer' }}
primary={metadata.host || metadata.destinationIP}
onClick={onShowDetail}
secondary={
<Box sx={{ display: "flex", flexWrap: "wrap" }}>
<Tag sx={{ textTransform: "uppercase", color: "success" }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
<Tag sx={{ textTransform: 'uppercase', color: 'success' }}>
{metadata.network}
</Tag>
@@ -73,7 +73,7 @@ export const ConnectionItem = (props: Props) => {
{!!metadata.process && <Tag>{metadata.process}</Tag>}
{chains?.length > 0 && (
<Tag>{[...chains].reverse().join(" / ")}</Tag>
<Tag>{[...chains].reverse().join(' / ')}</Tag>
)}
<Tag>{dayjs(start).fromNow()}</Tag>
@@ -87,5 +87,5 @@ export const ConnectionItem = (props: Props) => {
}
/>
</ListItem>
);
};
)
}
+249 -249
View File
@@ -1,5 +1,5 @@
import { ViewColumnRounded } from "@mui/icons-material";
import { Box, IconButton, Tooltip } from "@mui/material";
import { ViewColumnRounded } from '@mui/icons-material'
import { Box, IconButton, Tooltip } from '@mui/material'
import {
ColumnDef,
ColumnOrderState,
@@ -11,10 +11,10 @@ import {
Updater,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import dayjs from "dayjs";
import { useLocalStorage } from "foxact/use-local-storage";
} from '@tanstack/react-table'
import { useVirtualizer } from '@tanstack/react-virtual'
import dayjs from 'dayjs'
import { useLocalStorage } from 'foxact/use-local-storage'
import {
useCallback,
useEffect,
@@ -22,15 +22,15 @@ import {
useRef,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
} from 'react'
import { useTranslation } from 'react-i18next'
import parseTraffic from "@/utils/parse-traffic";
import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from '@/utils/parse-traffic'
import { truncateStr } from '@/utils/truncate-str'
import { ConnectionColumnManager } from "./connection-column-manager";
import { ConnectionColumnManager } from './connection-column-manager'
const ROW_HEIGHT = 40;
const ROW_HEIGHT = 40
/**
* Reconcile stored column order with base columns to handle added/removed fields
@@ -39,68 +39,68 @@ const reconcileColumnOrder = (
storedOrder: string[],
baseFields: string[],
): string[] => {
const filtered = storedOrder.filter((field) => baseFields.includes(field));
const missing = baseFields.filter((field) => !filtered.includes(field));
return [...filtered, ...missing];
};
const filtered = storedOrder.filter((field) => baseFields.includes(field))
const missing = baseFields.filter((field) => !filtered.includes(field))
return [...filtered, ...missing]
}
type ColumnField =
| "host"
| "download"
| "upload"
| "dlSpeed"
| "ulSpeed"
| "chains"
| "rule"
| "process"
| "time"
| "source"
| "remoteDestination"
| "type";
| 'host'
| 'download'
| 'upload'
| 'dlSpeed'
| 'ulSpeed'
| 'chains'
| 'rule'
| 'process'
| 'time'
| 'source'
| 'remoteDestination'
| 'type'
const getConnectionCellValue = (field: ColumnField, each: IConnectionsItem) => {
const { metadata, rulePayload } = each;
const { metadata, rulePayload } = each
switch (field) {
case "host":
case 'host':
return metadata.host
? `${metadata.host}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
case "download":
return each.download;
case "upload":
return each.upload;
case "dlSpeed":
return each.curDownload;
case "ulSpeed":
return each.curUpload;
case "chains":
return [...each.chains].reverse().join(" / ");
case "rule":
return rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
case "process":
return truncateStr(metadata.process || metadata.processPath);
case "time":
return each.start;
case "source":
return `${metadata.sourceIP}:${metadata.sourcePort}`;
case "remoteDestination":
: `${metadata.remoteDestination}:${metadata.destinationPort}`
case 'download':
return each.download
case 'upload':
return each.upload
case 'dlSpeed':
return each.curDownload
case 'ulSpeed':
return each.curUpload
case 'chains':
return [...each.chains].reverse().join(' / ')
case 'rule':
return rulePayload ? `${each.rule}(${rulePayload})` : each.rule
case 'process':
return truncateStr(metadata.process || metadata.processPath)
case 'time':
return each.start
case 'source':
return `${metadata.sourceIP}:${metadata.sourcePort}`
case 'remoteDestination':
return metadata.destinationIP
? `${metadata.destinationIP}:${metadata.destinationPort}`
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
case "type":
return `${metadata.type}(${metadata.network})`;
: `${metadata.remoteDestination}:${metadata.destinationPort}`
case 'type':
return `${metadata.type}(${metadata.network})`
default:
return "";
return ''
}
};
}
interface Props {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
columnManagerOpen: boolean;
onOpenColumnManager: () => void;
onCloseColumnManager: () => void;
connections: IConnectionsItem[]
onShowDetail: (data: IConnectionsItem) => void
columnManagerOpen: boolean
onOpenColumnManager: () => void
onCloseColumnManager: () => void
}
export const ConnectionTable = (props: Props) => {
@@ -110,215 +110,215 @@ export const ConnectionTable = (props: Props) => {
columnManagerOpen,
onOpenColumnManager,
onCloseColumnManager,
} = props;
const { t } = useTranslation();
} = props
const { t } = useTranslation()
const [columnWidths, setColumnWidths] = useLocalStorage<ColumnSizingState>(
"connection-table-widths",
'connection-table-widths',
// server-side value, this is the default value used by server-side rendering (if any)
// Do not omit (otherwise a Suspense boundary will be triggered)
{},
);
)
const [columnVisibilityModel, setColumnVisibilityModel] =
useLocalStorage<VisibilityState>(
"connection-table-visibility",
'connection-table-visibility',
{},
{
serializer: JSON.stringify,
deserializer: (value) => {
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object") return parsed;
const parsed = JSON.parse(value)
if (parsed && typeof parsed === 'object') return parsed
} catch (err) {
console.warn("Failed to parse connection-table-visibility", err);
console.warn('Failed to parse connection-table-visibility', err)
}
return {};
return {}
},
},
);
)
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
"connection-table-order",
'connection-table-order',
[],
{
serializer: JSON.stringify,
deserializer: (value) => {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) return parsed
} catch (err) {
console.warn("Failed to parse connection-table-order", err);
console.warn('Failed to parse connection-table-order', err)
}
return [];
return []
},
},
);
)
interface BaseColumn {
field: ColumnField;
headerName: string;
width?: number;
minWidth?: number;
align?: "left" | "right";
cell?: (row: IConnectionsItem) => ReactNode;
field: ColumnField
headerName: string
width?: number
minWidth?: number
align?: 'left' | 'right'
cell?: (row: IConnectionsItem) => ReactNode
}
const baseColumns = useMemo<BaseColumn[]>(() => {
return [
{
field: "host",
headerName: t("connections.components.fields.host"),
field: 'host',
headerName: t('connections.components.fields.host'),
width: 180,
minWidth: 140,
},
{
field: "download",
headerName: t("shared.labels.downloaded"),
field: 'download',
headerName: t('shared.labels.downloaded'),
width: 76,
minWidth: 60,
align: "right",
cell: (row) => parseTraffic(row.download).join(" "),
align: 'right',
cell: (row) => parseTraffic(row.download).join(' '),
},
{
field: "upload",
headerName: t("shared.labels.uploaded"),
field: 'upload',
headerName: t('shared.labels.uploaded'),
width: 76,
minWidth: 60,
align: "right",
cell: (row) => parseTraffic(row.upload).join(" "),
align: 'right',
cell: (row) => parseTraffic(row.upload).join(' '),
},
{
field: "dlSpeed",
headerName: t("connections.components.fields.dlSpeed"),
field: 'dlSpeed',
headerName: t('connections.components.fields.dlSpeed'),
width: 76,
minWidth: 60,
align: "right",
cell: (row) => `${parseTraffic(row.curDownload).join(" ")}/s`,
align: 'right',
cell: (row) => `${parseTraffic(row.curDownload).join(' ')}/s`,
},
{
field: "ulSpeed",
headerName: t("connections.components.fields.ulSpeed"),
field: 'ulSpeed',
headerName: t('connections.components.fields.ulSpeed'),
width: 76,
minWidth: 60,
align: "right",
cell: (row) => `${parseTraffic(row.curUpload).join(" ")}/s`,
align: 'right',
cell: (row) => `${parseTraffic(row.curUpload).join(' ')}/s`,
},
{
field: "chains",
headerName: t("connections.components.fields.chains"),
field: 'chains',
headerName: t('connections.components.fields.chains'),
width: 280,
minWidth: 160,
},
{
field: "rule",
headerName: t("connections.components.fields.rule"),
field: 'rule',
headerName: t('connections.components.fields.rule'),
width: 220,
minWidth: 160,
},
{
field: "process",
headerName: t("connections.components.fields.process"),
field: 'process',
headerName: t('connections.components.fields.process'),
width: 180,
minWidth: 140,
},
{
field: "time",
headerName: t("connections.components.fields.time"),
field: 'time',
headerName: t('connections.components.fields.time'),
width: 100,
minWidth: 80,
align: "right",
align: 'right',
// cell filled later with shared relativeNow ticker
},
{
field: "source",
headerName: t("connections.components.fields.source"),
field: 'source',
headerName: t('connections.components.fields.source'),
width: 160,
minWidth: 120,
},
{
field: "remoteDestination",
headerName: t("connections.components.fields.destination"),
field: 'remoteDestination',
headerName: t('connections.components.fields.destination'),
width: 160,
minWidth: 120,
},
{
field: "type",
headerName: t("connections.components.fields.type"),
field: 'type',
headerName: t('connections.components.fields.type'),
width: 120,
minWidth: 80,
},
];
}, [t]);
]
}, [t])
useEffect(() => {
setColumnOrder((prevValue) => {
const baseFields = baseColumns.map((col) => col.field);
const prev = Array.isArray(prevValue) ? prevValue : [];
const reconciled = reconcileColumnOrder(prev, baseFields);
const baseFields = baseColumns.map((col) => col.field)
const prev = Array.isArray(prevValue) ? prevValue : []
const reconciled = reconcileColumnOrder(prev, baseFields)
if (
reconciled.length === prev.length &&
reconciled.every((field, i) => field === prev[i])
) {
return prevValue;
return prevValue
}
return reconciled;
});
}, [baseColumns, setColumnOrder]);
return reconciled
})
}, [baseColumns, setColumnOrder])
const handleColumnVisibilityChange = useCallback(
(update: Updater<VisibilityState>) => {
setColumnVisibilityModel((prev) => {
const current = prev ?? {};
const current = prev ?? {}
const nextState =
typeof update === "function" ? update(current) : update;
typeof update === 'function' ? update(current) : update
const visibleCount = baseColumns.reduce((count, column) => {
const isVisible = (nextState[column.field] ?? true) !== false;
return count + (isVisible ? 1 : 0);
}, 0);
const isVisible = (nextState[column.field] ?? true) !== false
return count + (isVisible ? 1 : 0)
}, 0)
if (visibleCount === 0) {
return current;
return current
}
const sanitized: VisibilityState = {};
const sanitized: VisibilityState = {}
baseColumns.forEach((column) => {
if (nextState[column.field] === false) {
sanitized[column.field] = false;
sanitized[column.field] = false
}
});
return sanitized;
});
})
return sanitized
})
},
[baseColumns, setColumnVisibilityModel],
);
)
const handleColumnOrderChange = useCallback(
(update: Updater<ColumnOrderState>) => {
setColumnOrder((prev) => {
const current = Array.isArray(prev) ? prev : [];
const current = Array.isArray(prev) ? prev : []
const nextState =
typeof update === "function" ? update(current) : update;
const baseFields = baseColumns.map((col) => col.field);
return reconcileColumnOrder(nextState, baseFields);
});
typeof update === 'function' ? update(current) : update
const baseFields = baseColumns.map((col) => col.field)
return reconcileColumnOrder(nextState, baseFields)
})
},
[baseColumns, setColumnOrder],
);
)
const [sorting, setSorting] = useState<SortingState>([]);
const [relativeNow, setRelativeNow] = useState(() => Date.now());
const [sorting, setSorting] = useState<SortingState>([])
const [relativeNow, setRelativeNow] = useState(() => Date.now())
const columnDefs = useMemo<ColumnDef<IConnectionsItem>[]>(() => {
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) => ctx.getValue() as ReactNode;
: (ctx) => ctx.getValue() as ReactNode
const cell: ColumnDef<IConnectionsItem>["cell"] =
column.field === "time"
const cell: ColumnDef<IConnectionsItem>['cell'] =
column.field === 'time'
? (ctx) => dayjs(ctx.getValue() as string).from(relativeNow)
: baseCell;
: baseCell
return {
id: column.field,
@@ -327,42 +327,42 @@ export const ConnectionTable = (props: Props) => {
size: column.width,
minSize: column.minWidth,
meta: {
align: column.align ?? "left",
align: column.align ?? 'left',
field: column.field,
label: column.headerName,
},
cell,
} satisfies ColumnDef<IConnectionsItem>;
});
}, [baseColumns, relativeNow]);
} satisfies ColumnDef<IConnectionsItem>
})
}, [baseColumns, relativeNow])
useEffect(() => {
if (typeof window === "undefined") return undefined;
if (typeof window === 'undefined') return undefined
const timer = window.setInterval(() => {
setRelativeNow(Date.now());
}, 5000);
setRelativeNow(Date.now())
}, 5000)
return () => window.clearInterval(timer);
}, []);
return () => window.clearInterval(timer)
}, [])
const handleColumnSizingChange = useCallback(
(updater: Updater<ColumnSizingState>) => {
setColumnWidths((prev) => {
const prevState = prev ?? {};
const prevState = prev ?? {}
const nextState =
typeof updater === "function" ? updater(prevState) : updater;
const sanitized: ColumnSizingState = {};
typeof updater === 'function' ? updater(prevState) : updater
const sanitized: ColumnSizingState = {}
Object.entries(nextState).forEach(([key, size]) => {
if (typeof size === "number" && Number.isFinite(size)) {
sanitized[key] = size;
if (typeof size === 'number' && Number.isFinite(size)) {
sanitized[key] = size
}
});
return sanitized;
});
})
return sanitized
})
},
[setColumnWidths],
);
)
const table = useReactTable({
data: connections,
@@ -379,7 +379,7 @@ export const ConnectionTable = (props: Props) => {
minSize: 80,
enableResizing: true,
},
columnResizeMode: "onChange",
columnResizeMode: 'onChange',
enableSortingRemoval: true,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
@@ -389,61 +389,61 @@ export const ConnectionTable = (props: Props) => {
onColumnVisibilityChange: handleColumnVisibilityChange,
onColumnOrderChange: handleColumnOrderChange,
columns: columnDefs,
});
})
const handleManagerOrderChange = useCallback(
(order: string[]) => {
const baseFields = baseColumns.map((col) => col.field);
table.setColumnOrder(reconcileColumnOrder(order, baseFields));
const baseFields = baseColumns.map((col) => col.field)
table.setColumnOrder(reconcileColumnOrder(order, baseFields))
},
[baseColumns, table],
);
)
const handleResetColumns = useCallback(() => {
table.resetColumnVisibility();
table.resetColumnOrder();
}, [table]);
table.resetColumnVisibility()
table.resetColumnOrder()
}, [table])
const rows = table.getRowModel().rows;
const tableContainerRef = useRef<HTMLDivElement | null>(null);
const rows = table.getRowModel().rows
const tableContainerRef = useRef<HTMLDivElement | null>(null)
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 4,
});
})
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const tableWidth = table.getTotalSize();
const managerColumns = table.getAllLeafColumns();
const virtualRows = rowVirtualizer.getVirtualItems()
const totalSize = rowVirtualizer.getTotalSize()
const tableWidth = table.getTotalSize()
const managerColumns = table.getAllLeafColumns()
return (
<>
<Box
sx={{
display: "flex",
flexDirection: "column",
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
position: "relative",
position: 'relative',
fontFamily: (theme) => theme.typography.fontFamily,
}}
>
<Tooltip title={t("connections.components.columnManager.title")}>
<Tooltip title={t('connections.components.columnManager.title')}>
<IconButton
size="small"
onClick={onOpenColumnManager}
sx={{
position: "absolute",
position: 'absolute',
top: 4,
right: 4,
zIndex: 3,
backgroundColor: (theme) =>
theme.palette.mode === "dark"
theme.palette.mode === 'dark'
? theme.palette.background.default
: theme.palette.background.paper,
"&:hover": {
'&:hover': {
backgroundColor: (theme) => theme.palette.action.hover,
},
}}
@@ -456,25 +456,25 @@ export const ConnectionTable = (props: Props) => {
sx={{
flex: 1,
minHeight: 0,
overflow: "auto",
WebkitOverflowScrolling: "touch",
overscrollBehavior: "contain",
overflow: 'auto',
WebkitOverflowScrolling: 'touch',
overscrollBehavior: 'contain',
borderRadius: 1,
border: "none",
"&::-webkit-scrollbar": {
border: 'none',
'&::-webkit-scrollbar': {
height: 8,
},
}}
>
<Box
sx={{
minWidth: "100%",
minWidth: '100%',
width: tableWidth,
}}
>
<Box
sx={{
position: "sticky",
position: 'sticky',
top: 0,
zIndex: 2,
}}
@@ -483,7 +483,7 @@ export const ConnectionTable = (props: Props) => {
<Box
key={headerGroup.id}
sx={{
display: "flex",
display: 'flex',
borderBottom: (theme) =>
`1px solid ${theme.palette.divider}`,
backgroundColor: (theme) => theme.palette.background.paper,
@@ -491,12 +491,12 @@ export const ConnectionTable = (props: Props) => {
>
{headerGroup.headers.map((header) => {
if (header.isPlaceholder) {
return null;
return null
}
const meta = header.column.columnDef.meta as {
align?: "left" | "right";
field: string;
};
align?: 'left' | 'right'
field: string
}
return (
<Box
key={header.id}
@@ -504,15 +504,15 @@ export const ConnectionTable = (props: Props) => {
flex: `0 0 ${header.getSize()}px`,
minWidth: header.column.columnDef.minSize || 80,
maxWidth: header.column.columnDef.maxSize,
display: "flex",
alignItems: "center",
position: "relative",
boxSizing: "border-box",
display: 'flex',
alignItems: 'center',
position: 'relative',
boxSizing: 'border-box',
fontSize: 13,
fontWeight: 600,
color: "text.secondary",
userSelect: "none",
"&:hover": {
color: 'text.secondary',
userSelect: 'none',
'&:hover': {
backgroundColor: (theme) =>
theme.palette.action.hover,
},
@@ -527,18 +527,18 @@ export const ConnectionTable = (props: Props) => {
}
sx={{
flex: 1,
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
justifyContent:
meta?.align === "right"
? "flex-end"
: "flex-start",
meta?.align === 'right'
? 'flex-end'
: 'flex-start',
gap: 0.5,
px: 1,
py: 1,
cursor: header.column.getCanSort()
? "pointer"
: "default",
? 'pointer'
: 'default',
}}
>
{flexRender(
@@ -546,30 +546,30 @@ export const ConnectionTable = (props: Props) => {
header.getContext(),
)}
{{
asc: "▲",
desc: "▼",
asc: '▲',
desc: '▼',
}[header.column.getIsSorted() as string] ?? null}
</Box>
{header.column.getCanResize() && (
<Box
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => {
event.stopPropagation();
header.getResizeHandler()(event);
event.stopPropagation()
header.getResizeHandler()(event)
}}
onTouchStart={(event) => {
event.stopPropagation();
header.getResizeHandler()(event);
event.stopPropagation()
header.getResizeHandler()(event)
}}
sx={{
cursor: "col-resize",
position: "absolute",
cursor: 'col-resize',
position: 'absolute',
right: 0,
top: 0,
width: 4,
height: "100%",
transform: "translateX(50%)",
"&:hover": {
height: '100%',
transform: 'translateX(50%)',
'&:hover': {
backgroundColor: (theme) =>
theme.palette.action.active,
},
@@ -577,44 +577,44 @@ export const ConnectionTable = (props: Props) => {
/>
)}
</Box>
);
)
})}
</Box>
))}
</Box>
<Box
sx={{
position: "relative",
position: 'relative',
height: totalSize,
}}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
const row = rows[virtualRow.index]
if (!row) return null
return (
<Box
key={row.id}
onClick={() => onShowDetail(row.original)}
sx={{
display: "flex",
position: "absolute",
display: 'flex',
position: 'absolute',
left: 0,
right: 0,
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
borderBottom: (theme) =>
`1px solid ${theme.palette.divider}`,
cursor: "pointer",
"&:hover": {
cursor: 'pointer',
'&:hover': {
backgroundColor: (theme) => theme.palette.action.hover,
},
}}
>
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta as {
align?: "left" | "right";
};
align?: 'left' | 'right'
}
return (
<Box
key={cell.id}
@@ -622,18 +622,18 @@ export const ConnectionTable = (props: Props) => {
flex: `0 0 ${cell.column.getSize()}px`,
minWidth: cell.column.columnDef.minSize || 80,
maxWidth: cell.column.columnDef.maxSize,
boxSizing: "border-box",
boxSizing: 'border-box',
px: 1,
fontSize: 13,
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
justifyContent:
meta?.align === "right"
? "flex-end"
: "flex-start",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
meta?.align === 'right'
? 'flex-end'
: 'flex-start',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{flexRender(
@@ -641,10 +641,10 @@ export const ConnectionTable = (props: Props) => {
cell.getContext(),
)}
</Box>
);
)
})}
</Box>
);
)
})}
</Box>
</Box>
@@ -658,5 +658,5 @@ export const ConnectionTable = (props: Props) => {
onReset={handleResetColumns}
/>
</>
);
};
)
}
+29 -29
View File
@@ -1,47 +1,47 @@
import { DeveloperBoardOutlined } from "@mui/icons-material";
import { Divider, Stack, Typography } from "@mui/material";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { DeveloperBoardOutlined } from '@mui/icons-material'
import { Divider, Stack, Typography } from '@mui/material'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useClash } from "@/hooks/use-clash";
import { useAppData } from "@/providers/app-data-context";
import { useClash } from '@/hooks/use-clash'
import { useAppData } from '@/providers/app-data-context'
import { EnhancedCard } from "./enhanced-card";
import { EnhancedCard } from './enhanced-card'
// 将毫秒转换为时:分:秒格式的函数
const formatUptime = (uptimeMs: number) => {
const hours = Math.floor(uptimeMs / 3600000);
const minutes = Math.floor((uptimeMs % 3600000) / 60000);
const seconds = Math.floor((uptimeMs % 60000) / 1000);
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
const hours = Math.floor(uptimeMs / 3600000)
const minutes = Math.floor((uptimeMs % 3600000) / 60000)
const seconds = Math.floor((uptimeMs % 60000) / 1000)
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
export const ClashInfoCard = () => {
const { t } = useTranslation();
const { version: clashVersion } = useClash();
const { clashConfig, rules, uptime, systemProxyAddress } = useAppData();
const { t } = useTranslation()
const { version: clashVersion } = useClash()
const { clashConfig, rules, uptime, systemProxyAddress } = useAppData()
// 使用useMemo缓存格式化后的uptime,避免频繁计算
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]);
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime])
// 使用备忘录组件内容,减少重新渲染
const cardContent = useMemo(() => {
if (!clashConfig) return null;
if (!clashConfig) return null
return (
<Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.coreVersion")}
{t('home.components.clashInfo.fields.coreVersion')}
</Typography>
<Typography variant="body2" fontWeight="medium">
{clashVersion || "-"}
{clashVersion || '-'}
</Typography>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.systemProxyAddress")}
{t('home.components.clashInfo.fields.systemProxyAddress')}
</Typography>
<Typography variant="body2" fontWeight="medium">
{systemProxyAddress}
@@ -50,16 +50,16 @@ export const ClashInfoCard = () => {
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.mixedPort")}
{t('home.components.clashInfo.fields.mixedPort')}
</Typography>
<Typography variant="body2" fontWeight="medium">
{clashConfig.mixedPort || "-"}
{clashConfig.mixedPort || '-'}
</Typography>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.uptime")}
{t('home.components.clashInfo.fields.uptime')}
</Typography>
<Typography variant="body2" fontWeight="medium">
{formattedUptime}
@@ -68,14 +68,14 @@ export const ClashInfoCard = () => {
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("home.components.clashInfo.fields.rulesCount")}
{t('home.components.clashInfo.fields.rulesCount')}
</Typography>
<Typography variant="body2" fontWeight="medium">
{rules.length}
</Typography>
</Stack>
</Stack>
);
)
}, [
clashConfig,
clashVersion,
@@ -83,16 +83,16 @@ export const ClashInfoCard = () => {
formattedUptime,
rules.length,
systemProxyAddress,
]);
])
return (
<EnhancedCard
title={t("home.components.clashInfo.title")}
title={t('home.components.clashInfo.title')}
icon={<DeveloperBoardOutlined />}
iconColor="warning"
action={null}
>
{cardContent}
</EnhancedCard>
);
};
)
}
+77 -77
View File
@@ -2,63 +2,63 @@ import {
DirectionsRounded,
LanguageRounded,
MultipleStopRounded,
} from "@mui/icons-material";
import { Box, Paper, Stack, Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { closeAllConnections } from "tauri-plugin-mihomo-api";
} from '@mui/icons-material'
import { Box, Paper, Stack, Typography } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { closeAllConnections } from 'tauri-plugin-mihomo-api'
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context";
import { patchClashMode } from "@/services/cmds";
import type { TranslationKey } from "@/types/generated/i18n-keys";
import { useVerge } from '@/hooks/use-verge'
import { useAppData } from '@/providers/app-data-context'
import { patchClashMode } from '@/services/cmds'
import type { TranslationKey } from '@/types/generated/i18n-keys'
const CLASH_MODES = ["rule", "global", "direct"] as const;
type ClashMode = (typeof CLASH_MODES)[number];
const CLASH_MODES = ['rule', 'global', 'direct'] as const
type ClashMode = (typeof CLASH_MODES)[number]
const isClashMode = (mode: string): mode is ClashMode =>
(CLASH_MODES as readonly string[]).includes(mode);
(CLASH_MODES as readonly string[]).includes(mode)
const MODE_META: Record<
ClashMode,
{ label: TranslationKey; description: TranslationKey }
> = {
rule: {
label: "home.components.clashMode.labels.rule",
description: "home.components.clashMode.descriptions.rule",
label: 'home.components.clashMode.labels.rule',
description: 'home.components.clashMode.descriptions.rule',
},
global: {
label: "home.components.clashMode.labels.global",
description: "home.components.clashMode.descriptions.global",
label: 'home.components.clashMode.labels.global',
description: 'home.components.clashMode.descriptions.global',
},
direct: {
label: "home.components.clashMode.labels.direct",
description: "home.components.clashMode.descriptions.direct",
label: 'home.components.clashMode.labels.direct',
description: 'home.components.clashMode.descriptions.direct',
},
};
}
export const ClashModeCard = () => {
const { t } = useTranslation();
const { verge } = useVerge();
const { clashConfig, refreshClashConfig } = useAppData();
const { t } = useTranslation()
const { verge } = useVerge()
const { clashConfig, refreshClashConfig } = useAppData()
// 支持的模式列表
const modeList = CLASH_MODES;
const modeList = CLASH_MODES
// 直接使用API返回的模式,不维护本地状态
const currentMode = clashConfig?.mode?.toLowerCase();
const currentMode = clashConfig?.mode?.toLowerCase()
const currentModeKey =
typeof currentMode === "string" && isClashMode(currentMode)
typeof currentMode === 'string' && isClashMode(currentMode)
? currentMode
: undefined;
: undefined
const modeDescription = useMemo(() => {
if (currentModeKey) {
return t(MODE_META[currentModeKey].description);
return t(MODE_META[currentModeKey].description)
}
return t("home.components.clashMode.errors.communication");
}, [currentModeKey, t]);
return t('home.components.clashMode.errors.communication')
}, [currentModeKey, t])
// 模式图标映射
const modeIcons = useMemo(
@@ -68,87 +68,87 @@ export const ClashModeCard = () => {
direct: <DirectionsRounded fontSize="small" />,
}),
[],
);
)
// 切换模式的处理函数
const onChangeMode = useLockFn(async (mode: ClashMode) => {
if (mode === currentModeKey) return;
if (mode === currentModeKey) return
if (verge?.auto_close_connection) {
closeAllConnections();
closeAllConnections()
}
try {
await patchClashMode(mode);
await patchClashMode(mode)
// 使用共享的刷新方法
refreshClashConfig();
refreshClashConfig()
} catch (error) {
console.error("Failed to change mode:", error);
console.error('Failed to change mode:', error)
}
});
})
// 按钮样式
const buttonStyles = (mode: ClashMode) => ({
cursor: "pointer",
cursor: 'pointer',
px: 2,
py: 1.2,
display: "flex",
alignItems: "center",
justifyContent: "center",
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
bgcolor: mode === currentModeKey ? "primary.main" : "background.paper",
color: mode === currentModeKey ? "primary.contrastText" : "text.primary",
bgcolor: mode === currentModeKey ? 'primary.main' : 'background.paper',
color: mode === currentModeKey ? 'primary.contrastText' : 'text.primary',
borderRadius: 1.5,
transition: "all 0.2s ease-in-out",
position: "relative",
overflow: "visible",
"&:hover": {
transform: "translateY(-1px)",
transition: 'all 0.2s ease-in-out',
position: 'relative',
overflow: 'visible',
'&:hover': {
transform: 'translateY(-1px)',
boxShadow: 1,
},
"&:active": {
transform: "translateY(1px)",
'&:active': {
transform: 'translateY(1px)',
},
"&::after":
'&::after':
mode === currentModeKey
? {
content: '""',
position: "absolute",
position: 'absolute',
bottom: -16,
left: "50%",
left: '50%',
width: 2,
height: 16,
bgcolor: "primary.main",
transform: "translateX(-50%)",
bgcolor: 'primary.main',
transform: 'translateX(-50%)',
}
: {},
});
})
// 描述样式
const descriptionStyles = {
width: "95%",
textAlign: "center",
color: "text.secondary",
width: '95%',
textAlign: 'center',
color: 'text.secondary',
p: 0.8,
borderRadius: 1,
borderColor: "primary.main",
borderColor: 'primary.main',
borderWidth: 1,
borderStyle: "solid",
backgroundColor: "background.paper",
wordBreak: "break-word",
hyphens: "auto",
};
borderStyle: 'solid',
backgroundColor: 'background.paper',
wordBreak: 'break-word',
hyphens: 'auto',
}
return (
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
{/* 模式选择按钮组 */}
<Stack
direction="row"
spacing={1}
sx={{
display: "flex",
justifyContent: "center",
display: 'flex',
justifyContent: 'center',
py: 1,
position: "relative",
position: 'relative',
zIndex: 2,
}}
>
@@ -163,7 +163,7 @@ export const ClashModeCard = () => {
<Typography
variant="body2"
sx={{
textTransform: "capitalize",
textTransform: 'capitalize',
fontWeight: mode === currentModeKey ? 600 : 400,
}}
>
@@ -176,12 +176,12 @@ export const ClashModeCard = () => {
{/* 说明文本区域 */}
<Box
sx={{
width: "100%",
width: '100%',
my: 1,
position: "relative",
display: "flex",
justifyContent: "center",
overflow: "visible",
position: 'relative',
display: 'flex',
justifyContent: 'center',
overflow: 'visible',
}}
>
<Typography variant="caption" component="div" sx={descriptionStyles}>
@@ -189,5 +189,5 @@ export const ClashModeCard = () => {
</Typography>
</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 React, { forwardRef, ReactNode } from "react";
import { Box, Typography, alpha, useTheme } from '@mui/material'
import React, { forwardRef, ReactNode } from 'react'
// 自定义卡片组件接口
interface EnhancedCardProps {
title: ReactNode;
icon: ReactNode;
action?: ReactNode;
children: ReactNode;
iconColor?:
| "primary"
| "secondary"
| "error"
| "warning"
| "info"
| "success";
minHeight?: number | string;
noContentPadding?: boolean;
title: ReactNode
icon: ReactNode
action?: ReactNode
children: ReactNode
iconColor?: 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'
minHeight?: number | string
noContentPadding?: boolean
}
// 自定义卡片组件
@@ -26,33 +20,33 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
icon,
action,
children,
iconColor = "primary",
iconColor = 'primary',
minHeight,
noContentPadding = false,
},
ref,
) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const theme = useTheme()
const isDark = theme.palette.mode === 'dark'
// 统一的标题截断样式
const titleTruncateStyle = {
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
display: "block",
};
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}
return (
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff",
backgroundColor: isDark ? '#282a36' : '#ffffff',
}}
ref={ref}
>
@@ -60,27 +54,27 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
sx={{
px: 2,
py: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: 1,
borderColor: "divider",
borderColor: 'divider',
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
minWidth: 0,
flex: 1,
overflow: "hidden",
overflow: 'hidden',
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 1.5,
width: 38,
height: 38,
@@ -93,7 +87,7 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
{icon}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
{typeof title === "string" ? (
{typeof title === 'string' ? (
<Typography
variant="h6"
fontWeight="medium"
@@ -113,8 +107,8 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
display: 'flex',
flexDirection: 'column',
p: noContentPadding ? 0 : 2,
...(minHeight && { minHeight }),
}}
@@ -122,6 +116,6 @@ export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
{children}
</Box>
</Box>
);
)
},
);
)
+82 -82
View File
@@ -5,7 +5,7 @@ import {
CloudUploadRounded,
LinkRounded,
MemoryRounded,
} from "@mui/icons-material";
} from '@mui/icons-material'
import {
Grid,
PaletteColor,
@@ -13,74 +13,74 @@ import {
Typography,
alpha,
useTheme,
} from "@mui/material";
import { ReactNode, memo, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import { ReactNode, memo, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { TrafficErrorBoundary } from "@/components/shared/traffic-error-boundary";
import { useConnectionData } from "@/hooks/use-connection-data";
import { useMemoryData } from "@/hooks/use-memory-data";
import { useTrafficData } from "@/hooks/use-traffic-data";
import { useVerge } from "@/hooks/use-verge";
import { useVisibility } from "@/hooks/use-visibility";
import parseTraffic from "@/utils/parse-traffic";
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useConnectionData } from '@/hooks/use-connection-data'
import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVerge } from '@/hooks/use-verge'
import { useVisibility } from '@/hooks/use-visibility'
import parseTraffic from '@/utils/parse-traffic'
import {
EnhancedCanvasTrafficGraph,
type EnhancedCanvasTrafficGraphRef,
} from "./enhanced-canvas-traffic-graph";
} from './enhanced-canvas-traffic-graph'
interface StatCardProps {
icon: ReactNode;
title: string;
value: string | number;
unit: string;
color: "primary" | "secondary" | "error" | "warning" | "info" | "success";
onClick?: () => void;
icon: ReactNode
title: string
value: string | number
unit: string
color: 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'
onClick?: () => void
}
// 全局变量类型定义
declare global {
interface Window {
animationFrameId?: number;
animationFrameId?: number
lastTrafficData?: {
up: number;
down: number;
};
up: number
down: number
}
}
}
// 统计卡片组件 - 使用memo优化
const CompactStatCard = memo(
({ icon, title, value, unit, color, onClick }: StatCardProps) => {
const theme = useTheme();
const theme = useTheme()
// 获取调色板颜色 - 使用useMemo避免重复计算
const colorValue = useMemo(() => {
const palette = theme.palette;
const palette = theme.palette
if (
color in palette &&
palette[color as keyof typeof palette] &&
"main" in (palette[color as keyof typeof palette] as PaletteColor)
'main' in (palette[color as keyof typeof palette] as PaletteColor)
) {
return (palette[color as keyof typeof palette] as PaletteColor).main;
return (palette[color as keyof typeof palette] as PaletteColor).main
}
return palette.primary.main;
}, [theme.palette, color]);
return palette.primary.main
}, [theme.palette, color])
return (
<Paper
elevation={0}
sx={{
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
borderRadius: 2,
bgcolor: alpha(colorValue, 0.05),
border: `1px solid ${alpha(colorValue, 0.15)}`,
padding: "8px",
transition: "all 0.2s ease-in-out",
cursor: onClick ? "pointer" : "default",
"&:hover": onClick
padding: '8px',
transition: 'all 0.2s ease-in-out',
cursor: onClick ? 'pointer' : 'default',
'&:hover': onClick
? {
bgcolor: alpha(colorValue, 0.1),
border: `1px solid ${alpha(colorValue, 0.3)}`,
@@ -95,13 +95,13 @@ const CompactStatCard = memo(
component="div"
sx={{
mr: 1,
ml: "2px",
display: "flex",
alignItems: "center",
justifyContent: "center",
ml: '2px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: "50%",
borderRadius: '50%',
bgcolor: alpha(colorValue, 0.1),
color: colorValue,
}}
@@ -116,7 +116,7 @@ const CompactStatCard = memo(
</Typography>
<Grid
component="div"
sx={{ display: "flex", alignItems: "baseline" }}
sx={{ display: 'flex', alignItems: 'baseline' }}
>
<Typography
variant="body1"
@@ -132,48 +132,48 @@ const CompactStatCard = memo(
</Grid>
</Grid>
</Paper>
);
)
},
);
)
// 添加显示名称
CompactStatCard.displayName = "CompactStatCard";
CompactStatCard.displayName = 'CompactStatCard'
export const EnhancedTrafficStats = () => {
const { t } = useTranslation();
const theme = useTheme();
const { verge } = useVerge();
const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null);
const pageVisible = useVisibility();
const { t } = useTranslation()
const theme = useTheme()
const { verge } = useVerge()
const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null)
const pageVisible = useVisibility()
// 是否显示流量图表
const trafficGraph = verge?.traffic_graph ?? true;
const trafficGraph = verge?.traffic_graph ?? true
const {
response: { data: traffic },
} = useTrafficData({ enabled: trafficGraph && pageVisible });
} = useTrafficData({ enabled: trafficGraph && pageVisible })
const {
response: { data: memory },
} = useMemoryData();
} = useMemoryData()
const {
response: { data: connections },
} = useConnectionData();
} = useConnectionData()
// Canvas组件现在直接从全局Hook获取数据,无需手动添加数据点
// 使用useMemo计算解析后的流量数据
const parsedData = useMemo(() => {
const [up, upUnit] = parseTraffic(traffic?.up || 0);
const [down, downUnit] = parseTraffic(traffic?.down || 0);
const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0);
const [up, upUnit] = parseTraffic(traffic?.up || 0)
const [down, downUnit] = parseTraffic(traffic?.down || 0)
const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0)
const [uploadTotal, uploadTotalUnit] = parseTraffic(
connections?.uploadTotal,
);
)
const [downloadTotal, downloadTotalUnit] = parseTraffic(
connections?.downloadTotal,
);
)
return {
up,
@@ -187,86 +187,86 @@ export const EnhancedTrafficStats = () => {
downloadTotal,
downloadTotalUnit,
connectionsCount: connections?.activeConnections.length,
};
}, [traffic, memory, connections]);
}
}, [traffic, memory, connections])
// 渲染流量图表 - 使用useMemo缓存渲染结果
const trafficGraphComponent = useMemo(() => {
if (!trafficGraph || !pageVisible) return null;
if (!trafficGraph || !pageVisible) return null
return (
<Paper
elevation={0}
sx={{
height: 130,
cursor: "pointer",
cursor: 'pointer',
border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
borderRadius: 2,
overflow: "hidden",
overflow: 'hidden',
}}
onClick={() => trafficRef.current?.toggleStyle()}
>
<div style={{ height: "100%", position: "relative" }}>
<div style={{ height: '100%', position: 'relative' }}>
<EnhancedCanvasTrafficGraph ref={trafficRef} />
</div>
</Paper>
);
}, [trafficGraph, pageVisible, theme.palette.divider]);
)
}, [trafficGraph, pageVisible, theme.palette.divider])
// 使用useMemo计算统计卡片配置
const statCards = useMemo(
() => [
{
icon: <ArrowUpwardRounded fontSize="small" />,
title: t("home.components.traffic.metrics.uploadSpeed"),
title: t('home.components.traffic.metrics.uploadSpeed'),
value: parsedData.up,
unit: `${parsedData.upUnit}/s`,
color: "secondary" as const,
color: 'secondary' as const,
},
{
icon: <ArrowDownwardRounded fontSize="small" />,
title: t("home.components.traffic.metrics.downloadSpeed"),
title: t('home.components.traffic.metrics.downloadSpeed'),
value: parsedData.down,
unit: `${parsedData.downUnit}/s`,
color: "primary" as const,
color: 'primary' as const,
},
{
icon: <LinkRounded fontSize="small" />,
title: t("home.components.traffic.metrics.activeConnections"),
title: t('home.components.traffic.metrics.activeConnections'),
value: parsedData.connectionsCount,
unit: "",
color: "success" as const,
unit: '',
color: 'success' as const,
},
{
icon: <CloudUploadRounded fontSize="small" />,
title: t("shared.labels.uploaded"),
title: t('shared.labels.uploaded'),
value: parsedData.uploadTotal,
unit: parsedData.uploadTotalUnit,
color: "secondary" as const,
color: 'secondary' as const,
},
{
icon: <CloudDownloadRounded fontSize="small" />,
title: t("shared.labels.downloaded"),
title: t('shared.labels.downloaded'),
value: parsedData.downloadTotal,
unit: parsedData.downloadTotalUnit,
color: "primary" as const,
color: 'primary' as const,
},
{
icon: <MemoryRounded fontSize="small" />,
title: t("home.components.traffic.metrics.memoryUsage"),
title: t('home.components.traffic.metrics.memoryUsage'),
value: parsedData.inuse,
unit: parsedData.inuseUnit,
color: "error" as const,
color: 'error' as const,
onClick: undefined,
},
],
[t, parsedData],
);
)
return (
<TrafficErrorBoundary
onError={(error, errorInfo) => {
console.error("[EnhancedTrafficStats] 组件错误:", error, errorInfo);
console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
}}
>
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
@@ -284,5 +284,5 @@ export const EnhancedTrafficStats = () => {
))}
</Grid>
</TrafficErrorBoundary>
);
};
)
}
+116 -116
View File
@@ -6,7 +6,7 @@ import {
SpeedOutlined,
StorageOutlined,
UpdateOutlined,
} from "@mui/icons-material";
} from '@mui/icons-material'
import {
Box,
Button,
@@ -17,62 +17,62 @@ import {
alpha,
keyframes,
useTheme,
} from "@mui/material";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
} from '@mui/material'
import { useLockFn } from 'ahooks'
import dayjs from 'dayjs'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { useAppData } from "@/providers/app-data-context";
import { openWebUrl, updateProfile } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import parseTraffic from "@/utils/parse-traffic";
import { useAppData } from '@/providers/app-data-context'
import { openWebUrl, updateProfile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import parseTraffic from '@/utils/parse-traffic'
import { EnhancedCard } from "./enhanced-card";
import { EnhancedCard } from './enhanced-card'
// 定义旋转动画
const round = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
`
// 辅助函数解析URL和过期时间
const parseUrl = (url?: string) => {
if (!url) return "-";
if (url.startsWith("http")) return new URL(url).host;
return "local";
};
if (!url) return '-'
if (url.startsWith('http')) return new URL(url).host
return 'local'
}
const parseExpire = (expire?: number) => {
if (!expire) return "-";
return dayjs(expire * 1000).format("YYYY-MM-DD");
};
if (!expire) return '-'
return dayjs(expire * 1000).format('YYYY-MM-DD')
}
// 使用类型定义,而不是导入
interface ProfileExtra {
upload: number;
download: number;
total: number;
expire: number;
upload: number
download: number
total: number
expire: number
}
interface ProfileItem {
uid: string;
type?: "local" | "remote" | "merge" | "script";
name?: string;
desc?: string;
file?: string;
url?: string;
updated?: number;
extra?: ProfileExtra;
home?: string;
option?: any;
uid: string
type?: 'local' | 'remote' | 'merge' | 'script'
name?: string
desc?: string
file?: string
url?: string
updated?: number
extra?: ProfileExtra
home?: string
option?: any
}
interface HomeProfileCardProps {
current: ProfileItem | null | undefined;
onProfileUpdated?: () => void;
current: ProfileItem | null | undefined
onProfileUpdated?: () => void
}
// 提取独立组件减少主组件复杂度
@@ -81,23 +81,23 @@ const ProfileDetails = ({
onUpdateProfile,
updating,
}: {
current: ProfileItem;
onUpdateProfile: () => void;
updating: boolean;
current: ProfileItem
onUpdateProfile: () => void
updating: boolean
}) => {
const { t } = useTranslation();
const theme = useTheme();
const { t } = useTranslation()
const theme = useTheme()
const usedTraffic = useMemo(() => {
if (!current.extra) return 0;
return current.extra.upload + current.extra.download;
}, [current.extra]);
if (!current.extra) return 0
return current.extra.upload + current.extra.download
}, [current.extra])
const trafficPercentage = useMemo(() => {
if (!current.extra || !current.extra.total || current.extra.total <= 0)
return 0;
return Math.min(Math.round((usedTraffic / current.extra.total) * 100), 100);
}, [current.extra, usedTraffic]);
return 0
return Math.min(Math.round((usedTraffic / current.extra.total) * 100), 100)
}, [current.extra, usedTraffic])
return (
<Box>
@@ -109,19 +109,19 @@ const ProfileDetails = ({
variant="body2"
color="text.secondary"
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 ? (
<Link
component="button"
fontWeight="medium"
onClick={() => current.home && openWebUrl(current.home)}
sx={{
display: "inline-flex",
alignItems: "center",
display: 'inline-flex',
alignItems: 'center',
minWidth: 0,
maxWidth: "calc(100% - 40px)",
maxWidth: 'calc(100% - 40px)',
ml: 0.5,
}}
title={parseUrl(current.url)}
@@ -129,9 +129,9 @@ const ProfileDetails = ({
<Typography
component="span"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
flex: 1,
}}
@@ -142,7 +142,7 @@ const ProfileDetails = ({
fontSize="inherit"
sx={{
ml: 0.5,
fontSize: "0.8rem",
fontSize: '0.8rem',
opacity: 0.7,
flexShrink: 0,
}}
@@ -153,9 +153,9 @@ const ProfileDetails = ({
component="span"
fontWeight="medium"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
flex: 1,
ml: 0.5,
@@ -175,20 +175,20 @@ const ProfileDetails = ({
fontSize="small"
color="action"
sx={{
cursor: "pointer",
animation: updating ? `${round} 1.5s linear infinite` : "none",
cursor: 'pointer',
animation: updating ? `${round} 1.5s linear infinite` : 'none',
}}
onClick={onUpdateProfile}
/>
<Typography
variant="body2"
color="text.secondary"
sx={{ cursor: "pointer" }}
sx={{ cursor: 'pointer' }}
onClick={onUpdateProfile}
>
{t("shared.labels.updateTime")}:{" "}
{t('shared.labels.updateTime')}:{' '}
<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>
</Typography>
</Stack>
@@ -199,9 +199,9 @@ const ProfileDetails = ({
<Stack direction="row" alignItems="center" spacing={1}>
<SpeedOutlined fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{t("shared.labels.usedTotal")}:{" "}
{t('shared.labels.usedTotal')}:{' '}
<Box component="span" fontWeight="medium">
{parseTraffic(usedTraffic)} /{" "}
{parseTraffic(usedTraffic)} /{' '}
{parseTraffic(current.extra.total)}
</Box>
</Typography>
@@ -211,7 +211,7 @@ const ProfileDetails = ({
<Stack direction="row" alignItems="center" spacing={1}>
<EventOutlined fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{t("shared.labels.expireTime")}:{" "}
{t('shared.labels.expireTime')}:{' '}
<Box component="span" fontWeight="medium">
{parseExpire(current.extra.expire)}
</Box>
@@ -223,7 +223,7 @@ const ProfileDetails = ({
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 0.5, display: "block" }}
sx={{ mb: 0.5, display: 'block' }}
>
{trafficPercentage}%
</Typography>
@@ -241,78 +241,78 @@ const ProfileDetails = ({
)}
</Stack>
</Box>
);
};
)
}
// 提取空配置组件
const EmptyProfile = ({ onClick }: { onClick: () => void }) => {
const { t } = useTranslation();
const { t } = useTranslation()
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 2.4,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
borderRadius: 2,
}}
onClick={onClick}
>
<CloudUploadOutlined
sx={{ fontSize: 60, color: "primary.main", mb: 2 }}
sx={{ fontSize: 60, color: 'primary.main', mb: 2 }}
/>
<Typography variant="h6" gutterBottom>
{t("profiles.page.actions.import")} {t("profiles.page.title")}
{t('profiles.page.actions.import')} {t('profiles.page.title')}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("profiles.components.card.labels.clickToImport")}
{t('profiles.components.card.labels.clickToImport')}
</Typography>
</Box>
);
};
)
}
export const HomeProfileCard = ({
current,
onProfileUpdated,
}: HomeProfileCardProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshAll } = useAppData();
const { t } = useTranslation()
const navigate = useNavigate()
const { refreshAll } = useAppData()
// 更新当前订阅
const [updating, setUpdating] = useState(false);
const [updating, setUpdating] = useState(false)
const onUpdateProfile = useLockFn(async () => {
if (!current?.uid) return;
if (!current?.uid) return
setUpdating(true);
setUpdating(true)
try {
await updateProfile(current.uid, current.option);
onProfileUpdated?.();
await updateProfile(current.uid, current.option)
onProfileUpdated?.()
// 刷新首页数据
refreshAll();
refreshAll()
} catch (err) {
showNotice.error(err, 3000);
showNotice.error(err, 3000)
} finally {
setUpdating(false);
setUpdating(false)
}
});
})
// 导航到订阅页面
const goToProfiles = useCallback(() => {
navigate("/profile");
}, [navigate]);
navigate('/profile')
}, [navigate])
// 卡片标题
const cardTitle = useMemo(() => {
if (!current) return t("profiles.page.title");
if (!current) return t('profiles.page.title')
if (!current.home) return current.name;
if (!current.home) return current.name
return (
<Link
@@ -322,16 +322,16 @@ export const HomeProfileCard = ({
fontSize={18}
onClick={() => current.home && openWebUrl(current.home)}
sx={{
color: "inherit",
textDecoration: "none",
display: "flex",
alignItems: "center",
color: 'inherit',
textDecoration: 'none',
display: 'flex',
alignItems: 'center',
minWidth: 0,
maxWidth: "100%",
"& > span": {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: '100%',
'& > span': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
},
}}
@@ -342,18 +342,18 @@ export const HomeProfileCard = ({
fontSize="inherit"
sx={{
ml: 0.5,
fontSize: "0.8rem",
fontSize: '0.8rem',
opacity: 0.7,
flexShrink: 0,
}}
/>
</Link>
);
}, [current, t]);
)
}, [current, t])
// 卡片操作按钮
const cardAction = useMemo(() => {
if (!current) return null;
if (!current) return null
return (
<Button
@@ -363,10 +363,10 @@ export const HomeProfileCard = ({
endIcon={<StorageOutlined fontSize="small" />}
sx={{ borderRadius: 1.5 }}
>
{t("layout.components.navigation.tabs.profiles")}
{t('layout.components.navigation.tabs.profiles')}
</Button>
);
}, [current, goToProfiles, t]);
)
}, [current, goToProfiles, t])
return (
<EnhancedCard
@@ -385,5 +385,5 @@ export const HomeProfileCard = ({
<EmptyProfile onClick={goToProfiles} />
)}
</EnhancedCard>
);
};
)
}
+146 -146
View File
@@ -3,12 +3,12 @@ import {
RefreshOutlined,
VisibilityOffOutlined,
VisibilityOutlined,
} from "@mui/icons-material";
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useEffect } from "foxact/use-abortable-effect";
import { useIntersection } from "foxact/use-intersection";
import type { XOR } from "foxts/ts-xor";
} from '@mui/icons-material'
import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useEffect } from 'foxact/use-abortable-effect'
import { useIntersection } from 'foxact/use-intersection'
import type { XOR } from 'foxts/ts-xor'
import {
memo,
useCallback,
@@ -16,26 +16,26 @@ import {
useEffectEvent,
useMemo,
forwardRef,
} from "react";
import { useTranslation } from "react-i18next";
import useSWRImmutable from "swr/immutable";
} from 'react'
import { useTranslation } from 'react-i18next'
import useSWRImmutable from 'swr/immutable'
import { getIpInfo } from "@/services/api";
import { SWR_EXTERNAL_API } from "@/services/config";
import { getIpInfo } from '@/services/api'
import { SWR_EXTERNAL_API } from '@/services/config'
import { EnhancedCard } from "./enhanced-card";
import { EnhancedCard } from './enhanced-card'
// 定义刷新时间(秒)
const IP_REFRESH_SECONDS = 300;
const COUNTDOWN_TICK_INTERVAL = 5_000;
const IP_INFO_CACHE_KEY = "cv_ip_info_cache";
const IP_REFRESH_SECONDS = 300
const COUNTDOWN_TICK_INTERVAL = 5_000
const IP_INFO_CACHE_KEY = 'cv_ip_info_cache'
const InfoItem = memo(({ label, value }: { label: string; value?: string }) => (
<Box sx={{ mb: 0.7, display: "flex", alignItems: "flex-start" }}>
<Box sx={{ mb: 0.7, display: 'flex', alignItems: 'flex-start' }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ minwidth: 60, mr: 0.5, flexShrink: 0, textAlign: "right" }}
sx={{ minwidth: 60, mr: 0.5, flexShrink: 0, textAlign: 'right' }}
>
{label}:
</Typography>
@@ -43,46 +43,46 @@ const InfoItem = memo(({ label, value }: { label: string; value?: string }) => (
variant="body2"
sx={{
ml: 0.5,
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-word",
whiteSpace: "normal",
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
whiteSpace: 'normal',
flexGrow: 1,
}}
>
{value || "Unknown"}
{value || 'Unknown'}
</Typography>
</Box>
));
))
// 获取国旗表情
const getCountryFlag = (countryCode: string | undefined) => {
if (!countryCode) return "";
if (!countryCode) return ''
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
.split('')
.map((char) => 127397 + char.charCodeAt(0))
return String.fromCodePoint(...codePoints)
}
type CountDownState = XOR<
{
type: "countdown";
remainingSeconds: number;
type: 'countdown'
remainingSeconds: number
},
{
type: "revalidating";
type: 'revalidating'
}
>;
>
const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
({ children }, ref) => {
const { t } = useTranslation();
const { mutate } = useIPInfo();
const { t } = useTranslation()
const { mutate } = useIPInfo()
return (
<EnhancedCard
title={t("home.components.ipInfo.title")}
title={t('home.components.ipInfo.title')}
icon={<LocationOnOutlined />}
iconColor="info"
ref={ref}
@@ -94,41 +94,41 @@ const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
>
{children}
</EnhancedCard>
);
)
},
);
)
// IP信息卡片组件
export const IpInfoCard = () => {
const { t } = useTranslation();
const [showIp, setShowIp] = useState(false);
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
const { t } = useTranslation()
const [showIp, setShowIp] = useState(false)
const appWindow = useMemo(() => getCurrentWebviewWindow(), [])
// track ip info card has been in viewport or not
// hasIntersected default to false, and will be true once the card is in viewport
// and will never be false again afterwards (unless resetIntersected is called or
// the component is unmounted)
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
rootMargin: "0px",
});
rootMargin: '0px',
})
const [countdown, setCountdown] = useState<CountDownState>({
type: "countdown",
type: 'countdown',
remainingSeconds: IP_REFRESH_SECONDS,
});
})
const { data: ipInfo, error, isLoading, mutate } = useIPInfo();
const { data: ipInfo, error, isLoading, mutate } = useIPInfo()
// function useEffectEvent
const onCountdownTick = useEffectEvent(async () => {
const now = Date.now();
const ts = ipInfo?.lastFetchTs;
const now = Date.now()
const ts = ipInfo?.lastFetchTs
if (!ts) {
return;
return
}
const elapsed = Math.floor((now - ts) / 1000);
const remaining = IP_REFRESH_SECONDS - elapsed;
const elapsed = Math.floor((now - ts) / 1000)
const remaining = IP_REFRESH_SECONDS - elapsed
if (remaining <= 0) {
if (
@@ -139,21 +139,21 @@ export const IpInfoCard = () => {
// is online
navigator.onLine &&
// there is no ongoing revalidation already scheduled
countdown.type !== "revalidating" &&
countdown.type !== 'revalidating' &&
// window is visible
(await appWindow.isVisible())
) {
setCountdown({ type: "revalidating" });
setCountdown({ type: 'revalidating' })
// we do not care about the result of mutate here. after mutate is done,
// simply wait for next interval tick with `setCountdown({ type: "countdown", ... })`
try {
await mutate();
await mutate()
} finally {
// in case mutate throws error, we still need to reset the countdown state
setCountdown({
type: "countdown",
type: 'countdown',
remainingSeconds: IP_REFRESH_SECONDS,
});
})
}
} else {
// do nothing. we even skip "setCountdown" to reduce re-renders
@@ -164,15 +164,15 @@ export const IpInfoCard = () => {
}
} else {
setCountdown({
type: "countdown",
type: 'countdown',
remainingSeconds: remaining,
});
})
}
});
})
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
useEffect(() => {
let timer: number | null = null;
let timer: number | null = null
// Do not add document.hidden check here as it is not reliable in Tauri.
//
@@ -180,17 +180,17 @@ export const IpInfoCard = () => {
// instead of Tauri, which is reliable enough.
if (hasIntersected) {
console.debug(
"IP info card has entered the viewport, starting the countdown interval.",
);
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL);
'IP info card has entered the viewport, starting the countdown interval.',
)
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL)
} else {
console.debug(
"IP info card has not yet entered the viewport, no counting down.",
);
'IP info card has not yet entered the viewport, no counting down.',
)
}
// This will fire when the window is minimized or restored
document.addEventListener("visibilitychange", onVisibilityChange);
document.addEventListener('visibilitychange', onVisibilityChange)
// Tauri's visibility change detection is actually broken on some platforms:
// https://github.com/tauri-apps/tauri/issues/10592
//
@@ -199,100 +199,100 @@ export const IpInfoCard = () => {
// reduce power consumption.
function onVisibilityChange() {
if (document.hidden) {
console.debug("Document hidden, pause the interval");
console.debug('Document hidden, pause the interval')
// Pause the timer
if (timer != null) {
clearInterval(timer);
timer = null;
clearInterval(timer)
timer = null
}
} else if (hasIntersected) {
console.debug("Document visible, resume the interval");
console.debug('Document visible, resume the interval')
// Resume the timer only when previous one is cleared
if (timer == null) {
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL);
timer = window.setInterval(onCountdownTick, COUNTDOWN_TICK_INTERVAL)
}
} else {
console.debug(
"Document visible, but IP info card has never entered the viewport, not even once, not starting the interval.",
);
'Document visible, but IP info card has never entered the viewport, not even once, not starting the interval.',
)
}
}
return () => {
if (timer != null) clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [hasIntersected]);
if (timer != null) clearInterval(timer)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [hasIntersected])
const toggleShowIp = useCallback(() => {
setShowIp((prev) => !prev);
}, []);
setShowIp((prev) => !prev)
}, [])
let mainElement: React.ReactElement;
let mainElement: React.ReactElement
switch (true) {
case isLoading:
mainElement = (
<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="80%" height={24} />
<Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="50%" height={24} />
</Box>
);
break;
)
break
case !!error:
mainElement = (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "error.main",
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'error.main',
}}
>
<Typography variant="body1" color="error">
{error instanceof Error
? error.message
: t("home.components.ipInfo.errors.load")}
: t('home.components.ipInfo.errors.load')}
</Typography>
<Button onClick={() => mutate()} sx={{ mt: 2 }}>
{t("shared.actions.retry")}
{t('shared.actions.retry')}
</Button>
</Box>
);
break;
)
break
default: // Normal render
mainElement = (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box
sx={{
display: "flex",
flexDirection: "row",
display: 'flex',
flexDirection: 'row',
flex: 1,
overflow: "hidden",
overflow: 'hidden',
}}
>
{/* 左侧:国家和IP地址 */}
<Box sx={{ width: "40%", overflow: "hidden" }}>
<Box sx={{ width: '40%', overflow: 'hidden' }}>
<Box
sx={{
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
mb: 1,
overflow: "hidden",
overflow: 'hidden',
}}
>
<Box
component="span"
sx={{
fontSize: "1.5rem",
fontSize: '1.5rem',
mr: 1,
display: "inline-block",
display: 'inline-block',
width: 28,
textAlign: "center",
textAlign: 'center',
flexShrink: 0,
fontFamily: '"twemoji mozilla", sans-serif',
}}
@@ -302,46 +302,46 @@ export const IpInfoCard = () => {
<Typography
variant="subtitle1"
sx={{
fontWeight: "medium",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
fontWeight: 'medium',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{ipInfo?.country ||
t("home.components.ipInfo.labels.unknown")}
t('home.components.ipInfo.labels.unknown')}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{t("home.components.ipInfo.labels.ip")}:
{t('home.components.ipInfo.labels.ip')}:
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
ml: 1,
overflow: "hidden",
maxWidth: "calc(100% - 30px)",
overflow: 'hidden',
maxWidth: 'calc(100% - 30px)',
}}
>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
fontSize: "0.75rem",
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-all",
fontFamily: 'monospace',
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
}}
>
{showIp ? ipInfo?.ip : "••••••••••"}
{showIp ? ipInfo?.ip : '••••••••••'}
</Typography>
<IconButton size="small" onClick={toggleShowIp}>
{showIp ? (
@@ -354,29 +354,29 @@ export const IpInfoCard = () => {
</Box>
<InfoItem
label={t("home.components.ipInfo.labels.asn")}
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
label={t('home.components.ipInfo.labels.asn')}
value={ipInfo?.asn ? `AS${ipInfo.asn}` : 'N/A'}
/>
</Box>
{/* 右侧:组织、ISP和位置信息 */}
<Box sx={{ width: "60%", overflow: "auto" }}>
<Box sx={{ width: '60%', overflow: 'auto' }}>
<InfoItem
label={t("home.components.ipInfo.labels.isp")}
label={t('home.components.ipInfo.labels.isp')}
value={ipInfo?.organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.org")}
label={t('home.components.ipInfo.labels.org')}
value={ipInfo?.asn_organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.location")}
label={t('home.components.ipInfo.labels.location')}
value={[ipInfo?.city, ipInfo?.region]
.filter(Boolean)
.join(", ")}
.join(', ')}
/>
<InfoItem
label={t("home.components.ipInfo.labels.timezone")}
label={t('home.components.ipInfo.labels.timezone')}
value={ipInfo?.timezone}
/>
</Box>
@@ -384,43 +384,43 @@ export const IpInfoCard = () => {
<Box
sx={{
mt: "auto",
mt: 'auto',
pt: 0.5,
borderTop: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderColor: 'divider',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
opacity: 0.7,
fontSize: "0.7rem",
fontSize: '0.7rem',
}}
>
<Typography variant="caption">
{t("home.components.ipInfo.labels.autoRefresh")}
{countdown.type === "countdown"
{t('home.components.ipInfo.labels.autoRefresh')}
{countdown.type === 'countdown'
? `: ${countdown.remainingSeconds}s`
: "..."}
: '...'}
</Typography>
<Typography
variant="caption"
sx={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: 'ellipsis',
overflow: 'hidden',
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>
</Box>
</Box>
);
)
}
return (
<IPInfoCardContainer ref={containerRef}>{mainElement}</IPInfoCardContainer>
);
};
)
}
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,
HelpOutlineRounded,
SvgIconComponent,
} from "@mui/icons-material";
} from '@mui/icons-material'
import {
Box,
Typography,
@@ -13,24 +13,24 @@ import {
alpha,
useTheme,
Fade,
} from "@mui/material";
import { useState, useMemo, memo, FC } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import { useState, useMemo, memo, FC } from 'react'
import { useTranslation } from 'react-i18next'
import ProxyControlSwitches from "@/components/shared/proxy-control-switches";
import { useSystemProxyState } from "@/hooks/use-system-proxy-state";
import { useSystemState } from "@/hooks/use-system-state";
import { useVerge } from "@/hooks/use-verge";
import { showNotice } from "@/services/notice-service";
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
import { useSystemProxyState } from '@/hooks/use-system-proxy-state'
import { useSystemState } from '@/hooks/use-system-state'
import { useVerge } from '@/hooks/use-verge'
import { showNotice } from '@/services/notice-service'
const LOCAL_STORAGE_TAB_KEY = "clash-verge-proxy-active-tab";
const LOCAL_STORAGE_TAB_KEY = 'clash-verge-proxy-active-tab'
interface TabButtonProps {
isActive: boolean;
onClick: () => void;
icon: SvgIconComponent;
label: string;
hasIndicator?: boolean;
isActive: boolean
onClick: () => void
icon: SvgIconComponent
label: string
hasIndicator?: boolean
}
// Tab组件
@@ -40,34 +40,34 @@ const TabButton: FC<TabButtonProps> = memo(
elevation={isActive ? 2 : 0}
onClick={onClick}
sx={{
cursor: "pointer",
cursor: 'pointer',
px: 2,
py: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
bgcolor: isActive ? "primary.main" : "background.paper",
color: isActive ? "primary.contrastText" : "text.primary",
bgcolor: isActive ? 'primary.main' : 'background.paper',
color: isActive ? 'primary.contrastText' : 'text.primary',
borderRadius: 1.5,
flex: 1,
maxWidth: 160,
transition: "all 0.2s ease-in-out",
position: "relative",
"&:hover": {
transform: "translateY(-1px)",
transition: 'all 0.2s ease-in-out',
position: 'relative',
'&:hover': {
transform: 'translateY(-1px)',
boxShadow: 1,
},
"&:after": isActive
'&:after': isActive
? {
content: '""',
position: "absolute",
position: 'absolute',
bottom: -9,
left: "50%",
left: '50%',
width: 2,
height: 9,
bgcolor: "primary.main",
transform: "translateX(-50%)",
bgcolor: 'primary.main',
transform: 'translateX(-50%)',
}
: {},
}}
@@ -81,9 +81,9 @@ const TabButton: FC<TabButtonProps> = memo(
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: isActive ? "#fff" : "success.main",
position: "absolute",
borderRadius: '50%',
bgcolor: isActive ? '#fff' : 'success.main',
position: 'absolute',
top: 8,
right: 8,
}}
@@ -91,11 +91,11 @@ const TabButton: FC<TabButtonProps> = memo(
)}
</Paper>
),
);
)
interface TabDescriptionProps {
description: string;
tooltipTitle: string;
description: string
tooltipTitle: string
}
// 描述文本组件
@@ -106,21 +106,21 @@ const TabDescription: FC<TabDescriptionProps> = memo(
variant="caption"
component="div"
sx={{
width: "95%",
textAlign: "center",
color: "text.secondary",
width: '95%',
textAlign: 'center',
color: 'text.secondary',
p: 0.8,
borderRadius: 1,
borderColor: "primary.main",
borderColor: 'primary.main',
borderWidth: 1,
borderStyle: "solid",
backgroundColor: "background.paper",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderStyle: 'solid',
backgroundColor: 'background.paper',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
wordBreak: "break-word",
hyphens: "auto",
wordBreak: 'break-word',
hyphens: 'auto',
}}
>
{description}
@@ -132,47 +132,47 @@ const TabDescription: FC<TabDescriptionProps> = memo(
</Typography>
</Fade>
),
);
)
export const ProxyTunCard: FC = () => {
const { t } = useTranslation();
const theme = useTheme();
const { t } = useTranslation()
const theme = useTheme()
const [activeTab, setActiveTab] = useState<string>(
() => localStorage.getItem(LOCAL_STORAGE_TAB_KEY) || "system",
);
() => localStorage.getItem(LOCAL_STORAGE_TAB_KEY) || 'system',
)
const { verge } = useVerge();
const { isTunModeAvailable } = useSystemState();
const { configState: systemProxyConfigState } = useSystemProxyState();
const { verge } = useVerge()
const { isTunModeAvailable } = useSystemState()
const { configState: systemProxyConfigState } = useSystemProxyState()
const { enable_tun_mode } = verge ?? {};
const { enable_tun_mode } = verge ?? {}
const handleError = (err: unknown) => {
showNotice.error(err);
};
showNotice.error(err)
}
const handleTabChange = (tab: string) => {
setActiveTab(tab);
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab);
};
setActiveTab(tab)
localStorage.setItem(LOCAL_STORAGE_TAB_KEY, tab)
}
const tabDescription = useMemo(() => {
if (activeTab === "system") {
if (activeTab === 'system') {
return {
text: systemProxyConfigState
? t("home.components.proxyTun.status.systemProxyEnabled")
: t("home.components.proxyTun.status.systemProxyDisabled"),
tooltip: t("home.components.proxyTun.tooltips.systemProxy"),
};
? t('home.components.proxyTun.status.systemProxyEnabled')
: t('home.components.proxyTun.status.systemProxyDisabled'),
tooltip: t('home.components.proxyTun.tooltips.systemProxy'),
}
} else {
return {
text: !isTunModeAvailable
? t("home.components.proxyTun.status.tunModeServiceRequired")
? t('home.components.proxyTun.status.tunModeServiceRequired')
: enable_tun_mode
? t("home.components.proxyTun.status.tunModeEnabled")
: t("home.components.proxyTun.status.tunModeDisabled"),
tooltip: t("home.components.proxyTun.tooltips.tunMode"),
};
? t('home.components.proxyTun.status.tunModeEnabled')
: t('home.components.proxyTun.status.tunModeDisabled'),
tooltip: t('home.components.proxyTun.tooltips.tunMode'),
}
}
}, [
activeTab,
@@ -180,44 +180,44 @@ export const ProxyTunCard: FC = () => {
enable_tun_mode,
isTunModeAvailable,
t,
]);
])
return (
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<Stack
direction="row"
spacing={1}
sx={{
display: "flex",
justifyContent: "center",
position: "relative",
display: 'flex',
justifyContent: 'center',
position: 'relative',
zIndex: 2,
}}
>
<TabButton
isActive={activeTab === "system"}
onClick={() => handleTabChange("system")}
isActive={activeTab === 'system'}
onClick={() => handleTabChange('system')}
icon={ComputerRounded}
label={t("settings.sections.system.toggles.systemProxy")}
label={t('settings.sections.system.toggles.systemProxy')}
hasIndicator={systemProxyConfigState}
/>
<TabButton
isActive={activeTab === "tun"}
onClick={() => handleTabChange("tun")}
isActive={activeTab === 'tun'}
onClick={() => handleTabChange('tun')}
icon={TroubleshootRounded}
label={t("settings.sections.system.toggles.tunMode")}
label={t('settings.sections.system.toggles.tunMode')}
hasIndicator={enable_tun_mode && isTunModeAvailable}
/>
</Stack>
<Box
sx={{
width: "100%",
width: '100%',
my: 1,
position: "relative",
display: "flex",
justifyContent: "center",
overflow: "visible",
position: 'relative',
display: 'flex',
justifyContent: 'center',
overflow: 'visible',
}}
>
<TabDescription
@@ -237,13 +237,13 @@ export const ProxyTunCard: FC = () => {
<ProxyControlSwitches
onError={handleError}
label={
activeTab === "system"
? t("settings.sections.system.toggles.systemProxy")
: t("settings.sections.system.toggles.tunMode")
activeTab === 'system'
? t('settings.sections.system.toggles.systemProxy')
: t('settings.sections.system.toggles.tunMode')
}
noRightPadding={true}
/>
</Box>
</Box>
);
};
)
}
+119 -119
View File
@@ -4,195 +4,195 @@ import {
AdminPanelSettingsOutlined,
DnsOutlined,
ExtensionOutlined,
} from "@mui/icons-material";
import { Typography, Stack, Divider, Chip, IconButton } from "@mui/material";
import { useLockFn } from "ahooks";
import { useCallback, useEffect, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
} from '@mui/icons-material'
import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useReducer } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import { useServiceInstaller } from "@/hooks/use-service-installer";
import { useSystemState } from "@/hooks/use-system-state";
import { useUpdate } from "@/hooks/use-update";
import { useVerge } from "@/hooks/use-verge";
import { getSystemInfo } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import { version as appVersion } from "@root/package.json";
import { useServiceInstaller } from '@/hooks/use-service-installer'
import { useSystemState } from '@/hooks/use-system-state'
import { useUpdate } from '@/hooks/use-update'
import { useVerge } from '@/hooks/use-verge'
import { getSystemInfo } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { version as appVersion } from '@root/package.json'
import { EnhancedCard } from "./enhanced-card";
import { EnhancedCard } from './enhanced-card'
interface SystemState {
osInfo: string;
lastCheckUpdate: string;
osInfo: string
lastCheckUpdate: string
}
type SystemStateAction =
| { type: "set-os-info"; payload: string }
| { type: "set-last-check-update"; payload: string };
| { type: 'set-os-info'; payload: string }
| { type: 'set-last-check-update'; payload: string }
const systemStateReducer = (
state: SystemState,
action: SystemStateAction,
): SystemState => {
switch (action.type) {
case "set-os-info":
return { ...state, osInfo: action.payload };
case "set-last-check-update":
return { ...state, lastCheckUpdate: action.payload };
case 'set-os-info':
return { ...state, osInfo: action.payload }
case 'set-last-check-update':
return { ...state, lastCheckUpdate: action.payload }
default:
return state;
return state
}
};
}
export const SystemInfoCard = () => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
const navigate = useNavigate();
const { isAdminMode, isSidecarMode } = useSystemState();
const { installServiceAndRestartCore } = useServiceInstaller();
const { t } = useTranslation()
const { verge, patchVerge } = useVerge()
const navigate = useNavigate()
const { isAdminMode, isSidecarMode } = useSystemState()
const { installServiceAndRestartCore } = useServiceInstaller()
// 自动检查更新逻辑
const { checkUpdate: triggerCheckUpdate } = useUpdate(true, {
onSuccess: () => {
const now = Date.now();
localStorage.setItem("last_check_update", now.toString());
const now = Date.now()
localStorage.setItem('last_check_update', now.toString())
dispatchSystemState({
type: "set-last-check-update",
type: 'set-last-check-update',
payload: new Date(now).toLocaleString(),
});
})
},
});
})
// 系统信息状态
const [systemState, dispatchSystemState] = useReducer(systemStateReducer, {
osInfo: "",
lastCheckUpdate: "-",
});
osInfo: '',
lastCheckUpdate: '-',
})
// 初始化系统信息
useEffect(() => {
let timeoutId: number | undefined;
let timeoutId: number | undefined
getSystemInfo()
.then((info) => {
const lines = info.split("\n");
const lines = info.split('\n')
if (lines.length > 0) {
const sysName = lines[0].split(": ")[1] || "";
let sysVersion = lines[1].split(": ")[1] || "";
const sysName = lines[0].split(': ')[1] || ''
let sysVersion = lines[1].split(': ')[1] || ''
if (
sysName &&
sysVersion.toLowerCase().startsWith(sysName.toLowerCase())
) {
sysVersion = sysVersion.substring(sysName.length).trim();
sysVersion = sysVersion.substring(sysName.length).trim()
}
dispatchSystemState({
type: "set-os-info",
type: 'set-os-info',
payload: `${sysName} ${sysVersion}`,
});
})
}
})
.catch(console.error);
.catch(console.error)
// 获取最后检查更新时间
const lastCheck = localStorage.getItem("last_check_update");
const lastCheck = localStorage.getItem('last_check_update')
if (lastCheck) {
try {
const timestamp = parseInt(lastCheck, 10);
const timestamp = parseInt(lastCheck, 10)
if (!isNaN(timestamp)) {
dispatchSystemState({
type: "set-last-check-update",
type: 'set-last-check-update',
payload: new Date(timestamp).toLocaleString(),
});
})
}
} catch (e) {
console.error("Error parsing last check update time", e);
console.error('Error parsing last check update time', e)
}
} else if (verge?.auto_check_update) {
// 如果启用了自动检查更新但没有记录,设置当前时间并延迟检查
const now = Date.now();
localStorage.setItem("last_check_update", now.toString());
const now = Date.now()
localStorage.setItem('last_check_update', now.toString())
dispatchSystemState({
type: "set-last-check-update",
type: 'set-last-check-update',
payload: new Date(now).toLocaleString(),
});
})
timeoutId = window.setTimeout(() => {
if (verge?.auto_check_update) {
triggerCheckUpdate().catch(console.error);
triggerCheckUpdate().catch(console.error)
}
}, 5000);
}, 5000)
}
return () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
window.clearTimeout(timeoutId)
}
};
}, [verge?.auto_check_update, dispatchSystemState, triggerCheckUpdate]);
}
}, [verge?.auto_check_update, dispatchSystemState, triggerCheckUpdate])
// 导航到设置页面
const goToSettings = useCallback(() => {
navigate("/settings");
}, [navigate]);
navigate('/settings')
}, [navigate])
// 切换自启动状态
const toggleAutoLaunch = useCallback(async () => {
if (!verge) return;
if (!verge) return
try {
await patchVerge({ enable_auto_launch: !verge.enable_auto_launch });
await patchVerge({ enable_auto_launch: !verge.enable_auto_launch })
} catch (err) {
console.error("切换开机自启动状态失败:", err);
console.error('切换开机自启动状态失败:', err)
}
}, [verge, patchVerge]);
}, [verge, patchVerge])
// 点击运行模式处理,Sidecar或纯管理员模式允许安装服务
const handleRunningModeClick = useCallback(() => {
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
installServiceAndRestartCore();
installServiceAndRestartCore()
}
}, [isSidecarMode, isAdminMode, installServiceAndRestartCore]);
}, [isSidecarMode, isAdminMode, installServiceAndRestartCore])
// 检查更新
const onCheckUpdate = useLockFn(async () => {
try {
const info = await triggerCheckUpdate();
const info = await triggerCheckUpdate()
if (!info?.available) {
showNotice.success(
"settings.components.verge.advanced.notifications.latestVersion",
);
'settings.components.verge.advanced.notifications.latestVersion',
)
} else {
showNotice.info("shared.feedback.notifications.updateAvailable", 2000);
goToSettings();
showNotice.info('shared.feedback.notifications.updateAvailable', 2000)
goToSettings()
}
} catch (err) {
showNotice.error(err);
showNotice.error(err)
}
});
})
// 是否启用自启动
const autoLaunchEnabled = useMemo(
() => verge?.enable_auto_launch || false,
[verge],
);
)
// 运行模式样式
const runningModeStyle = useMemo(
() => ({
// Sidecar或纯管理员模式允许安装服务
cursor:
isSidecarMode || (isAdminMode && isSidecarMode) ? "pointer" : "default",
isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
textDecoration:
isSidecarMode || (isAdminMode && isSidecarMode) ? "underline" : "none",
display: "flex",
alignItems: "center",
isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none',
display: 'flex',
alignItems: 'center',
gap: 0.5,
"&:hover": {
'&:hover': {
opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1,
},
}),
[isSidecarMode, isAdminMode],
);
)
// 获取模式图标和文本
const getModeIcon = () => {
@@ -202,67 +202,67 @@ export const SystemInfoCard = () => {
return (
<>
<AdminPanelSettingsOutlined
sx={{ color: "primary.main", fontSize: 16 }}
titleAccess={t("home.components.systemInfo.badges.adminMode")}
sx={{ color: 'primary.main', fontSize: 16 }}
titleAccess={t('home.components.systemInfo.badges.adminMode')}
/>
<DnsOutlined
sx={{ color: "success.main", fontSize: 16, ml: 0.5 }}
titleAccess={t("home.components.systemInfo.badges.serviceMode")}
sx={{ color: 'success.main', fontSize: 16, ml: 0.5 }}
titleAccess={t('home.components.systemInfo.badges.serviceMode')}
/>
</>
);
)
}
return (
<AdminPanelSettingsOutlined
sx={{ color: "primary.main", fontSize: 16 }}
titleAccess={t("home.components.systemInfo.badges.adminMode")}
sx={{ color: 'primary.main', fontSize: 16 }}
titleAccess={t('home.components.systemInfo.badges.adminMode')}
/>
);
)
} else if (isSidecarMode) {
return (
<ExtensionOutlined
sx={{ color: "info.main", fontSize: 16 }}
titleAccess={t("home.components.systemInfo.badges.sidecarMode")}
sx={{ color: 'info.main', fontSize: 16 }}
titleAccess={t('home.components.systemInfo.badges.sidecarMode')}
/>
);
)
} else {
return (
<DnsOutlined
sx={{ color: "success.main", fontSize: 16 }}
titleAccess={t("home.components.systemInfo.badges.serviceMode")}
sx={{ color: 'success.main', fontSize: 16 }}
titleAccess={t('home.components.systemInfo.badges.serviceMode')}
/>
);
)
}
}
};
// 获取模式文本
const getModeText = () => {
if (isAdminMode) {
// 判断是否同时处于服务模式
if (!isSidecarMode) {
return t("home.components.systemInfo.badges.adminServiceMode");
return t('home.components.systemInfo.badges.adminServiceMode')
}
return t("home.components.systemInfo.badges.adminMode");
return t('home.components.systemInfo.badges.adminMode')
} else if (isSidecarMode) {
return t("home.components.systemInfo.badges.sidecarMode");
return t('home.components.systemInfo.badges.sidecarMode')
} else {
return t("home.components.systemInfo.badges.serviceMode");
return t('home.components.systemInfo.badges.serviceMode')
}
}
};
// 只有当verge存在时才渲染内容
if (!verge) return null;
if (!verge) return null
return (
<EnhancedCard
title={t("home.components.systemInfo.title")}
title={t('home.components.systemInfo.title')}
icon={<InfoOutlined />}
iconColor="error"
action={
<IconButton
size="small"
onClick={goToSettings}
title={t("home.components.systemInfo.actions.settings")}
title={t('home.components.systemInfo.actions.settings')}
>
<SettingsOutlined fontSize="small" />
</IconButton>
@@ -271,7 +271,7 @@ export const SystemInfoCard = () => {
<Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.osInfo")}
{t('home.components.systemInfo.fields.osInfo')}
</Typography>
<Typography variant="body2" fontWeight="medium">
{systemState.osInfo}
@@ -284,20 +284,20 @@ export const SystemInfoCard = () => {
alignItems="center"
>
<Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.autoLaunch")}
{t('home.components.systemInfo.fields.autoLaunch')}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Chip
size="small"
label={
autoLaunchEnabled
? t("shared.statuses.enabled")
: t("shared.statuses.disabled")
? t('shared.statuses.enabled')
: t('shared.statuses.disabled')
}
color={autoLaunchEnabled ? "success" : "default"}
variant={autoLaunchEnabled ? "filled" : "outlined"}
color={autoLaunchEnabled ? 'success' : 'default'}
variant={autoLaunchEnabled ? 'filled' : 'outlined'}
onClick={toggleAutoLaunch}
sx={{ cursor: "pointer" }}
sx={{ cursor: 'pointer' }}
/>
</Stack>
</Stack>
@@ -308,7 +308,7 @@ export const SystemInfoCard = () => {
alignItems="center"
>
<Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.runningMode")}
{t('home.components.systemInfo.fields.runningMode')}
</Typography>
<Typography
variant="body2"
@@ -323,16 +323,16 @@ export const SystemInfoCard = () => {
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.lastCheckUpdate")}
{t('home.components.systemInfo.fields.lastCheckUpdate')}
</Typography>
<Typography
variant="body2"
fontWeight="medium"
onClick={onCheckUpdate}
sx={{
cursor: "pointer",
textDecoration: "underline",
"&:hover": { opacity: 0.7 },
cursor: 'pointer',
textDecoration: 'underline',
'&:hover': { opacity: 0.7 },
}}
>
{systemState.lastCheckUpdate}
@@ -341,7 +341,7 @@ export const SystemInfoCard = () => {
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("home.components.systemInfo.fields.vergeVersion")}
{t('home.components.systemInfo.fields.vergeVersion')}
</Typography>
<Typography variant="body2" fontWeight="medium">
v{appVersion}
@@ -349,5 +349,5 @@ export const SystemInfoCard = () => {
</Stack>
</Stack>
</EnhancedCard>
);
};
)
}
+74 -74
View File
@@ -6,145 +6,145 @@ import {
useSensors,
DragEndEvent,
DragOverlay,
} from "@dnd-kit/core";
import { SortableContext } from "@dnd-kit/sortable";
import { Add, NetworkCheck } from "@mui/icons-material";
import { Box, IconButton, Tooltip, alpha, styled, Grid } from "@mui/material";
import { emit } from "@tauri-apps/api/event";
import { nanoid } from "nanoid";
import { useEffect, useRef, useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
} from '@dnd-kit/core'
import { SortableContext } from '@dnd-kit/sortable'
import { Add, NetworkCheck } from '@mui/icons-material'
import { Box, IconButton, Tooltip, alpha, styled, Grid } from '@mui/material'
import { emit } from '@tauri-apps/api/event'
import { nanoid } from 'nanoid'
import { useEffect, useRef, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
// test icons
import apple from "@/assets/image/test/apple.svg?raw";
import github from "@/assets/image/test/github.svg?raw";
import google from "@/assets/image/test/google.svg?raw";
import youtube from "@/assets/image/test/youtube.svg?raw";
import { TestItem } from "@/components/test/test-item";
import { TestViewer, TestViewerRef } from "@/components/test/test-viewer";
import { useVerge } from "@/hooks/use-verge";
import apple from '@/assets/image/test/apple.svg?raw'
import github from '@/assets/image/test/github.svg?raw'
import google from '@/assets/image/test/google.svg?raw'
import youtube from '@/assets/image/test/youtube.svg?raw'
import { TestItem } from '@/components/test/test-item'
import { TestViewer, TestViewerRef } from '@/components/test/test-viewer'
import { useVerge } from '@/hooks/use-verge'
import { EnhancedCard } from "./enhanced-card";
import { EnhancedCard } from './enhanced-card'
// 自定义滚动条样式
const ScrollBox = styled(Box)(({ theme }) => ({
maxHeight: "180px",
overflowY: "auto",
overflowX: "hidden",
"&::-webkit-scrollbar": {
width: "6px",
maxHeight: '180px',
overflowY: 'auto',
overflowX: 'hidden',
'&::-webkit-scrollbar': {
width: '6px',
},
"&::-webkit-scrollbar-thumb": {
'&::-webkit-scrollbar-thumb': {
backgroundColor: alpha(theme.palette.text.primary, 0.2),
borderRadius: "3px",
borderRadius: '3px',
},
}));
}))
// 默认测试列表,移到组件外部避免重复创建
const DEFAULT_TEST_LIST = [
{
uid: nanoid(),
name: "Apple",
url: "https://www.apple.com",
name: 'Apple',
url: 'https://www.apple.com',
icon: apple,
},
{
uid: nanoid(),
name: "GitHub",
url: "https://www.github.com",
name: 'GitHub',
url: 'https://www.github.com',
icon: github,
},
{
uid: nanoid(),
name: "Google",
url: "https://www.google.com",
name: 'Google',
url: 'https://www.google.com',
icon: google,
},
{
uid: nanoid(),
name: "YouTube",
url: "https://www.youtube.com",
name: 'YouTube',
url: 'https://www.youtube.com',
icon: youtube,
},
];
]
export const TestCard = () => {
const { t } = useTranslation();
const { t } = useTranslation()
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
);
const { verge, mutateVerge, patchVerge } = useVerge();
const viewerRef = useRef<TestViewerRef>(null);
)
const { verge, mutateVerge, patchVerge } = useVerge()
const viewerRef = useRef<TestViewerRef>(null)
// 使用useMemo优化测试列表,避免每次渲染重新计算
const testList = useMemo(() => {
return verge?.test_list ?? DEFAULT_TEST_LIST;
}, [verge?.test_list]);
return verge?.test_list ?? DEFAULT_TEST_LIST
}, [verge?.test_list])
// 使用useCallback优化函数引用,避免不必要的重新渲染
const onTestListItemChange = useCallback(
(uid: string, patch?: Partial<IVergeTestItem>) => {
if (!patch) {
mutateVerge();
return;
mutateVerge()
return
}
const newList = testList.map((x) =>
x.uid === uid ? { ...x, ...patch } : x,
);
)
mutateVerge({ ...verge, test_list: newList }, false);
mutateVerge({ ...verge, test_list: newList }, false)
},
[testList, verge, mutateVerge],
);
)
const onDeleteTestListItem = useCallback(
(uid: string) => {
const newList = testList.filter((x) => x.uid !== uid);
patchVerge({ test_list: newList });
mutateVerge({ ...verge, test_list: newList }, false);
const newList = testList.filter((x) => x.uid !== uid)
patchVerge({ test_list: newList })
mutateVerge({ ...verge, test_list: newList }, false)
},
[testList, verge, patchVerge, mutateVerge],
);
)
const onDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const { active, over } = event
if (!over || active.id === over.id) return
const old_index = testList.findIndex((x) => x.uid === active.id);
const new_index = testList.findIndex((x) => x.uid === over.id);
const old_index = testList.findIndex((x) => x.uid === active.id)
const new_index = testList.findIndex((x) => x.uid === over.id)
if (old_index >= 0 && new_index >= 0) {
const newList = [...testList];
const [removed] = newList.splice(old_index, 1);
newList.splice(new_index, 0, removed);
const newList = [...testList]
const [removed] = newList.splice(old_index, 1)
newList.splice(new_index, 0, removed)
// 优化:先本地更新,再异步 patch,避免UI卡死
mutateVerge({ ...verge, test_list: newList }, false);
mutateVerge({ ...verge, test_list: newList }, false)
const patchFn = () => {
try {
patchVerge({ test_list: newList });
patchVerge({ test_list: newList })
} catch {}
};
}
if (window.requestIdleCallback) {
window.requestIdleCallback(patchFn);
window.requestIdleCallback(patchFn)
} else {
setTimeout(patchFn, 0);
setTimeout(patchFn, 0)
}
}
},
[testList, verge, mutateVerge, patchVerge],
);
)
// 仅在verge首次加载时初始化测试列表
useEffect(() => {
if (verge && !verge.test_list) {
patchVerge({ test_list: DEFAULT_TEST_LIST });
patchVerge({ test_list: DEFAULT_TEST_LIST })
}
}, [verge, patchVerge]);
}, [verge, patchVerge])
// 使用useMemo优化UI内容,减少渲染计算
const renderTestItems = useMemo(
@@ -165,28 +165,28 @@ export const TestCard = () => {
</Grid>
),
[testList, onDeleteTestListItem],
);
)
const handleTestAll = useCallback(() => {
emit("verge://test-all");
}, []);
emit('verge://test-all')
}, [])
const handleCreateTest = useCallback(() => {
viewerRef.current?.create();
}, []);
viewerRef.current?.create()
}, [])
return (
<EnhancedCard
title={t("home.components.tests.title")}
title={t('home.components.tests.title')}
icon={<NetworkCheck />}
action={
<Box sx={{ display: "flex", gap: 1 }}>
<Tooltip title={t("tests.page.actions.testAll")} arrow>
<Box sx={{ display: 'flex', gap: 1 }}>
<Tooltip title={t('tests.page.actions.testAll')} arrow>
<IconButton size="small" onClick={handleTestAll}>
<NetworkCheck fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t("tests.modals.test.title.create")} arrow>
<Tooltip title={t('tests.modals.test.title.create')} arrow>
<IconButton size="small" onClick={handleCreateTest}>
<Add fontSize="small" />
</IconButton>
@@ -207,5 +207,5 @@ export const TestCard = () => {
<TestViewer ref={viewerRef} onChange={onTestListItemChange} />
</EnhancedCard>
);
};
)
}
+49 -49
View File
@@ -1,60 +1,60 @@
import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
} from '@dnd-kit/core'
import {
alpha,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import type { CSSProperties, ReactNode } from "react";
import { useMatch, useNavigate, useResolvedPath } from "react-router";
} from '@mui/material'
import type { CSSProperties, ReactNode } from 'react'
import { useMatch, useNavigate, useResolvedPath } from 'react-router'
import { useVerge } from "@/hooks/use-verge";
import { useVerge } from '@/hooks/use-verge'
interface SortableProps {
setNodeRef?: (element: HTMLElement | null) => void;
attributes?: DraggableAttributes;
listeners?: DraggableSyntheticListeners;
style?: CSSProperties;
isDragging?: boolean;
disabled?: boolean;
setNodeRef?: (element: HTMLElement | null) => void
attributes?: DraggableAttributes
listeners?: DraggableSyntheticListeners
style?: CSSProperties
isDragging?: boolean
disabled?: boolean
}
interface Props {
to: string;
children: string;
icon: ReactNode[];
sortable?: SortableProps;
to: string
children: string
icon: ReactNode[]
sortable?: SortableProps
}
export const LayoutItem = (props: Props) => {
const { to, children, icon, sortable } = props;
const { verge } = useVerge();
const { menu_icon } = verge ?? {};
const navCollapsed = verge?.collapse_navbar ?? false;
const resolved = useResolvedPath(to);
const match = useMatch({ path: resolved.pathname, end: true });
const navigate = useNavigate();
const { to, children, icon, sortable } = props
const { verge } = useVerge()
const { menu_icon } = verge ?? {}
const navCollapsed = verge?.collapse_navbar ?? false
const resolved = useResolvedPath(to)
const match = useMatch({ path: resolved.pathname, end: true })
const navigate = useNavigate()
const effectiveMenuIcon =
navCollapsed && menu_icon === "disable" ? "monochrome" : menu_icon;
navCollapsed && menu_icon === 'disable' ? 'monochrome' : menu_icon
const { setNodeRef, attributes, listeners, style, isDragging, disabled } =
sortable ?? {};
sortable ?? {}
const draggable = Boolean(sortable) && !disabled;
const draggable = Boolean(sortable) && !disabled
const dragHandleProps = draggable
? { ...(attributes ?? {}), ...(listeners ?? {}) }
: undefined;
: undefined
return (
<ListItem
ref={setNodeRef}
style={style}
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 } : {},
]}
>
@@ -68,54 +68,54 @@ export const LayoutItem = (props: Props) => {
paddingLeft: 1,
paddingRight: 1,
marginRight: 1.25,
cursor: draggable ? "grab" : "pointer",
"&:active": draggable ? { cursor: "grabbing" } : {},
"& .MuiListItemText-primary": {
color: "text.primary",
fontWeight: "700",
cursor: draggable ? 'grab' : 'pointer',
'&:active': draggable ? { cursor: 'grabbing' } : {},
'& .MuiListItemText-primary': {
color: 'text.primary',
fontWeight: '700',
},
},
({ palette: { mode, primary } }) => {
const bgcolor =
mode === "light"
mode === 'light'
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35);
const color = mode === "light" ? "#1f1f1f" : "#ffffff";
: alpha(primary.main, 0.35)
const color = mode === 'light' ? '#1f1f1f' : '#ffffff'
return {
"&.Mui-selected": { bgcolor },
"&.Mui-selected:hover": { bgcolor },
"&.Mui-selected .MuiListItemText-primary": { color },
};
'&.Mui-selected': { bgcolor },
'&.Mui-selected:hover': { bgcolor },
'&.Mui-selected .MuiListItemText-primary': { color },
}
},
]}
title={navCollapsed ? children : undefined}
aria-label={navCollapsed ? children : undefined}
onClick={() => navigate(to)}
>
{(effectiveMenuIcon === "monochrome" || !effectiveMenuIcon) && (
{(effectiveMenuIcon === 'monochrome' || !effectiveMenuIcon) && (
<ListItemIcon
sx={{
color: "text.primary",
marginLeft: "6px",
cursor: draggable ? "grab" : "inherit",
color: 'text.primary',
marginLeft: '6px',
cursor: draggable ? 'grab' : 'inherit',
}}
>
{icon[0]}
</ListItemIcon>
)}
{effectiveMenuIcon === "colorful" && (
<ListItemIcon sx={{ cursor: draggable ? "grab" : "inherit" }}>
{effectiveMenuIcon === 'colorful' && (
<ListItemIcon sx={{ cursor: draggable ? 'grab' : 'inherit' }}>
{icon[1]}
</ListItemIcon>
)}
<ListItemText
sx={{
textAlign: "center",
marginLeft: effectiveMenuIcon === "disable" ? "" : "-35px",
textAlign: 'center',
marginLeft: effectiveMenuIcon === 'disable' ? '' : '-35px',
}}
primary={children}
/>
</ListItemButton>
</ListItem>
);
};
)
}
+50 -50
View File
@@ -2,37 +2,37 @@ import {
ArrowDownwardRounded,
ArrowUpwardRounded,
MemoryRounded,
} from "@mui/icons-material";
import { Box, Typography } from "@mui/material";
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/icons-material'
import { Box, Typography } from '@mui/material'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { LightweightTrafficErrorBoundary } from "@/components/shared/traffic-error-boundary";
import { useMemoryData } from "@/hooks/use-memory-data";
import { useTrafficData } from "@/hooks/use-traffic-data";
import { useVerge } from "@/hooks/use-verge";
import { useVisibility } from "@/hooks/use-visibility";
import parseTraffic from "@/utils/parse-traffic";
import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
import { useMemoryData } from '@/hooks/use-memory-data'
import { useTrafficData } from '@/hooks/use-traffic-data'
import { useVerge } from '@/hooks/use-verge'
import { useVisibility } from '@/hooks/use-visibility'
import parseTraffic from '@/utils/parse-traffic'
import { TrafficGraph, type TrafficRef } from "./traffic-graph";
import { TrafficGraph, type TrafficRef } from './traffic-graph'
// setup the traffic
export const LayoutTraffic = () => {
const { t } = useTranslation();
const { verge } = useVerge();
const { t } = useTranslation()
const { verge } = useVerge()
// whether hide traffic graph
const trafficGraph = verge?.traffic_graph ?? true;
const trafficGraph = verge?.traffic_graph ?? true
const trafficRef = useRef<TrafficRef>(null);
const pageVisible = useVisibility();
const trafficRef = useRef<TrafficRef>(null)
const pageVisible = useVisibility()
const {
response: { data: traffic },
} = useTrafficData({ enabled: trafficGraph && pageVisible });
} = useTrafficData({ enabled: trafficGraph && pageVisible })
const {
response: { data: memory },
} = useMemoryData();
} = useMemoryData()
// 监听数据变化,为图表添加数据点
useEffect(() => {
@@ -40,45 +40,45 @@ export const LayoutTraffic = () => {
trafficRef.current.appendData({
up: traffic?.up || 0,
down: traffic?.down || 0,
});
})
}
}, [traffic]);
}, [traffic])
// 显示内存使用情况的设置
const displayMemory = verge?.enable_memory_usage ?? true;
const displayMemory = verge?.enable_memory_usage ?? true
// 使用parseTraffic统一处理转换,保持与首页一致的显示格式
const [up, upUnit] = parseTraffic(traffic?.up || 0);
const [down, downUnit] = parseTraffic(traffic?.down || 0);
const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0);
const [up, upUnit] = parseTraffic(traffic?.up || 0)
const [down, downUnit] = parseTraffic(traffic?.down || 0)
const [inuse, inuseUnit] = parseTraffic(memory?.inuse || 0)
const boxStyle: any = {
display: "flex",
alignItems: "center",
whiteSpace: "nowrap",
};
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
}
const iconStyle: any = {
sx: { mr: "8px", fontSize: 16 },
};
sx: { mr: '8px', fontSize: 16 },
}
const valStyle: any = {
component: "span",
textAlign: "center",
sx: { flex: "1 1 56px", userSelect: "none" },
};
component: 'span',
textAlign: 'center',
sx: { flex: '1 1 56px', userSelect: 'none' },
}
const unitStyle: any = {
component: "span",
color: "grey.500",
fontSize: "12px",
textAlign: "right",
sx: { flex: "0 1 27px", userSelect: "none" },
};
component: 'span',
color: 'grey.500',
fontSize: '12px',
textAlign: 'right',
sx: { flex: '0 1 27px', userSelect: 'none' },
}
return (
<LightweightTrafficErrorBoundary>
<Box position="relative">
{trafficGraph && pageVisible && (
<div
style={{ width: "100%", height: 60, marginBottom: 6 }}
style={{ width: '100%', height: 60, marginBottom: 6 }}
onClick={trafficRef.current?.toggleStyle}
>
<TrafficGraph ref={trafficRef} />
@@ -87,7 +87,7 @@ export const LayoutTraffic = () => {
<Box display="flex" flexDirection="column" gap={0.75}>
<Box
title={`${t("home.components.traffic.metrics.uploadSpeed")}`}
title={`${t('home.components.traffic.metrics.uploadSpeed')}`}
{...boxStyle}
sx={{
...boxStyle.sx,
@@ -96,7 +96,7 @@ export const LayoutTraffic = () => {
>
<ArrowUpwardRounded
{...iconStyle}
color={(traffic?.up || 0) > 0 ? "secondary" : "disabled"}
color={(traffic?.up || 0) > 0 ? 'secondary' : 'disabled'}
/>
<Typography {...valStyle} color="secondary">
{up}
@@ -105,7 +105,7 @@ export const LayoutTraffic = () => {
</Box>
<Box
title={`${t("home.components.traffic.metrics.downloadSpeed")}`}
title={`${t('home.components.traffic.metrics.downloadSpeed')}`}
{...boxStyle}
sx={{
...boxStyle.sx,
@@ -114,7 +114,7 @@ export const LayoutTraffic = () => {
>
<ArrowDownwardRounded
{...iconStyle}
color={(traffic?.down || 0) > 0 ? "primary" : "disabled"}
color={(traffic?.down || 0) > 0 ? 'primary' : 'disabled'}
/>
<Typography {...valStyle} color="primary">
{down}
@@ -124,13 +124,13 @@ export const LayoutTraffic = () => {
{displayMemory && (
<Box
title={`${t("home.components.traffic.metrics.memoryUsage")} `}
title={`${t('home.components.traffic.metrics.memoryUsage')} `}
{...boxStyle}
sx={{
cursor: "auto",
cursor: 'auto',
// opacity: memory?.is_fresh ? 1 : 0.6,
}}
color={"disabled"}
color={'disabled'}
onClick={async () => {
// isDebug && (await gc());
}}
@@ -143,5 +143,5 @@ export const LayoutTraffic = () => {
</Box>
</Box>
</LightweightTrafficErrorBoundary>
);
};
)
}
+90 -90
View File
@@ -1,180 +1,180 @@
import { CloseRounded } from "@mui/icons-material";
import { CloseRounded } from '@mui/icons-material'
import {
Snackbar,
Alert,
IconButton,
Box,
type SnackbarOrigin,
} from "@mui/material";
import React, { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import React, { useCallback, useMemo, useSyncExternalStore } from 'react'
import { useTranslation } from 'react-i18next'
import {
subscribeNotices,
hideNotice,
getSnapshotNotices,
showNotice,
} from "@/services/notice-service";
import type { TranslationKey } from "@/types/generated/i18n-keys";
} from '@/services/notice-service'
import type { TranslationKey } from '@/types/generated/i18n-keys'
type NoticePosition = NonNullable<IVergeConfig["notice_position"]>;
type NoticeItem = ReturnType<typeof getSnapshotNotices>[number];
type TranslationFn = ReturnType<typeof useTranslation>["t"];
type NoticePosition = NonNullable<IVergeConfig['notice_position']>
type NoticeItem = ReturnType<typeof getSnapshotNotices>[number]
type TranslationFn = ReturnType<typeof useTranslation>['t']
const VALID_POSITIONS: NoticePosition[] = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
];
'top-left',
'top-right',
'bottom-left',
'bottom-right',
]
const resolvePosition = (position?: NoticePosition | null): NoticePosition => {
if (position && VALID_POSITIONS.includes(position)) {
return position;
return position
}
return "top-right";
};
return 'top-right'
}
const getAnchorOrigin = (position: NoticePosition): SnackbarOrigin => {
const [vertical, horizontal] = position.split("-") as [
SnackbarOrigin["vertical"],
SnackbarOrigin["horizontal"],
];
return { vertical, horizontal };
};
const [vertical, horizontal] = position.split('-') as [
SnackbarOrigin['vertical'],
SnackbarOrigin['horizontal'],
]
return { vertical, horizontal }
}
const resolveNoticeMessage = (
notice: NoticeItem,
t: TranslationFn,
): React.ReactNode => {
const i18n = notice.i18n;
if (!i18n) return notice.message;
const i18n = notice.i18n
if (!i18n) return notice.message
const params = (i18n.params ?? {}) as Record<string, unknown>;
const { prefixKey, prefixParams, prefix, message, ...restParams } = params;
const params = (i18n.params ?? {}) as Record<string, unknown>
const { prefixKey, prefixParams, prefix, message, ...restParams } = params
const prefixKeyParams =
prefixParams && typeof prefixParams === "object"
prefixParams && typeof prefixParams === 'object'
? (prefixParams as Record<string, unknown>)
: undefined;
: undefined
const resolvedPrefix =
typeof prefixKey === "string"
typeof prefixKey === 'string'
? t(prefixKey as TranslationKey, {
defaultValue: prefixKey,
...(prefixKeyParams ?? {}),
...restParams,
})
: typeof prefix === "string"
: typeof prefix === 'string'
? prefix
: undefined;
: undefined
const messageStr = typeof message === "string" ? message : undefined;
const messageStr = typeof message === 'string' ? message : undefined
const defaultValue =
messageStr === undefined
? undefined
: resolvedPrefix
? `${resolvedPrefix} ${messageStr}`
: messageStr;
: messageStr
return t(i18n.key as TranslationKey, {
defaultValue,
...restParams,
...(resolvedPrefix !== undefined ? { prefix: resolvedPrefix } : {}),
...(messageStr !== undefined ? { message: messageStr } : {}),
});
};
})
}
const extractNoticeCopyText = (input: unknown): string | undefined => {
if (input === null || input === undefined) return undefined;
if (typeof input === "string") return input;
if (typeof input === "number" || typeof input === "boolean") {
return String(input);
if (input === null || input === undefined) return undefined
if (typeof input === 'string') return input
if (typeof input === 'number' || typeof input === 'boolean') {
return String(input)
}
if (input instanceof Error) {
return input.message || input.name;
return input.message || input.name
}
if (React.isValidElement(input)) return undefined;
if (typeof input === "object") {
const maybeMessage = (input as { message?: unknown }).message;
if (typeof maybeMessage === "string") return maybeMessage;
if (React.isValidElement(input)) return undefined
if (typeof input === 'object') {
const maybeMessage = (input as { message?: unknown }).message
if (typeof maybeMessage === 'string') return maybeMessage
}
try {
return JSON.stringify(input);
return JSON.stringify(input)
} catch {
return String(input);
return String(input)
}
};
}
const resolveNoticeCopyText = (
notice: NoticeItem,
t: TranslationFn,
): string | undefined => {
if (
notice.i18n?.key === "shared.feedback.notices.prefixedRaw" ||
notice.i18n?.key === "shared.feedback.notices.raw"
notice.i18n?.key === 'shared.feedback.notices.prefixedRaw' ||
notice.i18n?.key === 'shared.feedback.notices.raw'
) {
const rawText = extractNoticeCopyText(notice.i18n?.params?.message);
if (rawText) return rawText;
const rawText = extractNoticeCopyText(notice.i18n?.params?.message)
if (rawText) return rawText
}
return (
extractNoticeCopyText(resolveNoticeMessage(notice, t)) ??
extractNoticeCopyText(notice.message)
);
};
)
}
interface NoticeManagerProps {
position?: NoticePosition | null;
position?: NoticePosition | null
}
export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => {
const { t } = useTranslation();
const resolvedPosition = useMemo(() => resolvePosition(position), [position]);
const { t } = useTranslation()
const resolvedPosition = useMemo(() => resolvePosition(position), [position])
const anchorOrigin = useMemo(
() => getAnchorOrigin(resolvedPosition),
[resolvedPosition],
);
)
const currentNotices = useSyncExternalStore(
subscribeNotices,
getSnapshotNotices,
);
)
const handleClose = (id: number) => {
hideNotice(id);
};
hideNotice(id)
}
const handleNoticeCopy = useCallback(
async (notice: NoticeItem) => {
const text = resolveNoticeCopyText(notice, t);
if (!text) return;
const text = resolveNoticeCopyText(notice, t)
if (!text) return
try {
await navigator.clipboard.writeText(text);
await navigator.clipboard.writeText(text)
showNotice.success(
"shared.feedback.notifications.common.copySuccess",
'shared.feedback.notifications.common.copySuccess',
1000,
);
)
} catch (error) {
console.warn("[NoticeManager] copy to clipboard failed:", error);
console.warn('[NoticeManager] copy to clipboard failed:', error)
}
},
[t],
);
)
return (
<Box
sx={{
position: "fixed",
top: anchorOrigin.vertical === "top" ? "20px" : "auto",
bottom: anchorOrigin.vertical === "bottom" ? "20px" : "auto",
left: anchorOrigin.horizontal === "left" ? "20px" : "auto",
right: anchorOrigin.horizontal === "right" ? "20px" : "auto",
position: 'fixed',
top: anchorOrigin.vertical === 'top' ? '20px' : 'auto',
bottom: anchorOrigin.vertical === 'bottom' ? '20px' : 'auto',
left: anchorOrigin.horizontal === 'left' ? '20px' : 'auto',
right: anchorOrigin.horizontal === 'right' ? '20px' : 'auto',
zIndex: 1500,
display: "flex",
flexDirection: "column",
gap: "10px",
maxWidth: "360px",
display: 'flex',
flexDirection: 'column',
gap: '10px',
maxWidth: '360px',
}}
>
{currentNotices.map((notice) => (
@@ -183,23 +183,23 @@ export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => {
open={true}
anchorOrigin={anchorOrigin}
sx={{
position: "relative",
transform: "none",
top: "auto",
right: "auto",
bottom: "auto",
left: "auto",
width: "100%",
position: 'relative',
transform: 'none',
top: 'auto',
right: 'auto',
bottom: 'auto',
left: 'auto',
width: '100%',
}}
>
<Alert
severity={notice.type}
variant="filled"
sx={{ width: "100%" }}
sx={{ width: '100%' }}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
void handleNoticeCopy(notice);
event.preventDefault()
event.stopPropagation()
void handleNoticeCopy(notice)
}}
action={
<IconButton
@@ -216,5 +216,5 @@ export const NoticeManager: React.FC<NoticeManagerProps> = ({ position }) => {
</Snackbar>
))}
</Box>
);
};
)
}
+18 -18
View File
@@ -1,10 +1,10 @@
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { IconButton, Fade, SxProps, Theme } from "@mui/material";
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'
import { IconButton, Fade, SxProps, Theme } from '@mui/material'
interface Props {
onClick: () => void;
show: boolean;
sx?: SxProps<Theme>;
onClick: () => void
show: boolean
sx?: SxProps<Theme>
}
export const ScrollTopButton = ({ onClick, show, sx }: Props) => {
@@ -13,25 +13,25 @@ export const ScrollTopButton = ({ onClick, show, sx }: Props) => {
<IconButton
onClick={onClick}
sx={{
position: "absolute",
bottom: "20px",
right: "20px",
position: 'absolute',
bottom: '20px',
right: '20px',
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? "rgba(255,255,255,0.1)"
: "rgba(0,0,0,0.1)",
"&:hover": {
theme.palette.mode === 'dark'
? 'rgba(255,255,255,0.1)'
: 'rgba(0,0,0,0.1)',
'&:hover': {
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
theme.palette.mode === 'dark'
? 'rgba(255,255,255,0.2)'
: 'rgba(0,0,0,0.2)',
},
visibility: show ? "visible" : "hidden",
visibility: show ? 'visible' : 'hidden',
...sx,
}}
>
<KeyboardArrowUpIcon />
</IconButton>
</Fade>
);
};
)
}
+119 -119
View File
@@ -1,201 +1,201 @@
import { useTheme } from "@mui/material";
import { useEffect, useImperativeHandle, useRef, type Ref } from "react";
import { Traffic } from "tauri-plugin-mihomo-api";
import { useTheme } from '@mui/material'
import { useEffect, useImperativeHandle, useRef, type Ref } from 'react'
import { Traffic } from 'tauri-plugin-mihomo-api'
const maxPoint = 30;
const maxPoint = 30
const refLineAlpha = 1;
const refLineWidth = 2;
const refLineAlpha = 1
const refLineWidth = 2
const upLineAlpha = 0.6;
const upLineWidth = 4;
const upLineAlpha = 0.6
const upLineWidth = 4
const downLineAlpha = 1;
const downLineWidth = 4;
const downLineAlpha = 1
const downLineWidth = 4
const defaultList = Array(maxPoint + 2).fill({ up: 0, down: 0 });
const defaultList = Array(maxPoint + 2).fill({ up: 0, down: 0 })
export interface TrafficRef {
appendData: (data: Traffic) => void;
toggleStyle: () => void;
appendData: (data: Traffic) => void
toggleStyle: () => void
}
/**
* draw the traffic graph
*/
export function TrafficGraph({ ref }: { ref?: Ref<TrafficRef> }) {
const countRef = useRef(0);
const styleRef = useRef(true);
const listRef = useRef<Traffic[]>(defaultList);
const canvasRef = useRef<HTMLCanvasElement>(null!);
const countRef = useRef(0)
const styleRef = useRef(true)
const listRef = useRef<Traffic[]>(defaultList)
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, () => ({
appendData: (data: Traffic) => {
cacheRef.current = data;
cacheRef.current = data
},
toggleStyle: () => {
styleRef.current = !styleRef.current;
styleRef.current = !styleRef.current
},
}));
}))
useEffect(() => {
let timer: any;
const zero = { up: 0, down: 0 };
let timer: any
const zero = { up: 0, down: 0 }
const handleData = () => {
const data = cacheRef.current ? cacheRef.current : zero;
cacheRef.current = null;
const data = cacheRef.current ? cacheRef.current : zero
cacheRef.current = null
const list = listRef.current;
if (list.length > maxPoint + 2) list.shift();
list.push(data);
countRef.current = 0;
const list = listRef.current
if (list.length > maxPoint + 2) list.shift()
list.push(data)
countRef.current = 0
timer = setTimeout(handleData, 1000);
};
timer = setTimeout(handleData, 1000)
}
handleData();
handleData()
return () => {
if (timer) clearTimeout(timer);
};
}, []);
if (timer) clearTimeout(timer)
}
}, [])
useEffect(() => {
let raf = 0;
const canvas = canvasRef.current!;
let raf = 0
const canvas = canvasRef.current!
if (!canvas) return;
if (!canvas) return
const context = canvas.getContext("2d")!;
const context = canvas.getContext('2d')!
if (!context) return;
if (!context) return
const { primary, secondary, divider } = palette;
const refLineColor = divider || "rgba(0, 0, 0, 0.12)";
const upLineColor = secondary.main || "#9c27b0";
const downLineColor = primary.main || "#5b5c9d";
const { primary, secondary, divider } = palette
const refLineColor = divider || 'rgba(0, 0, 0, 0.12)'
const upLineColor = secondary.main || '#9c27b0'
const downLineColor = primary.main || '#5b5c9d'
const width = canvas.width;
const height = canvas.height;
const dx = width / maxPoint;
const dy = height / 7;
const l1 = dy;
const l2 = dy * 4;
const width = canvas.width
const height = canvas.height
const dx = width / maxPoint
const dy = height / 7
const l1 = dy
const l2 = dy * 4
const countY = (v: number) => {
const h = height;
const h = height
if (v == 0) return h - 1;
if (v <= 10) return h - (v / 10) * dy;
if (v <= 100) return h - (v / 100 + 1) * dy;
if (v <= 1024) return h - (v / 1024 + 2) * dy;
if (v <= 10240) return h - (v / 10240 + 3) * dy;
if (v <= 102400) return h - (v / 102400 + 4) * dy;
if (v <= 1048576) return h - (v / 1048576 + 5) * dy;
if (v <= 10485760) return h - (v / 10485760 + 6) * dy;
return 1;
};
if (v == 0) return h - 1
if (v <= 10) return h - (v / 10) * dy
if (v <= 100) return h - (v / 100 + 1) * dy
if (v <= 1024) return h - (v / 1024 + 2) * dy
if (v <= 10240) return h - (v / 10240 + 3) * dy
if (v <= 102400) return h - (v / 102400 + 4) * dy
if (v <= 1048576) return h - (v / 1048576 + 5) * dy
if (v <= 10485760) return h - (v / 10485760 + 6) * dy
return 1
}
const drawBezier = (list: number[], offset: number) => {
const points = list.map((y, i) => [
(dx * (i - 1) - offset + 3) | 0,
countY(y),
]);
])
context.moveTo(points[0][0], points[0][1]);
context.moveTo(points[0][0], points[0][1])
for (let i = 1; i < points.length; i++) {
const p1 = points[i];
const p2 = points[i + 1] || p1;
const p1 = points[i]
const p2 = points[i + 1] || p1
const x1 = (p1[0] + p2[0]) / 2;
const y1 = (p1[1] + p2[1]) / 2;
const x1 = (p1[0] + p2[0]) / 2
const y1 = (p1[1] + p2[1]) / 2
context.quadraticCurveTo(p1[0], p1[1], x1, y1);
context.quadraticCurveTo(p1[0], p1[1], x1, y1)
}
}
};
const drawLine = (list: number[], offset: number) => {
const points = list.map((y, i) => [
(dx * (i - 1) - offset) | 0,
countY(y),
]);
])
context.moveTo(points[0][0], points[0][1]);
context.moveTo(points[0][0], points[0][1])
for (let i = 1; i < points.length; i++) {
const p = points[i];
context.lineTo(p[0], p[1]);
const p = points[i]
context.lineTo(p[0], p[1])
}
}
};
const drawGraph = (lastTime: number) => {
const listUp = listRef.current.map((v) => v.up);
const listDown = listRef.current.map((v) => v.down);
const lineStyle = styleRef.current;
const listUp = listRef.current.map((v) => v.up)
const listDown = listRef.current.map((v) => v.down)
const lineStyle = styleRef.current
const now = Date.now();
const diff = now - lastTime;
const now = Date.now()
const diff = now - lastTime
if (diff < 33) {
raf = requestAnimationFrame(() => drawGraph(lastTime));
return;
raf = requestAnimationFrame(() => drawGraph(lastTime))
return
}
const temp = Math.min((diff / 1000) * dx + countRef.current, dx);
const offset = countRef.current === 0 ? 0 : temp;
countRef.current = temp;
const temp = Math.min((diff / 1000) * dx + countRef.current, dx)
const offset = countRef.current === 0 ? 0 : temp
countRef.current = temp
context.clearRect(0, 0, width, height);
context.clearRect(0, 0, width, height)
// Reference lines
context.beginPath();
context.globalAlpha = refLineAlpha;
context.lineWidth = refLineWidth;
context.strokeStyle = refLineColor;
context.moveTo(0, l1);
context.lineTo(width, l1);
context.moveTo(0, l2);
context.lineTo(width, l2);
context.stroke();
context.closePath();
context.beginPath()
context.globalAlpha = refLineAlpha
context.lineWidth = refLineWidth
context.strokeStyle = refLineColor
context.moveTo(0, l1)
context.lineTo(width, l1)
context.moveTo(0, l2)
context.lineTo(width, l2)
context.stroke()
context.closePath()
context.beginPath();
context.globalAlpha = upLineAlpha;
context.lineWidth = upLineWidth;
context.strokeStyle = upLineColor;
context.beginPath()
context.globalAlpha = upLineAlpha
context.lineWidth = upLineWidth
context.strokeStyle = upLineColor
if (lineStyle) {
drawBezier(listUp, offset);
drawBezier(listUp, offset)
} else {
drawLine(listUp, offset);
drawLine(listUp, offset)
}
context.stroke();
context.closePath();
context.stroke()
context.closePath()
context.beginPath();
context.globalAlpha = downLineAlpha;
context.lineWidth = downLineWidth;
context.strokeStyle = downLineColor;
context.beginPath()
context.globalAlpha = downLineAlpha
context.lineWidth = downLineWidth
context.strokeStyle = downLineColor
if (lineStyle) {
drawBezier(listDown, offset);
drawBezier(listDown, offset)
} else {
drawLine(listDown, offset);
drawLine(listDown, offset)
}
context.stroke();
context.closePath();
context.stroke()
context.closePath()
raf = requestAnimationFrame(() => drawGraph(now));
};
raf = requestAnimationFrame(() => drawGraph(now))
}
drawGraph(Date.now());
drawGraph(Date.now())
return () => {
cancelAnimationFrame(raf);
};
}, [palette]);
cancelAnimationFrame(raf)
}
}, [palette])
return <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 { useRef } from "react";
import { Button } from '@mui/material'
import { useRef } from 'react'
import { DialogRef } from "@/components/base";
import { useUpdate } from "@/hooks/use-update";
import { DialogRef } from '@/components/base'
import { useUpdate } from '@/hooks/use-update'
import { UpdateViewer } from "../setting/mods/update-viewer";
import { UpdateViewer } from '../setting/mods/update-viewer'
interface Props {
className?: string;
className?: string
}
export const UpdateButton = (props: Props) => {
const { className } = props;
const viewerRef = useRef<DialogRef>(null);
const { className } = props
const viewerRef = useRef<DialogRef>(null)
const { updateInfo } = useUpdate();
const { updateInfo } = useUpdate()
if (!updateInfo?.available) return null;
if (!updateInfo?.available) return null
return (
<>
@@ -32,5 +32,5 @@ export const UpdateButton = (props: Props) => {
New
</Button>
</>
);
};
)
}
+19 -19
View File
@@ -1,12 +1,12 @@
import { Close, CropSquare, FilterNone, Minimize } from "@mui/icons-material";
import { Box, IconButton } from "@mui/material";
import { forwardRef, useImperativeHandle } from "react";
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
import { Box, IconButton } from '@mui/material'
import { forwardRef, useImperativeHandle } from 'react'
import { useWindowControls } from "@/hooks/use-window";
import getSystem from "@/utils/get-system";
import { useWindowControls } from '@/hooks/use-window'
import getSystem from '@/utils/get-system'
export const WindowControls = forwardRef(function WindowControls(props, ref) {
const OS = getSystem();
const OS = getSystem()
const {
currentWindow,
maximized,
@@ -14,7 +14,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
close,
toggleFullscreen,
toggleMaximize,
} = useWindowControls();
} = useWindowControls()
useImperativeHandle(
ref,
@@ -34,7 +34,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
toggleFullscreen,
toggleMaximize,
],
);
)
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
@@ -42,15 +42,15 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
return (
<Box
sx={{
display: "flex",
display: 'flex',
gap: 1,
alignItems: "center",
"> button": {
cursor: "default",
alignItems: 'center',
'> button': {
cursor: 'default',
},
}}
>
{OS === "macos" && (
{OS === 'macos' && (
<>
{/* macOS 风格:关闭 → 最小化 → 全屏 */}
<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 风格:最小化 → 最大化 → 关闭 */}
<IconButton size="small" sx={{ fontSize: 16 }} onClick={minimize}>
@@ -92,7 +92,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
</IconButton>
<IconButton
size="small"
sx={{ fontSize: 16, ":hover": { bgcolor: "red", color: "white" } }}
sx={{ fontSize: 16, ':hover': { bgcolor: 'red', color: 'white' } }}
onClick={close}
>
<Close fontSize="inherit" color="inherit" />
@@ -100,7 +100,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
</>
)}
{OS === "linux" && (
{OS === 'linux' && (
<>
{/* Linux 桌面常见布局(GNOME/KDE 多为:最小化 → 最大化 → 关闭) */}
<IconButton size="small" sx={{ fontSize: 16 }} onClick={minimize}>
@@ -119,7 +119,7 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
</IconButton>
<IconButton
size="small"
sx={{ fontSize: 16, ":hover": { bgcolor: "red", color: "white" } }}
sx={{ fontSize: 16, ':hover': { bgcolor: 'red', color: 'white' } }}
onClick={close}
>
<Close fontSize="inherit" color="inherit" />
@@ -127,5 +127,5 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
</>
)}
</Box>
);
});
)
})
+50 -50
View File
@@ -1,26 +1,26 @@
import { styled, Box } from "@mui/material";
import type { ReactNode } from "react";
import { styled, Box } from '@mui/material'
import type { ReactNode } from 'react'
import type { SearchState } from "@/components/base";
import type { SearchState } from '@/components/base'
const Item = styled(Box)(({ theme: { palette, typography } }) => ({
padding: "8px 0",
margin: "0 12px",
padding: '8px 0',
margin: '0 12px',
lineHeight: 1.35,
borderBottom: `1px solid ${palette.divider}`,
fontSize: "0.875rem",
fontSize: '0.875rem',
fontFamily: typography.fontFamily,
userSelect: "text",
"& .time": {
userSelect: 'text',
'& .time': {
color: palette.text.secondary,
},
"& .type": {
display: "inline-block",
'& .type': {
display: 'inline-block',
marginLeft: 8,
textAlign: "center",
textAlign: 'center',
borderRadius: 2,
textTransform: "uppercase",
fontWeight: "600",
textTransform: 'uppercase',
fontWeight: '600',
},
'& .type[data-type="error"], & .type[data-type="err"]': {
color: palette.error.main,
@@ -31,84 +31,84 @@ const Item = styled(Box)(({ theme: { palette, typography } }) => ({
'& .type[data-type="info"], & .type[data-type="inf"]': {
color: palette.info.main,
},
"& .data": {
'& .data': {
color: palette.text.primary,
overflowWrap: "anywhere",
overflowWrap: 'anywhere',
},
"& .highlight": {
backgroundColor: palette.mode === "dark" ? "#ffeb3b40" : "#ffeb3b90",
'& .highlight': {
backgroundColor: palette.mode === 'dark' ? '#ffeb3b40' : '#ffeb3b90',
borderRadius: 2,
padding: "0 2px",
padding: '0 2px',
},
}));
}))
interface Props {
value: ILogItem;
searchState?: SearchState;
value: ILogItem
searchState?: SearchState
}
const LogItem = ({ value, searchState }: Props) => {
const renderHighlightText = (text: string) => {
if (!searchState?.text.trim()) return text;
if (!searchState?.text.trim()) return text
try {
const searchText = searchState.text;
let pattern: string;
const searchText = searchState.text
let pattern: string
if (searchState.useRegularExpression) {
try {
new RegExp(searchText);
pattern = searchText;
new RegExp(searchText)
pattern = searchText
} catch {
pattern = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
pattern = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
} else {
const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
pattern = searchState.matchWholeWord ? `\\b${escaped}\\b` : escaped;
const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
pattern = searchState.matchWholeWord ? `\\b${escaped}\\b` : escaped
}
const flags = searchState.matchCase ? "g" : "gi";
const regex = new RegExp(pattern, flags);
const elements: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
const flags = searchState.matchCase ? 'g' : 'gi'
const regex = new RegExp(pattern, flags)
const elements: ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
const start = match.index;
const matchText = match[0];
const start = match.index
const matchText = match[0]
if (matchText === "") {
regex.lastIndex += 1;
continue;
if (matchText === '') {
regex.lastIndex += 1
continue
}
if (start > lastIndex) {
elements.push(text.slice(lastIndex, start));
elements.push(text.slice(lastIndex, start))
}
elements.push(
<span key={`highlight-${start}`} className="highlight">
{matchText}
</span>,
);
)
lastIndex = start + matchText.length;
lastIndex = start + matchText.length
}
if (lastIndex < text.length) {
elements.push(text.slice(lastIndex));
elements.push(text.slice(lastIndex))
}
return elements.length ? elements : text;
return elements.length ? elements : text
} catch {
return text;
return text
}
}
};
return (
<Item>
<div>
<span className="time">{renderHighlightText(value.time || "")}</span>
<span className="time">{renderHighlightText(value.time || '')}</span>
<span className="type" data-type={value.type.toLowerCase()}>
{renderHighlightText(value.type)}
</span>
@@ -117,7 +117,7 @@ const LogItem = ({ value, searchState }: Props) => {
<span className="data">{renderHighlightText(value.payload)}</span>
</div>
</Item>
);
};
)
}
export default LogItem;
export default LogItem
+17 -17
View File
@@ -4,43 +4,43 @@ import {
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
open: boolean;
title: string;
message: string;
onClose: () => void;
onConfirm: () => void;
open: boolean
title: string
message: string
onClose: () => void
onConfirm: () => void
}
export const ConfirmViewer = (props: Props) => {
const { open, title, message, onClose, onConfirm } = props;
const { open, title, message, onClose, onConfirm } = props
const { t } = useTranslation();
const { t } = useTranslation()
useEffect(() => {
if (!open) return;
}, [open]);
if (!open) return
}, [open])
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ pb: 1, userSelect: "text" }}>
<DialogContent sx={{ pb: 1, userSelect: 'text' }}>
{message}
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("shared.actions.cancel")}
{t('shared.actions.cancel')}
</Button>
<Button onClick={onConfirm} variant="contained">
{t("shared.actions.confirm")}
{t('shared.actions.confirm')}
</Button>
</DialogActions>
</Dialog>
);
};
)
}
+106 -112
View File
@@ -1,10 +1,10 @@
import MonacoEditor from "@monaco-editor/react";
import MonacoEditor from '@monaco-editor/react'
import {
CloseFullscreenRounded,
ContentPasteRounded,
FormatPaintRounded,
OpenInFullRounded,
} from "@mui/icons-material";
} from '@mui/icons-material'
import {
Button,
ButtonGroup,
@@ -13,44 +13,38 @@ import {
DialogContent,
DialogTitle,
IconButton,
} from "@mui/material";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useLockFn } from "ahooks";
import type { editor } from "monaco-editor";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { useLockFn } from 'ahooks'
import type { editor } from 'monaco-editor'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseLoadingOverlay } from "@/components/base";
import { beforeEditorMount } from "@/services/monaco";
import { showNotice } from "@/services/notice-service";
import { useThemeMode } from "@/services/states";
import debounce from "@/utils/debounce";
import getSystem from "@/utils/get-system";
import { BaseLoadingOverlay } from '@/components/base'
import { beforeEditorMount } from '@/services/monaco'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import debounce from '@/utils/debounce'
import getSystem from '@/utils/get-system'
const appWindow = getCurrentWebviewWindow();
const appWindow = getCurrentWebviewWindow()
export type EditorLanguage = "yaml" | "javascript" | "css";
export type EditorLanguage = 'yaml' | 'javascript' | 'css'
export interface EditorViewerProps {
open: boolean;
title?: string | ReactNode;
value: string;
language: EditorLanguage;
path: string;
readOnly?: boolean;
loading?: boolean;
dirty?: boolean;
saveDisabled?: boolean;
onChange?: (value: string) => void;
onSave?: () => void | Promise<void>;
onClose: () => void;
onValidate?: (markers: editor.IMarker[]) => void;
open: boolean
title?: string | ReactNode
value: string
language: EditorLanguage
path: string
readOnly?: boolean
loading?: boolean
dirty?: boolean
saveDisabled?: boolean
onChange?: (value: string) => void
onSave?: () => void | Promise<void>
onClose: () => void
onValidate?: (markers: editor.IMarker[]) => void
}
export const EditorViewer = ({
@@ -68,111 +62,111 @@ export const EditorViewer = ({
onClose,
onValidate,
}: EditorViewerProps) => {
const { t } = useTranslation();
const themeMode = useThemeMode();
const [isMaximized, setIsMaximized] = useState(false);
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const { t } = useTranslation()
const themeMode = useThemeMode()
const [isMaximized, setIsMaximized] = useState(false)
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const resolvedTitle = title ?? t("profiles.components.menu.editFile");
const disableSave = loading || saveDisabled || dirty === false;
const resolvedTitle = title ?? t('profiles.components.menu.editFile')
const disableSave = loading || saveDisabled || dirty === false
const syncMaximizedState = useCallback(async () => {
try {
setIsMaximized(await appWindow.isMaximized());
setIsMaximized(await appWindow.isMaximized())
} catch {
setIsMaximized(false);
setIsMaximized(false)
}
}, []);
}, [])
const handleSave = useLockFn(async () => {
try {
if (!readOnly) {
await onSave?.();
await onSave?.()
}
onClose();
onClose()
} catch (error) {
showNotice.error(error);
showNotice.error(error)
}
});
})
const handleClose = () => {
try {
onClose();
onClose()
} catch (error) {
showNotice.error(error);
showNotice.error(error)
}
}
};
const handlePaste = useLockFn(async () => {
try {
if (readOnly || loading || !editorRef.current) return;
if (readOnly || loading || !editorRef.current) return
const text = await navigator.clipboard.readText();
if (!text) return;
const text = await navigator.clipboard.readText()
if (!text) return
const editorInstance = editorRef.current;
const model = editorInstance.getModel();
const selections = editorInstance.getSelections();
if (!model || !selections || selections.length === 0) return;
const editorInstance = editorRef.current
const model = editorInstance.getModel()
const selections = editorInstance.getSelections()
if (!model || !selections || selections.length === 0) return
editorInstance.pushUndoStop();
editorInstance.pushUndoStop()
editorInstance.executeEdits(
"explicit-paste",
'explicit-paste',
selections.map((selection) => ({
range: selection,
text,
forceMoveMarkers: true,
})),
);
editorInstance.pushUndoStop();
editorInstance.focus();
)
editorInstance.pushUndoStop()
editorInstance.focus()
} catch (error) {
showNotice.error(error);
showNotice.error(error)
}
});
})
const handleFormat = useLockFn(async () => {
try {
if (loading) return;
await editorRef.current?.getAction("editor.action.formatDocument")?.run();
if (loading) return
await editorRef.current?.getAction('editor.action.formatDocument')?.run()
} catch (error) {
showNotice.error(error);
showNotice.error(error)
}
});
})
const handleToggleMaximize = useLockFn(async () => {
try {
await appWindow.toggleMaximize();
await syncMaximizedState();
editorRef.current?.layout();
await appWindow.toggleMaximize()
await syncMaximizedState()
editorRef.current?.layout()
} catch (error) {
showNotice.error(error);
showNotice.error(error)
}
});
})
useEffect(() => {
if (!open) return;
void syncMaximizedState();
}, [open, syncMaximizedState]);
if (!open) return
void syncMaximizedState()
}, [open, syncMaximizedState])
useEffect(() => {
if (!open) return;
if (!open) return
const onResized = debounce(() => {
void syncMaximizedState();
void syncMaximizedState()
try {
editorRef.current?.layout();
editorRef.current?.layout()
} catch {
// Ignore transient layout errors during window transitions.
}
}, 100);
}, 100)
const unlistenResized = appWindow.onResized(onResized);
const unlistenResized = appWindow.onResized(onResized)
return () => {
unlistenResized.then((unlisten) => unlisten());
};
}, [open, syncMaximizedState]);
unlistenResized.then((unlisten) => unlisten())
}
}, [open, syncMaximizedState])
return (
<Dialog
@@ -186,14 +180,14 @@ export const EditorViewer = ({
<DialogContent
sx={{
width: "auto",
height: "calc(100vh - 185px)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
width: 'auto',
height: 'calc(100vh - 185px)',
display: 'flex',
flexDirection: 'column',
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} />
{!loading && (
<MonacoEditor
@@ -201,30 +195,30 @@ export const EditorViewer = ({
path={path}
value={value}
language={language}
theme={themeMode === "light" ? "light" : "vs-dark"}
theme={themeMode === 'light' ? 'light' : 'vs-dark'}
loading={null}
saveViewState
keepCurrentModel={false}
beforeMount={beforeEditorMount}
onMount={(editorInstance) => {
editorRef.current = editorInstance;
editorRef.current = editorInstance
}}
onChange={(nextValue) => onChange?.(nextValue ?? "")}
onChange={(nextValue) => onChange?.(nextValue ?? '')}
onValidate={onValidate}
options={{
automaticLayout: true,
tabSize: 2,
minimap: {
enabled:
typeof document !== "undefined" &&
typeof document !== 'undefined' &&
document.documentElement.clientWidth >= 1500,
},
mouseWheelZoom: true,
readOnly,
readOnlyMessage: {
value: t("profiles.modals.editor.messages.readOnly"),
value: t('profiles.modals.editor.messages.readOnly'),
},
renderValidationDecorations: "on",
renderValidationDecorations: 'on',
quickSuggestions: {
strings: true,
comments: true,
@@ -234,7 +228,7 @@ export const EditorViewer = ({
top: 33,
},
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
getSystem() === 'windows' ? ', twemoji mozilla' : ''
}`,
fontLigatures: false,
smoothScrolling: true,
@@ -245,16 +239,16 @@ export const EditorViewer = ({
<ButtonGroup
variant="contained"
sx={{ position: "absolute", left: "14px", bottom: "8px" }}
sx={{ position: 'absolute', left: '14px', bottom: '8px' }}
>
<IconButton
size="medium"
color="inherit"
sx={{ display: readOnly ? "none" : "" }}
title={t("profiles.page.importForm.actions.paste")}
sx={{ display: readOnly ? 'none' : '' }}
title={t('profiles.page.importForm.actions.paste')}
disabled={loading}
onClick={() => {
void handlePaste();
void handlePaste()
}}
>
<ContentPasteRounded fontSize="inherit" />
@@ -262,11 +256,11 @@ export const EditorViewer = ({
<IconButton
size="medium"
color="inherit"
sx={{ display: readOnly ? "none" : "" }}
title={t("profiles.modals.editor.actions.format")}
sx={{ display: readOnly ? 'none' : '' }}
title={t('profiles.modals.editor.actions.format')}
disabled={loading}
onClick={() => {
void handleFormat();
void handleFormat()
}}
>
<FormatPaintRounded fontSize="inherit" />
@@ -275,10 +269,10 @@ export const EditorViewer = ({
size="medium"
color="inherit"
title={t(
isMaximized ? "shared.window.minimize" : "shared.window.maximize",
isMaximized ? 'shared.window.minimize' : 'shared.window.maximize',
)}
onClick={() => {
void handleToggleMaximize();
void handleToggleMaximize()
}}
>
{isMaximized ? <CloseFullscreenRounded /> : <OpenInFullRounded />}
@@ -288,20 +282,20 @@ export const EditorViewer = ({
<DialogActions>
<Button onClick={handleClose} variant="outlined">
{t(readOnly ? "shared.actions.close" : "shared.actions.cancel")}
{t(readOnly ? 'shared.actions.close' : 'shared.actions.cancel')}
</Button>
{!readOnly && (
<Button
onClick={() => {
void handleSave();
void handleSave()
}}
variant="contained"
disabled={disableSave}
>
{t("shared.actions.save")}
{t('shared.actions.save')}
</Button>
)}
</DialogActions>
</Dialog>
);
};
)
}
+29 -29
View File
@@ -1,61 +1,61 @@
import { Box, Button, Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, Button, Typography } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
onChange: (file: File, value: string) => void;
onChange: (file: File, value: string) => void
}
export const FileInput = (props: Props) => {
const { onChange } = props;
const { onChange } = props
const { t } = useTranslation();
const { t } = useTranslation()
// file input
const inputRef = useRef<any>(undefined);
const [loading, setLoading] = useState(false);
const [fileName, setFileName] = useState("");
const inputRef = useRef<any>(undefined)
const [loading, setLoading] = useState(false)
const [fileName, setFileName] = useState('')
const onFileInput = useLockFn(async (e: any) => {
const file = e.target.files?.[0] as File;
const file = e.target.files?.[0] as File
if (!file) return;
if (!file) return
setFileName(file.name);
setLoading(true);
setFileName(file.name)
setLoading(true)
return new Promise((resolve, reject) => {
const reader = new FileReader();
const reader = new FileReader()
reader.onload = (event) => {
resolve(null);
onChange(file, event.target?.result as string);
};
reader.onerror = reject;
reader.readAsText(file);
}).finally(() => setLoading(false));
});
resolve(null)
onChange(file, event.target?.result as string)
}
reader.onerror = reject
reader.readAsText(file)
}).finally(() => setLoading(false))
})
return (
<Box sx={{ mt: 2, mb: 1, display: "flex", alignItems: "center" }}>
<Box sx={{ mt: 2, mb: 1, display: 'flex', alignItems: 'center' }}>
<Button
variant="outlined"
sx={{ flex: "none" }}
sx={{ flex: 'none' }}
onClick={() => inputRef.current?.click()}
>
{t("profiles.components.fileInput.chooseFile")}
{t('profiles.components.fileInput.chooseFile')}
</Button>
<input
type="file"
accept=".yaml,.yml"
ref={inputRef}
style={{ display: "none" }}
style={{ display: 'none' }}
onChange={onFileInput}
/>
<Typography noWrap sx={{ ml: 1 }}>
{loading ? "Loading..." : fileName}
{loading ? 'Loading...' : fileName}
</Typography>
</Box>
);
};
)
}
+57 -57
View File
@@ -1,6 +1,6 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { DeleteForeverRounded, UndoRounded } from '@mui/icons-material'
import {
Box,
IconButton,
@@ -8,18 +8,18 @@ import {
ListItemText,
alpha,
styled,
} from "@mui/material";
} from '@mui/material'
import { useIconCache } from "@/hooks/use-icon-cache";
import { useIconCache } from '@/hooks/use-icon-cache'
interface Props {
type: "prepend" | "original" | "delete" | "append";
group: IProxyGroupConfig;
onDelete: () => void;
type: 'prepend' | 'original' | 'delete' | 'append'
group: IProxyGroupConfig
onDelete: () => void
}
export const GroupItem = (props: Props) => {
const { type, group, onDelete } = props;
const sortable = type === "prepend" || type === "append";
const { type, group, onDelete } = props
const sortable = type === 'prepend' || type === 'append'
const {
attributes: sortableAttributes,
@@ -31,64 +31,64 @@ export const GroupItem = (props: Props) => {
} = useSortable({
id: group.name,
disabled: !sortable,
});
const dragAttributes = sortable ? sortableAttributes : undefined;
const dragListeners = sortable ? sortableListeners : undefined;
const dragNodeRef = sortable ? sortableSetNodeRef : undefined;
})
const dragAttributes = sortable ? sortableAttributes : undefined
const dragListeners = sortable ? sortableListeners : undefined
const dragNodeRef = sortable ? sortableSetNodeRef : undefined
const iconCachePath = useIconCache({
icon: group.icon,
cacheKey: group.name.replaceAll(" ", ""),
});
cacheKey: group.name.replaceAll(' ', ''),
})
return (
<ListItem
dense
sx={({ palette }) => ({
position: "relative",
position: 'relative',
background:
type === "original"
? palette.mode === "dark"
type === 'original'
? palette.mode === 'dark'
? alpha(palette.background.paper, 0.3)
: alpha(palette.grey[400], 0.3)
: type === "delete"
: type === 'delete'
? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3),
height: "100%",
margin: "8px 0",
borderRadius: "8px",
height: '100%',
margin: '8px 0',
borderRadius: '8px',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
zIndex: isDragging ? 'calc(infinity)' : undefined,
})}
>
{group.icon && group.icon?.trim().startsWith("http") && (
{group.icon && group.icon?.trim().startsWith('http') && (
<img
src={iconCachePath === "" ? group.icon : iconCachePath}
src={iconCachePath === '' ? group.icon : iconCachePath}
width="32px"
style={{
marginRight: "12px",
borderRadius: "6px",
marginRight: '12px',
borderRadius: '6px',
}}
/>
)}
{group.icon && group.icon?.trim().startsWith("data") && (
{group.icon && group.icon?.trim().startsWith('data') && (
<img
src={group.icon}
width="32px"
style={{
marginRight: "12px",
borderRadius: "6px",
marginRight: '12px',
borderRadius: '6px',
}}
/>
)}
{group.icon && group.icon?.trim().startsWith("<svg") && (
{group.icon && group.icon?.trim().startsWith('<svg') && (
<img
src={`data:image/svg+xml;base64,${btoa(group.icon ?? "")}`}
src={`data:image/svg+xml;base64,${btoa(group.icon ?? '')}`}
width="32px"
style={{
marginRight: "12px",
borderRadius: "6px",
marginRight: '12px',
borderRadius: '6px',
}}
/>
)}
@@ -96,10 +96,10 @@ export const GroupItem = (props: Props) => {
{...(dragAttributes ?? {})}
{...(dragListeners ?? {})}
ref={dragNodeRef}
sx={{ cursor: sortable ? "move" : "" }}
sx={{ cursor: sortable ? 'move' : '' }}
primary={
<StyledPrimary
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
sx={{ textDecoration: type === 'delete' ? 'line-through' : '' }}
>
{group.name}
</StyledPrimary>
@@ -107,13 +107,13 @@ export const GroupItem = (props: Props) => {
secondary={
<ListItemTextChild
sx={{
overflow: "hidden",
display: "flex",
alignItems: "center",
pt: "2px",
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
pt: '2px',
}}
>
<Box sx={{ marginTop: "2px" }}>
<Box sx={{ marginTop: '2px' }}>
<StyledTypeBox>{group.type}</StyledTypeBox>
</Box>
</ListItemTextChild>
@@ -121,41 +121,41 @@ export const GroupItem = (props: Props) => {
slotProps={{
secondary: {
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
display: 'flex',
alignItems: 'center',
color: '#ccc',
},
},
}}
/>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
{type === 'delete' ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
);
};
)
}
const StyledPrimary = styled("div")`
const StyledPrimary = styled('div')`
font-size: 15px;
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
`
const ListItemTextChild = styled("span")`
const ListItemTextChild = styled('span')`
display: block;
`;
`
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
display: 'inline-block',
border: '1px solid #ccc',
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
padding: "0 4px",
padding: '0 4px',
lineHeight: 1.5,
marginRight: "8px",
}));
marginRight: '8px',
}))
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -7,33 +7,33 @@ import {
DialogTitle,
Divider,
Typography,
} from "@mui/material";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseEmpty } from "@/components/base";
import { BaseEmpty } from '@/components/base'
interface Props {
open: boolean;
logInfo: [string, string][];
onClose: () => void;
open: boolean
logInfo: [string, string][]
onClose: () => void
}
export const LogViewer = (props: Props) => {
const { open, logInfo, onClose } = props;
const { open, logInfo, onClose } = props
const { t } = useTranslation();
const { t } = useTranslation()
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("profiles.modals.logViewer.title")}</DialogTitle>
<DialogTitle>{t('profiles.modals.logViewer.title')}</DialogTitle>
<DialogContent
sx={{
width: 400,
height: 300,
overflowX: "hidden",
userSelect: "text",
overflowX: 'hidden',
userSelect: 'text',
pb: 1,
}}
>
@@ -45,9 +45,9 @@ export const LogViewer = (props: Props) => {
size="small"
variant="outlined"
color={
level === "error" || level === "exception"
? "error"
: "default"
level === 'error' || level === 'exception'
? 'error'
: 'default'
}
sx={{ mr: 1 }}
/>
@@ -62,9 +62,9 @@ export const LogViewer = (props: Props) => {
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("shared.actions.close")}
{t('shared.actions.close')}
</Button>
</DialogActions>
</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)(({
theme,
"aria-selected": selected,
'aria-selected': selected,
}) => {
const { mode, primary, text } = theme.palette;
const key = `${mode}-${!!selected}`;
const { mode, primary, text } = theme.palette
const key = `${mode}-${!!selected}`
const backgroundColor = mode === "light" ? "#ffffff" : "#282A36";
const backgroundColor = mode === 'light' ? '#ffffff' : '#282A36'
const color = {
"light-true": text.secondary,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.65),
"dark-false": alpha(text.secondary, 0.65),
}[key]!;
'light-true': text.secondary,
'light-false': text.secondary,
'dark-true': alpha(text.secondary, 0.65),
'dark-false': alpha(text.secondary, 0.65),
}[key]!
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.main,
"dark-false": text.primary,
}[key]!;
'light-true': primary.main,
'light-false': text.primary,
'dark-true': primary.main,
'dark-false': text.primary,
}[key]!
const borderSelect = {
"light-true": {
'light-true': {
borderLeft: `3px solid ${primary.main}`,
width: `calc(100% + 3px)`,
marginLeft: `-3px`,
},
"light-false": {
width: "100%",
'light-false': {
width: '100%',
},
"dark-true": {
'dark-true': {
borderLeft: `3px solid ${primary.main}`,
width: `calc(100% + 3px)`,
marginLeft: `-3px`,
},
"dark-false": {
width: "100%",
'dark-false': {
width: '100%',
},
}[key];
}[key]
return {
position: "relative",
display: "block",
cursor: "pointer",
textAlign: "left",
padding: "8px 16px",
boxSizing: "border-box",
position: 'relative',
display: 'block',
cursor: 'pointer',
textAlign: 'left',
padding: '8px 16px',
boxSizing: 'border-box',
backgroundColor,
...borderSelect,
borderRadius: "8px",
borderRadius: '8px',
color,
"& h2": { color: h2color },
};
});
'& h2': { color: h2color },
}
})
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 {
Box,
Badge,
@@ -7,99 +7,99 @@ import {
Menu,
MenuItem,
Typography,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { EditorViewer } from "@/components/profile/editor-viewer";
import { useEditorDocument } from "@/hooks/use-editor-document";
import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import { EditorViewer } from '@/components/profile/editor-viewer'
import { useEditorDocument } from '@/hooks/use-editor-document'
import { viewProfile, readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { LogViewer } from "./log-viewer";
import { ProfileBox } from "./profile-box";
import { LogViewer } from './log-viewer'
import { ProfileBox } from './profile-box'
interface Props {
logInfo?: [string, string][];
id: "Merge" | "Script";
onSave?: (prev?: string, curr?: string) => void;
logInfo?: [string, string][]
id: 'Merge' | 'Script'
onSave?: (prev?: string, curr?: string) => void
}
const EMPTY_LOG_INFO: [string, string][] = [];
const EMPTY_LOG_INFO: [string, string][] = []
// profile enhanced item
export const ProfileMore = (props: Props) => {
const { id, logInfo, onSave } = props;
const { id, logInfo, onSave } = props
const entries = logInfo ?? EMPTY_LOG_INFO;
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [fileOpen, setFileOpen] = useState(false);
const [logOpen, setLogOpen] = useState(false);
const entries = logInfo ?? EMPTY_LOG_INFO
const { t } = useTranslation()
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [position, setPosition] = useState({ left: 0, top: 0 })
const [fileOpen, setFileOpen] = useState(false)
const [logOpen, setLogOpen] = useState(false)
const loadDocument = useCallback(() => readProfileFile(id), [id]);
const loadDocument = useCallback(() => readProfileFile(id), [id])
const document = useEditorDocument({
open: fileOpen,
load: loadDocument,
});
})
const onEditFile = () => {
setAnchorEl(null);
setFileOpen(true);
};
setAnchorEl(null)
setFileOpen(true)
}
const onOpenFile = useLockFn(async () => {
setAnchorEl(null);
setAnchorEl(null)
try {
await viewProfile(id);
await viewProfile(id)
} catch (err) {
showNotice.error(err);
showNotice.error(err)
}
});
})
const hasError = entries.some(([level]) => level === "exception");
const hasError = entries.some(([level]) => level === 'exception')
const globalTitles: Record<Props["id"], string> = {
Merge: "profiles.components.more.global.merge",
Script: "profiles.components.more.global.script",
};
const globalTitles: Record<Props['id'], string> = {
Merge: 'profiles.components.more.global.merge',
Script: 'profiles.components.more.global.script',
}
const chipLabels: Record<Props["id"], string> = {
Merge: "profiles.components.more.chips.merge",
Script: "profiles.components.more.chips.script",
};
const chipLabels: Record<Props['id'], string> = {
Merge: 'profiles.components.more.chips.merge',
Script: 'profiles.components.more.chips.script',
}
const itemMenu = [
{ label: "profiles.components.menu.editFile", handler: onEditFile },
{ label: "profiles.components.menu.openFile", handler: onOpenFile },
];
{ label: 'profiles.components.menu.editFile', handler: onEditFile },
{ label: 'profiles.components.menu.openFile', handler: onOpenFile },
]
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
lineHeight: 1,
};
}
const handleSave = useLockFn(async () => {
const currentValue = document.value;
await saveProfileFile(id, currentValue);
onSave?.(document.savedValue, currentValue);
document.markSaved(currentValue);
});
const currentValue = document.value
await saveProfileFile(id, currentValue)
onSave?.(document.savedValue, currentValue)
document.markSaved(currentValue)
})
return (
<>
<ProfileBox
onDoubleClick={onEditFile}
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget as HTMLElement);
event.preventDefault();
const { clientX, clientY } = event
setPosition({ top: clientY, left: clientX })
setAnchorEl(event.currentTarget as HTMLElement)
event.preventDefault()
}}
>
<Box
@@ -123,19 +123,19 @@ export const ProfileMore = (props: Props) => {
color="primary"
size="small"
variant="outlined"
sx={{ height: 20, textTransform: "capitalize" }}
sx={{ height: 20, textTransform: 'capitalize' }}
/>
</Box>
<Box sx={boxStyle}>
{id === "Script" &&
{id === 'Script' &&
(hasError ? (
<Badge color="error" variant="dot" overlap="circular">
<IconButton
size="small"
edge="start"
color="error"
title={t("profiles.modals.logViewer.title")}
title={t('profiles.modals.logViewer.title')}
onClick={() => setLogOpen(true)}
>
<FeaturedPlayListRounded fontSize="inherit" />
@@ -146,7 +146,7 @@ export const ProfileMore = (props: Props) => {
size="small"
edge="start"
color="inherit"
title={t("profiles.modals.logViewer.title")}
title={t('profiles.modals.logViewer.title')}
onClick={() => setLogOpen(true)}
>
<FeaturedPlayListRounded fontSize="inherit" />
@@ -164,8 +164,8 @@ export const ProfileMore = (props: Props) => {
transitionDuration={225}
MenuListProps={{ sx: { py: 0.5 } }}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
setAnchorEl(null)
e.preventDefault()
}}
>
{itemMenu
@@ -179,10 +179,10 @@ export const ProfileMore = (props: Props) => {
(theme) => {
return {
color:
item.label === "Delete"
item.label === 'Delete'
? theme.palette.error.main
: undefined,
};
}
},
]}
dense
@@ -196,8 +196,8 @@ export const ProfileMore = (props: Props) => {
open={true}
title={t(globalTitles[id])}
value={document.value}
language={id === "Merge" ? "yaml" : "javascript"}
path={`profile-more:${id}.${id === "Merge" ? "yaml" : "js"}`}
language={id === 'Merge' ? 'yaml' : 'javascript'}
path={`profile-more:${id}.${id === 'Merge' ? 'yaml' : 'js'}`}
loading={document.loading}
dirty={document.dirty}
onChange={document.setValue}
@@ -213,5 +213,5 @@ export const ProfileMore = (props: Props) => {
/>
)}
</>
);
};
)
}
+124 -124
View File
@@ -7,146 +7,146 @@ import {
Select,
styled,
TextField,
} from "@mui/material";
import { useLockFn } from "ahooks";
import type { Ref } from "react";
import { useEffect, useImperativeHandle, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import { useLockFn } from 'ahooks'
import type { Ref } from 'react'
import { useEffect, useImperativeHandle, useRef, useState } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { BaseDialog, Switch } from "@/components/base";
import { useProfiles } from "@/hooks/use-profiles";
import { createProfile, patchProfile } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import { version } from "@root/package.json";
import { BaseDialog, Switch } from '@/components/base'
import { useProfiles } from '@/hooks/use-profiles'
import { createProfile, patchProfile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { version } from '@root/package.json'
import { FileInput } from "./file-input";
import { FileInput } from './file-input'
interface Props {
onChange: (isActivating?: boolean) => void;
onChange: (isActivating?: boolean) => void
}
export interface ProfileViewerRef {
create: () => void;
edit: (item: IProfileItem) => void;
create: () => void
edit: (item: IProfileItem) => void
}
// create or edit the profile
// remote / local
type ProfileViewerProps = Props & { ref?: Ref<ProfileViewerRef> };
type ProfileViewerProps = Props & { ref?: Ref<ProfileViewerRef> }
export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const [loading, setLoading] = useState(false);
const { profiles } = useProfiles();
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [openType, setOpenType] = useState<'new' | 'edit'>('new')
const [loading, setLoading] = useState(false)
const { profiles } = useProfiles()
// file input
const fileDataRef = useRef<string | null>(null);
const fileDataRef = useRef<string | null>(null)
const { control, watch, setValue, reset, handleSubmit, getValues } =
useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "",
desc: "",
url: "",
type: 'remote',
name: '',
desc: '',
url: '',
option: {
with_proxy: false,
self_proxy: false,
},
},
});
})
useImperativeHandle(ref, () => ({
create: () => {
setOpenType("new");
setOpen(true);
setOpenType('new')
setOpen(true)
},
edit: (item: IProfileItem) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
setValue(key as any, value);
});
setValue(key as any, value)
})
}
setOpenType("edit");
setOpen(true);
setOpenType('edit')
setOpen(true)
},
}));
}))
const selfProxy = watch("option.self_proxy");
const withProxy = watch("option.with_proxy");
const selfProxy = watch('option.self_proxy')
const withProxy = watch('option.with_proxy')
useEffect(() => {
if (selfProxy) setValue("option.with_proxy", false);
}, [selfProxy, setValue]);
if (selfProxy) setValue('option.with_proxy', false)
}, [selfProxy, setValue])
useEffect(() => {
if (withProxy) setValue("option.self_proxy", false);
}, [setValue, withProxy]);
if (withProxy) setValue('option.self_proxy', false)
}, [setValue, withProxy])
const handleOk = useLockFn(
handleSubmit(async (form) => {
setLoading(true);
setLoading(true)
try {
// 基本验证
if (!form.type) throw new Error("`Type` should not be null");
if (form.type === "remote" && !form.url) {
throw new Error("The URL should not be null");
if (!form.type) throw new Error('`Type` should not be null')
if (form.type === 'remote' && !form.url) {
throw new Error('The URL should not be null')
}
// 处理表单数据
const option = form.option ? { ...form.option } : undefined;
const option = form.option ? { ...form.option } : undefined
if (option?.timeout_seconds) {
option.timeout_seconds = +option.timeout_seconds;
option.timeout_seconds = +option.timeout_seconds
}
if (option?.update_interval) {
option.update_interval = +option.update_interval;
option.update_interval = +option.update_interval
} else if (option) {
option.update_interval = undefined;
option.update_interval = undefined
}
if (option?.user_agent === "") {
option.user_agent = undefined;
if (option?.user_agent === '') {
option.user_agent = undefined
}
const name = form.name || `${form.type} file`;
const item = { ...form, name, option };
const isRemote = form.type === "remote";
const isUpdate = openType === "edit";
const name = form.name || `${form.type} file`
const item = { ...form, name, option }
const isRemote = form.type === 'remote'
const isUpdate = openType === 'edit'
// 判断是否是当前激活的配置
const isActivating = isUpdate && form.uid === (profiles?.current ?? "");
const isActivating = isUpdate && form.uid === (profiles?.current ?? '')
// 保存原始代理设置以便回退成功后恢复
const originalOptions = {
with_proxy: form.option?.with_proxy,
self_proxy: form.option?.self_proxy,
};
}
// 执行创建或更新操作,本地配置不需要回退机制
if (!isRemote) {
if (openType === "new") {
await createProfile(item, fileDataRef.current);
if (openType === 'new') {
await createProfile(item, fileDataRef.current)
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
if (!form.uid) throw new Error('UID not found')
await patchProfile(form.uid, item)
}
} else {
// 远程配置使用回退机制
try {
// 尝试正常操作
if (openType === "new") {
await createProfile(item, fileDataRef.current);
if (openType === 'new') {
await createProfile(item, fileDataRef.current)
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
if (!form.uid) throw new Error('UID not found')
await patchProfile(form.uid, item)
}
} catch {
// 首次创建/更新失败,尝试使用自身代理
showNotice.info(
"profiles.modals.profileForm.feedback.notifications.creationRetry",
);
'profiles.modals.profileForm.feedback.notifications.creationRetry',
)
// 使用自身代理的配置
const retryItem = {
@@ -156,76 +156,76 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
with_proxy: false,
self_proxy: true,
},
};
}
// 使用自身代理再次尝试
if (openType === "new") {
await createProfile(retryItem, fileDataRef.current);
if (openType === 'new') {
await createProfile(retryItem, fileDataRef.current)
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, retryItem);
if (!form.uid) throw new Error('UID not found')
await patchProfile(form.uid, retryItem)
// 编辑模式下恢复原始代理设置
await patchProfile(form.uid, { option: originalOptions });
await patchProfile(form.uid, { option: originalOptions })
}
showNotice.success(
"profiles.modals.profileForm.feedback.notifications.creationSuccess",
);
'profiles.modals.profileForm.feedback.notifications.creationSuccess',
)
}
}
// 成功后的操作
setOpen(false);
setTimeout(() => reset(), 500);
fileDataRef.current = null;
setOpen(false)
setTimeout(() => reset(), 500)
fileDataRef.current = null
// 优化:UI先关闭,异步通知父组件
setTimeout(() => {
onChange(isActivating);
}, 0);
onChange(isActivating)
}, 0)
} catch (err) {
showNotice.error(err);
showNotice.error(err)
} finally {
setLoading(false);
setLoading(false)
}
}),
);
)
const handleClose = () => {
try {
setOpen(false);
fileDataRef.current = null;
setTimeout(() => reset(), 500);
setOpen(false)
fileDataRef.current = null
setTimeout(() => reset(), 500)
} catch (e) {
console.warn("[ProfileViewer] handleClose error:", e);
console.warn('[ProfileViewer] handleClose error:', e)
}
}
};
const text = {
fullWidth: true,
size: "small",
margin: "normal",
variant: "outlined",
autoComplete: "off",
autoCorrect: "off",
} as const;
size: 'small',
margin: 'normal',
variant: 'outlined',
autoComplete: 'off',
autoCorrect: 'off',
} as const
const formType = watch("type");
const isRemote = formType === "remote";
const isLocal = formType === "local";
const formType = watch('type')
const isRemote = formType === 'remote'
const isLocal = formType === 'local'
return (
<BaseDialog
open={open}
title={
openType === "new"
? t("profiles.modals.profileForm.title.create")
: t("profiles.modals.profileForm.title.edit")
openType === 'new'
? t('profiles.modals.profileForm.title.create')
: t('profiles.modals.profileForm.title.edit')
}
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }}
okBtn={t("shared.actions.save")}
cancelBtn={t("shared.actions.cancel")}
contentSx={{ width: 375, pb: 0, maxHeight: '80%' }}
okBtn={t('shared.actions.save')}
cancelBtn={t('shared.actions.cancel')}
onClose={handleClose}
onCancel={handleClose}
onOk={handleOk}
@@ -237,12 +237,12 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => (
<FormControl size="small" fullWidth sx={{ mt: 1, mb: 1 }}>
<InputLabel>
{t("profiles.modals.profileForm.fields.type")}
{t('profiles.modals.profileForm.fields.type')}
</InputLabel>
<Select
{...field}
autoFocus
label={t("profiles.modals.profileForm.fields.type")}
label={t('profiles.modals.profileForm.fields.type')}
>
<MenuItem value="remote">Remote</MenuItem>
<MenuItem value="local">Local</MenuItem>
@@ -255,7 +255,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
name="name"
control={control}
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
{...text}
{...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}
{...field}
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}
type="number"
placeholder="60"
label={t("profiles.modals.profileForm.fields.httpTimeout")}
label={t('profiles.modals.profileForm.fields.httpTimeout')}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("shared.units.seconds")}
{t('shared.units.seconds')}
</InputAdornment>
),
},
@@ -333,12 +333,12 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
{...text}
{...field}
type="number"
label={t("profiles.modals.profileForm.fields.updateInterval")}
label={t('profiles.modals.profileForm.fields.updateInterval')}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("shared.units.minutes")}
{t('shared.units.minutes')}
</InputAdornment>
),
},
@@ -348,11 +348,11 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
/>
)}
{isLocal && openType === "new" && (
{isLocal && openType === 'new' && (
<FileInput
onChange={(file, val) => {
setValue("name", getValues("name") || file.name);
fileDataRef.current = val;
setValue('name', getValues('name') || file.name)
fileDataRef.current = val
}}
/>
)}
@@ -365,7 +365,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => (
<StyledBox>
<InputLabel>
{t("profiles.modals.profileForm.fields.useSystemProxy")}
{t('profiles.modals.profileForm.fields.useSystemProxy')}
</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
</StyledBox>
@@ -378,7 +378,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => (
<StyledBox>
<InputLabel>
{t("profiles.modals.profileForm.fields.useClashProxy")}
{t('profiles.modals.profileForm.fields.useClashProxy')}
</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
</StyledBox>
@@ -391,7 +391,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => (
<StyledBox>
<InputLabel>
{t("profiles.modals.profileForm.fields.acceptInvalidCerts")}
{t('profiles.modals.profileForm.fields.acceptInvalidCerts')}
</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
</StyledBox>
@@ -404,7 +404,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
render={({ field }) => (
<StyledBox>
<InputLabel>
{t("profiles.modals.profileForm.fields.allowAutoUpdate")}
{t('profiles.modals.profileForm.fields.allowAutoUpdate')}
</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
</StyledBox>
@@ -413,12 +413,12 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
</>
)}
</BaseDialog>
);
)
}
const StyledBox = styled(Box)(() => ({
margin: "8px 0 8px 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}));
margin: '8px 0 8px 8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}))
+168 -171
View File
@@ -6,16 +6,13 @@ import {
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import MonacoEditor from "@monaco-editor/react";
} from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import MonacoEditor from '@monaco-editor/react'
import {
VerticalAlignBottomRounded,
VerticalAlignTopRounded,
} from "@mui/icons-material";
} from '@mui/icons-material'
import {
Box,
Button,
@@ -27,62 +24,62 @@ import {
ListItem,
TextField,
styled,
} from "@mui/material";
import { useLockFn } from "ahooks";
import yaml from "js-yaml";
} from '@mui/material'
import { useLockFn } from 'ahooks'
import yaml from 'js-yaml'
import {
startTransition,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Virtuoso } from "react-virtuoso";
} from 'react'
import { useTranslation } from 'react-i18next'
import { Virtuoso } from 'react-virtuoso'
import { BaseSearchBox } from "@/components/base";
import { ProxyItem } from "@/components/profile/proxy-item";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import { showNotice } from "@/services/notice-service";
import { useThemeMode } from "@/services/states";
import getSystem from "@/utils/get-system";
import parseUri from "@/utils/uri-parser";
import { BaseSearchBox } from '@/components/base'
import { ProxyItem } from '@/components/profile/proxy-item'
import { readProfileFile, saveProfileFile } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useThemeMode } from '@/services/states'
import getSystem from '@/utils/get-system'
import parseUri from '@/utils/uri-parser'
interface Props {
profileUid: string;
property: string;
open: boolean;
onClose: () => void;
onSave?: (prev?: string, curr?: string) => void;
profileUid: string
property: string
open: boolean
onClose: () => void
onSave?: (prev?: string, curr?: string) => void
}
export const ProxiesEditorViewer = (props: Props) => {
const { profileUid, property, open, onClose, onSave } = props;
const { t } = useTranslation();
const themeMode = useThemeMode();
const [prevData, setPrevData] = useState("");
const [currData, setCurrData] = useState("");
const [visualization, setVisualization] = useState(true);
const [match, setMatch] = useState(() => (_: string) => true);
const [proxyUri, setProxyUri] = useState<string>("");
const { profileUid, property, open, onClose, onSave } = props
const { t } = useTranslation()
const themeMode = useThemeMode()
const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true)
const [match, setMatch] = useState(() => (_: string) => true)
const [proxyUri, setProxyUri] = useState<string>('')
const [proxyList, setProxyList] = useState<IProxyConfig[]>([]);
const [prependSeq, setPrependSeq] = useState<IProxyConfig[]>([]);
const [appendSeq, setAppendSeq] = useState<IProxyConfig[]>([]);
const [deleteSeq, setDeleteSeq] = useState<string[]>([]);
const [proxyList, setProxyList] = useState<IProxyConfig[]>([])
const [prependSeq, setPrependSeq] = useState<IProxyConfig[]>([])
const [appendSeq, setAppendSeq] = useState<IProxyConfig[]>([])
const [deleteSeq, setDeleteSeq] = useState<string[]>([])
const filteredPrependSeq = useMemo(
() => prependSeq.filter((proxy) => match(proxy.name)),
[prependSeq, match],
);
)
const filteredProxyList = useMemo(
() => proxyList.filter((proxy) => match(proxy.name)),
[proxyList, match],
);
)
const filteredAppendSeq = useMemo(
() => appendSeq.filter((proxy) => match(proxy.name)),
[appendSeq, match],
);
)
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -91,138 +88,138 @@ export const ProxiesEditorViewer = (props: Props) => {
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
)
const reorder = (
list: IProxyConfig[],
startIndex: number,
endIndex: number,
) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
const result = Array.from(list)
const [removed] = result.splice(startIndex, 1)
result.splice(endIndex, 0, removed)
return result
}
const onPrependDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
const { active, over } = event
if (over) {
if (active.id !== over.id) {
let activeIndex = 0;
let overIndex = 0;
let activeIndex = 0
let overIndex = 0
prependSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index;
activeIndex = index
}
if (item.name === over.id) {
overIndex = index;
overIndex = index
}
});
})
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
setPrependSeq(reorder(prependSeq, activeIndex, overIndex))
}
}
}
};
const onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
const { active, over } = event
if (over) {
if (active.id !== over.id) {
let activeIndex = 0;
let overIndex = 0;
let activeIndex = 0
let overIndex = 0
appendSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index;
activeIndex = index
}
if (item.name === over.id) {
overIndex = index;
overIndex = index
}
})
setAppendSeq(reorder(appendSeq, activeIndex, overIndex))
}
});
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
}
}
};
// 优化:异步分片解析,避免主线程阻塞,解析完成后批量setState
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
const proxies: IProxyConfig[] = [];
const names: string[] = [];
let uris: string;
const proxies: IProxyConfig[] = []
const names: string[] = []
let uris: string
try {
uris = atob(proxyUri);
uris = atob(proxyUri)
} catch {
uris = proxyUri;
uris = proxyUri
}
const lines = uris.trim().split("\n");
let idx = 0;
const batchSize = 50;
let parseTimer: number | undefined;
const lines = uris.trim().split('\n')
let idx = 0
const batchSize = 50
let parseTimer: number | undefined
const parseBatch = () => {
const end = Math.min(idx + batchSize, lines.length);
const end = Math.min(idx + batchSize, lines.length)
for (; idx < end; idx++) {
const uri = lines[idx];
const uri = lines[idx]
try {
const proxy = parseUri(uri.trim());
const proxy = parseUri(uri.trim())
if (!names.includes(proxy.name)) {
proxies.push(proxy);
names.push(proxy.name);
proxies.push(proxy)
names.push(proxy.name)
}
} catch (err) {
console.warn(
"[ProxiesEditorViewer] parseUri failed for line:",
'[ProxiesEditorViewer] parseUri failed for line:',
uri,
err,
);
)
// 不阻塞主流程
}
}
if (idx < lines.length) {
parseTimer = window.setTimeout(parseBatch, 0);
parseTimer = window.setTimeout(parseBatch, 0)
} else {
if (parseTimer !== undefined) {
clearTimeout(parseTimer);
parseTimer = undefined;
clearTimeout(parseTimer)
parseTimer = undefined
}
cb(proxies);
cb(proxies)
}
}
parseBatch()
}
};
parseBatch();
};
const fetchProfile = useCallback(async () => {
const data = await readProfileFile(profileUid);
const data = await readProfileFile(profileUid)
const originProxiesObj = yaml.load(data) as {
proxies: IProxyConfig[];
} | null;
proxies: IProxyConfig[]
} | null
setProxyList(originProxiesObj?.proxies || []);
}, [profileUid]);
setProxyList(originProxiesObj?.proxies || [])
}, [profileUid])
const fetchContent = useCallback(async () => {
const data = await readProfileFile(property);
const obj = yaml.load(data) as ISeqProfileConfig | null;
const data = await readProfileFile(property)
const obj = yaml.load(data) as ISeqProfileConfig | null
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
setPrependSeq(obj?.prepend || [])
setAppendSeq(obj?.append || [])
setDeleteSeq(obj?.delete || [])
setPrevData(data);
setCurrData(data);
}, [property]);
setPrevData(data)
setCurrData(data)
}, [property])
useEffect(() => {
if (currData === "" || visualization !== true) {
return;
if (currData === '' || visualization !== true) {
return
}
const obj = yaml.load(currData) as ISeqProfileConfig | null;
const obj = yaml.load(currData) as ISeqProfileConfig | null
startTransition(() => {
setPrependSeq(obj?.prepend ?? []);
setAppendSeq(obj?.append ?? []);
setDeleteSeq(obj?.delete ?? []);
});
}, [currData, visualization]);
setPrependSeq(obj?.prepend ?? [])
setAppendSeq(obj?.append ?? [])
setDeleteSeq(obj?.delete ?? [])
})
}, [currData, visualization])
useEffect(() => {
if (!(prependSeq && appendSeq && deleteSeq)) {
return;
return
}
const serialize = () => {
@@ -232,45 +229,45 @@ export const ProxiesEditorViewer = (props: Props) => {
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true },
),
);
)
} catch (e) {
console.warn("[ProxiesEditorViewer] yaml.dump failed:", e);
console.warn('[ProxiesEditorViewer] yaml.dump failed:', e)
// 防止异常导致UI卡死
}
};
let idleId: number | undefined;
let timeoutId: number | undefined;
}
let idleId: number | undefined
let timeoutId: number | undefined
if (window.requestIdleCallback) {
idleId = window.requestIdleCallback(serialize);
idleId = window.requestIdleCallback(serialize)
} else {
timeoutId = window.setTimeout(serialize, 0);
timeoutId = window.setTimeout(serialize, 0)
}
return () => {
if (idleId !== undefined && window.cancelIdleCallback) {
window.cancelIdleCallback(idleId);
window.cancelIdleCallback(idleId)
}
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
clearTimeout(timeoutId)
}
};
}, [prependSeq, appendSeq, deleteSeq]);
}
}, [prependSeq, appendSeq, deleteSeq])
useEffect(() => {
if (!open) return;
fetchContent();
fetchProfile();
}, [fetchContent, fetchProfile, open]);
if (!open) return
fetchContent()
fetchProfile()
}, [fetchContent, fetchProfile, open])
const handleSave = useLockFn(async () => {
try {
await saveProfileFile(property, currData);
showNotice.success("shared.feedback.notifications.saved");
onSave?.(prevData, currData);
onClose();
await saveProfileFile(property, currData)
showNotice.success('shared.feedback.notifications.saved')
onSave?.(prevData, currData)
onClose()
} catch (err) {
showNotice.error(err);
showNotice.error(err)
}
});
})
return (
<Dialog
@@ -283,18 +280,18 @@ export const ProxiesEditorViewer = (props: Props) => {
<DialogTitle>
{
<Box display="flex" justifyContent="space-between">
{t("profiles.modals.proxiesEditor.title")}
{t('profiles.modals.proxiesEditor.title')}
<Box>
<Button
variant="contained"
size="small"
onClick={() => {
setVisualization((prev) => !prev);
setVisualization((prev) => !prev)
}}
>
{visualization
? t("shared.editorModes.advanced")
: t("shared.editorModes.visualization")}
? t('shared.editorModes.advanced')
: t('shared.editorModes.visualization')}
</Button>
</Box>
</Box>
@@ -302,27 +299,27 @@ export const ProxiesEditorViewer = (props: Props) => {
</DialogTitle>
<DialogContent
sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
sx={{ display: 'flex', width: 'auto', height: 'calc(100vh - 185px)' }}
>
{visualization ? (
<>
<List
sx={{
width: "50%",
padding: "0 10px",
width: '50%',
padding: '0 10px',
}}
>
<Box
sx={{
height: "calc(100% - 80px)",
overflowY: "auto",
height: 'calc(100% - 80px)',
overflowY: 'auto',
}}
>
<Item>
<TextField
autoComplete="new-password"
placeholder={t(
"profiles.modals.proxiesEditor.placeholders.multiUri",
'profiles.modals.proxiesEditor.placeholders.multiUri',
)}
fullWidth
rows={9}
@@ -339,11 +336,11 @@ export const ProxiesEditorViewer = (props: Props) => {
startIcon={<VerticalAlignTopRounded />}
onClick={() => {
handleParseAsync((proxies) => {
setPrependSeq((prev) => [...proxies, ...prev]);
});
setPrependSeq((prev) => [...proxies, ...prev])
})
}}
>
{t("profiles.modals.proxiesEditor.actions.prepend")}
{t('profiles.modals.proxiesEditor.actions.prepend')}
</Button>
</Item>
<Item>
@@ -353,24 +350,24 @@ export const ProxiesEditorViewer = (props: Props) => {
startIcon={<VerticalAlignBottomRounded />}
onClick={() => {
handleParseAsync((proxies) => {
setAppendSeq((prev) => [...prev, ...proxies]);
});
setAppendSeq((prev) => [...prev, ...proxies])
})
}}
>
{t("profiles.modals.proxiesEditor.actions.append")}
{t('profiles.modals.proxiesEditor.actions.append')}
</Button>
</Item>
</List>
<List
sx={{
width: "50%",
padding: "0 10px",
width: '50%',
padding: '0 10px',
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
totalCount={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
@@ -378,7 +375,7 @@ export const ProxiesEditorViewer = (props: Props) => {
}
increaseViewportBy={256}
itemContent={(index) => {
const shift = filteredPrependSeq.length > 0 ? 1 : 0;
const shift = filteredPrependSeq.length > 0 ? 1 : 0
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
@@ -388,7 +385,7 @@ export const ProxiesEditorViewer = (props: Props) => {
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name;
return x.name
})}
>
{filteredPrependSeq.map((item) => {
@@ -402,23 +399,23 @@ export const ProxiesEditorViewer = (props: Props) => {
prependSeq.filter(
(v) => v.name !== item.name,
),
);
)
}}
/>
);
)
})}
</SortableContext>
</DndContext>
);
)
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift;
const newIndex = index - shift
return (
<ProxyItem
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? "delete"
: "original"
? 'delete'
: 'original'
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
@@ -429,16 +426,16 @@ export const ProxiesEditorViewer = (props: Props) => {
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
);
)
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
]);
])
}
}}
/>
);
)
} else {
return (
<DndContext
@@ -448,7 +445,7 @@ export const ProxiesEditorViewer = (props: Props) => {
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name;
return x.name
})}
>
{filteredAppendSeq.map((item) => {
@@ -462,14 +459,14 @@ export const ProxiesEditorViewer = (props: Props) => {
appendSeq.filter(
(v) => v.name !== item.name,
),
);
)
}}
/>
);
)
})}
</SortableContext>
</DndContext>
);
)
}
}}
/>
@@ -480,7 +477,7 @@ export const ProxiesEditorViewer = (props: Props) => {
height="100%"
language="yaml"
value={currData}
theme={themeMode === "light" ? "light" : "vs-dark"}
theme={themeMode === 'light' ? 'light' : 'vs-dark'}
options={{
tabSize: 2, // 根据语言类型设置缩进大小
minimap: {
@@ -496,29 +493,29 @@ export const ProxiesEditorViewer = (props: Props) => {
top: 33, // 顶部padding防止遮挡snippets
},
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
getSystem() === 'windows' ? ', twemoji mozilla' : ''
}`,
fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动
}}
onChange={(value) => setCurrData(value ?? "")}
onChange={(value) => setCurrData(value ?? '')}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("shared.actions.cancel")}
{t('shared.actions.cancel')}
</Button>
<Button onClick={handleSave} variant="contained">
{t("shared.actions.save")}
{t('shared.actions.save')}
</Button>
</DialogActions>
</Dialog>
);
};
)
}
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 { CSS } from "@dnd-kit/utilities";
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { DeleteForeverRounded, UndoRounded } from '@mui/icons-material'
import {
Box,
IconButton,
@@ -8,17 +8,17 @@ import {
ListItemText,
alpha,
styled,
} from "@mui/material";
} from '@mui/material'
interface Props {
type: "prepend" | "original" | "delete" | "append";
proxy: IProxyConfig;
onDelete: () => void;
type: 'prepend' | 'original' | 'delete' | 'append'
proxy: IProxyConfig
onDelete: () => void
}
export const ProxyItem = (props: Props) => {
const { type, proxy, onDelete } = props;
const sortable = type === "prepend" || type === "append";
const { type, proxy, onDelete } = props
const sortable = type === 'prepend' || type === 'append'
const {
attributes: sortableAttributes,
@@ -30,41 +30,41 @@ export const ProxyItem = (props: Props) => {
} = useSortable({
id: proxy.name,
disabled: !sortable,
});
const dragAttributes = sortable ? sortableAttributes : undefined;
const dragListeners = sortable ? sortableListeners : undefined;
const dragNodeRef = sortable ? sortableSetNodeRef : undefined;
})
const dragAttributes = sortable ? sortableAttributes : undefined
const dragListeners = sortable ? sortableListeners : undefined
const dragNodeRef = sortable ? sortableSetNodeRef : undefined
return (
<ListItem
dense
sx={({ palette }) => ({
position: "relative",
position: 'relative',
background:
type === "original"
? palette.mode === "dark"
type === 'original'
? palette.mode === 'dark'
? alpha(palette.background.paper, 0.3)
: alpha(palette.grey[400], 0.3)
: type === "delete"
: type === 'delete'
? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3),
height: "100%",
margin: "8px 0",
borderRadius: "8px",
height: '100%',
margin: '8px 0',
borderRadius: '8px',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
zIndex: isDragging ? 'calc(infinity)' : undefined,
})}
>
<ListItemText
{...(dragAttributes ?? {})}
{...(dragListeners ?? {})}
ref={dragNodeRef}
sx={{ cursor: sortable ? "move" : "" }}
sx={{ cursor: sortable ? 'move' : '' }}
primary={
<StyledPrimary
title={proxy.name}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
sx={{ textDecoration: type === 'delete' ? 'line-through' : '' }}
>
{proxy.name}
</StyledPrimary>
@@ -72,13 +72,13 @@ export const ProxyItem = (props: Props) => {
secondary={
<ListItemTextChild
sx={{
overflow: "hidden",
display: "flex",
alignItems: "center",
pt: "2px",
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
pt: '2px',
}}
>
<Box sx={{ marginTop: "2px" }}>
<Box sx={{ marginTop: '2px' }}>
<StyledTypeBox>{proxy.type}</StyledTypeBox>
</Box>
</ListItemTextChild>
@@ -86,41 +86,41 @@ export const ProxyItem = (props: Props) => {
slotProps={{
secondary: {
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
display: 'flex',
alignItems: 'center',
color: '#ccc',
},
},
}}
/>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
{type === 'delete' ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
);
};
)
}
const StyledPrimary = styled("div")`
const StyledPrimary = styled('div')`
font-size: 15px;
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
`
const ListItemTextChild = styled("span")`
const ListItemTextChild = styled('span')`
display: block;
`;
`
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
display: 'inline-block',
border: '1px solid #ccc',
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
padding: "0 4px",
padding: '0 4px',
lineHeight: 1.5,
marginRight: "8px",
}));
marginRight: '8px',
}))
+51 -51
View File
@@ -1,6 +1,6 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { DeleteForeverRounded, UndoRounded } from '@mui/icons-material'
import {
Box,
IconButton,
@@ -8,23 +8,23 @@ import {
ListItemText,
alpha,
styled,
} from "@mui/material";
} from '@mui/material'
interface Props {
type: "prepend" | "original" | "delete" | "append";
ruleRaw: string;
onDelete: () => void;
type: 'prepend' | 'original' | 'delete' | 'append'
ruleRaw: string
onDelete: () => void
}
export const RuleItem = (props: Props) => {
const { type, ruleRaw, onDelete } = props;
const sortable = type === "prepend" || type === "append";
const rule = ruleRaw.replace(",no-resolve", "");
const { type, ruleRaw, onDelete } = props
const sortable = type === 'prepend' || type === 'append'
const rule = ruleRaw.replace(',no-resolve', '')
const ruleType = rule.match(/^[^,]+/)?.[0] ?? "";
const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? "";
const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1);
const ruleType = rule.match(/^[^,]+/)?.[0] ?? ''
const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? ''
const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1)
const $sortable = useSortable({ id: ruleRaw });
const $sortable = useSortable({ id: ruleRaw })
const {
attributes,
@@ -42,55 +42,55 @@ export const RuleItem = (props: Props) => {
transform: null,
transition: null,
isDragging: false,
};
}
return (
<ListItem
dense
sx={({ palette }) => ({
position: "relative",
position: 'relative',
background:
type === "original"
? palette.mode === "dark"
type === 'original'
? palette.mode === 'dark'
? alpha(palette.background.paper, 0.3)
: alpha(palette.grey[400], 0.3)
: type === "delete"
: type === 'delete'
? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3),
height: "100%",
margin: "8px 0",
borderRadius: "8px",
height: '100%',
margin: '8px 0',
borderRadius: '8px',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
zIndex: isDragging ? 'calc(infinity)' : undefined,
})}
>
<ListItemText
{...attributes}
{...listeners}
ref={setNodeRef}
sx={{ cursor: sortable ? "move" : "" }}
sx={{ cursor: sortable ? 'move' : '' }}
primary={
<StyledPrimary
title={ruleContent || "-"}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
title={ruleContent || '-'}
sx={{ textDecoration: type === 'delete' ? 'line-through' : '' }}
>
{ruleContent || "-"}
{ruleContent || '-'}
</StyledPrimary>
}
secondary={
<ListItemTextChild
sx={{
width: "62%",
overflow: "hidden",
display: "flex",
justifyContent: "space-between",
pt: "2px",
width: '62%',
overflow: 'hidden',
display: 'flex',
justifyContent: 'space-between',
pt: '2px',
}}
>
<Box sx={{ marginTop: "2px" }}>
<Box sx={{ marginTop: '2px' }}>
<StyledTypeBox>{ruleType}</StyledTypeBox>
</Box>
<StyledSubtitle sx={{ color: "text.secondary" }}>
<StyledSubtitle sx={{ color: 'text.secondary' }}>
{proxyPolicy}
</StyledSubtitle>
</ListItemTextChild>
@@ -98,49 +98,49 @@ export const RuleItem = (props: Props) => {
slotProps={{
secondary: {
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
display: 'flex',
alignItems: 'center',
color: '#ccc',
},
},
}}
/>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
{type === 'delete' ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
);
};
)
}
const StyledPrimary = styled("div")`
const StyledPrimary = styled('div')`
font-size: 15px;
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
`
const StyledSubtitle = styled("span")`
const StyledSubtitle = styled('span')`
font-size: 13px;
overflow: hidden;
color: text.secondary;
text-overflow: ellipsis;
white-space: nowrap;
`;
`
const ListItemTextChild = styled("span")`
const ListItemTextChild = styled('span')`
display: block;
`;
`
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
display: 'inline-block',
border: '1px solid #ccc',
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
padding: "0 4px",
padding: '0 4px',
lineHeight: 1.5,
marginRight: "8px",
}));
marginRight: '8px',
}))
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 {
Box,
Button,
@@ -15,126 +15,126 @@ import {
Typography,
alpha,
styled,
} from "@mui/material";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateProxyProvider } from "tauri-plugin-mihomo-api";
} from '@mui/material'
import { useLockFn } from 'ahooks'
import dayjs from 'dayjs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { updateProxyProvider } from 'tauri-plugin-mihomo-api'
import { useAppData } from "@/providers/app-data-context";
import { showNotice } from "@/services/notice-service";
import parseTraffic from "@/utils/parse-traffic";
import { useAppData } from '@/providers/app-data-context'
import { showNotice } from '@/services/notice-service'
import parseTraffic from '@/utils/parse-traffic'
// 样式化组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
display: 'inline-block',
border: '1px solid #ccc',
borderColor: alpha(theme.palette.secondary.main, 0.5),
color: alpha(theme.palette.secondary.main, 0.8),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
marginRight: '4px',
padding: '0 2px',
lineHeight: 1.25,
}));
}))
// 解析过期时间
const parseExpire = (expire?: number) => {
if (!expire) return "-";
return dayjs(expire * 1000).format("YYYY-MM-DD");
};
if (!expire) return '-'
return dayjs(expire * 1000).format('YYYY-MM-DD')
}
export const ProviderButton = () => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData();
const [updating, setUpdating] = useState<Record<string, boolean>>({});
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData()
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) => {
try {
// 设置更新状态
setUpdating((prev) => ({ ...prev, [name]: true }));
setUpdating((prev) => ({ ...prev, [name]: true }))
await updateProxyProvider(name);
await updateProxyProvider(name)
// 刷新数据
await refreshProxy();
await refreshProxyProviders();
await refreshProxy()
await refreshProxyProviders()
showNotice.success(
"proxies.feedback.notifications.provider.updateSuccess",
'proxies.feedback.notifications.provider.updateSuccess',
{
name,
},
);
)
} catch (err) {
showNotice.error("proxies.feedback.notifications.provider.updateFailed", {
showNotice.error('proxies.feedback.notifications.provider.updateFailed', {
name,
message: String(err),
});
})
} finally {
// 清除更新状态
setUpdating((prev) => ({ ...prev, [name]: false }));
setUpdating((prev) => ({ ...prev, [name]: false }))
}
});
})
// 更新所有代理提供者
const updateAllProviders = useLockFn(async () => {
try {
// 获取所有provider的名称
const allProviders = Object.keys(proxyProviders || {});
const allProviders = Object.keys(proxyProviders || {})
if (allProviders.length === 0) {
showNotice.info("proxies.feedback.notifications.provider.none");
return;
showNotice.info('proxies.feedback.notifications.provider.none')
return
}
// 设置所有provider为更新中状态
const newUpdating = allProviders.reduce(
(acc, key) => {
acc[key] = true;
return acc;
acc[key] = true
return acc
},
{} as Record<string, boolean>,
);
setUpdating(newUpdating);
)
setUpdating(newUpdating)
// 改为串行逐个更新所有provider
for (const name of allProviders) {
try {
await updateProxyProvider(name);
await updateProxyProvider(name)
// 每个更新完成后更新状态
setUpdating((prev) => ({ ...prev, [name]: false }));
setUpdating((prev) => ({ ...prev, [name]: false }))
} catch (err) {
console.error(`更新 ${name} 失败`, err);
console.error(`更新 ${name} 失败`, err)
// 继续执行下一个,不中断整体流程
}
}
// 刷新数据
await refreshProxy();
await refreshProxyProviders();
await refreshProxy()
await refreshProxyProviders()
showNotice.success("proxies.feedback.notifications.provider.allUpdated");
showNotice.success('proxies.feedback.notifications.provider.allUpdated')
} catch (err) {
showNotice.error("proxies.feedback.notifications.provider.genericError", {
showNotice.error('proxies.feedback.notifications.provider.genericError', {
message: String(err),
});
})
} finally {
// 清除所有更新状态
setUpdating({});
setUpdating({})
}
});
})
const handleClose = () => {
setOpen(false);
};
setOpen(false)
}
if (!hasProviders) return null;
if (!hasProviders) return null
return (
<>
@@ -145,7 +145,7 @@ export const ProviderButton = () => {
onClick={() => setOpen(true)}
sx={{ mr: 1 }}
>
{t("proxies.page.provider.title")}
{t('proxies.page.provider.title')}
</Button>
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
@@ -156,16 +156,16 @@ export const ProviderButton = () => {
alignItems="center"
>
<Typography variant="h6">
{t("proxies.page.provider.title")}
{t('proxies.page.provider.title')}
</Typography>
<Box>
<Button
variant="contained"
size="small"
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>
</Box>
</Box>
@@ -176,17 +176,17 @@ export const ProviderButton = () => {
{Object.entries(proxyProviders || {})
.sort()
.map(([key, item]) => {
const provider = item;
const time = dayjs(provider.updatedAt);
const isUpdating = updating[key];
const provider = item
const time = dayjs(provider.updatedAt)
const isUpdating = updating[key]
// 订阅信息
const sub = provider.subscriptionInfo;
const hasSubInfo = !!sub;
const upload = sub?.Upload || 0;
const download = sub?.Download || 0;
const total = sub?.Total || 0;
const expire = sub?.Expire || 0;
const sub = provider.subscriptionInfo
const hasSubInfo = !!sub
const upload = sub?.Upload || 0
const download = sub?.Download || 0
const total = sub?.Total || 0
const expire = sub?.Expire || 0
// 流量使用进度
const progress =
@@ -195,7 +195,7 @@ export const ProviderButton = () => {
Math.round(((download + upload) * 100) / total) + 1,
100,
)
: 0;
: 0
return (
<ListItem
@@ -203,25 +203,24 @@ export const ProviderButton = () => {
sx={[
{
p: 0,
mb: "8px",
mb: '8px',
borderRadius: 2,
overflow: "hidden",
transition: "all 0.2s",
overflow: 'hidden',
transition: 'all 0.2s',
},
({ palette: { mode, primary } }) => {
const bgcolor =
mode === "light" ? "#ffffff" : "#24252f";
const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
const hoverColor =
mode === "light"
mode === 'light'
? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2);
: alpha(primary.main, 0.2)
return {
backgroundColor: bgcolor,
"&:hover": {
'&:hover': {
backgroundColor: hoverColor,
},
};
}
},
]}
>
@@ -230,9 +229,9 @@ export const ProviderButton = () => {
primary={
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography
@@ -240,9 +239,9 @@ export const ProviderButton = () => {
component="div"
noWrap
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">
{provider.proxies.length}
</TypeBox>
@@ -256,7 +255,7 @@ export const ProviderButton = () => {
color="text.secondary"
noWrap
>
<small>{t("shared.labels.updateAt")}: </small>
<small>{t('shared.labels.updateAt')}: </small>
{time.fromNow()}
</Typography>
</Box>
@@ -269,20 +268,20 @@ export const ProviderButton = () => {
<Box
sx={{
mb: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span
title={t("shared.labels.usedTotal") as string}
title={t('shared.labels.usedTotal') as string}
>
{parseTraffic(upload + download)} /{" "}
{parseTraffic(upload + download)} /{' '}
{parseTraffic(total)}
</span>
<span
title={
t("shared.labels.expireTime") as string
t('shared.labels.expireTime') as string
}
>
{parseExpire(expire)}
@@ -308,45 +307,45 @@ export const ProviderButton = () => {
<Box
sx={{
width: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<IconButton
size="small"
color="primary"
onClick={() => {
updateProvider(key);
updateProvider(key)
}}
disabled={isUpdating}
sx={{
animation: isUpdating
? "spin 1s linear infinite"
: "none",
"@keyframes spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
? 'spin 1s linear infinite'
: 'none',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
title={t("proxies.page.provider.actions.update")}
aria-label={t("proxies.page.provider.actions.update")}
title={t('proxies.page.provider.actions.update')}
aria-label={t('proxies.page.provider.actions.update')}
>
<RefreshRounded />
</IconButton>
</Box>
</ListItem>
);
)
})}
</List>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} variant="outlined">
{t("shared.actions.close")}
{t('shared.actions.close')}
</Button>
</DialogActions>
</Dialog>
</>
);
};
)
}
+193 -193
View File
@@ -6,22 +6,22 @@ import {
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
ArrowDownward,
Delete as DeleteIcon,
DragIndicator,
Link,
LinkOff,
} from "@mui/icons-material";
} from '@mui/icons-material'
import {
Alert,
Box,
@@ -31,55 +31,55 @@ import {
Paper,
Typography,
useTheme,
} from "@mui/material";
import yaml from "js-yaml";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import yaml from 'js-yaml'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
closeAllConnections,
selectNodeForGroup,
} from "tauri-plugin-mihomo-api";
} from 'tauri-plugin-mihomo-api'
import { useAppData } from "@/providers/app-data-context";
import { updateProxyChainConfigInRuntime } from "@/services/cmds";
import { debugLog } from "@/utils/debug";
import { useAppData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import { debugLog } from '@/utils/debug'
interface ProxyChainItem {
id: string;
name: string;
type?: string;
delay?: number;
id: string
name: string
type?: string
delay?: number
}
interface ParsedChainConfig {
proxies?: Array<{
name: string;
type: string;
[key: string]: any;
}>;
name: string
type: string
[key: string]: any
}>
}
interface ProxyChainProps {
proxyChain: ProxyChainItem[];
onUpdateChain: (chain: ProxyChainItem[]) => void;
chainConfigData?: string | null;
onMarkUnsavedChanges?: () => void;
mode?: string;
selectedGroup?: string | null;
proxyChain: ProxyChainItem[]
onUpdateChain: (chain: ProxyChainItem[]) => void
chainConfigData?: string | null
onMarkUnsavedChanges?: () => void
mode?: string
selectedGroup?: string | null
}
interface SortableItemProps {
proxy: ProxyChainItem;
index: number;
isFirst: boolean;
isLast: boolean;
onRemove: (id: string) => void;
proxy: ProxyChainItem
index: number
isFirst: boolean
isLast: boolean
onRemove: (id: string) => void
}
const toChainItems = (
parsedConfig: ParsedChainConfig | null | undefined,
): ProxyChainItem[] => {
const timestamp = Date.now();
const timestamp = Date.now()
return (
parsedConfig?.proxies?.map((proxy, index) => ({
@@ -88,8 +88,8 @@ const toChainItems = (
type: proxy.type,
delay: undefined,
})) || []
);
};
)
}
const SortableItem = ({
proxy,
@@ -98,8 +98,8 @@ const SortableItem = ({
isLast,
onRemove,
}: SortableItemProps) => {
const theme = useTheme();
const { t } = useTranslation();
const theme = useTheme()
const { t } = useTranslation()
const {
attributes,
listeners,
@@ -107,25 +107,25 @@ const SortableItem = ({
transform,
transition,
isDragging,
} = useSortable({ id: proxy.id });
} = useSortable({ id: proxy.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
}
const roleLabel = isFirst
? t("proxies.page.chain.entryNode")
? t('proxies.page.chain.entryNode')
: isLast
? t("proxies.page.chain.exitNode")
: undefined;
? t('proxies.page.chain.exitNode')
: undefined
const roleColor = isFirst
? theme.palette.success.main
: isLast
? theme.palette.warning.main
: undefined;
: undefined
return (
<Box
@@ -133,8 +133,8 @@ const SortableItem = ({
style={style}
sx={{
mb: 0,
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
p: 1,
backgroundColor: isDragging
? theme.palette.action.selected
@@ -144,20 +144,20 @@ const SortableItem = ({
? `1.5px solid ${roleColor}`
: `1px solid ${theme.palette.divider}`,
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
{...attributes}
{...listeners}
sx={{
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
mr: 1,
color: theme.palette.text.secondary,
cursor: "grab",
"&:active": {
cursor: "grabbing",
cursor: 'grab',
'&:active': {
cursor: 'grabbing',
},
}}
>
@@ -171,7 +171,7 @@ const SortableItem = ({
sx={{
mr: 1,
fontWeight: 700,
color: "#fff",
color: '#fff',
backgroundColor: roleColor,
}}
/>
@@ -189,9 +189,9 @@ const SortableItem = ({
sx={{
flex: 1,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{proxy.name}
@@ -211,17 +211,17 @@ const SortableItem = ({
label={
proxy.delay > 0
? `${proxy.delay}ms`
: t("shared.labels.timeout") || "超时"
: t('shared.labels.timeout') || '超时'
}
size="small"
color={
proxy.delay > 0 && proxy.delay < 200
? "success"
? 'success'
: proxy.delay > 0 && proxy.delay < 800
? "warning"
: "error"
? 'warning'
: '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)}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: theme.palette.error.light + "20",
'&:hover': {
backgroundColor: theme.palette.error.light + '20',
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
);
};
)
}
export const ProxyChain = ({
proxyChain,
@@ -249,48 +249,48 @@ export const ProxyChain = ({
mode,
selectedGroup,
}: ProxyChainProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { proxies, refreshProxy } = useAppData();
const [isConnecting, setIsConnecting] = useState(false);
const theme = useTheme()
const { t } = useTranslation()
const { proxies, refreshProxy } = useAppData()
const [isConnecting, setIsConnecting] = useState(false)
const markUnsavedChanges = useCallback(() => {
onMarkUnsavedChanges?.();
}, [onMarkUnsavedChanges]);
onMarkUnsavedChanges?.()
}, [onMarkUnsavedChanges])
const isConnected = useMemo(() => {
if (!proxies || proxyChain.length < 2) {
return false;
return false
}
const lastNode = proxyChain[proxyChain.length - 1];
const lastNode = proxyChain[proxyChain.length - 1]
if (mode === "global") {
return proxies.global?.now === lastNode.name;
if (mode === 'global') {
return proxies.global?.now === lastNode.name
}
if (!selectedGroup || !Array.isArray(proxies.groups)) {
return false;
return false
}
const proxyChainGroup = proxies.groups.find(
(group: { name: string }) => group.name === selectedGroup,
);
)
return proxyChainGroup?.now === lastNode.name;
}, [proxies, proxyChain, mode, selectedGroup]);
return proxyChainGroup?.now === lastNode.name
}, [proxies, proxyChain, mode, selectedGroup])
// 监听链的变化,但排除从配置加载的情况
const chainLengthRef = useRef(proxyChain.length);
const chainLengthRef = useRef(proxyChain.length)
useEffect(() => {
// 只有当链长度发生变化且不是初始加载时,才标记为未保存
if (
chainLengthRef.current !== proxyChain.length &&
chainLengthRef.current !== 0
) {
markUnsavedChanges();
markUnsavedChanges()
}
chainLengthRef.current = proxyChain.length;
}, [proxyChain.length, markUnsavedChanges]);
chainLengthRef.current = proxyChain.length
}, [proxyChain.length, markUnsavedChanges])
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -299,50 +299,50 @@ export const ProxyChain = ({
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = proxyChain.findIndex((item) => item.id === active.id);
const newIndex = proxyChain.findIndex((item) => item.id === over?.id);
const oldIndex = proxyChain.findIndex((item) => item.id === active.id)
const newIndex = proxyChain.findIndex((item) => item.id === over?.id)
onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex));
markUnsavedChanges();
onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex))
markUnsavedChanges()
}
},
[proxyChain, onUpdateChain, markUnsavedChanges],
);
)
const handleRemoveProxy = useCallback(
(id: string) => {
const newChain = proxyChain.filter((item) => item.id !== id);
onUpdateChain(newChain);
markUnsavedChanges();
const newChain = proxyChain.filter((item) => item.id !== id)
onUpdateChain(newChain)
markUnsavedChanges()
},
[proxyChain, onUpdateChain, markUnsavedChanges],
);
)
const handleConnect = useCallback(async () => {
if (isConnected) {
setIsConnecting(true);
setIsConnecting(true)
try {
await updateProxyChainConfigInRuntime(null);
await updateProxyChainConfigInRuntime(null)
const targetGroup =
mode === "global"
? "GLOBAL"
: selectedGroup || localStorage.getItem("proxy-chain-group");
mode === 'global'
? 'GLOBAL'
: selectedGroup || localStorage.getItem('proxy-chain-group')
if (targetGroup) {
try {
await selectNodeForGroup(targetGroup, "DIRECT");
await selectNodeForGroup(targetGroup, 'DIRECT')
} catch {
if (proxyChain.length >= 1) {
try {
await selectNodeForGroup(targetGroup, proxyChain[0].name);
await selectNodeForGroup(targetGroup, proxyChain[0].name)
} catch {
// ignore
}
@@ -350,59 +350,59 @@ export const ProxyChain = ({
}
}
localStorage.removeItem("proxy-chain-group");
localStorage.removeItem("proxy-chain-exit-node");
localStorage.removeItem("proxy-chain-items");
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
await closeAllConnections();
await refreshProxy();
await closeAllConnections()
await refreshProxy()
onUpdateChain([]);
onUpdateChain([])
} catch (error) {
console.error("Failed to disconnect from proxy chain:", error);
alert(t("proxies.page.chain.disconnectFailed") || "断开链式代理失败");
console.error('Failed to disconnect from proxy chain:', error)
alert(t('proxies.page.chain.disconnectFailed') || '断开链式代理失败')
} finally {
setIsConnecting(false);
setIsConnecting(false)
}
return;
return
}
if (proxyChain.length < 2) {
alert(t("proxies.page.chain.minimumNodes") || "链式代理至少需要2个节点");
return;
alert(t('proxies.page.chain.minimumNodes') || '链式代理至少需要2个节点')
return
}
setIsConnecting(true);
setIsConnecting(true)
try {
// 第一步:保存链式代理配置
const chainProxies = proxyChain.map((node) => node.name);
debugLog("Saving chain config:", chainProxies);
await updateProxyChainConfigInRuntime(chainProxies);
debugLog("Chain configuration saved successfully");
const chainProxies = proxyChain.map((node) => node.name)
debugLog('Saving chain config:', chainProxies)
await updateProxyChainConfigInRuntime(chainProxies)
debugLog('Chain configuration saved successfully')
// 第二步:连接到代理链的最后一个节点
const lastNode = proxyChain[proxyChain.length - 1];
debugLog(`Connecting to proxy chain, last node: ${lastNode.name}`);
const lastNode = proxyChain[proxyChain.length - 1]
debugLog(`Connecting to proxy chain, last node: ${lastNode.name}`)
// 根据模式确定使用的代理组名称
if (mode !== "global" && !selectedGroup) {
throw new Error("规则模式下必须选择代理组");
if (mode !== 'global' && !selectedGroup) {
throw new Error('规则模式下必须选择代理组')
}
const targetGroup = mode === "global" ? "GLOBAL" : selectedGroup;
const targetGroup = mode === 'global' ? 'GLOBAL' : selectedGroup
await selectNodeForGroup(targetGroup || "GLOBAL", lastNode.name);
localStorage.setItem("proxy-chain-group", targetGroup || "GLOBAL");
localStorage.setItem("proxy-chain-exit-node", lastNode.name);
await selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name)
localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL')
localStorage.setItem('proxy-chain-exit-node', lastNode.name)
// 刷新代理信息以更新连接状态
refreshProxy();
debugLog("Successfully connected to proxy chain");
refreshProxy()
debugLog('Successfully connected to proxy chain')
} catch (error) {
console.error("Failed to connect to proxy chain:", error);
alert(t("proxies.page.chain.connectFailed") || "连接链式代理失败");
console.error('Failed to connect to proxy chain:', error)
alert(t('proxies.page.chain.connectFailed') || '连接链式代理失败')
} finally {
setIsConnecting(false);
setIsConnecting(false)
}
}, [
proxyChain,
@@ -412,112 +412,112 @@ export const ProxyChain = ({
mode,
selectedGroup,
onUpdateChain,
]);
])
const proxyChainRef = useRef(proxyChain);
const onUpdateChainRef = useRef(onUpdateChain);
const proxyChainRef = useRef(proxyChain)
const onUpdateChainRef = useRef(onUpdateChain)
useEffect(() => {
proxyChainRef.current = proxyChain;
onUpdateChainRef.current = onUpdateChain;
}, [proxyChain, onUpdateChain]);
proxyChainRef.current = proxyChain
onUpdateChainRef.current = onUpdateChain
}, [proxyChain, onUpdateChain])
// 处理链式代理配置数据
useEffect(() => {
if (chainConfigData) {
try {
// JSON is valid YAML, so one parser covers both persisted formats.
const parsedConfig = yaml.load(chainConfigData) as ParsedChainConfig;
const chainItems = toChainItems(parsedConfig);
const parsedConfig = yaml.load(chainConfigData) as ParsedChainConfig
const chainItems = toChainItems(parsedConfig)
if (chainItems.length > 0) {
onUpdateChain(chainItems);
onUpdateChain(chainItems)
}
} 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(() => {
if (!proxies?.records) return;
if (!proxies?.records) return
const updateDelays = () => {
const currentChain = proxyChainRef.current;
if (currentChain.length === 0) return;
const currentChain = proxyChainRef.current
if (currentChain.length === 0) return
const updatedChain = currentChain.map((item) => {
const proxyRecord = proxies.records[item.name];
const proxyRecord = proxies.records[item.name]
if (
proxyRecord &&
proxyRecord.history &&
proxyRecord.history.length > 0
) {
const latestDelay =
proxyRecord.history[proxyRecord.history.length - 1].delay;
return { ...item, delay: latestDelay };
proxyRecord.history[proxyRecord.history.length - 1].delay
return { ...item, delay: latestDelay }
}
return item;
});
return item
})
// 只有在延迟数据确实发生变化时才更新
const hasChanged = updatedChain.some(
(item, index) => item.delay !== currentChain[index]?.delay,
);
)
if (hasChanged) {
onUpdateChainRef.current(updatedChain);
onUpdateChainRef.current(updatedChain)
}
}
};
// 立即更新一次延迟
updateDelays();
updateDelays()
// 设置定时器,每5秒更新一次延迟
const interval = setInterval(updateDelays, 5000);
const interval = setInterval(updateDelays, 5000)
return () => clearInterval(interval);
}, [proxies?.records]); // 只依赖proxies.records
return () => clearInterval(interval)
}, [proxies?.records]) // 只依赖proxies.records
return (
<Paper
elevation={1}
sx={{
height: "100%",
height: '100%',
p: 2,
display: "flex",
flexDirection: "column",
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
>
<Typography variant="h6">{t("proxies.page.chain.header")}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography variant="h6">{t('proxies.page.chain.header')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{proxyChain.length > 0 && (
<IconButton
size="small"
onClick={() => {
updateProxyChainConfigInRuntime(null);
localStorage.removeItem("proxy-chain-group");
localStorage.removeItem("proxy-chain-exit-node");
localStorage.removeItem("proxy-chain-items");
onUpdateChain([]);
updateProxyChainConfigInRuntime(null)
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
onUpdateChain([])
}}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: theme.palette.error.light + "20",
'&:hover': {
backgroundColor: theme.palette.error.light + '20',
},
}}
title={
t("proxies.page.actions.clearChainConfig") || "删除链式配置"
t('proxies.page.actions.clearChainConfig') || '删除链式配置'
}
>
<DeleteIcon fontSize="small" />
@@ -531,51 +531,51 @@ export const ProxyChain = ({
disabled={
isConnecting ||
proxyChain.length < 2 ||
(mode !== "global" && !selectedGroup)
(mode !== 'global' && !selectedGroup)
}
color={isConnected ? "error" : "success"}
color={isConnected ? 'error' : 'success'}
sx={{
minWidth: 90,
}}
title={
proxyChain.length < 2
? t("proxies.page.chain.minimumNodes") ||
"链式代理至少需要2个节点"
? t('proxies.page.chain.minimumNodes') ||
'链式代理至少需要2个节点'
: undefined
}
>
{isConnecting
? t("proxies.page.actions.connecting") || "连接中..."
? t('proxies.page.actions.connecting') || '连接中...'
: isConnected
? t("proxies.page.actions.disconnect") || "断开"
: t("proxies.page.actions.connect") || "连接"}
? t('proxies.page.actions.disconnect') || '断开'
: t('proxies.page.actions.connect') || '连接'}
</Button>
</Box>
</Box>
<Alert
severity={proxyChain.length === 1 ? "warning" : "info"}
severity={proxyChain.length === 1 ? 'warning' : 'info'}
sx={{ mb: 2 }}
>
{proxyChain.length === 1
? t("proxies.page.chain.minimumNodesHint") ||
"链式代理至少需要2个节点,请再添加一个节点。"
: t("proxies.page.chain.instruction") ||
"按顺序点击节点添加到代理链中"}
? t('proxies.page.chain.minimumNodesHint') ||
'链式代理至少需要2个节点,请再添加一个节点。'
: t('proxies.page.chain.instruction') ||
'按顺序点击节点添加到代理链中'}
</Alert>
<Box sx={{ flex: 1, overflow: "auto" }}>
<Box sx={{ flex: 1, overflow: 'auto' }}>
{proxyChain.length === 0 ? (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: theme.palette.text.secondary,
}}
>
<Typography>{t("proxies.page.chain.empty")}</Typography>
<Typography>{t('proxies.page.chain.empty')}</Typography>
</Box>
) : (
<DndContext
@@ -608,8 +608,8 @@ export const ProxyChain = ({
{index < proxyChain.length - 1 && (
<Box
sx={{
display: "flex",
justifyContent: "center",
display: 'flex',
justifyContent: 'center',
py: 0.25,
}}
>
@@ -630,5 +630,5 @@ export const ProxyChain = ({
)}
</Box>
</Paper>
);
};
)
}
+61 -61
View File
@@ -1,23 +1,23 @@
import { Box, Button, Tooltip } from "@mui/material";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Box, Button, Tooltip } from '@mui/material'
import { useCallback, useEffect, useMemo, useRef } from 'react'
interface ProxyGroupNavigatorProps {
proxyGroupNames: string[];
onGroupLocation: (groupName: string) => void;
enableHoverJump?: boolean;
hoverDelay?: number;
proxyGroupNames: string[]
onGroupLocation: (groupName: string) => void
enableHoverJump?: boolean
hoverDelay?: number
}
export const DEFAULT_HOVER_DELAY = 280;
export const DEFAULT_HOVER_DELAY = 280
// 提取代理组名的第一个字符
const getGroupDisplayChar = (groupName: string): string => {
if (!groupName) return "?";
if (!groupName) return '?'
// 直接返回第一个字符,支持表情符号
const firstChar = Array.from(groupName)[0];
return firstChar || "?";
};
const firstChar = Array.from(groupName)[0]
return firstChar || '?'
}
export const ProxyGroupNavigator = ({
proxyGroupNames,
@@ -25,55 +25,55 @@ export const ProxyGroupNavigator = ({
enableHoverJump = true,
hoverDelay = DEFAULT_HOVER_DELAY,
}: ProxyGroupNavigatorProps) => {
const lastHoveredRef = useRef<string | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHoveredRef = useRef<string | 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(() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
}
}, []);
}, [])
useEffect(() => {
if (!enableHoverJump) {
clearHoverTimer();
lastHoveredRef.current = null;
clearHoverTimer()
lastHoveredRef.current = null
}
return () => {
clearHoverTimer();
};
}, [clearHoverTimer, enableHoverJump]);
clearHoverTimer()
}
}, [clearHoverTimer, enableHoverJump])
const handleGroupClick = useCallback(
(groupName: string) => {
clearHoverTimer();
lastHoveredRef.current = groupName;
onGroupLocation(groupName);
clearHoverTimer()
lastHoveredRef.current = groupName
onGroupLocation(groupName)
},
[clearHoverTimer, onGroupLocation],
);
)
const handleGroupHover = useCallback(
(groupName: string) => {
if (!enableHoverJump) return;
if (lastHoveredRef.current === groupName) return;
clearHoverTimer();
if (!enableHoverJump) return
if (lastHoveredRef.current === groupName) return
clearHoverTimer()
hoverTimerRef.current = setTimeout(() => {
hoverTimerRef.current = null;
lastHoveredRef.current = groupName;
onGroupLocation(groupName);
}, hoverDelayMs);
hoverTimerRef.current = null
lastHoveredRef.current = groupName
onGroupLocation(groupName)
}, hoverDelayMs)
},
[clearHoverTimer, enableHoverJump, hoverDelayMs, onGroupLocation],
);
)
const handleButtonLeave = useCallback(() => {
clearHoverTimer();
lastHoveredRef.current = null;
}, [clearHoverTimer]);
clearHoverTimer()
lastHoveredRef.current = null
}, [clearHoverTimer])
// 处理代理组数据,去重和排序
const processedGroups = useMemo(() => {
@@ -82,36 +82,36 @@ export const ProxyGroupNavigator = ({
.map((name) => ({
name,
displayChar: getGroupDisplayChar(name),
}));
}, [proxyGroupNames]);
}))
}, [proxyGroupNames])
if (processedGroups.length === 0) {
return null;
return null
}
return (
<Box
sx={{
position: "absolute",
position: 'absolute',
right: 2,
top: "50%",
transform: "translateY(-50%)",
top: '50%',
transform: 'translateY(-50%)',
zIndex: 10,
display: "flex",
flexDirection: "column",
display: 'flex',
flexDirection: 'column',
gap: 0.25,
bgcolor: "transparent",
bgcolor: 'transparent',
borderRadius: 0.5,
boxShadow: 0,
p: 0.25,
maxHeight: "70vh",
overflowY: "auto",
scrollbarWidth: "none",
"&::-webkit-scrollbar": {
maxHeight: '70vh',
overflowY: 'auto',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': {
width: 0,
height: 0,
},
minWidth: "auto",
minWidth: 'auto',
}}
>
{processedGroups.map(({ name, displayChar }) => (
@@ -129,17 +129,17 @@ export const ProxyGroupNavigator = ({
minHeight: 28,
width: 28,
height: 28,
fontSize: "12px",
fontSize: '12px',
fontWeight: 600,
padding: 0,
borderRadius: 0.25,
color: "text.secondary",
textAlign: "center",
justifyContent: "center",
textTransform: "none",
"&:hover": {
bgcolor: "primary.light",
color: "primary.contrastText",
color: 'text.secondary',
textAlign: 'center',
justifyContent: 'center',
textTransform: 'none',
'&:hover': {
bgcolor: 'primary.light',
color: 'primary.contrastText',
},
}}
>
@@ -148,5 +148,5 @@ export const ProxyGroupNavigator = ({
</Tooltip>
))}
</Box>
);
};
)
}
+226 -228
View File
@@ -1,4 +1,4 @@
import { ExpandMoreRounded } from "@mui/icons-material";
import { ExpandMoreRounded } from '@mui/icons-material'
import {
Alert,
Box,
@@ -8,255 +8,253 @@ import {
MenuItem,
Snackbar,
Typography,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { delayGroup, healthcheckProxyProvider } from "tauri-plugin-mihomo-api";
} from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api'
import { BaseEmpty } from "@/components/base";
import { useProxySelection } from "@/hooks/use-proxy-selection";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-context";
import { updateProxyChainConfigInRuntime } from "@/services/cmds";
import delayManager from "@/services/delay";
import { debugLog } from "@/utils/debug";
import { BaseEmpty } from '@/components/base'
import { useProxySelection } from '@/hooks/use-proxy-selection'
import { useVerge } from '@/hooks/use-verge'
import { useAppData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import delayManager from '@/services/delay'
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 {
DEFAULT_HOVER_DELAY,
ProxyGroupNavigator,
} from "./proxy-group-navigator";
import { ProxyRender } from "./proxy-render";
import { useRenderList } from "./use-render-list";
} from './proxy-group-navigator'
import { ProxyRender } from './proxy-render'
import { useRenderList } from './use-render-list'
interface Props {
mode: string;
isChainMode?: boolean;
chainConfigData?: string | null;
mode: string
isChainMode?: boolean
chainConfigData?: string | null
}
interface ProxyChainItem {
id: string;
name: string;
type?: string;
delay?: number;
id: string
name: string
type?: string
delay?: number
}
const VirtuosoFooter = () => <div style={{ height: "8px" }} />;
const VirtuosoFooter = () => <div style={{ height: '8px' }} />
export const ProxyGroups = (props: Props) => {
const { t } = useTranslation();
const { mode, isChainMode = false, chainConfigData } = props;
const { t } = useTranslation()
const { mode, isChainMode = false, chainConfigData } = props
const [proxyChain, setProxyChain] = useState<ProxyChainItem[]>(() => {
try {
const saved = localStorage.getItem("proxy-chain-items");
const saved = localStorage.getItem('proxy-chain-items')
if (saved) {
return JSON.parse(saved);
return JSON.parse(saved)
}
} catch {
// ignore
}
return [];
});
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
return []
})
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
useEffect(() => {
if (proxyChain.length > 0) {
localStorage.setItem("proxy-chain-items", JSON.stringify(proxyChain));
localStorage.setItem('proxy-chain-items', JSON.stringify(proxyChain))
} else {
localStorage.removeItem("proxy-chain-items");
localStorage.removeItem('proxy-chain-items')
}
}, [proxyChain]);
const [ruleMenuAnchor, setRuleMenuAnchor] = useState<null | HTMLElement>(
null,
);
}, [proxyChain])
const [ruleMenuAnchor, setRuleMenuAnchor] = useState<null | HTMLElement>(null)
const [duplicateWarning, setDuplicateWarning] = useState<{
open: boolean;
message: string;
}>({ open: false, message: "" });
open: boolean
message: string
}>({ open: false, message: '' })
const { verge } = useVerge();
const { proxies: proxiesData } = useAppData();
const groups = proxiesData?.groups;
const { verge } = useVerge()
const { proxies: proxiesData } = useAppData()
const groups = proxiesData?.groups
const availableGroups = useMemo(() => {
if (!groups) return [];
if (!groups) return []
// 在链式代理模式下,仅显示支持选择节点的 Selector 代理组
return isChainMode
? groups.filter((g: any) => g.type === "Selector")
: groups;
}, [groups, isChainMode]);
? groups.filter((g: any) => g.type === 'Selector')
: groups
}, [groups, isChainMode])
const defaultRuleGroup = useMemo(() => {
if (isChainMode && mode === "rule" && availableGroups.length > 0) {
return availableGroups[0].name;
if (isChainMode && mode === 'rule' && availableGroups.length > 0) {
return availableGroups[0].name
}
return null;
}, [availableGroups, isChainMode, mode]);
return null
}, [availableGroups, isChainMode, mode])
const activeSelectedGroup = useMemo(
() => selectedGroup ?? defaultRuleGroup,
[selectedGroup, defaultRuleGroup],
);
)
const { renderList, onProxies, onHeadState } = useRenderList(
mode,
isChainMode,
activeSelectedGroup,
);
)
const getGroupHeadState = useCallback(
(groupName: string) => {
const headItem = renderList.find(
(item) => item.type === 1 && item.group?.name === groupName,
);
return headItem?.headState;
)
return headItem?.headState
},
[renderList],
);
)
// 统代理选择
const { handleProxyGroupChange } = useProxySelection({
onSuccess: () => {
onProxies();
onProxies()
},
onError: (error) => {
console.error("代理切换失败", error);
onProxies();
console.error('代理切换失败', error)
onProxies()
},
});
})
const timeout = verge?.default_latency_timeout || 10000;
const timeout = verge?.default_latency_timeout || 10000
const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollPositionRef = useRef<Record<string, number>>({});
const [showScrollTop, setShowScrollTop] = useState(false);
const scrollerRef = useRef<Element | null>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null)
const scrollPositionRef = useRef<Record<string, number>>({})
const [showScrollTop, setShowScrollTop] = useState(false)
const scrollerRef = useRef<Element | null>(null)
// 从 localStorage 恢复滚动位置
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 {
const savedPositions = localStorage.getItem("proxy-scroll-positions");
const savedPositions = localStorage.getItem('proxy-scroll-positions')
if (savedPositions) {
const positions = JSON.parse(savedPositions);
scrollPositionRef.current = positions;
const savedPosition = positions[mode];
const positions = JSON.parse(savedPositions)
scrollPositionRef.current = positions
const savedPosition = positions[mode]
if (savedPosition !== undefined) {
restoreTimer = setTimeout(() => {
virtuosoRef.current?.scrollTo({
top: savedPosition,
behavior: "auto",
});
}, 100);
behavior: 'auto',
})
}, 100)
}
}
} catch (e) {
console.error("Error restoring scroll position:", e);
console.error('Error restoring scroll position:', e)
}
return () => {
if (restoreTimer) {
clearTimeout(restoreTimer);
clearTimeout(restoreTimer)
}
};
}, [mode, renderList.length]);
}
}, [mode, renderList.length])
// 改为使用节流函数保存滚动位置
const saveScrollPosition = useCallback(
(scrollTop: number) => {
try {
scrollPositionRef.current[mode] = scrollTop;
scrollPositionRef.current[mode] = scrollTop
localStorage.setItem(
"proxy-scroll-positions",
'proxy-scroll-positions',
JSON.stringify(scrollPositionRef.current),
);
)
} catch (e) {
console.error("Error saving scroll position:", e);
console.error('Error saving scroll position:', e)
}
},
[mode],
);
)
// 使用改进的滚动处理
const handleScroll = useMemo(
() =>
throttle((event: Event) => {
const target = event.target as HTMLElement | null;
const scrollTop = target?.scrollTop ?? 0;
setShowScrollTop(scrollTop > 100);
const target = event.target as HTMLElement | null
const scrollTop = target?.scrollTop ?? 0
setShowScrollTop(scrollTop > 100)
// 使用稳定的节流来保存位置,而不是setTimeout
saveScrollPosition(scrollTop);
saveScrollPosition(scrollTop)
}, 500), // 增加到500ms以确保平滑滚动
[saveScrollPosition],
);
)
// 添加和清理滚动事件监听器
useEffect(() => {
const node = scrollerRef.current;
if (!node) return;
const node = scrollerRef.current
if (!node) return
const listener = handleScroll as EventListener;
const options: AddEventListenerOptions = { passive: true };
const listener = handleScroll as EventListener
const options: AddEventListenerOptions = { passive: true }
node.addEventListener("scroll", listener, options);
node.addEventListener('scroll', listener, options)
return () => {
node.removeEventListener("scroll", listener, options);
};
}, [handleScroll]);
node.removeEventListener('scroll', listener, options)
}
}, [handleScroll])
// 滚动到顶部
const scrollToTop = useCallback(() => {
virtuosoRef.current?.scrollTo?.({
top: 0,
behavior: "smooth",
});
saveScrollPosition(0);
}, [saveScrollPosition]);
behavior: 'smooth',
})
saveScrollPosition(0)
}, [saveScrollPosition])
// 关闭重复节点警告
const handleCloseDuplicateWarning = useCallback(() => {
setDuplicateWarning({ open: false, message: "" });
}, []);
setDuplicateWarning({ open: false, message: '' })
}, [])
const currentGroup = useMemo(() => {
if (!activeSelectedGroup) return null;
if (!activeSelectedGroup) return null
return (
availableGroups.find(
(group: any) => group.name === activeSelectedGroup,
) ?? null
);
}, [activeSelectedGroup, availableGroups]);
)
}, [activeSelectedGroup, availableGroups])
// 处理代理组选择菜单
const handleGroupMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setRuleMenuAnchor(event.currentTarget);
};
setRuleMenuAnchor(event.currentTarget)
}
const handleGroupMenuClose = () => {
setRuleMenuAnchor(null);
};
setRuleMenuAnchor(null)
}
const handleGroupSelect = (groupName: string) => {
setSelectedGroup(groupName);
handleGroupMenuClose();
setSelectedGroup(groupName)
handleGroupMenuClose()
if (isChainMode && mode === "rule") {
updateProxyChainConfigInRuntime(null);
localStorage.removeItem("proxy-chain-group");
localStorage.removeItem("proxy-chain-exit-node");
localStorage.removeItem("proxy-chain-items");
setProxyChain([]);
if (isChainMode && mode === 'rule') {
updateProxyChainConfigInRuntime(null)
localStorage.removeItem('proxy-chain-group')
localStorage.removeItem('proxy-chain-exit-node')
localStorage.removeItem('proxy-chain-items')
setProxyChain([])
}
}
};
const handleChangeProxy = useCallback(
(group: IProxyGroupItem, proxy: IProxyItem) => {
@@ -265,69 +263,69 @@ export const ProxyGroups = (props: Props) => {
setProxyChain((prev) => {
// 检查是否已经存在相同名称的代理,防止重复添加
if (prev.some((item) => item.name === proxy.name)) {
const warningMessage = t("proxies.page.chain.duplicateNode");
const warningMessage = t('proxies.page.chain.duplicateNode')
setDuplicateWarning({
open: true,
message: warningMessage,
});
return prev; // 返回原来的状态,不做任何更改
})
return prev // 返回原来的状态,不做任何更改
}
// 安全获取延迟数据,如果没有延迟数据则设为 undefined
const delay =
proxy.history && proxy.history.length > 0
? proxy.history[proxy.history.length - 1].delay
: undefined;
: undefined
const chainItem: ProxyChainItem = {
id: `${proxy.name}_${Date.now()}`,
name: proxy.name,
type: proxy.type,
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],
);
)
// 测全部延迟
const handleCheckAll = useLockFn(async (groupName: string) => {
debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`);
debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`)
const proxies = renderList
.filter(
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4),
)
.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) {
debugLog(`[ProxyGroups] 发现提供者,数量: ${providers.size}`);
debugLog(`[ProxyGroups] 发现提供者,数量: ${providers.size}`)
Promise.allSettled(
[...providers].map((p) => healthcheckProxyProvider(p)),
).then(() => {
debugLog(`[ProxyGroups] 提供者健康检查完成`);
onProxies();
});
debugLog(`[ProxyGroups] 提供者健康检查完成`)
onProxies()
})
}
const names = proxies.filter((p) => !p!.provider).map((p) => p!.name);
debugLog(`[ProxyGroups] 过滤后需要测试的代理数量: ${names.length}`);
const names = proxies.filter((p) => !p!.provider).map((p) => p!.name)
debugLog(`[ProxyGroups] 过滤后需要测试的代理数量: ${names.length}`)
const url = delayManager.getUrl(groupName);
debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`);
const url = delayManager.getUrl(groupName)
debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`)
try {
await Promise.race([
@@ -336,116 +334,116 @@ export const ProxyGroups = (props: Props) => {
debugLog(
`[ProxyGroups] getGroupProxyDelays返回结果数量:`,
Object.keys(result || {}).length,
);
)
}), // 查询group delays 将清除fixed(不关注调用结果)
]);
debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`);
])
debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`)
} catch (error) {
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error);
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error)
} finally {
const headState = getGroupHeadState(groupName);
const headState = getGroupHeadState(groupName)
if (headState?.sortType === 1) {
onHeadState(groupName, { sortType: headState.sortType });
onHeadState(groupName, { sortType: headState.sortType })
}
onProxies();
onProxies()
}
});
})
// 滚到对应的节点
const handleLocation = (group: IProxyGroupItem) => {
if (!group) return;
const { name, now } = group;
if (!group) return
const { name, now } = group
const index = renderList.findIndex(
(e) =>
e.group?.name === name &&
((e.type === 2 && e.proxy?.name === now) ||
(e.type === 4 && e.proxyCol?.some((p) => p.name === now))),
);
)
if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({
index,
align: "center",
behavior: "smooth",
});
align: 'center',
behavior: 'smooth',
})
}
}
};
// 定位到指定的代理组
const handleGroupLocationByName = useCallback(
(groupName: string) => {
const index = renderList.findIndex(
(item) => item.type === 0 && item.group?.name === groupName,
);
)
if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({
index,
align: "start",
behavior: "smooth",
});
align: 'start',
behavior: 'smooth',
})
}
},
[renderList],
);
)
const proxyGroupNames = useMemo(() => {
const names = renderList
.filter((item) => item.type === 0 && item.group?.name)
.map((item) => item.group!.name);
return Array.from(new Set(names));
}, [renderList]);
.map((item) => item.group!.name)
return Array.from(new Set(names))
}, [renderList])
if (mode === "direct") {
return <BaseEmpty textKey="proxies.page.messages.directMode" />;
if (mode === 'direct') {
return <BaseEmpty textKey="proxies.page.messages.directMode" />
}
if (isChainMode) {
// 获取所有代理组
const proxyGroups = proxiesData?.groups || [];
const proxyGroups = proxiesData?.groups || []
return (
<>
<Box sx={{ display: "flex", height: "100%", gap: 2 }}>
<Box sx={{ flex: 1, position: "relative" }}>
<Box sx={{ display: 'flex', height: '100%', gap: 2 }}>
<Box sx={{ flex: 1, position: 'relative' }}>
{/* 代理规则标题和代理组按钮栏 */}
{mode === "rule" && proxyGroups.length > 0 && (
<Box sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
{mode === 'rule' && proxyGroups.length > 0 && (
<Box sx={{ borderBottom: '1px solid', borderColor: 'divider' }}>
{/* 代理规则标题 */}
<Box
sx={{
px: 2,
py: 1.5,
borderBottom: "1px solid",
borderColor: "divider",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Typography
variant="h6"
sx={{ fontWeight: 600, fontSize: "16px" }}
sx={{ fontWeight: 600, fontSize: '16px' }}
>
{t("proxies.page.rules.title")}
{t('proxies.page.rules.title')}
</Typography>
{currentGroup && (
<Box
sx={{ display: "flex", alignItems: "center", gap: 1 }}
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Chip
size="small"
label={`${currentGroup.name} (${currentGroup.type})`}
variant="outlined"
sx={{
fontSize: "12px",
maxWidth: "200px",
"& .MuiChip-label": {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: '12px',
maxWidth: '200px',
'& .MuiChip-label': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}}
/>
@@ -458,17 +456,17 @@ export const ProxyGroups = (props: Props) => {
size="small"
onClick={handleGroupMenuOpen}
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: "4px",
padding: "4px 8px",
border: '1px solid',
borderColor: 'divider',
borderRadius: '4px',
padding: '4px 8px',
}}
>
<Typography
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>
<ExpandMoreRounded fontSize="small" />
</IconButton>
@@ -481,16 +479,16 @@ export const ProxyGroups = (props: Props) => {
ref={virtuosoRef}
style={{
height:
mode === "rule" && proxyGroups.length > 0
? "calc(100% - 80px)" // 只有标题的高度
: "calc(100% - 14px)",
mode === 'rule' && proxyGroups.length > 0
? 'calc(100% - 80px)' // 只有标题的高度
: 'calc(100% - 14px)',
}}
totalCount={renderList.length}
increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150}
defaultItemHeight={56}
scrollerRef={(ref) => {
scrollerRef.current = ref as Element;
scrollerRef.current = ref as Element
}}
components={{
Footer: VirtuosoFooter,
@@ -501,7 +499,7 @@ export const ProxyGroups = (props: Props) => {
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
@@ -513,7 +511,7 @@ export const ProxyGroups = (props: Props) => {
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</Box>
<Box sx={{ width: "400px", minWidth: "300px" }}>
<Box sx={{ width: '400px', minWidth: '300px' }}>
<ProxyChain
proxyChain={proxyChain}
onUpdateChain={setProxyChain}
@@ -528,7 +526,7 @@ export const ProxyGroups = (props: Props) => {
open={duplicateWarning.open}
autoHideDuration={3000}
onClose={handleCloseDuplicateWarning}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert
onClose={handleCloseDuplicateWarning}
@@ -559,15 +557,15 @@ export const ProxyGroups = (props: Props) => {
onClick={() => handleGroupSelect(group.name)}
selected={activeSelectedGroup === group.name}
sx={{
fontSize: "14px",
fontSize: '14px',
py: 1,
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
@@ -588,15 +586,15 @@ export const ProxyGroups = (props: Props) => {
)}
</Menu>
</>
);
)
}
return (
<div
style={{ position: "relative", height: "100%", willChange: "transform" }}
style={{ position: 'relative', height: '100%', willChange: 'transform' }}
>
{/* 代理组导航栏 */}
{mode === "rule" && (
{mode === 'rule' && (
<ProxyGroupNavigator
proxyGroupNames={proxyGroupNames}
onGroupLocation={handleGroupLocationByName}
@@ -607,13 +605,13 @@ export const ProxyGroups = (props: Props) => {
<Virtuoso
ref={virtuosoRef}
style={{ height: "calc(100% - 14px)" }}
style={{ height: 'calc(100% - 14px)' }}
totalCount={renderList.length}
increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150}
defaultItemHeight={56}
scrollerRef={(ref) => {
scrollerRef.current = ref as Element;
scrollerRef.current = ref as Element
}}
components={{
Footer: VirtuosoFooter,
@@ -625,7 +623,7 @@ export const ProxyGroups = (props: Props) => {
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
indent={mode === 'rule' || mode === 'script'}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
@@ -635,34 +633,34 @@ export const ProxyGroups = (props: Props) => {
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</div>
);
};
)
}
// 替换简单防抖函数为更优的节流函数
function throttle<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
let previous = 0;
let timer: ReturnType<typeof setTimeout> | null = null
let previous = 0
return function (...args: Parameters<T>) {
const now = Date.now();
const remaining = wait - (now - previous);
const now = Date.now()
const remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) {
if (timer) {
clearTimeout(timer);
timer = null;
clearTimeout(timer)
timer = null
}
previous = now;
func(...args);
previous = now
func(...args)
} else if (!timer) {
timer = setTimeout(() => {
previous = Date.now();
timer = null;
func(...args);
}, remaining);
previous = Date.now()
timer = null
func(...args)
}, remaining)
}
}
};
}
+54 -54
View File
@@ -10,30 +10,30 @@ import {
WifiTetheringOffRounded,
SortByAlphaRounded,
SortRounded,
} from "@mui/icons-material";
import { Box, IconButton, TextField, SxProps } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/icons-material'
import { Box, IconButton, TextField, SxProps } from '@mui/material'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseSearchBox } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import delayManager from "@/services/delay";
import { debugLog } from "@/utils/debug";
import { BaseSearchBox } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { debugLog } from '@/utils/debug'
import type { ProxySortType } from "./use-filter-sort";
import type { HeadState } from "./use-head-state";
import type { ProxySortType } from './use-filter-sort'
import type { HeadState } from './use-head-state'
interface Props {
sx?: SxProps;
url?: string;
groupName: string;
headState: HeadState;
onLocation: () => void;
onCheckDelay: () => void;
onHeadState: (val: Partial<HeadState>) => void;
sx?: SxProps
url?: string
groupName: string
headState: HeadState
onLocation: () => void
onCheckDelay: () => void
onHeadState: (val: Partial<HeadState>) => void
}
const defaultSx: SxProps = {};
const defaultSx: SxProps = {}
export const ProxyHead = ({
sx = defaultSx,
@@ -53,31 +53,31 @@ export const ProxyHead = ({
filterMatchCase,
filterMatchWholeWord,
filterUseRegularExpression,
} = headState;
} = headState
const { t } = useTranslation();
const [autoFocus, setAutoFocus] = useState(false);
const { t } = useTranslation()
const [autoFocus, setAutoFocus] = useState(false)
useEffect(() => {
// fix the focus conflict
const timer = setTimeout(() => setAutoFocus(true), 100);
return () => clearTimeout(timer);
}, []);
const timer = setTimeout(() => setAutoFocus(true), 100)
return () => clearTimeout(timer)
}, [])
const { verge } = useVerge();
const { verge } = useVerge()
const defaultLatencyUrl =
verge?.default_latency_test?.trim() || "http://cp.cloudflare.com";
verge?.default_latency_test?.trim() || 'http://cp.cloudflare.com'
useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl);
}, [groupName, testUrl, defaultLatencyUrl, url]);
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)
}, [groupName, testUrl, defaultLatencyUrl, url])
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, ...sx }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, ...sx }}>
<IconButton
size="small"
color="inherit"
title={t("proxies.page.tooltips.locate")}
title={t('proxies.page.tooltips.locate')}
onClick={onLocation}
>
<MyLocationRounded />
@@ -86,15 +86,15 @@ export const ProxyHead = ({
<IconButton
size="small"
color="inherit"
title={t("proxies.page.tooltips.delayCheck")}
title={t('proxies.page.tooltips.delayCheck')}
onClick={() => {
debugLog(`[ProxyHead] 点击延迟测试按钮,组: ${groupName}`);
debugLog(`[ProxyHead] 点击延迟测试按钮,组: ${groupName}`)
// Remind the user that it is custom test url
if (testUrl?.trim() && textState !== "filter") {
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`);
onHeadState({ textState: "url" });
if (testUrl?.trim() && textState !== 'filter') {
debugLog(`[ProxyHead] 使用自定义测试URL: ${testUrl}`)
onHeadState({ textState: 'url' })
}
onCheckDelay();
onCheckDelay()
}}
>
<NetworkCheckRounded />
@@ -105,9 +105,9 @@ export const ProxyHead = ({
color="inherit"
title={
[
t("proxies.page.tooltips.sortDefault"),
t("proxies.page.tooltips.sortDelay"),
t("proxies.page.tooltips.sortName"),
t('proxies.page.tooltips.sortDefault'),
t('proxies.page.tooltips.sortDelay'),
t('proxies.page.tooltips.sortName'),
][sortType]
}
onClick={() =>
@@ -122,12 +122,12 @@ export const ProxyHead = ({
<IconButton
size="small"
color="inherit"
title={t("proxies.page.tooltips.delayCheckUrl")}
title={t('proxies.page.tooltips.delayCheckUrl')}
onClick={() =>
onHeadState({ textState: textState === "url" ? null : "url" })
onHeadState({ textState: textState === 'url' ? null : 'url' })
}
>
{textState === "url" ? (
{textState === 'url' ? (
<WifiTetheringRounded />
) : (
<WifiTetheringOffRounded />
@@ -139,8 +139,8 @@ export const ProxyHead = ({
color="inherit"
title={
showType
? t("proxies.page.tooltips.showBasic")
: t("proxies.page.tooltips.showDetail")
? t('proxies.page.tooltips.showBasic')
: t('proxies.page.tooltips.showDetail')
}
onClick={() => onHeadState({ showType: !showType })}
>
@@ -150,20 +150,20 @@ export const ProxyHead = ({
<IconButton
size="small"
color="inherit"
title={t("proxies.page.tooltips.filter")}
title={t('proxies.page.tooltips.filter')}
onClick={() =>
onHeadState({ textState: textState === "filter" ? null : "filter" })
onHeadState({ textState: textState === 'filter' ? null : 'filter' })
}
>
{textState === "filter" ? (
{textState === 'filter' ? (
<FilterAltRounded />
) : (
<FilterAltOffRounded />
)}
</IconButton>
{textState === "filter" && (
<Box sx={{ ml: 0.5, flex: "1 1 auto" }}>
{textState === 'filter' && (
<Box sx={{ ml: 0.5, flex: '1 1 auto' }}>
<BaseSearchBox
autoFocus={autoFocus}
value={filterText}
@@ -184,7 +184,7 @@ export const ProxyHead = ({
</Box>
)}
{textState === "url" && (
{textState === 'url' && (
<TextField
autoComplete="new-password"
autoFocus={autoFocus}
@@ -193,11 +193,11 @@ export const ProxyHead = ({
value={testUrl}
size="small"
variant="outlined"
placeholder={t("proxies.page.placeholders.delayCheckUrl")}
placeholder={t('proxies.page.placeholders.delayCheckUrl')}
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>
);
};
)
}
+114 -111
View File
@@ -1,88 +1,91 @@
import { CheckCircleOutlineRounded } from "@mui/icons-material";
import { alpha, Box, ListItemButton, styled, Typography } from "@mui/material";
import { useLockFn } from "ahooks";
import { useCallback, useEffect, useReducer } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircleOutlineRounded } from '@mui/icons-material'
import { alpha, Box, ListItemButton, styled, Typography } from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useReducer } from 'react'
import { useTranslation } from 'react-i18next'
import { BaseLoading } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import delayManager, { DelayUpdate } from "@/services/delay";
import { BaseLoading } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import delayManager, { DelayUpdate } from '@/services/delay'
interface Props {
group: IProxyGroupItem;
proxy: IProxyItem;
selected: boolean;
showType?: boolean;
onClick?: (name: string) => void;
group: IProxyGroupItem
proxy: IProxyItem
selected: boolean
showType?: boolean
onClick?: (name: string) => void
}
// 多列布局
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 isPreset = presetList.includes(proxy.name);
const presetList = ['DIRECT', 'REJECT', 'REJECT-DROP', 'PASS', 'COMPATIBLE']
const isPreset = presetList.includes(proxy.name)
// -1/<=0 为不显示,-2 为 loading
const [delayState, setDelayState] = useReducer(
(_: DelayUpdate, next: DelayUpdate) => next,
{ delay: -1, updatedAt: 0 },
);
const { verge } = useVerge();
const timeout = verge?.default_latency_timeout || 10000;
{
delay: -1,
updatedAt: 0,
},
)
const { verge } = useVerge()
const timeout = verge?.default_latency_timeout || 10000
useEffect(() => {
if (isPreset) return;
delayManager.setListener(proxy.name, group.name, setDelayState);
if (isPreset) return
delayManager.setListener(proxy.name, group.name, setDelayState)
return () => {
delayManager.removeListener(proxy.name, group.name);
};
}, [isPreset, proxy.name, group.name]);
delayManager.removeListener(proxy.name, group.name)
}
}, [isPreset, proxy.name, group.name])
const updateDelay = useCallback(() => {
if (!proxy) return;
const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name);
if (!proxy) return
const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name)
if (cachedUpdate) {
setDelayState({ ...cachedUpdate });
return;
setDelayState({ ...cachedUpdate })
return
}
const fallbackDelay = delayManager.getDelayFix(proxy, group.name);
const fallbackDelay = delayManager.getDelayFix(proxy, group.name)
if (fallbackDelay === -1) {
setDelayState({ delay: -1, updatedAt: 0 });
return;
setDelayState({ delay: -1, updatedAt: 0 })
return
}
let updatedAt = 0;
const history = proxy.history;
let updatedAt = 0
const history = proxy.history
if (history && history.length > 0) {
const lastRecord = history[history.length - 1];
const parsed = Date.parse(lastRecord.time);
const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time)
if (!Number.isNaN(parsed)) {
updatedAt = parsed;
updatedAt = parsed
}
}
setDelayState({
delay: fallbackDelay,
updatedAt,
});
}, [proxy, group.name]);
})
}, [proxy, group.name])
useEffect(() => {
updateDelay();
}, [updateDelay]);
updateDelay()
}, [updateDelay])
const onDelay = useLockFn(async () => {
setDelayState({ delay: -2, updatedAt: Date.now() });
setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState(
await delayManager.checkDelay(proxy.name, group.name, timeout),
);
});
)
})
const delayValue = delayState.delay;
const delayValue = delayState.delay
return (
<ListItemButton
@@ -95,53 +98,53 @@ export const ProxyItemMini = (props: Props) => {
borderRadius: 1.5,
pl: 1.5,
pr: 1,
justifyContent: "space-between",
alignItems: "center",
justifyContent: 'space-between',
alignItems: 'center',
},
({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f";
const showDelay = delayValue > 0;
const selectColor = mode === "light" ? primary.main : primary.light;
const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
const showDelay = delayValue > 0
const selectColor = mode === 'light' ? primary.main : primary.light
return {
"&:hover .the-check": { display: !showDelay ? "block" : "none" },
"&:hover .the-delay": { display: showDelay ? "block" : "none" },
"&:hover .the-icon": { display: "none" },
"& .the-pin, & .the-unpin": {
position: "absolute",
fontSize: "12px",
top: "-5px",
right: "-5px",
'&:hover .the-check': { display: !showDelay ? 'block' : 'none' },
'&:hover .the-delay': { display: showDelay ? 'block' : 'none' },
'&:hover .the-icon': { display: 'none' },
'& .the-pin, & .the-unpin': {
position: 'absolute',
fontSize: '12px',
top: '-5px',
right: '-5px',
},
"& .the-unpin": { filter: "grayscale(1)" },
"&.Mui-selected": {
'& .the-unpin': { filter: 'grayscale(1)' },
'&.Mui-selected': {
width: `calc(100% + 3px)`,
marginLeft: `-3px`,
borderLeft: `3px solid ${selectColor}`,
bgcolor:
mode === "light"
mode === 'light'
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35),
},
backgroundColor: bgcolor,
};
}
},
]}
>
<Box
title={`${proxy.name}\n${proxy.now ?? ""}`}
sx={{ overflow: "hidden" }}
title={`${proxy.name}\n${proxy.now ?? ''}`}
sx={{ overflow: 'hidden' }}
>
<Typography
variant="body2"
component="div"
color="text.primary"
sx={{
display: "block",
textOverflow: "ellipsis",
wordBreak: "break-all",
overflow: "hidden",
whiteSpace: "nowrap",
display: 'block',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{proxy.name}
@@ -150,10 +153,10 @@ export const ProxyItemMini = (props: Props) => {
{showType && (
<Box
sx={{
display: "flex",
flexWrap: "nowrap",
flex: "none",
marginTop: "4px",
display: 'flex',
flexWrap: 'nowrap',
flex: 'none',
marginTop: '4px',
}}
>
{proxy.now && (
@@ -162,12 +165,12 @@ export const ProxyItemMini = (props: Props) => {
component="div"
color="text.secondary"
sx={{
display: "block",
textOverflow: "ellipsis",
wordBreak: "break-all",
overflow: "hidden",
whiteSpace: "nowrap",
marginRight: "8px",
display: 'block',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
overflow: 'hidden',
whiteSpace: 'nowrap',
marginRight: '8px',
}}
>
{proxy.now}
@@ -210,7 +213,7 @@ export const ProxyItemMini = (props: Props) => {
)}
</Box>
<Box
sx={{ ml: 0.5, color: "primary.main", display: isPreset ? "none" : "" }}
sx={{ ml: 0.5, color: 'primary.main', display: isPreset ? 'none' : '' }}
>
{delayValue === -2 && (
<Widget>
@@ -222,13 +225,13 @@ export const ProxyItemMini = (props: Props) => {
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
e.preventDefault()
e.stopPropagation()
onDelay()
}}
sx={({ palette }) => ({
display: "none", // hover 时显示
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
display: 'none', // hover 时显示
':hover': { bgcolor: alpha(palette.primary.main, 0.15) },
})}
>
Check
@@ -240,68 +243,68 @@ export const ProxyItemMini = (props: Props) => {
<Widget
className="the-delay"
onClick={(e) => {
if (proxy.provider) return;
e.preventDefault();
e.stopPropagation();
onDelay();
if (proxy.provider) return
e.preventDefault()
e.stopPropagation()
onDelay()
}}
color={delayManager.formatDelayColor(delayValue, timeout)}
sx={({ palette }) =>
!proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } }
? { ':hover': { bgcolor: alpha(palette.primary.main, 0.15) } }
: {}
}
>
{delayManager.formatDelay(delayValue, timeout)}
</Widget>
)}
{proxy.type !== "Direct" &&
{proxy.type !== 'Direct' &&
delayValue !== -2 &&
delayValue < 0 &&
selected && (
// 展示已选择的 icon
<CheckCircleOutlineRounded
className="the-icon"
sx={{ fontSize: 16, mr: 0.5, display: "block" }}
sx={{ fontSize: 16, mr: 0.5, display: 'block' }}
/>
)}
</Box>
{group.fixed && group.fixed === proxy.name && (
// 展示 fixed 状态
<span
className={proxy.name === group.now ? "the-pin" : "the-unpin"}
className={proxy.name === group.now ? 'the-pin' : 'the-unpin'}
title={
group.type === "URLTest"
? t("proxies.page.labels.delayCheckReset")
: ""
group.type === 'URLTest'
? t('proxies.page.labels.delayCheckReset')
: ''
}
>
📌
</span>
)}
</ListItemButton>
);
};
)
}
const Widget = styled(Box)(({ theme: { typography } }) => ({
padding: "2px 4px",
padding: '2px 4px',
fontSize: 14,
fontFamily: typography.fontFamily,
borderRadius: "4px",
}));
borderRadius: '4px',
}))
const TypeBox = styled(Box, {
shouldForwardProp: (prop) => prop !== "component",
shouldForwardProp: (prop) => prop !== 'component',
})<{ component?: React.ElementType }>(({ theme: { typography } }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: "text.secondary",
color: "text.secondary",
display: 'inline-block',
border: '1px solid #ccc',
borderColor: 'text.secondary',
color: 'text.secondary',
borderRadius: 4,
fontSize: 10,
fontFamily: typography.fontFamily,
marginRight: "4px",
marginTop: "auto",
padding: "0 4px",
marginRight: '4px',
marginTop: 'auto',
padding: '0 4px',
lineHeight: 1.5,
}));
}))
+87 -84
View File
@@ -1,4 +1,4 @@
import { CheckCircleOutlineRounded } from "@mui/icons-material";
import { CheckCircleOutlineRounded } from '@mui/icons-material'
import {
alpha,
Box,
@@ -9,105 +9,108 @@ import {
styled,
SxProps,
Theme,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { useCallback, useEffect, useReducer } from "react";
} from '@mui/material'
import { useLockFn } from 'ahooks'
import { useCallback, useEffect, useReducer } from 'react'
import { BaseLoading } from "@/components/base";
import { useVerge } from "@/hooks/use-verge";
import delayManager, { DelayUpdate } from "@/services/delay";
import { BaseLoading } from '@/components/base'
import { useVerge } from '@/hooks/use-verge'
import delayManager, { DelayUpdate } from '@/services/delay'
interface Props {
group: IProxyGroupItem;
proxy: IProxyItem;
selected: boolean;
showType?: boolean;
sx?: SxProps<Theme>;
onClick?: (name: string) => void;
group: IProxyGroupItem
proxy: IProxyItem
selected: boolean
showType?: boolean
sx?: SxProps<Theme>
onClick?: (name: string) => void
}
const Widget = styled(Box)(() => ({
padding: "3px 6px",
padding: '3px 6px',
fontSize: 14,
borderRadius: "4px",
}));
borderRadius: '4px',
}))
const TypeBox = styled("span")(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
const TypeBox = styled('span')(({ theme }) => ({
display: 'inline-block',
border: '1px solid #ccc',
borderColor: alpha(theme.palette.text.secondary, 0.36),
color: alpha(theme.palette.text.secondary, 0.42),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
marginRight: '4px',
padding: '0 2px',
lineHeight: 1.25,
}));
}))
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 isPreset = presetList.includes(proxy.name);
const presetList = ['DIRECT', 'REJECT', 'REJECT-DROP', 'PASS', 'COMPATIBLE']
const isPreset = presetList.includes(proxy.name)
// -1/<=0 为不显示,-2 为 loading
const [delayState, setDelayState] = useReducer(
(_: DelayUpdate, next: DelayUpdate) => next,
{ delay: -1, updatedAt: 0 },
);
const { verge } = useVerge();
const timeout = verge?.default_latency_timeout || 10000;
{
delay: -1,
updatedAt: 0,
},
)
const { verge } = useVerge()
const timeout = verge?.default_latency_timeout || 10000
useEffect(() => {
if (isPreset) return;
delayManager.setListener(proxy.name, group.name, setDelayState);
if (isPreset) return
delayManager.setListener(proxy.name, group.name, setDelayState)
return () => {
delayManager.removeListener(proxy.name, group.name);
};
}, [proxy.name, group.name, isPreset]);
delayManager.removeListener(proxy.name, group.name)
}
}, [proxy.name, group.name, isPreset])
const updateDelay = useCallback(() => {
if (!proxy) return;
const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name);
if (!proxy) return
const cachedUpdate = delayManager.getDelayUpdate(proxy.name, group.name)
if (cachedUpdate) {
setDelayState({ ...cachedUpdate });
return;
setDelayState({ ...cachedUpdate })
return
}
const fallbackDelay = delayManager.getDelayFix(proxy, group.name);
const fallbackDelay = delayManager.getDelayFix(proxy, group.name)
if (fallbackDelay === -1) {
setDelayState({ delay: -1, updatedAt: 0 });
return;
setDelayState({ delay: -1, updatedAt: 0 })
return
}
let updatedAt = 0;
const history = proxy.history;
let updatedAt = 0
const history = proxy.history
if (history && history.length > 0) {
const lastRecord = history[history.length - 1];
const parsed = Date.parse(lastRecord.time);
const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time)
if (!Number.isNaN(parsed)) {
updatedAt = parsed;
updatedAt = parsed
}
}
setDelayState({
delay: fallbackDelay,
updatedAt,
});
}, [proxy, group.name]);
})
}, [proxy, group.name])
useEffect(() => {
updateDelay();
}, [updateDelay]);
updateDelay()
}, [updateDelay])
const onDelay = useLockFn(async () => {
setDelayState({ delay: -2, updatedAt: Date.now() });
setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState(
await delayManager.checkDelay(proxy.name, group.name, timeout),
);
});
)
})
const delayValue = delayState.delay;
const delayValue = delayState.delay
return (
<ListItem sx={sx}>
@@ -118,27 +121,27 @@ export const ProxyItem = (props: Props) => {
sx={[
{ borderRadius: 1 },
({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f";
const selectColor = mode === "light" ? primary.main : primary.light;
const showDelay = delayValue > 0;
const bgcolor = mode === 'light' ? '#ffffff' : '#24252f'
const selectColor = mode === 'light' ? primary.main : primary.light
const showDelay = delayValue > 0
return {
"&:hover .the-check": { display: !showDelay ? "block" : "none" },
"&:hover .the-delay": { display: showDelay ? "block" : "none" },
"&:hover .the-icon": { display: "none" },
"&.Mui-selected": {
'&:hover .the-check': { display: !showDelay ? 'block' : 'none' },
'&:hover .the-delay': { display: showDelay ? 'block' : 'none' },
'&:hover .the-icon': { display: 'none' },
'&.Mui-selected': {
width: `calc(100% + 3px)`,
marginLeft: `-3px`,
borderLeft: `3px solid ${selectColor}`,
bgcolor:
mode === "light"
mode === 'light'
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35),
},
backgroundColor: bgcolor,
marginBottom: "8px",
height: "40px",
};
marginBottom: '8px',
height: '40px',
}
},
]}
>
@@ -148,10 +151,10 @@ export const ProxyItem = (props: Props) => {
<>
<Box
sx={{
display: "inline-block",
marginRight: "8px",
fontSize: "14px",
color: "text.primary",
display: 'inline-block',
marginRight: '8px',
fontSize: '14px',
color: 'text.primary',
}}
>
{proxy.name}
@@ -172,9 +175,9 @@ export const ProxyItem = (props: Props) => {
<ListItemIcon
sx={{
justifyContent: "flex-end",
color: "primary.main",
display: isPreset ? "none" : "",
justifyContent: 'flex-end',
color: 'primary.main',
display: isPreset ? 'none' : '',
}}
>
{delayValue === -2 && (
@@ -188,13 +191,13 @@ export const ProxyItem = (props: Props) => {
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
e.preventDefault()
e.stopPropagation()
onDelay()
}}
sx={({ palette }) => ({
display: "none", // hover 时显示
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
display: 'none', // hover 时显示
':hover': { bgcolor: alpha(palette.primary.main, 0.15) },
})}
>
Check
@@ -206,15 +209,15 @@ export const ProxyItem = (props: Props) => {
<Widget
className="the-delay"
onClick={(e) => {
if (proxy.provider) return;
e.preventDefault();
e.stopPropagation();
onDelay();
if (proxy.provider) return
e.preventDefault()
e.stopPropagation()
onDelay()
}}
color={delayManager.formatDelayColor(delayValue, timeout)}
sx={({ palette }) =>
!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>
</ListItemButton>
</ListItem>
);
};
)
}
+77 -77
View File
@@ -2,7 +2,7 @@ import {
ExpandLessRounded,
ExpandMoreRounded,
InboxRounded,
} from "@mui/icons-material";
} from '@mui/icons-material'
import {
alpha,
Box,
@@ -12,35 +12,35 @@ import {
styled,
Chip,
Tooltip,
} from "@mui/material";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
} from '@mui/material'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useIconCache } from "@/hooks/use-icon-cache";
import { useVerge } from "@/hooks/use-verge";
import { useThemeMode } from "@/services/states";
import { useIconCache } from '@/hooks/use-icon-cache'
import { useVerge } from '@/hooks/use-verge'
import { useThemeMode } from '@/services/states'
import { ProxyHead } from "./proxy-head";
import { ProxyItem } from "./proxy-item";
import { ProxyItemMini } from "./proxy-item-mini";
import { HeadState } from "./use-head-state";
import type { IRenderItem } from "./use-render-list";
import { ProxyHead } from './proxy-head'
import { ProxyItem } from './proxy-item'
import { ProxyItemMini } from './proxy-item-mini'
import { HeadState } from './use-head-state'
import type { IRenderItem } from './use-render-list'
interface RenderProps {
item: IRenderItem;
indent: boolean;
isChainMode?: boolean;
onLocation: (group: IRenderItem["group"]) => void;
onCheckAll: (groupName: string) => void;
onHeadState: (groupName: string, patch: Partial<HeadState>) => void;
item: IRenderItem
indent: boolean
isChainMode?: boolean
onLocation: (group: IRenderItem['group']) => void
onCheckAll: (groupName: string) => void
onHeadState: (groupName: string, patch: Partial<HeadState>) => void
onChangeProxy: (
group: IRenderItem["group"],
proxy: IRenderItem["proxy"] & { name: string },
) => void;
group: IRenderItem['group'],
proxy: IRenderItem['proxy'] & { name: string },
) => void
}
export const ProxyRender = (props: RenderProps) => {
const { t } = useTranslation();
const { t } = useTranslation()
const {
indent,
item,
@@ -49,35 +49,35 @@ export const ProxyRender = (props: RenderProps) => {
onHeadState,
onChangeProxy,
isChainMode: _ = false,
} = props;
const { type, group, headState, proxy, proxyCol } = item;
const { verge } = useVerge();
const enable_group_icon = verge?.enable_group_icon ?? true;
const mode = useThemeMode();
const isDark = mode === "light" ? false : true;
const itembackgroundcolor = isDark ? "#282A36" : "#ffffff";
} = props
const { type, group, headState, proxy, proxyCol } = item
const { verge } = useVerge()
const enable_group_icon = verge?.enable_group_icon ?? true
const mode = useThemeMode()
const isDark = mode === 'light' ? false : true
const itembackgroundcolor = isDark ? '#282A36' : '#ffffff'
const iconCachePath = useIconCache({
icon: group.icon,
cacheKey: group.name.replaceAll(" ", ""),
cacheKey: group.name.replaceAll(' ', ''),
enabled: enable_group_icon,
});
})
const proxyColItemsMemo = useMemo(() => {
if (type !== 4 || !proxyCol) {
return null;
return null
}
return proxyCol.map((proxyItem) => (
<ProxyItemMini
key={`${item.key}-${proxyItem?.name ?? "unknown"}`}
key={`${item.key}-${proxyItem?.name ?? 'unknown'}`}
group={group}
proxy={proxyItem!}
selected={group.now === proxyItem?.name}
showType={headState?.showType}
onClick={() => onChangeProxy(group, proxyItem!)}
/>
));
}, [type, proxyCol, item.key, group, headState, onChangeProxy]);
))
}, [type, proxyCol, item.key, group, headState, onChangeProxy])
if (type === 0) {
return (
@@ -85,33 +85,33 @@ export const ProxyRender = (props: RenderProps) => {
dense
style={{
background: itembackgroundcolor,
height: "100%",
margin: "8px 8px",
borderRadius: "8px",
height: '100%',
margin: '8px 8px',
borderRadius: '8px',
}}
onClick={() => onHeadState(group.name, { open: !headState?.open })}
>
{enable_group_icon &&
group.icon &&
group.icon.trim().startsWith("http") && (
group.icon.trim().startsWith('http') && (
<img
src={iconCachePath === "" ? group.icon : iconCachePath}
src={iconCachePath === '' ? group.icon : iconCachePath}
width="32px"
style={{ marginRight: "12px", borderRadius: "6px" }}
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon &&
group.icon &&
group.icon.trim().startsWith("data") && (
group.icon.trim().startsWith('data') && (
<img
src={group.icon}
width="32px"
style={{ marginRight: "12px", borderRadius: "6px" }}
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon &&
group.icon &&
group.icon.trim().startsWith("<svg") && (
group.icon.trim().startsWith('<svg') && (
<img
src={`data:image/svg+xml;base64,${btoa(group.icon)}`}
width="32px"
@@ -122,15 +122,15 @@ export const ProxyRender = (props: RenderProps) => {
secondary={
<Box
sx={{
overflow: "hidden",
display: "flex",
alignItems: "center",
pt: "2px",
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
pt: '2px',
}}
>
<Box component="span" sx={{ marginTop: "2px" }}>
<Box component="span" sx={{ marginTop: '2px' }}>
<StyledTypeBox>{group.type}</StyledTypeBox>
<StyledSubtitle sx={{ color: "text.secondary" }}>
<StyledSubtitle sx={{ color: 'text.secondary' }}>
{group.now}
</StyledSubtitle>
</Box>
@@ -138,13 +138,13 @@ export const ProxyRender = (props: RenderProps) => {
}
slotProps={{
secondary: {
component: "div",
sx: { display: "flex", alignItems: "center", color: "#ccc" },
component: 'div',
sx: { display: 'flex', alignItems: 'center', color: '#ccc' },
},
}}
/>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title={t("proxies.page.labels.proxyCount")} arrow>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Tooltip title={t('proxies.page.labels.proxyCount')} arrow>
<Chip
size="small"
label={`${group.all.length}`}
@@ -159,7 +159,7 @@ export const ProxyRender = (props: RenderProps) => {
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
</Box>
</ListItemButton>
);
)
}
if (type === 1) {
@@ -173,7 +173,7 @@ export const ProxyRender = (props: RenderProps) => {
onCheckDelay={() => onCheckAll(group.name)}
onHeadState={(p) => onHeadState(group.name, p)}
/>
);
)
}
if (type === 2) {
@@ -186,7 +186,7 @@ export const ProxyRender = (props: RenderProps) => {
sx={{ py: 0, pl: 2 }}
onClick={() => onChangeProxy(group, proxy!)}
/>
);
)
}
if (type === 3) {
@@ -195,16 +195,16 @@ export const ProxyRender = (props: RenderProps) => {
sx={{
py: 2,
pl: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<InboxRounded sx={{ fontSize: "2.5em", color: "inherit" }} />
<Typography sx={{ color: "inherit" }}>No Proxies</Typography>
<InboxRounded sx={{ fontSize: '2.5em', color: 'inherit' }} />
<Typography sx={{ color: 'inherit' }}>No Proxies</Typography>
</Box>
);
)
}
if (type === 4) {
@@ -212,7 +212,7 @@ export const ProxyRender = (props: RenderProps) => {
<Box
sx={{
height: 56,
display: "grid",
display: 'grid',
gap: 1,
pl: 2,
pr: 2,
@@ -222,36 +222,36 @@ export const ProxyRender = (props: RenderProps) => {
>
{proxyColItemsMemo}
</Box>
);
)
}
return null;
};
return null
}
const StyledPrimary = styled("span")`
const StyledPrimary = styled('span')`
font-size: 16px;
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledSubtitle = styled("span")`
`
const StyledSubtitle = styled('span')`
font-size: 13px;
overflow: hidden;
color: text.secondary;
text-overflow: ellipsis;
white-space: nowrap;
`;
`
const StyledTypeBox = styled(Box)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
display: 'inline-block',
border: '1px solid #ccc',
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
padding: "0 4px",
padding: '0 4px',
lineHeight: 1.5,
marginRight: "8px",
}));
marginRight: '8px',
}))
+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 delayManager from "@/services/delay";
import { compileStringMatcher } from "@/utils/search-matcher";
import { useVerge } from '@/hooks/use-verge'
import delayManager from '@/services/delay'
import { compileStringMatcher } from '@/utils/search-matcher'
// default | delay | alphabet
export type ProxySortType = 0 | 1 | 2;
export type ProxySortType = 0 | 1 | 2
export type ProxySearchState = {
matchCase?: boolean;
matchWholeWord?: boolean;
useRegularExpression?: boolean;
};
matchCase?: boolean
matchWholeWord?: boolean
useRegularExpression?: boolean
}
export default function useFilterSort(
proxies: IProxyItem[],
@@ -20,39 +20,39 @@ export default function useFilterSort(
sortType: ProxySortType,
searchState?: ProxySearchState,
) {
const { verge } = useVerge();
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0);
const { verge } = useVerge()
const [_, bumpRefresh] = useReducer((count: number) => count + 1, 0)
const lastInputRef = useRef<{ text: string; sort: ProxySortType } | null>(
null,
);
const debounceTimerRef = useRef<number | null>(null);
)
const debounceTimerRef = useRef<number | null>(null)
useEffect(() => {
let last = 0;
let last = 0
delayManager.setGroupListener(groupName, () => {
// 简单节流
const now = Date.now();
const now = Date.now()
if (now - last > 666) {
last = now;
bumpRefresh();
last = now
bumpRefresh()
}
});
})
return () => {
delayManager.removeGroupListener(groupName);
};
}, [groupName]);
delayManager.removeGroupListener(groupName)
}
}, [groupName])
const compute = useMemo(() => {
const fp = filterProxies(proxies, groupName, filterText, searchState);
const fp = filterProxies(proxies, groupName, filterText, searchState)
const sp = sortProxies(
fp,
groupName,
sortType,
verge?.default_latency_timeout,
);
return sp;
)
return sp
}, [
proxies,
groupName,
@@ -60,40 +60,40 @@ export default function useFilterSort(
sortType,
searchState,
verge?.default_latency_timeout,
]);
])
const [result, setResult] = useReducer(
(_prev: IProxyItem[], next: IProxyItem[]) => next,
compute,
);
)
useEffect(() => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
window.clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null
}
const prev = lastInputRef.current;
const prev = lastInputRef.current
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(() => {
setResult(compute);
debounceTimerRef.current = null;
}, delay);
setResult(compute)
debounceTimerRef.current = null
}, delay)
return () => {
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
window.clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null
}
};
}, [compute, filterText, sortType]);
}
}, [compute, filterText, sortType])
return result;
return result
}
export function filterSort(
@@ -104,16 +104,16 @@ export function filterSort(
latencyTimeout?: number,
searchState?: ProxySearchState,
) {
const fp = filterProxies(proxies, groupName, filterText, searchState);
const sp = sortProxies(fp, groupName, sortType, latencyTimeout);
return sp;
const fp = filterProxies(proxies, groupName, filterText, searchState)
const sp = sortProxies(fp, groupName, sortType, latencyTimeout)
return sp
}
/**
* 可以通过延迟数/节点类型 过滤
*/
const regex1 = /delay([=<>])(\d+|timeout|error)/i;
const regex2 = /type=(.*)/i;
const regex1 = /delay([=<>])(\d+|timeout|error)/i
const regex2 = /type=(.*)/i
/**
* filter the proxy
@@ -125,49 +125,49 @@ function filterProxies(
filterText: string,
searchState?: ProxySearchState,
) {
const query = filterText.trim();
if (!query) return proxies;
const query = filterText.trim()
if (!query) return proxies
const res1 = regex1.exec(query);
const res1 = regex1.exec(query)
if (res1) {
const symbol = res1[1];
const symbol2 = res1[2].toLowerCase();
const symbol = res1[1]
const symbol2 = res1[2].toLowerCase()
const value =
symbol2 === "error" ? 1e5 : symbol2 === "timeout" ? 3000 : +symbol2;
symbol2 === 'error' ? 1e5 : symbol2 === 'timeout' ? 3000 : +symbol2
return proxies.filter((p) => {
const delay = delayManager.getDelayFix(p, groupName);
const delay = delayManager.getDelayFix(p, groupName)
if (delay < 0) return false;
if (symbol === "=" && symbol2 === "error") return delay >= 1e5;
if (symbol === "=" && symbol2 === "timeout")
return delay < 1e5 && delay >= 3000;
if (symbol === "=") return delay == value;
if (symbol === "<") return delay <= value;
if (symbol === ">") return delay >= value;
return false;
});
if (delay < 0) return false
if (symbol === '=' && symbol2 === 'error') return delay >= 1e5
if (symbol === '=' && symbol2 === 'timeout')
return delay < 1e5 && delay >= 3000
if (symbol === '=') return delay == value
if (symbol === '<') return delay <= value
if (symbol === '>') return delay >= value
return false
})
}
const res2 = regex2.exec(query);
const res2 = regex2.exec(query)
if (res2) {
const type = res2[1].toLowerCase();
return proxies.filter((p) => p.type.toLowerCase().includes(type));
const type = res2[1].toLowerCase()
return proxies.filter((p) => p.type.toLowerCase().includes(type))
}
const {
matchCase = false,
matchWholeWord = false,
useRegularExpression = false,
} = searchState ?? {};
} = searchState ?? {}
const compiled = compileStringMatcher(query, {
matchCase,
matchWholeWord,
useRegularExpression,
});
})
if (!compiled.isValid) return [];
return proxies.filter((p) => compiled.matcher(p.name));
if (!compiled.isValid) return []
return proxies.filter((p) => compiled.matcher(p.name))
}
/**
@@ -179,41 +179,41 @@ function sortProxies(
sortType: ProxySortType,
latencyTimeout?: number,
) {
if (!proxies) return [];
if (sortType === 0) return proxies;
if (!proxies) return []
if (sortType === 0) return proxies
const list = proxies.slice();
const list = proxies.slice()
const effectiveTimeout =
typeof latencyTimeout === "number" && latencyTimeout > 0
typeof latencyTimeout === 'number' && latencyTimeout > 0
? latencyTimeout
: 10000;
: 10000
if (sortType === 1) {
const categorizeDelay = (delay: number): [number, number] => {
if (!Number.isFinite(delay)) return [3, Number.MAX_SAFE_INTEGER];
if (delay > 1e5) return [4, delay];
if (!Number.isFinite(delay)) return [3, Number.MAX_SAFE_INTEGER]
if (delay > 1e5) return [4, delay]
if (delay === 0 || (delay >= effectiveTimeout && delay <= 1e5)) {
return [3, delay || effectiveTimeout];
return [3, delay || effectiveTimeout]
}
if (delay < 0) {
// 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) => {
const ad = delayManager.getDelayFix(a, groupName);
const bd = delayManager.getDelayFix(b, groupName);
const [ar, av] = categorizeDelay(ad);
const [br, bv] = categorizeDelay(bd);
const ad = delayManager.getDelayFix(a, groupName)
const bd = delayManager.getDelayFix(b, groupName)
const [ar, av] = categorizeDelay(ad)
const [br, bv] = categorizeDelay(bd)
if (ar !== br) return ar - br;
return av - bv;
});
if (ar !== br) return ar - br
return av - bv
})
} 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