mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-14 21:20:48 +08:00
refactor: lint (#6511)
* refactor: lint * chore: remove eslint-plugin/config-prettier
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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*
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,3 +9,4 @@ src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
target
|
||||
Cargo.lock
|
||||
|
||||
+3
-14
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ members = [
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -5,7 +5,7 @@ notifications:
|
||||
body: ダッシュボードの表示状態が更新されました。
|
||||
clashModeChanged:
|
||||
title: モード切り替え
|
||||
body: "{mode} に切り替えました。"
|
||||
body: '{mode} に切り替えました。'
|
||||
systemProxyToggled:
|
||||
title: システムプロキシ
|
||||
body: システムプロキシの状態が更新されました。
|
||||
|
||||
@@ -5,7 +5,7 @@ notifications:
|
||||
body: 대시보드 표시 상태가 업데이트되었습니다.
|
||||
clashModeChanged:
|
||||
title: 모드 전환
|
||||
body: "{mode}(으)로 전환되었습니다."
|
||||
body: '{mode}(으)로 전환되었습니다.'
|
||||
systemProxyToggled:
|
||||
title: 시스템 프록시
|
||||
body: 시스템 프록시 상태가 업데이트되었습니다.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,7 +5,7 @@ notifications:
|
||||
body: Идарә панеленең күренеше яңартылды.
|
||||
clashModeChanged:
|
||||
title: Режим алыштыру
|
||||
body: "{mode} режимына күчтел."
|
||||
body: '{mode} режимына күчтел.'
|
||||
systemProxyToggled:
|
||||
title: Системалы прокси
|
||||
body: Системалы прокси хәле яңартылды.
|
||||
|
||||
+83
-86
@@ -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
@@ -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",
|
||||
|
||||
Generated
+23
-63
@@ -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:
|
||||
|
||||
+610
-619
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+33
-33
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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%' }} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}))
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user