Compare commits
143 Commits
feat/auto-
...
yovinchen/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
052029b3b0 | ||
|
|
5dc59dc7f8 | ||
|
|
331e48a530 | ||
|
|
aefc5699a2 | ||
|
|
061aef1c2f | ||
|
|
498920dea6 | ||
|
|
9932b92745 | ||
|
|
b4b176580e | ||
|
|
1c9a9af11c | ||
|
|
3ad11acdb2 | ||
|
|
d7fe4a7165 | ||
|
|
f8c40d591f | ||
|
|
ce593248fc | ||
|
|
4fc76200e8 | ||
|
|
e0908701b4 | ||
|
|
d86994eb7e | ||
|
|
94e93137a2 | ||
|
|
db832a9654 | ||
|
|
45a639e73f | ||
|
|
f74d641f86 | ||
|
|
fcfa9574e8 | ||
|
|
d739bb36e5 | ||
|
|
0bcc04adce | ||
|
|
fee0762e3e | ||
|
|
1a8ae85e55 | ||
|
|
c5aa244d65 | ||
|
|
0bedbb2663 | ||
|
|
5f3caa1484 | ||
|
|
fd0e83ebd5 | ||
|
|
e969bdbd73 | ||
|
|
7435a34c66 | ||
|
|
5d2d15690c | ||
|
|
11ee8bddf7 | ||
|
|
186c361a79 | ||
|
|
cc1caea36d | ||
|
|
9ede0ad27d | ||
|
|
20f0dd7e1c | ||
|
|
4dd07dfd85 | ||
|
|
8c01be42fa | ||
|
|
aaf1af0743 | ||
|
|
aeb0007957 | ||
|
|
077d491720 | ||
|
|
7e9930fe50 | ||
|
|
b17d915086 | ||
|
|
3e834e2c38 | ||
|
|
cae625dab1 | ||
|
|
122d7f1ad6 | ||
|
|
7eaf284400 | ||
|
|
86ef7afbdf | ||
|
|
615c431875 | ||
|
|
d041ea7a56 | ||
|
|
c4c1747563 | ||
|
|
c284fe8348 | ||
|
|
8f932b7358 | ||
|
|
d9e940e7a7 | ||
|
|
2147db6707 | ||
|
|
8c826b3073 | ||
|
|
54f1357bcc | ||
|
|
b8d2daccde | ||
|
|
21205272a5 | ||
|
|
ef067a6968 | ||
|
|
84204889f0 | ||
|
|
31cdc2a5cf | ||
|
|
7522ba3e03 | ||
|
|
3ac3f122eb | ||
|
|
67db492330 | ||
|
|
358d6e001e | ||
|
|
8a26cb51d8 | ||
|
|
9f8c745f8c | ||
|
|
3a9a8036d2 | ||
|
|
04e81ebbe3 | ||
|
|
c6e4f3599e | ||
|
|
60eb9ce2a4 | ||
|
|
50244f0055 | ||
|
|
eca14db58c | ||
|
|
463e430a3d | ||
|
|
32e66e054b | ||
|
|
2a9f093210 | ||
|
|
9bf216b102 | ||
|
|
b69d7f7979 | ||
|
|
efff780eea | ||
|
|
19dcc84c83 | ||
|
|
4e9e63f524 | ||
|
|
1d1440f52f | ||
|
|
36b78d1b4b | ||
|
|
2b59a5d51b | ||
|
|
15c12c8e65 | ||
|
|
3256b2f842 | ||
|
|
7374b934c7 | ||
|
|
d9d7c5c342 | ||
|
|
f4f7e10953 | ||
|
|
6ad7e04a95 | ||
|
|
7122e10646 | ||
|
|
bb685be43d | ||
|
|
c5b3b4027f | ||
|
|
daba6b094b | ||
|
|
711ad843ce | ||
|
|
189a70280f | ||
|
|
7ccef5f385 | ||
|
|
85ba24f1c3 | ||
|
|
0d2dedbb6d | ||
|
|
d76c675feb | ||
|
|
9372ecd3c6 | ||
|
|
d0b654f63e | ||
|
|
f035796654 | ||
|
|
160da2729e | ||
|
|
14db6b8a8f | ||
|
|
d91bbb122c | ||
|
|
6df5dfc123 | ||
|
|
c8327f7632 | ||
|
|
4a0e63d0b7 | ||
|
|
e63b4e069b | ||
|
|
687c7de111 | ||
|
|
876605e983 | ||
|
|
442b05507c | ||
|
|
eca9c02147 | ||
|
|
9fbce5d0cf | ||
|
|
c597b9b122 | ||
|
|
54b88d9c89 | ||
|
|
319e5fa61a | ||
|
|
310086d5c9 | ||
|
|
4297703ebe | ||
|
|
ca7ce99702 | ||
|
|
af8b9289fe | ||
|
|
bf7e13d4e9 | ||
|
|
b015af173a | ||
|
|
4a4779a7e7 | ||
|
|
92a39a1a34 | ||
|
|
ea56794a37 | ||
|
|
fd4864115c | ||
|
|
74d4b42936 | ||
|
|
a95f974787 | ||
|
|
29057c1fe0 | ||
|
|
63285acba8 | ||
|
|
f99b614888 | ||
|
|
41f3aa7d76 | ||
|
|
f23898a5c9 | ||
|
|
664391568c | ||
|
|
081aabe10f | ||
|
|
036069a5c1 | ||
|
|
9b7091ba88 | ||
|
|
2357d976dc | ||
|
|
df43692bb9 |
261
.github/workflows/release.yml
vendored
@@ -8,15 +8,19 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
- os: ubuntu-latest
|
||||
- os: macos-latest
|
||||
- os: windows-2022
|
||||
- os: ubuntu-22.04
|
||||
- os: macos-14
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -74,7 +78,7 @@ jobs:
|
||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
@@ -83,22 +87,72 @@ jobs:
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Prepare Tauri signing key
|
||||
shell: bash
|
||||
run: |
|
||||
# 调试:检查 Secret 是否存在
|
||||
if [ -z "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" ]; then
|
||||
echo "❌ TAURI_SIGNING_PRIVATE_KEY Secret 为空或不存在" >&2
|
||||
echo "请检查 GitHub 仓库 Settings > Secrets and variables > Actions" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RAW="${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
|
||||
# 目标:提供正确的私钥“文件路径”给 Tauri CLI,避免内容解码歧义
|
||||
KEY_PATH="$RUNNER_TEMP/tauri_signing.key"
|
||||
# 情况 1:原始两行文本(第一行以 "untrusted comment:" 开头)
|
||||
if echo "$RAW" | head -n1 | grep -q '^untrusted comment:'; then
|
||||
printf '%s\n' "$RAW" > "$KEY_PATH"
|
||||
echo "✅ 使用原始两行密钥文件格式"
|
||||
else
|
||||
# 情况 2:整体被 base64 包裹(解包后应当是两行)
|
||||
if DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \
|
||||
&& echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then
|
||||
printf '%s\n' "$DECODED" > "$KEY_PATH"
|
||||
echo "✅ 成功解码 base64 包裹密钥,已还原为两行文件"
|
||||
else
|
||||
# 情况 3:已是第二行(纯 Base64 一行)→ 构造两行文件
|
||||
if echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then
|
||||
ONE=$(printf '%s' "$RAW" | tr -d '\r\n')
|
||||
printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH"
|
||||
echo "✅ 使用一行 Base64 私钥,已构造两行文件"
|
||||
else
|
||||
echo "❌ TAURI_SIGNING_PRIVATE_KEY 格式无法识别:既不是两行原文,也不是其 base64,亦非一行 base64" >&2
|
||||
echo "密钥前10个字符: $(echo "$RAW" | head -c 10)..." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
# 将“完整两行内容”作为环境变量注入(Tauri 支持传入完整私钥文本或文件路径)
|
||||
# 使用多行写入语法,保持换行以便解析
|
||||
# 将完整两行私钥内容进行 base64 编码,作为单行内容注入环境变量
|
||||
if command -v base64 >/dev/null 2>&1; then
|
||||
KEY_B64=$(base64 < "$KEY_PATH" | tr -d '\r\n')
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
KEY_B64=$(openssl base64 -A -in "$KEY_PATH")
|
||||
else
|
||||
KEY_B64=$(KEY_PATH="$KEY_PATH" node -e "process.stdout.write(require('fs').readFileSync(process.env.KEY_PATH).toString('base64'))")
|
||||
fi
|
||||
if [ -z "$KEY_B64" ]; then
|
||||
echo "❌ 无法生成私钥 base64 内容" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV"
|
||||
if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then
|
||||
echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> $GITHUB_ENV
|
||||
fi
|
||||
echo "✅ Tauri signing key prepared"
|
||||
|
||||
- name: Build Tauri App (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: pnpm tauri build --target universal-apple-darwin
|
||||
|
||||
- name: Build Tauri App (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Build Tauri App (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: pnpm tauri build
|
||||
|
||||
- name: Prepare macOS Assets
|
||||
@@ -107,29 +161,34 @@ jobs:
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
echo "Looking for .app bundle..."
|
||||
APP_PATH=""
|
||||
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
||||
TAR_GZ=""; APP_PATH=""
|
||||
for path in \
|
||||
"src-tauri/target/release/bundle/macos" \
|
||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos"; do
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/release/bundle/macos"; do
|
||||
if [ -d "$path" ]; then
|
||||
APP_PATH=$(find "$path" -name "*.app" -type d | head -1)
|
||||
[ -n "$APP_PATH" ] && break
|
||||
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
||||
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
||||
fi
|
||||
done
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "No .app found" >&2
|
||||
if [ -z "$TAR_GZ" ]; then
|
||||
echo "No macOS .tar.gz updater artifact found" >&2
|
||||
exit 1
|
||||
fi
|
||||
APP_DIR=$(dirname "$APP_PATH")
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
cd "$APP_DIR"
|
||||
# 使用 ditto 打包更兼容资源分叉
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
||||
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
||||
echo "macOS zip ready"
|
||||
cp "$TAR_GZ" release-assets/
|
||||
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" release-assets/ || echo ".sig for macOS not found yet"
|
||||
echo "macOS updater artifact copied: $(basename "$TAR_GZ")"
|
||||
if [ -n "$APP_PATH" ]; then
|
||||
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
||||
cd "$APP_DIR"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
||||
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
||||
echo "macOS zip ready: CC-Switch-macOS.zip"
|
||||
else
|
||||
echo "No .app found to zip (optional)" >&2
|
||||
fi
|
||||
|
||||
- name: Prepare Windows Assets
|
||||
if: runner.os == 'Windows'
|
||||
@@ -137,18 +196,27 @@ jobs:
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||
# 安装器(优先 NSIS,其次 MSI)
|
||||
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } |
|
||||
Select-Object -First 1
|
||||
if ($null -ne $installer) {
|
||||
$dest = if ($installer.Extension -ieq '.msi') { 'CC-Switch-Setup.msi' } else { 'CC-Switch-Setup.exe' }
|
||||
Copy-Item $installer.FullName (Join-Path release-assets $dest)
|
||||
Write-Host "Installer copied: $dest"
|
||||
} else {
|
||||
Write-Warning 'No Windows installer found'
|
||||
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -eq $msi) {
|
||||
# 兜底:全局搜索 .msi
|
||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
}
|
||||
# 绿色版(portable):仅可执行文件
|
||||
if ($null -ne $msi) {
|
||||
$dest = 'CC-Switch-Setup.msi'
|
||||
Copy-Item $msi.FullName (Join-Path release-assets $dest)
|
||||
Write-Host "Installer copied: $dest"
|
||||
$sigPath = "$($msi.FullName).sig"
|
||||
if (Test-Path $sigPath) {
|
||||
Copy-Item $sigPath (Join-Path release-assets ("$dest.sig"))
|
||||
Write-Host "Signature copied: $dest.sig"
|
||||
} else {
|
||||
Write-Warning "Signature not found for $($msi.Name)"
|
||||
}
|
||||
} else {
|
||||
Write-Warning 'No Windows MSI installer found'
|
||||
}
|
||||
# 绿色版(portable):仅可执行文件打 zip(不参与 Updater)
|
||||
$exeCandidates = @(
|
||||
'src-tauri/target/release/cc-switch.exe',
|
||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||
@@ -158,6 +226,12 @@ jobs:
|
||||
$portableDir = 'release-assets/CC-Switch-Portable'
|
||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||
Copy-Item $exePath $portableDir
|
||||
$portableIniPath = Join-Path $portableDir 'portable.ini'
|
||||
$portableContent = @(
|
||||
'# CC Switch portable build marker',
|
||||
'portable=true'
|
||||
)
|
||||
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
|
||||
Compress-Archive -Path "$portableDir/*" -DestinationPath 'release-assets/CC-Switch-Windows-Portable.zip' -Force
|
||||
Remove-Item -Recurse -Force $portableDir
|
||||
Write-Host 'Windows portable zip created'
|
||||
@@ -171,14 +245,22 @@ jobs:
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
# 仅上传安装包(deb)
|
||||
# Updater artifact: AppImage(含对应 .sig)
|
||||
APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true)
|
||||
if [ -n "$APPIMAGE" ]; then
|
||||
cp "$APPIMAGE" release-assets/
|
||||
[ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" release-assets/ || echo ".sig for AppImage not found"
|
||||
echo "AppImage copied"
|
||||
else
|
||||
echo "No AppImage found under target/release/bundle" >&2
|
||||
fi
|
||||
# 额外上传 .deb(用于手动安装,不参与 Updater)
|
||||
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
||||
if [ -n "$DEB" ]; then
|
||||
cp "$DEB" release-assets/
|
||||
echo "Deb package copied"
|
||||
else
|
||||
echo "No .deb found" >&2
|
||||
exit 1
|
||||
echo "No .deb found (optional)"
|
||||
fi
|
||||
|
||||
- name: List prepared assets
|
||||
@@ -189,18 +271,16 @@ jobs:
|
||||
- name: Collect Signatures
|
||||
shell: bash
|
||||
run: |
|
||||
# 查找并复制签名文件到 release-assets
|
||||
find src-tauri/target -name "*.sig" -type f 2>/dev/null | while read sig; do
|
||||
cp "$sig" release-assets/ || true
|
||||
done
|
||||
echo "Collected signatures:"
|
||||
set -euo pipefail
|
||||
echo "Collected signatures (if any alongside artifacts):"
|
||||
ls -la release-assets/*.sig || echo "No signatures found"
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: CC Switch ${{ github.ref_name }}
|
||||
prerelease: true
|
||||
body: |
|
||||
## CC Switch ${{ github.ref_name }}
|
||||
|
||||
@@ -209,7 +289,7 @@ jobs:
|
||||
### 下载
|
||||
|
||||
- macOS: `CC-Switch-macOS.zip`(解压即用)
|
||||
- Windows: `CC-Switch-Setup.exe` 或 `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
|
||||
- Windows: `CC-Switch-Setup.msi`(安装版);`CC-Switch-Windows-Portable.zip`(绿色版)
|
||||
- Linux: `*.deb`(Debian/Ubuntu 安装包)
|
||||
|
||||
---
|
||||
@@ -224,3 +304,92 @@ jobs:
|
||||
run: |
|
||||
echo "Listing bundles in src-tauri/target..."
|
||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
||||
|
||||
assemble-latest-json:
|
||||
name: Assemble latest.json
|
||||
runs-on: ubuntu-22.04
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Prepare GH
|
||||
run: |
|
||||
gh --version || (type -p curl >/dev/null && sudo apt-get update && sudo apt-get install -y gh || true)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Download all release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
mkdir -p dl
|
||||
gh release download "$TAG" --dir dl --repo "$GITHUB_REPOSITORY"
|
||||
ls -la dl || true
|
||||
- name: Generate latest.json
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${TAG#v}"
|
||||
PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
base_url="https://github.com/$REPO/releases/download/$TAG"
|
||||
# 初始化空平台映射
|
||||
mac_url=""; mac_sig=""
|
||||
win_url=""; win_sig=""
|
||||
linux_url=""; linux_sig=""
|
||||
shopt -s nullglob
|
||||
for sig in dl/*.sig; do
|
||||
base=${sig%.sig}
|
||||
fname=$(basename "$base")
|
||||
url="$base_url/$fname"
|
||||
sig_content=$(cat "$sig")
|
||||
case "$fname" in
|
||||
*.tar.gz)
|
||||
# 视为 macOS updater artifact
|
||||
mac_url="$url"; mac_sig="$sig_content";;
|
||||
*.AppImage|*.appimage)
|
||||
linux_url="$url"; linux_sig="$sig_content";;
|
||||
*.msi|*.exe)
|
||||
win_url="$url"; win_sig="$sig_content";;
|
||||
esac
|
||||
done
|
||||
# 构造 JSON(仅包含存在的目标)
|
||||
tmp_json=$(mktemp)
|
||||
{
|
||||
echo '{'
|
||||
echo " \"version\": \"$VERSION\",";
|
||||
echo " \"notes\": \"Release $TAG\",";
|
||||
echo " \"pub_date\": \"$PUB_DATE\",";
|
||||
echo ' "platforms": {'
|
||||
first=1
|
||||
if [ -n "$mac_url" ] && [ -n "$mac_sig" ]; then
|
||||
# 为兼容 arm64 / x64,重复写入两个键,指向同一 universal 包
|
||||
for key in darwin-aarch64 darwin-x86_64; do
|
||||
[ $first -eq 0 ] && echo ','
|
||||
echo " \"$key\": {\"signature\": \"$mac_sig\", \"url\": \"$mac_url\"}"
|
||||
first=0
|
||||
done
|
||||
fi
|
||||
if [ -n "$win_url" ] && [ -n "$win_sig" ]; then
|
||||
[ $first -eq 0 ] && echo ','
|
||||
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
|
||||
first=0
|
||||
fi
|
||||
if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then
|
||||
[ $first -eq 0 ] && echo ','
|
||||
echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}"
|
||||
first=0
|
||||
fi
|
||||
echo ' }'
|
||||
echo '}'
|
||||
} > "$tmp_json"
|
||||
echo "Generated latest.json:" && cat "$tmp_json"
|
||||
mv "$tmp_json" latest.json
|
||||
- name: Upload latest.json to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
gh release upload "$GITHUB_REF_NAME" latest.json --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
1
.gitignore
vendored
@@ -9,3 +9,4 @@ release/
|
||||
.npmrc
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
/.claude
|
||||
|
||||
1
.node-version
Normal file
@@ -0,0 +1 @@
|
||||
v22.4.1
|
||||
65
CHANGELOG.md
@@ -5,6 +5,71 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.4.0] - 2025-10-01
|
||||
|
||||
### ✨ Features
|
||||
- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher
|
||||
- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)
|
||||
- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max
|
||||
- Support portable mode launches and enforce a single running instance to avoid conflicts
|
||||
|
||||
### 🔧 Improvements
|
||||
- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows
|
||||
- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section
|
||||
- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex
|
||||
- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability
|
||||
|
||||
### 🐛 Fixes
|
||||
- Remove the unnecessary OpenAI auth requirement from third-party provider configurations
|
||||
- Fix layout shifts while switching app types with Claude plugin sync enabled
|
||||
- Align Enable/In Use button states to avoid visual jank across app views
|
||||
|
||||
## [3.3.0] - 2025-09-22
|
||||
|
||||
### ✨ Features
|
||||
- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants *(Removed in 3.4.x)*
|
||||
- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently *(Removed in 3.4.x)*
|
||||
- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance
|
||||
- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces
|
||||
|
||||
### 🔧 Improvements
|
||||
- Keep the tray menu responsive when the window is hidden and standardize button styling and copy
|
||||
- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon
|
||||
- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows
|
||||
- Add a `created_at` timestamp to provider records for future sorting and analytics
|
||||
|
||||
### 🐛 Fixes
|
||||
- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues
|
||||
- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank
|
||||
- Bundle `@codemirror/lint` to reinstate live linting in config editors
|
||||
|
||||
## [3.2.0] - 2025-09-13
|
||||
|
||||
### ✨ New Features
|
||||
- System tray provider switching with dynamic menu for Claude/Codex
|
||||
- Frontend receives `provider-switched` events and refreshes active app
|
||||
- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge
|
||||
|
||||
### 🔧 Improvements
|
||||
- Single source of truth for provider configs; no duplicate copy files
|
||||
- One-time migration imports existing copies into `config.json` and archives originals
|
||||
- Duplicate provider de-duplication by name + API key at startup
|
||||
- Atomic writes for Codex `auth.json` + `config.toml` with rollback on failure
|
||||
- Logging standardized (Rust): use `log::{info,warn,error}` instead of stdout prints
|
||||
- Tailwind v4 integration and refined dark mode handling
|
||||
|
||||
### 🐛 Fixes
|
||||
- Remove/minimize debug console logs in production builds
|
||||
- Fix CSS minifier warnings for scrollbar pseudo-elements
|
||||
- Prettier formatting across codebase for consistent style
|
||||
|
||||
### 📦 Dependencies
|
||||
- Tauri: 2.8.x (core, updater, process, opener, log plugins)
|
||||
- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x
|
||||
|
||||
### 🔄 Notes
|
||||
- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed
|
||||
|
||||
## [3.1.1] - 2025-09-03
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
97
README.md
@@ -1,26 +1,30 @@
|
||||
# Claude Code & Codex 供应商切换器
|
||||
|
||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
|
||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||
|
||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文““迁移与备份”)。
|
||||
> v3.4.0 :新增 i18next 国际化(还有部分未完成)、对新模型(qwen-3-max, GLM-4.6, DeepSeek-V3.2-Exp)的支持、Claude 插件、单实例守护、托盘最小化及安装器优化等。
|
||||
|
||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB),启动速度提升 10 倍!
|
||||
> v3.3.0 :VS Code Codex 插件一键配置/移除(默认自动同步)、Codex 通用配置片段与自定义向导增强、WSL 环境支持、跨平台托盘与 UI 优化。(该 VS Code 写入功能已在 v3.4.x 停用)
|
||||
|
||||
## 功能特性
|
||||
> v3.2.0 :全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源(SSOT)与一次性迁移/归档。
|
||||
|
||||
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
|
||||
- 一键切换不同供应商
|
||||
- 同时支持 Claude Code 与 Codex 的供应商切换与导入
|
||||
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
|
||||
- 支持添加自定义供应商
|
||||
- 随时切换官方登录
|
||||
- 简洁美观的图形界面
|
||||
- 信息存储在本地 ~/.cc-switch/config.json,无隐私风险
|
||||
- 超小体积 - 仅 ~5MB 安装包
|
||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
|
||||
|
||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
|
||||
|
||||
## 功能特性(v3.4.0)
|
||||
|
||||
- **国际化与语言切换**:内置 i18next,默认显示中文,可在设置中快速切换到英文,界面文文案自动实时刷新。
|
||||
- **Claude 插件同步**:内置按钮可一键应用或恢复 Claude 插件配置,切换供应商后立即生效。
|
||||
- **VS Code Codex 设置停用**:由于新版 Codex 插件无需修改 `settings.json`,应用不再写入 VS Code 设置,避免潜在冲突。
|
||||
- **供应商预设扩展**:新增 DeepSeek--V3.2-Exp、Qwen3-Max、GLM-4.6 等最新模型。
|
||||
- **系统托盘与窗口行为**:窗口关闭可最小化到托盘,macOS 支持托盘模式下隐藏/显示 Dock,托盘切换时同步 Claude/Codex/插件状态。
|
||||
- **单实例**:保证同一时间仅运行一个实例,避免多开冲突。
|
||||
- **UI 与安装体验优化**:设置面板改为可滚动布局并加入保存图标,按钮宽度与状态一致性加强,Windows MSI 安装默认写入 per-user LocalAppData 并改进组件跟踪,Windows 便携版现在指向最新 release 页面,不再自动更为为安装版。
|
||||
|
||||
## 界面预览
|
||||
|
||||
@@ -52,40 +56,57 @@
|
||||
|
||||
### Linux 用户
|
||||
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包。
|
||||
从 [Releases](../../releases) 页面下载最新版本的 `.deb` 包或者 `AppImage`安装包。
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 点击"添加供应商"添加你的 API 配置
|
||||
2. 选择要使用的供应商,点击单选按钮切换
|
||||
3. 配置会自动保存到对应应用的配置文件中
|
||||
4. 重启或者新打开终端以生效
|
||||
5. 如果需要切回 Claude 官方登录,可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
|
||||
2. 切换方式:
|
||||
- 在主界面选择供应商后点击切换
|
||||
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
|
||||
3. 切换会写入对应应用的“live 配置文件”(Claude:`settings.json`;Codex:`auth.json` + `config.toml`)
|
||||
4. 重启或新开终端以确保生效
|
||||
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
|
||||
|
||||
### Codex 说明
|
||||
### 检查更新
|
||||
|
||||
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
|
||||
|
||||
### Codex 说明(SSOT)
|
||||
|
||||
- 配置目录:`~/.codex/`
|
||||
- 主配置文件:`auth.json`(必需)、`config.toml`(可为空)
|
||||
- 供应商副本:`auth-<name>.json`、`config-<name>.toml`
|
||||
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
|
||||
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
||||
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json`、`config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`。
|
||||
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前;`config.toml` 不存在时按空处理。
|
||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`)
|
||||
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
|
||||
|
||||
### Claude Code 说明
|
||||
### Claude Code 说明(SSOT)
|
||||
|
||||
- 配置目录:`~/.claude/`
|
||||
- 主配置文件:`settings.json`(推荐)或 `claude.json`(旧版兼容,若存在则继续使用)
|
||||
- 供应商副本:`settings-<name>.json`
|
||||
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
|
||||
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
||||
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。
|
||||
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前。
|
||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
|
||||
- 切换行为(不再写“副本文件”):
|
||||
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`)
|
||||
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
|
||||
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
|
||||
|
||||
### 迁移与备份
|
||||
### 迁移与归档(自 v3.2.0 起)
|
||||
|
||||
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json`。
|
||||
- 实际生效的应用配置文件(如 `~/.claude/settings.json`、`~/.codex/auth.json`/`config.toml`)不会被修改,切换仅在用户点击“切换”时按副本覆盖到主配置。
|
||||
- 一次性迁移:首次启动 3.2.0 及以上版本会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
|
||||
- Claude:`~/.claude/settings-*.json`(排除 `settings.json` / 历史 `claude.json`)
|
||||
- Codex:`~/.codex/auth-*.json` 与 `config-*.toml`(按名称成对合并)
|
||||
- 去重与当前项:按“名称(忽略大小写)+ API Key”去重;若当前为空,将 live 合并项设为当前
|
||||
- 归档与清理:
|
||||
- 归档目录:`~/.cc-switch/archive/<timestamp>/<category>/...`
|
||||
- 归档成功后删除原副本;失败则保留原文件(保守策略)
|
||||
- v1 → v2 结构升级:会额外生成 `~/.cc-switch/config.v1.backup.<timestamp>.json` 以便回滚
|
||||
- 注意:迁移后不再持续归档日常切换/编辑操作,如需长期审计请自备备份方案
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -138,7 +159,7 @@ cargo test
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **[Tauri 2.0](https://tauri.app/)** - 跨平台桌面应用框架
|
||||
- **[Tauri 2](https://tauri.app/)** - 跨平台桌面应用框架(集成 updater/process/opener/log/tray-icon)
|
||||
- **[React 18](https://react.dev/)** - 用户界面库
|
||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
||||
@@ -177,6 +198,10 @@ cargo test
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#farion1231/cc-switch&Date)
|
||||
|
||||
## License
|
||||
|
||||
MIT © Jason Young
|
||||
|
||||
76
README_i18n.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# CC Switch 国际化功能说明
|
||||
|
||||
## 已完成的工作
|
||||
|
||||
1. **安装依赖**:添加了 `react-i18next` 和 `i18next` 包
|
||||
2. **配置国际化**:在 `src/i18n/` 目录下创建了配置文件
|
||||
3. **翻译文件**:创建了英文和中文翻译文件
|
||||
4. **组件更新**:替换了主要组件中的硬编码文案
|
||||
5. **语言切换器**:添加了语言切换按钮
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── i18n/
|
||||
│ ├── index.ts # 国际化配置文件
|
||||
│ └── locales/
|
||||
│ ├── en.json # 英文翻译
|
||||
│ └── zh.json # 中文翻译
|
||||
├── components/
|
||||
│ └── LanguageSwitcher.tsx # 语言切换组件
|
||||
└── main.tsx # 导入国际化配置
|
||||
```
|
||||
|
||||
## 默认语言设置
|
||||
|
||||
- **默认语言**:英文 (en)
|
||||
- **回退语言**:英文 (en)
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 在组件中导入 `useTranslation`:
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation();
|
||||
return <div>{t('common.save')}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
2. 切换语言:
|
||||
```tsx
|
||||
const { i18n } = useTranslation();
|
||||
i18n.changeLanguage('zh'); // 切换到中文
|
||||
```
|
||||
|
||||
## 翻译键结构
|
||||
|
||||
- `common.*` - 通用文案(保存、取消、设置等)
|
||||
- `header.*` - 头部相关文案
|
||||
- `provider.*` - 供应商相关文案
|
||||
- `notifications.*` - 通知消息
|
||||
- `settings.*` - 设置页面文案
|
||||
- `apps.*` - 应用名称
|
||||
- `console.*` - 控制台日志信息
|
||||
|
||||
## 测试功能
|
||||
|
||||
应用已添加了语言切换按钮(地球图标),点击可以在中英文之间切换,验证国际化功能是否正常工作。
|
||||
|
||||
## 已更新的组件
|
||||
|
||||
- ✅ App.tsx - 主应用组件
|
||||
- ✅ ConfirmDialog.tsx - 确认对话框
|
||||
- ✅ AddProviderModal.tsx - 添加供应商弹窗
|
||||
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
||||
- ✅ ProviderList.tsx - 供应商列表
|
||||
- ✅ LanguageSwitcher.tsx - 语言切换器
|
||||
- 🔄 SettingsModal.tsx - 设置弹窗(部分完成)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有新的文案都应该添加到翻译文件中,而不是硬编码
|
||||
2. 翻译键名应该有意义且结构化
|
||||
3. 可以通过修改 `src/i18n/index.ts` 中的 `lng` 配置来更改默认语言
|
||||
@@ -1,193 +0,0 @@
|
||||
# CC Switch 加密配置与切换重构方案(V1)
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
- 目标:将 `~/.cc-switch/config.json` 作为单一真实来源(SSOT),改为“加密落盘”;切换时从解密后的内存配置写入目标应用主配置(Claude/Codex)。
|
||||
- 范围:
|
||||
- 后端(Rust/Tauri)新增加密模块与读写改造。
|
||||
- 调整切换逻辑为“内存 → 主配置”,切换前回填 live 配置到当前供应商,避免用户外部手改丢失。
|
||||
- 新增“旧文件清理与归档”能力:默认仅归档不删除,并在迁移成功后提醒用户执行;可在设置页手动触发。
|
||||
- 兼容旧明文配置(v1/v2),首次保存迁移为加密文件。
|
||||
|
||||
## 2. 背景现状(简述)
|
||||
|
||||
- 当前:
|
||||
- 全局配置:`~/.cc-switch/config.json`(v2:`MultiAppConfig`,含多个 `ProviderManager`)。
|
||||
- 切换:依赖“供应商副本文件”(Claude:`~/.claude/settings-<name>.json`;Codex:`~/.codex/auth-<name>.json`、`config-<name>.toml`)→ 恢复到主配置。
|
||||
- 启动:若对应 App 的供应商列表为空,可从现有主配置自动创建一条“默认项”并设为当前。
|
||||
- 问题:存在“副本 ↔ 总配置”双来源,可能不一致;明文落盘有泄露风险。
|
||||
|
||||
## 3. 总体方案
|
||||
|
||||
- 以加密文件 `~/.cc-switch/config.enc.json` 替代明文存储;进程启动时解密一次加载到内存,后续以内存为准;保存时加密写盘。
|
||||
- 切换时:直接从内存 `Provider.settings_config` 写入目标应用主配置;切换前回填当前 live 配置到当前选中供应商(由 `manager.current` 指向),保留外部修改。
|
||||
- 明文兼容:若无加密文件,读取旧 `config.json`(含 v1→v2 迁移),首次保存写加密文件,并备份旧明文。
|
||||
- 旧文件清理:提供“可回滚归档”而非删除。扫描 `~/.cc-switch/config.json`(v1/v2)与 Claude/Codex 的历史副本文件,用户确认后移动到 `~/.cc-switch/archive/<ts>/`,生成 `manifest.json` 以便恢复;默认不做静默清理。
|
||||
|
||||
## 4. 密钥管理
|
||||
|
||||
- 存储:系统级凭据管家(keyring crate)。
|
||||
- Service:`cc-switch`;Account:`config-key-v1`;内容:Base64 编码的 32 字节随机密钥(AES-256)。
|
||||
- 首次运行:生成随机密钥,写入 Keychain。
|
||||
- 进程内缓存:启动加载后缓存密钥,避免重复 IO。
|
||||
- 轮换(后续):支持命令触发“旧密钥解密 → 新密钥加密”的原子迁移。
|
||||
- 回退策略:Keychain 不可用时进入“只读模式”并提示用户(不建议将密钥落盘)。
|
||||
|
||||
## 5. 加密封装格式
|
||||
|
||||
- 文件:`~/.cc-switch/config.enc.json`
|
||||
- 结构(JSON 封装,便于演进):
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"alg": "AES-256-GCM",
|
||||
"nonce": "<base64-nonce>",
|
||||
"ct": "<base64-ciphertext>"
|
||||
}
|
||||
```
|
||||
- 明文:`serde_json::to_vec(MultiAppConfig)`;加密:AES-GCM(12 字节随机 nonce);每次保存生成新 nonce。
|
||||
|
||||
## 6. 模块与改造点
|
||||
|
||||
- 新增 `src-tauri/src/secure_store.rs`:
|
||||
- `get_or_create_key() -> Result<[u8;32], String>`:从 Keychain 获取/生成密钥。
|
||||
- `encrypt_bytes(key, plaintext) -> (nonce, ciphertext)`;`decrypt_bytes(key, nonce, ciphertext)`。
|
||||
- `read_encrypted_config() -> Result<MultiAppConfig, String>`:读取 `config.enc.json`、解析封装、解密、反序列化。
|
||||
- `write_encrypted_config(cfg: &MultiAppConfig) -> Result<(), String>`:序列化→加密→原子写入。
|
||||
- 新增 `src-tauri/src/legacy_cleanup.rs`(旧文件清理/归档):
|
||||
- `scan_legacy_files() -> LegacyScanReport`:扫描旧 `config.json`(v1/v2)与 Claude/Codex 副本文件(`settings-*.json`、`auth-*.json`、`config-*.toml`),返回分组清单、大小、mtime;永不将 live 文件(`settings.json`、`auth.json`、`config.toml`、`config.enc.json`)列为可归档。
|
||||
- `archive_legacy_files(selection) -> ArchiveResult`:将选中文件移动到 `~/.cc-switch/archive/<ts>/` 下对应子目录(`cc-switch/`、`claude/`、`codex/`),生成 `manifest.json`(记录原路径、归档路径、大小、mtime、sha256、类别);同分区 `rename`,跨分区“copy + fsync + remove”。
|
||||
- `restore_from_archive(manifest_path, items?) -> RestoreResult`:从归档恢复选中文件;若原路径已有同名文件则中止并提示冲突。
|
||||
- 可选:`purge_archived(before_days)` 仅删除 `archive/` 内的过期归档;默认关闭。
|
||||
- 安全护栏:操作前后做 mtime/hash 复核(CAS);发生变化中止并提示“外部已修改”。
|
||||
- 调整 `src-tauri/src/app_config.rs`:
|
||||
- `MultiAppConfig::load()`:优先 `read_encrypted_config()`;若无则读旧明文:
|
||||
- 若检测到 v1(`ProviderManager`)→ 迁移到 v2(原有逻辑保留)。
|
||||
- `MultiAppConfig::save()`:统一调用 `write_encrypted_config()`;若检测到旧 `config.json`,首次保存时备份为 `config.v1.backup.<ts>.json`(或保留为只读,视实现选择)。
|
||||
- 调整 `src-tauri/src/commands.rs::switch_provider`:
|
||||
- Claude:
|
||||
1. 回填:若 `~/.claude/settings.json` 存在且存在当前指针 → 读取 JSON,写回 `manager.providers[manager.current].settings_config`。
|
||||
2. 切换:从目标 `provider.settings_config` 直接写 `~/.claude/settings.json`(确保父目录存在)。
|
||||
- Codex:
|
||||
1. 回填:读取 `~/.codex/auth.json`(JSON)与 `~/.codex/config.toml`(字符串;非空做 TOML 校验)→ 合成为 `{auth, config}` → 写回 `manager.providers[manager.current].settings_config`。
|
||||
2. 切换:从目标 `provider.settings_config` 中取 `auth`(必需)与 `config`(可空)写入对应主配置(非空 `config` 校验 TOML)。
|
||||
- 更新 `manager.current = id`,`state.save()` → 触发加密保存。
|
||||
- 保留/清理:
|
||||
- 阶段一保留 `codex_config.rs` 与 `config.rs` 的副本读写函数(减少改动面),但切换不再依赖“副本恢复”。
|
||||
- 阶段二可移除 add/update 时的“副本写入”,转为仅更新内存并保存加密配置。
|
||||
|
||||
## 7. 数据流与时序
|
||||
|
||||
- 启动:`AppState::new()` → `MultiAppConfig::load()`(优先加密)→ 进程内持有解密后的配置。
|
||||
- 添加/编辑/删除:更新内存中的 `ProviderManager` → `state.save()`(加密写盘)。
|
||||
- 切换:回填 live → 以目标供应商内存配置写入主配置 → 更新当前指针(`manager.current`)→ `state.save()`。
|
||||
- 迁移后提醒:若首次从旧明文迁移成功,弹出“发现旧配置,可归档”提示;用户可进入“存储与清理”页面查看并执行归档。
|
||||
|
||||
## 8. 迁移策略
|
||||
|
||||
- 读取顺序:`config.enc.json`(新)→ `config.json`(旧)。
|
||||
- 旧版支持:
|
||||
- v1 明文(单 `ProviderManager`)→ 自动迁移为 v2(已有逻辑)。
|
||||
- v2 明文 → 直接加载。
|
||||
- 首次保存:写 `config.enc.json`;若存在旧 `config.json`,备份为 `config.v1.backup.<ts>.json`(或保留为只读)。
|
||||
- 失败处理:解密失败/破损 → 明确提示并拒绝覆盖;允许用户手动回滚备份。
|
||||
- 旧文件处理:默认不自动删除。提供“扫描→归档”的可选流程,将旧 `config.json` 与历史副本文件移动到 `~/.cc-switch/archive/<ts>/`,保留 `manifest.json` 以支持恢复。
|
||||
|
||||
## 9. 回滚策略
|
||||
|
||||
- 加密回滚:保留 `config.v1.backup.<ts>.json` 作为明文快照;必要时让 `load()` 回退到该备份(手动步骤)。
|
||||
- 切换回退:临时切换回“副本恢复”路径(现有代码仍在,快速恢复可用)。
|
||||
|
||||
## 10. 安全与性能
|
||||
|
||||
- 算法:AES-256-GCM(AEAD);随机 12 字节 nonce;每次保存新 nonce。
|
||||
- 性能:对几十 KB 级别文件,加解密开销远低于磁盘 IO 和 JSON 处理;冷启动 Keychain 取密钥 1–20ms,可缓存。
|
||||
- 可靠性:原子写入(临时文件 + rename);写入失败不破坏现有文件。
|
||||
- 可选增强:`zeroize` 清理密钥与明文;Claude 配置 JSON Schema 校验。
|
||||
- 清理安全:归档而非删除;不触及 live 文件;归档/恢复采用 CAS 校验与错误回滚;归档路径冲突加后缀去重(如 `-2`、`-3`)。
|
||||
|
||||
## 11. API 与 UX 影响
|
||||
|
||||
- 前端 API:现有行为不变;新增清理相关命令(Tauri)供 UI 调用:`scan_legacy_files`、`archive_legacy_files`、`restore_from_archive`(`purge_archived` 可选)。
|
||||
- UI 提示:在“配置文件位置”旁提示“已加密存储”。
|
||||
- 清理入口:设置页新增“存储与清理”面板,展示扫描结果、支持归档与从归档恢复;首次迁移成功后弹出提醒(可稍后再说)。
|
||||
- 文案约定:明确“仅归档、不删除;删除需二次确认且默认关闭自动删除”。
|
||||
|
||||
## 12. 开发任务拆解(阶段一为本次交付)
|
||||
|
||||
- 阶段一(核心改造 + 清理能力最小闭环)
|
||||
- 新增模块 `secure_store.rs`:Keychain 与加解密工具函数。
|
||||
- 改造 `app_config.rs`:`load()/save()` 支持加密文件与旧明文迁移、原子写入、备份。
|
||||
- 改造 `commands.rs::switch_provider`:
|
||||
- 回填 live 配置 → 写入目标主配置(Claude/Codex)。
|
||||
- 去除对“副本恢复”的依赖(保留函数以便回退)。
|
||||
- 旧文件清理:新增 `legacy_cleanup.rs` 与对应 Tauri 命令,完成“扫描→归档→恢复”;首次迁移成功后在 UI 弹提醒,指向“设置 > 存储与清理”。
|
||||
- 保持 `import_default_config`、`get_config_status` 行为不变。
|
||||
- 阶段二(清理与增强)
|
||||
- 移除 add/update 对“副本文件”的写入,完全以内存+加密文件为中心。
|
||||
- Claude settings 的 JSON Schema 校验;导出明文快照;只读模式显式开关。
|
||||
- 阶段三(安全升级)
|
||||
- 密钥轮换;可选 passphrase(KDF: Argon2id + salt)。
|
||||
|
||||
## 14. 验收标准
|
||||
|
||||
- 功能:
|
||||
- 无加密明文文件也能启动并正确读写;
|
||||
- 切换成功将内存配置写入主配置;
|
||||
- 外部手改在下一次切换前被回填保存;
|
||||
- 旧配置自动迁移并生成加密文件;
|
||||
- Keychain/解密异常时不损坏已有文件,给出可理解错误。
|
||||
- 清理:扫描能准确识别旧明文与副本文件;执行归档后原路径不再存在文件、归档目录生成 `manifest.json`;从归档恢复可还原到原路径(不覆盖已存在文件)。
|
||||
- 质量:
|
||||
- 关键路径加错误处理与日志;
|
||||
- 写入采用原子替换;
|
||||
- 代码变更集中、最小侵入,与现有风格一致。
|
||||
- 清理操作具备 CAS 校验、错误回滚、绝不触及 live 文件与 `config.enc.json`。
|
||||
|
||||
## 15. 风险与对策
|
||||
|
||||
- Keychain 不可用或权限受限:
|
||||
- 对策:只读模式 + 明确提示;不覆盖落盘;允许手动恢复明文备份。
|
||||
- 加密文件损坏:
|
||||
- 对策:严格校验与错误分支;保留旧文件;不做“盲目重置”。
|
||||
- 与“副本文件”并存导致混淆:
|
||||
- 对策:阶段一保留但不依赖;阶段二移除写入,文档化行为变更。
|
||||
- 清理误删或不可逆:
|
||||
- 对策:默认仅归档不删除;删除需二次确认且仅作用于 `archive/`;提供 `manifest.json` 恢复;归档/恢复全程 CAS 校验与回滚。
|
||||
|
||||
## 16. 发布与回退
|
||||
|
||||
- 发布:随 Tauri 应用正常发布,无需前端变更。
|
||||
- 回退:保留旧明文备份;将切换逻辑临时改回“副本恢复”路径可快速回退。
|
||||
|
||||
## 17. 旧文件清理与归档(新增)
|
||||
|
||||
- 归档对象:
|
||||
- `~/.cc-switch/config.json`(v1/v2,迁移成功后)
|
||||
- `~/.claude/settings-*.json`(保留 `settings.json`)
|
||||
- `~/.codex/auth-*.json`、`~/.codex/config-*.toml`(保留 `auth.json`、`config.toml`)
|
||||
- 归档位置与结构:`~/.cc-switch/archive/<timestamp>/{cc-switch,claude,codex}/...`
|
||||
- `manifest.json`:记录原路径、归档路径、大小、mtime、sha256、类别(v1/v2/claude/codex);用于恢复与可视化。
|
||||
- 提醒策略:首次迁移成功后弹窗提醒;设置页“存储与清理”提供扫描、归档、恢复操作;默认不自动删除,可选“删除归档 >N 天”开关(默认关闭)。
|
||||
- 护栏:永不移动/删除 live 文件与 `config.enc.json`;执行前后 CAS 校验;跨分区采用“copy+fsync+remove”;失败即时回滚并提示。
|
||||
|
||||
## 18. 变更点清单(代码)
|
||||
|
||||
- 新增:`src-tauri/src/secure_store.rs`
|
||||
- 修改:
|
||||
- `src-tauri/src/app_config.rs`(load/save 加密化、迁移与原子写入)
|
||||
- `src-tauri/src/commands.rs`(switch_provider 改为内存 → 主配置,并回填 live)
|
||||
- `src-tauri/src/legacy_cleanup.rs`(扫描/归档/恢复旧文件)
|
||||
- 保持:
|
||||
- `src-tauri/src/config.rs`、`src-tauri/src/codex_config.rs`(读写工具与校验,阶段一不大动)
|
||||
- 前端 `src/lib/tauri-api.ts` 与 UI 逻辑
|
||||
|
||||
## 19. 开放问题(待确认)
|
||||
|
||||
- Keychain 失败时是否提供“本地明文密钥文件(600 权限)”的应急模式(当前建议:不提供,保持只读)。
|
||||
- 加密文件名固定为 `config.enc.json` 是否满足预期,或需隐藏(如 `.config.enc`)。
|
||||
- 是否需要提供“自动删除归档 >N 天”的开关(默认关闭,建议 N=30)。
|
||||
|
||||
---
|
||||
|
||||
以上方案为“阶段一”可落地版本,能在保持前端无感的前提下完成“加密存储 + 内存驱动切换”的核心目标。如需,我可以继续补充任务看板(Issue 列表)与实施顺序的 PR 规划。
|
||||
7
docs/roadmap.md
Normal file
@@ -0,0 +1,7 @@
|
||||
- 自动升级自定义路径 ✅
|
||||
- win 绿色版报毒问题 ✅
|
||||
- codex 更多预设供应商
|
||||
- mcp 管理器
|
||||
- i18n
|
||||
- gemini cli
|
||||
- homebrew 支持
|
||||
@@ -1,91 +0,0 @@
|
||||
# 更新功能开发计划(Tauri v2 Updater)
|
||||
|
||||
> 目标:基于 Tauri v2 官方 Updater,完成“检查更新 → 下载 → 安装 → 重启”的完整闭环;提供清晰的前后端接口、配置与测试/发布流程。
|
||||
|
||||
## 范围与目标
|
||||
- 能力:静态 JSON 与动态接口两种更新源;可选稳定/测试通道;进度反馈与错误处理。
|
||||
- 平台:macOS `.app` 优先;Windows 使用安装器(NSIS/MSI)。
|
||||
- 安全:启用 Ed25519 更新签名校验;上线前建议平台代码签名与公证。
|
||||
|
||||
## 架构与依赖
|
||||
- 插件:`tauri-plugin-updater`(更新)、`@tauri-apps/plugin-updater`(JS);`tauri-plugin-process` 与 `@tauri-apps/plugin-process`(重启)。
|
||||
- 签名与构建:`tauri signer generate` 生成密钥;CI/本机注入 `TAURI_SIGNING_PRIVATE_KEY`;`bundle.createUpdaterArtifacts: true` 生成签名制品。
|
||||
- 权限:在 `src-tauri/capabilities/default.json` 启用 `updater:default` 与 `process:allow-restart`。
|
||||
- 配置(`src-tauri/tauri.conf.json`):
|
||||
- `plugins.updater.pubkey: "<PUBLICKEY.PEM>"`
|
||||
- `plugins.updater.endpoints: ["<更新源 URL 列表>"]`
|
||||
- Windows(可选):`plugins.updater.windows.installMode: "passive|basicUi|quiet"`
|
||||
|
||||
## 前端接口设计(TypeScript)
|
||||
- 类型
|
||||
- `type UpdateChannel = 'stable' | 'beta'`
|
||||
- `type UpdaterPhase = 'idle' | 'checking' | 'available' | 'downloading' | 'installing' | 'restarting' | 'upToDate' | 'error'`
|
||||
- `type UpdateInfo = { currentVersion: string; availableVersion: string; notes?: string; pubDate?: string }`
|
||||
- `type UpdateProgressEvent = { event: 'Started' | 'Progress' | 'Finished'; total?: number; downloaded?: number }`
|
||||
- `type UpdateError = { code: string; message: string; cause?: unknown }`
|
||||
- `type CheckOptions = { timeout?: number; channel?: UpdateChannel }`
|
||||
- API(`src/lib/updater.ts`)
|
||||
- `getCurrentVersion(): Promise<string>` 读取当前版本。
|
||||
- `checkForUpdate(opts?: CheckOptions)` → `up-to-date` 或 `{ status: 'available', info, update }`。
|
||||
- `downloadAndInstall(update, onProgress?)` 下载并安装,进度回调映射 Started/Progress/Finished。
|
||||
- `relaunchApp()` 调用 `@tauri-apps/plugin-process.relaunch()`。
|
||||
- `runUpdateFlow(opts?)` 编排:检查 → 下载安装 → 重启;错误统一抛出 `UpdateError`。
|
||||
- `setUpdateChannel(channel)` 前端记录偏好;实际端点切换见“端点动态化”。
|
||||
- Hook(可选 `useUpdater()`)
|
||||
- 返回 `{ phase, info?, progress?, error?, actions: { check, startUpdate, relaunch } }`。
|
||||
- UI(组件建议)
|
||||
- `UpdateBanner`:发现新版本时展示;`UpdaterDialog`:显示说明、进度与错误/重试。
|
||||
|
||||
## Rust 集成与权限
|
||||
- 插件注册(`src-tauri/src/main.rs`):
|
||||
- `app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;`
|
||||
- `.plugin(tauri_plugin_process::init())` 用于重启。
|
||||
- Windows 清理钩子(可选):`UpdaterExt::on_before_exit(app.cleanup_before_exit)`,避免安装器启动前文件占用。
|
||||
- 端点动态化(可选):在 `setup` 根据配置/环境切换 `endpoints`、超时、代理或 headers。
|
||||
|
||||
## 更新源与格式
|
||||
- 静态 JSON(latest.json):字段 `version`、`platforms[target].url`、`platforms[target].signature`(`.sig` 内容);可选 `notes`、`pub_date`。
|
||||
- 动态接口:
|
||||
- 无更新:HTTP 204
|
||||
- 有更新:HTTP 200 → `{ version, url, signature, notes?, pub_date? }`
|
||||
- 通道组织:`/stable/latest.json`、`/beta/latest.json`;CDN 缓存需可控,回滚可强制刷新。
|
||||
|
||||
## 用户流程与 UX
|
||||
- 流程:检查 → 展示版本/日志 → 下载进度(累计/百分比)→ 安装 → 提示并重启。
|
||||
- 错误:网络异常(超时/断网/证书)、签名不匹配、权限/文件占用(Win)。提供“重试/稍后更新”。
|
||||
- 平台提示:
|
||||
- macOS:建议安装在 `~/Applications`,避免 `/Applications` 提权导致失败。
|
||||
- Windows:优先安装器分发,并选择合适 `installMode`。
|
||||
|
||||
## 测试计划
|
||||
- 功能:有更新/无更新(204)/下载中断/重试/安装后重启成功与版本号提升。
|
||||
- 安全:签名不匹配必须拒绝更新;端点不可用/被劫持有清晰提示。
|
||||
- 网络:超时/断网/代理场景提示与恢复。
|
||||
- 平台:
|
||||
- macOS:`/Applications` 与 `~/Applications` 的权限差异。
|
||||
- Windows:`passive|basicUi|quiet` 行为差异与成功率。
|
||||
- 本地自测:以 v1.0.0 运行,构建 v1.0.1 制品+`.sig`,本地 HTTP 托管 `latest.json`,验证全链路。
|
||||
|
||||
## 发布与回滚
|
||||
- 发布(CI 推荐):注入 `TAURI_SIGNING_PRIVATE_KEY` → 构建生成各平台制品+签名 → 上传产物与 `latest.json` 至 Releases/CDN。
|
||||
- 回滚:撤下问题版本或将 `latest.json` 指回上一个稳定版本;如需降级,Rust 侧可定制版本比较策略(可选)。
|
||||
|
||||
## 里程碑与验收
|
||||
- D1:密钥与基础集成(插件/配置/权限)。
|
||||
- D2:前端入口与进度 UI,静态 JSON 自测通过。
|
||||
- D3:Releases/CDN 端到端验证,平台专项测试。
|
||||
- D4:文档完善、回滚与异常流程演练。
|
||||
- 验收:两平台完成“发现→下载→安装→重启→版本提升”;签名校验生效;异常有明确提示与可行恢复。
|
||||
|
||||
## 待确认
|
||||
- 更新源托管(GitHub Releases 还是自有 CDN)。
|
||||
- 是否需要 beta 通道与运行时切换。
|
||||
- Windows 是否仅支持安装器分发;便携版兼容策略是否需要明确说明。
|
||||
- UI 文案与样式偏好。
|
||||
|
||||
## 落地步骤(实施顺序)
|
||||
1) 生成 Ed25519 密钥,将公钥写入 `plugins.updater.pubkey`,在构建环境配置 `TAURI_SIGNING_PRIVATE_KEY`。
|
||||
2) `src-tauri` 注册 `tauri-plugin-updater` 与 `tauri-plugin-process`,补齐 `capabilities/default.json` 与 `tauri.conf.json`。
|
||||
3) 前端新增 `src/lib/updater.ts` 封装与 `UpdateBanner`/`UpdaterDialog` 组件,接入入口按钮。
|
||||
4) 本地静态 `latest.json` 自测全链路;完善错误与进度提示。
|
||||
5) 配置 CI 发布产物与 `latest.json`;编写发布/回滚操作手册。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.1.1",
|
||||
"version": "3.4.0",
|
||||
"description": "Claude Code & Codex 供应商切换工具",
|
||||
"scripts": {
|
||||
"dev": "pnpm tauri dev",
|
||||
@@ -27,17 +27,22 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lint": "^6.8.5",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.38.2",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"i18next": "^25.5.2",
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"tailwindcss": "^4.1.13"
|
||||
}
|
||||
}
|
||||
|
||||
86
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@codemirror/lang-json':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
'@codemirror/lint':
|
||||
specifier: ^6.8.5
|
||||
version: 6.8.5
|
||||
'@codemirror/state':
|
||||
specifier: ^6.5.2
|
||||
version: 6.5.2
|
||||
@@ -26,6 +29,9 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.8.0
|
||||
version: 2.8.0
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.0.0
|
||||
version: 2.3.0
|
||||
@@ -35,6 +41,12 @@ importers:
|
||||
codemirror:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
i18next:
|
||||
specifier: ^25.5.2
|
||||
version: 25.5.2(typescript@5.9.2)
|
||||
jsonc-parser:
|
||||
specifier: ^3.2.1
|
||||
version: 3.3.1
|
||||
lucide-react:
|
||||
specifier: ^0.542.0
|
||||
version: 0.542.0(react@18.3.1)
|
||||
@@ -44,6 +56,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
react-i18next:
|
||||
specifier: ^16.0.0
|
||||
version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2)
|
||||
tailwindcss:
|
||||
specifier: ^4.1.13
|
||||
version: 4.1.13
|
||||
@@ -150,6 +165,10 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.28.4':
|
||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -632,6 +651,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.4.0':
|
||||
resolution: {integrity: sha512-OvXkrEBfWwtd8tzVCEXIvRfNEX87qs2jv6SqmVPiHcJjBhSF/GUvjqUNIDmKByb5N8nvDqVUM7+g1sXwdC/S9w==}
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.0':
|
||||
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
|
||||
|
||||
@@ -738,6 +760,17 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
i18next@25.5.2:
|
||||
resolution: {integrity: sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==}
|
||||
peerDependencies:
|
||||
typescript: ^5
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
jiti@2.5.1:
|
||||
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
|
||||
hasBin: true
|
||||
@@ -755,6 +788,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -875,6 +911,22 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-i18next@16.0.0:
|
||||
resolution: {integrity: sha512-JQ+dFfLnFSKJQt7W01lJHWRC0SX7eDPobI+MSTJ3/gP39xH2g33AuTE7iddAfXYHamJdAeMGM0VFboPaD3G68Q==}
|
||||
peerDependencies:
|
||||
i18next: '>= 25.5.2'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
typescript: ^5
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
react-refresh@0.17.0:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -958,6 +1010,10 @@ packages:
|
||||
terser:
|
||||
optional: true
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
@@ -1064,6 +1120,8 @@ snapshots:
|
||||
'@babel/core': 7.28.0
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/runtime@7.28.4': {}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
@@ -1439,6 +1497,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.8.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.8.1
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.4.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.8.0
|
||||
@@ -1572,6 +1634,16 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
||||
i18next@25.5.2(typescript@5.9.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
optionalDependencies:
|
||||
typescript: 5.9.2
|
||||
|
||||
jiti@2.5.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -1580,6 +1652,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
optional: true
|
||||
|
||||
@@ -1671,6 +1745,16 @@ snapshots:
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-i18next@16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 25.5.2(typescript@5.9.2)
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
typescript: 5.9.2
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react@18.3.1:
|
||||
@@ -1746,6 +1830,8 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
lightningcss: 1.30.1
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 162 KiB |
1172
src-tauri/Cargo.lock
generated
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.1.1"
|
||||
version = "3.4.0"
|
||||
description = "Claude Code & Codex 供应商配置管理工具"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/jasonyoung/cc-switch"
|
||||
repository = "https://github.com/farion1231/cc-switch"
|
||||
edition = "2021"
|
||||
rust-version = "1.85.0"
|
||||
|
||||
@@ -21,14 +21,30 @@ tauri-build = { version = "2.4.0", features = [] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
chrono = "0.4"
|
||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
futures = "0.3"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||
|
||||
# Optimize release binary size to help reduce AppImage footprint
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
"core:window:allow-set-skip-taskbar",
|
||||
"process:allow-restart",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
||||
BIN
src-tauri/icons/tray/macos/statusTemplate.png
Normal file
|
After Width: | Height: | Size: 564 KiB |
BIN
src-tauri/icons/tray/macos/statusTemplate@2x.png
Normal file
|
After Width: | Height: | Size: 572 KiB |
103
src-tauri/src/claude_plugin.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const CLAUDE_DIR: &str = ".claude";
|
||||
const CLAUDE_CONFIG_FILE: &str = "config.json";
|
||||
const CLAUDE_CONFIG_PAYLOAD: &str = "{\n \"primaryApiKey\": \"any\"\n}\n";
|
||||
|
||||
fn claude_dir() -> Result<PathBuf, String> {
|
||||
let home = dirs::home_dir().ok_or_else(|| "无法获取用户主目录".to_string())?;
|
||||
Ok(home.join(CLAUDE_DIR))
|
||||
}
|
||||
|
||||
pub fn claude_config_path() -> Result<PathBuf, String> {
|
||||
Ok(claude_dir()?.join(CLAUDE_CONFIG_FILE))
|
||||
}
|
||||
|
||||
pub fn ensure_claude_dir_exists() -> Result<PathBuf, String> {
|
||||
let dir = claude_dir()?;
|
||||
if !dir.exists() {
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("创建 Claude 配置目录失败: {}", e))?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn read_claude_config() -> Result<Option<String>, String> {
|
||||
let path = claude_config_path()?;
|
||||
if path.exists() {
|
||||
let content =
|
||||
fs::read_to_string(&path).map_err(|e| format!("读取 Claude 配置失败: {}", e))?;
|
||||
Ok(Some(content))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_managed_config(content: &str) -> bool {
|
||||
match serde_json::from_str::<serde_json::Value>(content) {
|
||||
Ok(value) => value
|
||||
.get("primaryApiKey")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|val| val == "any")
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_claude_config() -> Result<bool, String> {
|
||||
let path = claude_config_path()?;
|
||||
ensure_claude_dir_exists()?;
|
||||
let need_write = match read_claude_config()? {
|
||||
Some(existing) => existing != CLAUDE_CONFIG_PAYLOAD,
|
||||
None => true,
|
||||
};
|
||||
if need_write {
|
||||
fs::write(&path, CLAUDE_CONFIG_PAYLOAD)
|
||||
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
|
||||
}
|
||||
Ok(need_write)
|
||||
}
|
||||
|
||||
pub fn clear_claude_config() -> Result<bool, String> {
|
||||
let path = claude_config_path()?;
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let content = match read_claude_config()? {
|
||||
Some(content) => content,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
let mut value = match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
|
||||
let obj = match value.as_object_mut() {
|
||||
Some(obj) => obj,
|
||||
None => return Ok(false),
|
||||
};
|
||||
|
||||
if obj.remove("primaryApiKey").is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let serialized = serde_json::to_string_pretty(&value)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {}", e))?;
|
||||
fs::write(&path, format!("{}\n", serialized))
|
||||
.map_err(|e| format!("写入 Claude 配置失败: {}", e))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn claude_config_status() -> Result<(bool, PathBuf), String> {
|
||||
let path = claude_config_path()?;
|
||||
Ok((path.exists(), path))
|
||||
}
|
||||
|
||||
pub fn is_claude_config_applied() -> Result<bool, String> {
|
||||
match read_claude_config()? {
|
||||
Some(content) => Ok(is_managed_config(&content)),
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ use std::path::Path;
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,48 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use tauri::State;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::claude_plugin;
|
||||
use crate::codex_config;
|
||||
use crate::config::{get_claude_settings_path, ConfigStatus};
|
||||
use crate::provider::Provider;
|
||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
use crate::speedtest;
|
||||
use crate::store::AppState;
|
||||
|
||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
if !provider.settings_config.is_object() {
|
||||
return Err("Claude 配置必须是 JSON 对象".to_string());
|
||||
}
|
||||
}
|
||||
AppType::Codex => {
|
||||
let settings = provider
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| "Codex 配置必须是 JSON 对象".to_string())?;
|
||||
let auth = settings
|
||||
.get("auth")
|
||||
.ok_or_else(|| "Codex 配置缺少 auth 字段".to_string())?;
|
||||
if !auth.is_object() {
|
||||
return Err("Codex auth 配置必须是 JSON 对象".to_string());
|
||||
}
|
||||
if let Some(config_value) = settings.get("config") {
|
||||
if !(config_value.is_string() || config_value.is_null()) {
|
||||
return Err("Codex config 字段必须是字符串".to_string());
|
||||
}
|
||||
if let Some(cfg_text) = config_value.as_str() {
|
||||
codex_config::validate_config_toml(cfg_text)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有供应商
|
||||
#[tauri::command]
|
||||
pub async fn get_providers(
|
||||
@@ -74,6 +108,8 @@ pub async fn add_provider(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// 读取当前是否是激活供应商(短锁)
|
||||
let is_current = {
|
||||
let config = state
|
||||
@@ -139,6 +175,8 @@ pub async fn update_provider(
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
validate_provider_settings(&app_type, &provider)?;
|
||||
|
||||
// 读取校验 & 是否当前(短锁)
|
||||
let (exists, is_current) = {
|
||||
let config = state
|
||||
@@ -483,6 +521,26 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||
Ok(get_claude_settings_path().to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 获取当前生效的配置目录
|
||||
#[tauri::command]
|
||||
pub async fn get_config_dir(
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let app = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
|
||||
let dir = match app {
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
};
|
||||
|
||||
Ok(dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 打开配置文件夹
|
||||
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
|
||||
#[tauri::command]
|
||||
@@ -516,6 +574,38 @@ pub async fn open_config_folder(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 弹出系统目录选择器并返回用户选择的路径
|
||||
#[tauri::command]
|
||||
pub async fn pick_directory(
|
||||
app: tauri::AppHandle,
|
||||
default_path: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let initial = default_path
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty());
|
||||
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut builder = app.dialog().file();
|
||||
if let Some(path) = initial {
|
||||
builder = builder.set_directory(path);
|
||||
}
|
||||
builder.blocking_pick_folder()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
|
||||
|
||||
match result {
|
||||
Some(file_path) => {
|
||||
let resolved = file_path
|
||||
.simplified()
|
||||
.into_path()
|
||||
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
|
||||
Ok(Some(resolved.to_string_lossy().to_string()))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开外部链接
|
||||
#[tauri::command]
|
||||
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
|
||||
@@ -566,21 +656,14 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
|
||||
|
||||
/// 获取设置
|
||||
#[tauri::command]
|
||||
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
// 暂时返回默认设置
|
||||
Ok(serde_json::json!({
|
||||
"showInDock": true
|
||||
}))
|
||||
pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
||||
Ok(crate::settings::get_settings())
|
||||
}
|
||||
|
||||
/// 保存设置
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(
|
||||
_state: State<'_, AppState>,
|
||||
settings: serde_json::Value,
|
||||
) -> Result<bool, String> {
|
||||
// TODO: 实现设置保存逻辑
|
||||
log::info!("保存设置: {:?}", settings);
|
||||
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
||||
crate::settings::update_settings(settings)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -591,10 +674,243 @@ pub async fn check_for_updates(handle: tauri::AppHandle) -> Result<bool, String>
|
||||
handle
|
||||
.opener()
|
||||
.open_url(
|
||||
"https://github.com/farion1231/cc-switch/releases",
|
||||
"https://github.com/farion1231/cc-switch/releases/latest",
|
||||
None::<String>,
|
||||
)
|
||||
.map_err(|e| format!("打开更新页面失败: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 判断是否为便携版(绿色版)运行
|
||||
#[tauri::command]
|
||||
pub async fn is_portable_mode() -> Result<bool, String> {
|
||||
let exe_path = std::env::current_exe().map_err(|e| format!("获取可执行路径失败: {}", e))?;
|
||||
if let Some(dir) = exe_path.parent() {
|
||||
Ok(dir.join("portable.ini").is_file())
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude 插件:获取 ~/.claude/config.json 状态
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_plugin_status() -> Result<ConfigStatus, String> {
|
||||
match claude_plugin::claude_config_status() {
|
||||
Ok((exists, path)) => Ok(ConfigStatus {
|
||||
exists,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
}),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude 插件:读取配置内容(若不存在返回 Ok(None))
|
||||
#[tauri::command]
|
||||
pub async fn read_claude_plugin_config() -> Result<Option<String>, String> {
|
||||
claude_plugin::read_claude_config()
|
||||
}
|
||||
|
||||
/// Claude 插件:写入/清除固定配置
|
||||
#[tauri::command]
|
||||
pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String> {
|
||||
if official {
|
||||
claude_plugin::clear_claude_config()
|
||||
} else {
|
||||
claude_plugin::write_claude_config()
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude 插件:检测是否已写入目标配置
|
||||
#[tauri::command]
|
||||
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
||||
claude_plugin::is_claude_config_applied()
|
||||
}
|
||||
|
||||
/// 测试第三方/自定义供应商端点的网络延迟
|
||||
#[tauri::command]
|
||||
pub async fn test_api_endpoints(
|
||||
urls: Vec<String>,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<Vec<speedtest::EndpointLatency>, String> {
|
||||
let filtered: Vec<String> = urls
|
||||
.into_iter()
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
.collect();
|
||||
speedtest::test_endpoints(filtered, timeout_secs).await
|
||||
}
|
||||
|
||||
/// 获取自定义端点列表
|
||||
#[tauri::command]
|
||||
pub async fn get_custom_endpoints(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
providerId: Option<String>,
|
||||
) -> Result<Vec<crate::settings::CustomEndpoint>, String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
let Some(provider) = manager.providers.get_mut(&provider_id) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
// 首选从 provider.meta 读取
|
||||
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
|
||||
if !meta.custom_endpoints.is_empty() {
|
||||
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
|
||||
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// 添加自定义端点
|
||||
#[tauri::command]
|
||||
pub async fn add_custom_endpoint(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
providerId: Option<String>,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
if normalized.is_empty() {
|
||||
return Err("URL 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
let Some(provider) = manager.providers.get_mut(&provider_id) else {
|
||||
return Err("供应商不存在或未选择".to_string());
|
||||
};
|
||||
let meta = provider.meta.get_or_insert_with(ProviderMeta::default);
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64;
|
||||
|
||||
let endpoint = crate::settings::CustomEndpoint {
|
||||
url: normalized.clone(),
|
||||
added_at: timestamp,
|
||||
last_used: None,
|
||||
};
|
||||
meta.custom_endpoints.insert(normalized, endpoint);
|
||||
drop(cfg_guard);
|
||||
state.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除自定义端点
|
||||
#[tauri::command]
|
||||
pub async fn remove_custom_endpoint(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
providerId: Option<String>,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
if let Some(provider) = manager.providers.get_mut(&provider_id) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
meta.custom_endpoints.remove(&normalized);
|
||||
}
|
||||
}
|
||||
drop(cfg_guard);
|
||||
state.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新端点最后使用时间
|
||||
#[tauri::command]
|
||||
pub async fn update_endpoint_last_used(
|
||||
state: State<'_, crate::store::AppState>,
|
||||
app_type: Option<AppType>,
|
||||
app: Option<String>,
|
||||
appType: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
providerId: Option<String>,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let app_type = app_type
|
||||
.or_else(|| app.as_deref().map(|s| s.into()))
|
||||
.or_else(|| appType.as_deref().map(|s| s.into()))
|
||||
.unwrap_or(AppType::Claude);
|
||||
let provider_id = provider_id
|
||||
.or(providerId)
|
||||
.ok_or_else(|| "缺少 providerId".to_string())?;
|
||||
let normalized = url.trim().trim_end_matches('/').to_string();
|
||||
|
||||
let mut cfg_guard = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("获取锁失败: {}", e))?;
|
||||
let manager = cfg_guard
|
||||
.get_manager_mut(&app_type)
|
||||
.ok_or_else(|| format!("应用类型不存在: {:?}", app_type))?;
|
||||
|
||||
if let Some(provider) = manager.providers.get_mut(&provider_id) {
|
||||
if let Some(meta) = provider.meta.as_mut() {
|
||||
if let Some(endpoint) = meta.custom_endpoints.get_mut(&normalized) {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64;
|
||||
endpoint.last_used = Some(timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cfg_guard);
|
||||
state.save()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
/// 获取 Claude Code 配置目录路径
|
||||
pub fn get_claude_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_claude_override_dir() {
|
||||
return custom;
|
||||
}
|
||||
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户主目录")
|
||||
.join(".claude")
|
||||
@@ -175,7 +179,19 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性)
|
||||
if path.exists() {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
fs::rename(&tmp, path).map_err(|e| format!("原子替换失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
170
src-tauri/src/import_export.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// 默认仅保留最近 10 份备份,避免目录无限膨胀
|
||||
const MAX_BACKUPS: usize = 10;
|
||||
|
||||
/// 创建配置文件备份
|
||||
pub fn create_backup(config_path: &PathBuf) -> Result<String, String> {
|
||||
if !config_path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let backup_id = format!("backup_{}", timestamp);
|
||||
|
||||
let backup_dir = config_path
|
||||
.parent()
|
||||
.ok_or("Invalid config path")?
|
||||
.join("backups");
|
||||
|
||||
// 创建备份目录
|
||||
fs::create_dir_all(&backup_dir)
|
||||
.map_err(|e| format!("Failed to create backup directory: {}", e))?;
|
||||
|
||||
let backup_path = backup_dir.join(format!("{}.json", backup_id));
|
||||
|
||||
// 复制配置文件到备份
|
||||
fs::copy(config_path, backup_path).map_err(|e| format!("Failed to create backup: {}", e))?;
|
||||
|
||||
// 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份)
|
||||
cleanup_old_backups(&backup_dir, MAX_BACKUPS)?;
|
||||
|
||||
Ok(backup_id)
|
||||
}
|
||||
|
||||
fn cleanup_old_backups(backup_dir: &PathBuf, retain: usize) -> Result<(), String> {
|
||||
if retain == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut entries: Vec<_> = match fs::read_dir(backup_dir) {
|
||||
Ok(iter) => iter
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "json")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
if entries.len() <= retain {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remove_count = entries.len().saturating_sub(retain);
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
let a_time = a.metadata().and_then(|m| m.modified()).ok();
|
||||
let b_time = b.metadata().and_then(|m| m.modified()).ok();
|
||||
a_time.cmp(&b_time)
|
||||
});
|
||||
|
||||
for entry in entries.into_iter().take(remove_count) {
|
||||
if let Err(err) = fs::remove_file(entry.path()) {
|
||||
log::warn!(
|
||||
"Failed to remove old backup {}: {}",
|
||||
entry.path().display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导出配置文件
|
||||
#[tauri::command]
|
||||
pub async fn export_config_to_file(file_path: String) -> Result<Value, String> {
|
||||
// 读取当前配置文件
|
||||
let config_path = crate::config::get_app_config_path();
|
||||
let config_content = fs::read_to_string(&config_path)
|
||||
.map_err(|e| format!("Failed to read configuration: {}", e))?;
|
||||
|
||||
// 写入到指定文件
|
||||
fs::write(&file_path, &config_content).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"message": "Configuration exported successfully",
|
||||
"filePath": file_path
|
||||
}))
|
||||
}
|
||||
|
||||
/// 从文件导入配置
|
||||
#[tauri::command]
|
||||
pub async fn import_config_from_file(
|
||||
file_path: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<Value, String> {
|
||||
// 读取导入的文件
|
||||
let import_content =
|
||||
fs::read_to_string(&file_path).map_err(|e| format!("Failed to read import file: {}", e))?;
|
||||
|
||||
// 验证并解析为配置对象
|
||||
let new_config: crate::app_config::MultiAppConfig = serde_json::from_str(&import_content)
|
||||
.map_err(|e| format!("Invalid configuration file: {}", e))?;
|
||||
|
||||
// 备份当前配置
|
||||
let config_path = crate::config::get_app_config_path();
|
||||
let backup_id = create_backup(&config_path)?;
|
||||
|
||||
// 写入新配置到磁盘
|
||||
fs::write(&config_path, &import_content)
|
||||
.map_err(|e| format!("Failed to write configuration: {}", e))?;
|
||||
|
||||
// 更新内存中的状态
|
||||
{
|
||||
let mut config_state = state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock config: {}", e))?;
|
||||
*config_state = new_config;
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"message": "Configuration imported successfully",
|
||||
"backupId": backup_id
|
||||
}))
|
||||
}
|
||||
|
||||
/// 保存文件对话框
|
||||
#[tauri::command]
|
||||
pub async fn save_file_dialog<R: tauri::Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
default_name: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let dialog = app.dialog();
|
||||
let result = dialog
|
||||
.file()
|
||||
.add_filter("JSON", &["json"])
|
||||
.set_file_name(&default_name)
|
||||
.blocking_save_file();
|
||||
|
||||
Ok(result.map(|p| p.to_string()))
|
||||
}
|
||||
|
||||
/// 打开文件对话框
|
||||
#[tauri::command]
|
||||
pub async fn open_file_dialog<R: tauri::Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let dialog = app.dialog();
|
||||
let result = dialog
|
||||
.file()
|
||||
.add_filter("JSON", &["json"])
|
||||
.blocking_pick_file();
|
||||
|
||||
Ok(result.map(|p| p.to_string()))
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
mod app_config;
|
||||
mod claude_plugin;
|
||||
mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod import_export;
|
||||
mod migration;
|
||||
mod provider;
|
||||
mod settings;
|
||||
mod store;
|
||||
mod speedtest;
|
||||
|
||||
use store::AppState;
|
||||
use tauri::{
|
||||
menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::{ActivationPolicy, RunEvent};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
/// 创建动态托盘菜单
|
||||
@@ -25,6 +31,11 @@ fn create_tray_menu(
|
||||
|
||||
let mut menu_builder = MenuBuilder::new(app);
|
||||
|
||||
// 顶部:打开主界面
|
||||
let show_main_item = MenuItem::with_id(app, "show_main", "打开主界面", true, None::<&str>)
|
||||
.map_err(|e| format!("创建打开主界面菜单失败: {}", e))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// 直接添加所有供应商到主菜单(扁平化结构,更简单可靠)
|
||||
if let Some(claude_manager) = config.get_manager(&crate::app_config::AppType::Claude) {
|
||||
// 添加Claude标题(禁用状态,仅作为分组标识)
|
||||
@@ -107,18 +118,50 @@ fn create_tray_menu(
|
||||
.map_err(|e| format!("构建菜单失败: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||
let desired_policy = if dock_visible {
|
||||
ActivationPolicy::Regular
|
||||
} else {
|
||||
ActivationPolicy::Accessory
|
||||
};
|
||||
|
||||
if let Err(err) = app.set_dock_visibility(dock_visible) {
|
||||
log::warn!("设置 Dock 显示状态失败: {}", err);
|
||||
}
|
||||
|
||||
if let Err(err) = app.set_activation_policy(desired_policy) {
|
||||
log::warn!("设置激活策略失败: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理托盘菜单事件
|
||||
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
println!("处理托盘菜单事件: {}", event_id);
|
||||
log::info!("处理托盘菜单事件: {}", event_id);
|
||||
|
||||
match event_id {
|
||||
"show_main" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
apply_tray_policy(app, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
println!("退出应用");
|
||||
log::info!("退出应用");
|
||||
app.exit(0);
|
||||
}
|
||||
id if id.starts_with("claude_") => {
|
||||
let provider_id = id.strip_prefix("claude_").unwrap();
|
||||
println!("切换到Claude供应商: {}", provider_id);
|
||||
log::info!("切换到Claude供应商: {}", provider_id);
|
||||
|
||||
// 执行切换
|
||||
let app_handle = app.clone();
|
||||
@@ -131,13 +174,13 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("切换Claude供应商失败: {}", e);
|
||||
log::error!("切换Claude供应商失败: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
id if id.starts_with("codex_") => {
|
||||
let provider_id = id.strip_prefix("codex_").unwrap();
|
||||
println!("切换到Codex供应商: {}", provider_id);
|
||||
log::info!("切换到Codex供应商: {}", provider_id);
|
||||
|
||||
// 执行切换
|
||||
let app_handle = app.clone();
|
||||
@@ -150,16 +193,18 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("切换Codex供应商失败: {}", e);
|
||||
log::error!("切换Codex供应商失败: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
println!("未处理的菜单事件: {}", event_id);
|
||||
log::warn!("未处理的菜单事件: {}", event_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
/// 内部切换供应商函数
|
||||
async fn switch_provider_internal(
|
||||
app: &tauri::AppHandle,
|
||||
@@ -184,7 +229,7 @@ async fn switch_provider_internal(
|
||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||
eprintln!("更新托盘菜单失败: {}", e);
|
||||
log::error!("更新托盘菜单失败: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +240,7 @@ async fn switch_provider_internal(
|
||||
"providerId": provider_id_clone
|
||||
});
|
||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||
eprintln!("发射供应商切换事件失败: {}", e);
|
||||
log::error!("发射供应商切换事件失败: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -219,8 +264,44 @@ async fn update_tray_menu(
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let builder = builder
|
||||
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
let settings = crate::settings::get_settings();
|
||||
|
||||
if settings.minimize_to_tray_on_close {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(true);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
apply_tray_policy(&window.app_handle(), false);
|
||||
}
|
||||
} else {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
// 注册 Updater 插件(桌面端)
|
||||
@@ -294,33 +375,23 @@ pub fn run() {
|
||||
// 创建动态托盘菜单
|
||||
let menu = create_tray_menu(&app.handle(), &app_state)?;
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main")
|
||||
.on_tray_icon_event(|tray, event| match event {
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} => {
|
||||
println!("left click pressed and released");
|
||||
// 在这个例子中,当点击托盘图标时,将展示并聚焦于主窗口
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
println!("unhandled event {event:?}");
|
||||
}
|
||||
// 构建托盘
|
||||
let mut tray_builder = TrayIconBuilder::with_id("main")
|
||||
.on_tray_icon_event(|_tray, event| match event {
|
||||
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
|
||||
TrayIconEvent::Click { .. } => {}
|
||||
_ => log::debug!("unhandled event {event:?}"),
|
||||
})
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| {
|
||||
handle_tray_menu_event(app, &event.id.0);
|
||||
})
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.show_menu_on_left_click(true)
|
||||
.build(app)?;
|
||||
.show_menu_on_left_click(true);
|
||||
|
||||
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
|
||||
tray_builder = tray_builder.icon(app.default_window_icon().unwrap().clone());
|
||||
|
||||
let _tray = tray_builder.build(app)?;
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
Ok(())
|
||||
@@ -336,15 +407,60 @@ pub fn run() {
|
||||
commands::get_claude_config_status,
|
||||
commands::get_config_status,
|
||||
commands::get_claude_code_config_path,
|
||||
commands::get_config_dir,
|
||||
commands::open_config_folder,
|
||||
commands::pick_directory,
|
||||
commands::open_external,
|
||||
commands::get_app_config_path,
|
||||
commands::open_app_config_folder,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::check_for_updates,
|
||||
commands::is_portable_mode,
|
||||
commands::get_claude_plugin_status,
|
||||
commands::read_claude_plugin_config,
|
||||
commands::apply_claude_plugin_config,
|
||||
commands::is_claude_plugin_applied,
|
||||
// ours: endpoint speed test + custom endpoint management
|
||||
commands::test_api_endpoints,
|
||||
commands::get_custom_endpoints,
|
||||
commands::add_custom_endpoint,
|
||||
commands::remove_custom_endpoint,
|
||||
commands::update_endpoint_last_used,
|
||||
// theirs: config import/export and dialogs
|
||||
import_export::export_config_to_file,
|
||||
import_export::import_config_from_file,
|
||||
import_export::save_file_dialog,
|
||||
import_export::open_file_dialog,
|
||||
update_tray_menu,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
// macOS 在 Dock 图标被点击并重新激活应用时会触发 Reopen 事件,这里手动恢复主窗口
|
||||
match event {
|
||||
RunEvent::Reopen { .. } => {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = window.set_skip_taskbar(false);
|
||||
}
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
apply_tray_policy(app_handle, true);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = (app_handle, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,8 +145,11 @@ fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)>
|
||||
}
|
||||
|
||||
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> {
|
||||
// 如果已迁移过则跳过
|
||||
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
|
||||
let marker = get_marker_path();
|
||||
if let Some(parent) = marker.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("创建迁移标记目录失败: {}", e))?;
|
||||
}
|
||||
if marker.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ pub struct Provider {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "websiteUrl")]
|
||||
pub website_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: Option<i64>,
|
||||
/// 供应商元数据(不写入 live 配置,仅存于 ~/.cc-switch/config.json)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<ProviderMeta>,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
@@ -29,6 +37,9 @@ impl Provider {
|
||||
name,
|
||||
settings_config,
|
||||
website_url,
|
||||
category: None,
|
||||
created_at: None,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +60,14 @@ impl Default for ProviderManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
/// 自定义端点列表(按 URL 去重存储)
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// 获取所有供应商
|
||||
pub fn get_all_providers(&self) -> &HashMap<String, Provider> {
|
||||
|
||||
177
src-tauri/src/settings.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
/// 自定义端点配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomEndpoint {
|
||||
pub url: String,
|
||||
pub added_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
/// 应用设置结构,允许覆盖默认配置目录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppSettings {
|
||||
#[serde(default = "default_show_in_tray")]
|
||||
pub show_in_tray: bool,
|
||||
#[serde(default = "default_minimize_to_tray_on_close")]
|
||||
pub minimize_to_tray_on_close: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub codex_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
/// Claude 自定义端点列表
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints_claude: HashMap<String, CustomEndpoint>,
|
||||
/// Codex 自定义端点列表
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints_codex: HashMap<String, CustomEndpoint>,
|
||||
}
|
||||
|
||||
fn default_show_in_tray() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_minimize_to_tray_on_close() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_in_tray: true,
|
||||
minimize_to_tray_on_close: true,
|
||||
claude_config_dir: None,
|
||||
codex_config_dir: None,
|
||||
language: None,
|
||||
custom_endpoints_claude: HashMap::new(),
|
||||
custom_endpoints_codex: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
fn settings_path() -> PathBuf {
|
||||
crate::config::get_app_config_dir().join("settings.json")
|
||||
}
|
||||
|
||||
fn normalize_paths(&mut self) {
|
||||
self.claude_config_dir = self
|
||||
.claude_config_dir
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.codex_config_dir = self
|
||||
.codex_config_dir
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
self.language = self
|
||||
.language
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| matches!(*s, "en" | "zh"))
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
let path = Self::settings_path();
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
match serde_json::from_str::<AppSettings>(&content) {
|
||||
Ok(mut settings) => {
|
||||
settings.normalize_paths();
|
||||
settings
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let mut normalized = self.clone();
|
||||
normalized.normalize_paths();
|
||||
let path = Self::settings_path();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建设置目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&normalized)
|
||||
.map_err(|e| format!("序列化设置失败: {}", e))?;
|
||||
fs::write(&path, json).map_err(|e| format!("写入设置失败: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn settings_store() -> &'static RwLock<AppSettings> {
|
||||
static STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
|
||||
STORE.get_or_init(|| RwLock::new(AppSettings::load()))
|
||||
}
|
||||
|
||||
fn resolve_override_path(raw: &str) -> PathBuf {
|
||||
if raw == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home;
|
||||
}
|
||||
} else if let Some(stripped) = raw.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(stripped);
|
||||
}
|
||||
} else if let Some(stripped) = raw.strip_prefix("~\\") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
PathBuf::from(raw)
|
||||
}
|
||||
|
||||
pub fn get_settings() -> AppSettings {
|
||||
settings_store().read().expect("读取设置锁失败").clone()
|
||||
}
|
||||
|
||||
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), String> {
|
||||
new_settings.normalize_paths();
|
||||
new_settings.save()?;
|
||||
|
||||
let mut guard = settings_store().write().expect("写入设置锁失败");
|
||||
*guard = new_settings;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_claude_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
.claude_config_dir
|
||||
.as_ref()
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_codex_override_dir() -> Option<PathBuf> {
|
||||
let settings = settings_store().read().ok()?;
|
||||
settings
|
||||
.codex_config_dir
|
||||
.as_ref()
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
102
src-tauri/src/speedtest.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use futures::future::join_all;
|
||||
use reqwest::{Client, Url};
|
||||
use serde::Serialize;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 8;
|
||||
const MAX_TIMEOUT_SECS: u64 = 30;
|
||||
const MIN_TIMEOUT_SECS: u64 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct EndpointLatency {
|
||||
pub url: String,
|
||||
pub latency: Option<u128>,
|
||||
pub status: Option<u16>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn build_client(timeout_secs: u64) -> Result<Client, String> {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.user_agent("cc-switch-speedtest/1.0")
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))
|
||||
}
|
||||
|
||||
fn sanitize_timeout(timeout_secs: Option<u64>) -> u64 {
|
||||
let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
|
||||
}
|
||||
|
||||
pub async fn test_endpoints(
|
||||
urls: Vec<String>,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<Vec<EndpointLatency>, String> {
|
||||
if urls.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let timeout = sanitize_timeout(timeout_secs);
|
||||
let client = build_client(timeout)?;
|
||||
|
||||
let tasks = urls.into_iter().map(|raw_url| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
let trimmed = raw_url.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return EndpointLatency {
|
||||
url: raw_url,
|
||||
latency: None,
|
||||
status: None,
|
||||
error: Some("URL 不能为空".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let parsed_url = match Url::parse(&trimmed) {
|
||||
Ok(url) => url,
|
||||
Err(err) => {
|
||||
return EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: None,
|
||||
status: None,
|
||||
error: Some(format!("URL 无效: {err}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
match client.get(parsed_url).send().await {
|
||||
Ok(resp) => {
|
||||
let latency = start.elapsed().as_millis();
|
||||
EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: Some(latency),
|
||||
status: Some(resp.status().as_u16()),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let status = err.status().map(|s| s.as_u16());
|
||||
let error_message = if err.is_timeout() {
|
||||
"请求超时".to_string()
|
||||
} else if err.is_connect() {
|
||||
"连接失败".to_string()
|
||||
} else {
|
||||
err.to_string()
|
||||
};
|
||||
|
||||
EndpointLatency {
|
||||
url: trimmed,
|
||||
latency: None,
|
||||
status,
|
||||
error: Some(error_message),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let results = join_all(tasks).await;
|
||||
Ok(results)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.1.1",
|
||||
"version": "3.4.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -37,14 +37,18 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
,
|
||||
],
|
||||
"windows": {
|
||||
"wix": {
|
||||
"template": "wix/per-user-main.wxs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRERTRCNEUxQUE3MDA4QTYKUldTbUNIQ3E0YlRrVFF2cnFVVE1jczlNZFlmemxXd0h6cTdibXRJWjBDSytQODdZOTYvR3d3d2oK",
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
||||
"endpoints": [
|
||||
"https://github.com/jasonyoung/cc-switch/releases/latest/download/latest.json"
|
||||
"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
360
src-tauri/wix/per-user-main.wxs
Normal file
@@ -0,0 +1,360 @@
|
||||
<?if $(sys.BUILDARCH)="x86"?>
|
||||
<?define Win64 = "no" ?>
|
||||
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
|
||||
<?elseif $(sys.BUILDARCH)="x64"?>
|
||||
<?define Win64 = "yes" ?>
|
||||
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
|
||||
<?elseif $(sys.BUILDARCH)="arm64"?>
|
||||
<?define Win64 = "yes" ?>
|
||||
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
|
||||
<?else?>
|
||||
<?error Unsupported value of sys.BUILDARCH=$(sys.BUILDARCH)?>
|
||||
<?endif?>
|
||||
|
||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||
<Product
|
||||
Id="*"
|
||||
Name="{{product_name}}"
|
||||
UpgradeCode="{{upgrade_code}}"
|
||||
Language="!(loc.TauriLanguage)"
|
||||
Manufacturer="{{manufacturer}}"
|
||||
Version="{{version}}">
|
||||
|
||||
<Package Id="*"
|
||||
Keywords="Installer"
|
||||
InstallerVersion="450"
|
||||
Languages="0"
|
||||
Compressed="yes"
|
||||
InstallScope="perUser"
|
||||
InstallPrivileges="limited"
|
||||
SummaryCodepage="!(loc.TauriCodepage)"/>
|
||||
|
||||
<!-- https://docs.microsoft.com/en-us/windows/win32/msi/reinstallmode -->
|
||||
<!-- reinstall all files; rewrite all registry entries; reinstall all shortcuts -->
|
||||
<Property Id="REINSTALLMODE" Value="amus" />
|
||||
|
||||
<!-- Auto launch app after installation, useful for passive mode which usually used in updates -->
|
||||
<Property Id="AUTOLAUNCHAPP" Secure="yes" />
|
||||
<!-- Property to forward cli args to the launched app to not lose those of the pre-update instance -->
|
||||
<Property Id="LAUNCHAPPARGS" Secure="yes" />
|
||||
|
||||
{{#if allow_downgrades}}
|
||||
<MajorUpgrade Schedule="afterInstallInitialize" AllowDowngrades="yes" />
|
||||
{{else}}
|
||||
<MajorUpgrade Schedule="afterInstallInitialize" DowngradeErrorMessage="!(loc.DowngradeErrorMessage)" AllowSameVersionUpgrades="yes" />
|
||||
{{/if}}
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<RemoveShortcuts>Installed AND NOT UPGRADINGPRODUCTCODE</RemoveShortcuts>
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<Media Id="1" Cabinet="app.cab" EmbedCab="yes" />
|
||||
|
||||
{{#if banner_path}}
|
||||
<WixVariable Id="WixUIBannerBmp" Value="{{banner_path}}" />
|
||||
{{/if}}
|
||||
{{#if dialog_image_path}}
|
||||
<WixVariable Id="WixUIDialogBmp" Value="{{dialog_image_path}}" />
|
||||
{{/if}}
|
||||
{{#if license}}
|
||||
<WixVariable Id="WixUILicenseRtf" Value="{{license}}" />
|
||||
{{/if}}
|
||||
|
||||
<Icon Id="ProductIcon" SourceFile="{{icon_path}}"/>
|
||||
<Property Id="ARPPRODUCTICON" Value="ProductIcon" />
|
||||
<Property Id="ARPNOREPAIR" Value="yes" Secure="yes" /> <!-- Remove repair -->
|
||||
<SetProperty Id="ARPNOMODIFY" Value="1" After="InstallValidate" Sequence="execute"/>
|
||||
|
||||
{{#if homepage}}
|
||||
<Property Id="ARPURLINFOABOUT" Value="{{homepage}}"/>
|
||||
<Property Id="ARPHELPLINK" Value="{{homepage}}"/>
|
||||
<Property Id="ARPURLUPDATEINFO" Value="{{homepage}}"/>
|
||||
{{/if}}
|
||||
|
||||
<Property Id="INSTALLDIR">
|
||||
<!-- First attempt: Search for "InstallDir" -->
|
||||
<RegistrySearch Id="PrevInstallDirWithName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="InstallDir" Type="raw" />
|
||||
|
||||
<!-- Second attempt: If the first fails, search for the default key value (this is how the nsis installer currently stores the path) -->
|
||||
<RegistrySearch Id="PrevInstallDirNoName" Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Type="raw" />
|
||||
</Property>
|
||||
|
||||
<!-- launch app checkbox -->
|
||||
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="!(loc.LaunchApp)" />
|
||||
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1"/>
|
||||
<CustomAction Id="LaunchApplication" Impersonate="yes" FileKey="Path" ExeCommand="[LAUNCHAPPARGS]" Return="asyncNoWait" />
|
||||
|
||||
<UI>
|
||||
<!-- launch app checkbox -->
|
||||
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchApplication">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish>
|
||||
|
||||
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR" />
|
||||
|
||||
{{#unless license}}
|
||||
<!-- Skip license dialog -->
|
||||
<Publish Dialog="WelcomeDlg"
|
||||
Control="Next"
|
||||
Event="NewDialog"
|
||||
Value="InstallDirDlg"
|
||||
Order="2">1</Publish>
|
||||
<Publish Dialog="InstallDirDlg"
|
||||
Control="Back"
|
||||
Event="NewDialog"
|
||||
Value="WelcomeDlg"
|
||||
Order="2">1</Publish>
|
||||
{{/unless}}
|
||||
</UI>
|
||||
|
||||
<UIRef Id="WixUI_InstallDir" />
|
||||
|
||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||
<Directory Id="DesktopFolder" Name="Desktop">
|
||||
<Component Id="ApplicationShortcutDesktop" Guid="*">
|
||||
<Shortcut Id="ApplicationDesktopShortcut" Name="{{product_name}}" Description="Runs {{product_name}}" Target="[!Path]" WorkingDirectory="INSTALLDIR" />
|
||||
<RemoveFolder Id="DesktopFolder" On="uninstall" />
|
||||
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Desktop Shortcut" Type="integer" Value="1" KeyPath="yes" />
|
||||
</Component>
|
||||
</Directory>
|
||||
<Directory Id="LocalAppDataFolder">
|
||||
<Directory Id="TauriLocalAppDataPrograms" Name="Programs">
|
||||
<Directory Id="INSTALLDIR" Name="{{product_name}}"/>
|
||||
</Directory>
|
||||
</Directory>
|
||||
<Directory Id="ProgramMenuFolder">
|
||||
<Directory Id="ApplicationProgramsFolder" Name="{{product_name}}"/>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<DirectoryRef Id="INSTALLDIR">
|
||||
<Component Id="RegistryEntries" Guid="*">
|
||||
<RegistryKey Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}">
|
||||
<RegistryValue Name="InstallDir" Type="string" Value="[INSTALLDIR]" KeyPath="yes" />
|
||||
</RegistryKey>
|
||||
<!-- Change the Root to HKCU for perUser installations -->
|
||||
{{#each deep_link_protocols as |protocol| ~}}
|
||||
<RegistryKey Root="HKCU" Key="Software\Classes\\{{protocol}}">
|
||||
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
|
||||
<RegistryValue Type="string" Value="URL:{{bundle_id}} protocol"/>
|
||||
<RegistryKey Key="DefaultIcon">
|
||||
<RegistryValue Type="string" Value=""[!Path]",0" />
|
||||
</RegistryKey>
|
||||
<RegistryKey Key="shell\open\command">
|
||||
<RegistryValue Type="string" Value=""[!Path]" "%1"" />
|
||||
</RegistryKey>
|
||||
</RegistryKey>
|
||||
{{/each~}}
|
||||
</Component>
|
||||
<Component Id="Path" Guid="{{path_component_guid}}" Win64="$(var.Win64)">
|
||||
<File Id="Path" Source="{{main_binary_path}}" KeyPath="no" Checksum="yes"/>
|
||||
<RegistryValue Root="HKCU" Key="Software\{{manufacturer}}\{{product_name}}" Name="PathComponent" Type="integer" Value="1" KeyPath="yes" />
|
||||
{{#each file_associations as |association| ~}}
|
||||
{{#each association.ext as |ext| ~}}
|
||||
<ProgId Id="{{../../product_name}}.{{ext}}" Advertise="yes" Description="{{association.description}}">
|
||||
<Extension Id="{{ext}}" Advertise="yes">
|
||||
<Verb Id="open" Command="Open with {{../../product_name}}" Argument=""%1"" />
|
||||
</Extension>
|
||||
</ProgId>
|
||||
{{/each~}}
|
||||
{{/each~}}
|
||||
</Component>
|
||||
{{#each binaries as |bin| ~}}
|
||||
<Component Id="{{ bin.id }}" Guid="{{bin.guid}}" Win64="$(var.Win64)">
|
||||
<File Id="Bin_{{ bin.id }}" Source="{{bin.path}}" KeyPath="yes"/>
|
||||
</Component>
|
||||
{{/each~}}
|
||||
{{#if enable_elevated_update_task}}
|
||||
<Component Id="UpdateTask" Guid="C492327D-9720-4CD5-8DB8-F09082AF44BE" Win64="$(var.Win64)">
|
||||
<File Id="UpdateTask" Source="update.xml" KeyPath="yes" Checksum="yes"/>
|
||||
</Component>
|
||||
<Component Id="UpdateTaskInstaller" Guid="011F25ED-9BE3-50A7-9E9B-3519ED2B9932" Win64="$(var.Win64)">
|
||||
<File Id="UpdateTaskInstaller" Source="install-task.ps1" KeyPath="yes" Checksum="yes"/>
|
||||
</Component>
|
||||
<Component Id="UpdateTaskUninstaller" Guid="D4F6CC3F-32DC-5FD0-95E8-782FFD7BBCE1" Win64="$(var.Win64)">
|
||||
<File Id="UpdateTaskUninstaller" Source="uninstall-task.ps1" KeyPath="yes" Checksum="yes"/>
|
||||
</Component>
|
||||
{{/if}}
|
||||
{{resources}}
|
||||
<Component Id="CMP_UninstallShortcut" Guid="*">
|
||||
|
||||
<Shortcut Id="UninstallShortcut"
|
||||
Name="Uninstall {{product_name}}"
|
||||
Description="Uninstalls {{product_name}}"
|
||||
Target="[System64Folder]msiexec.exe"
|
||||
Arguments="/x [ProductCode]" />
|
||||
|
||||
<RemoveFile Id="RemoveUserProgramsFiles" Directory="TauriLocalAppDataPrograms" Name="*" On="uninstall" />
|
||||
<RemoveFolder Id="RemoveUserProgramsFolder" Directory="TauriLocalAppDataPrograms" On="uninstall" />
|
||||
|
||||
<RemoveFolder Id="INSTALLDIR"
|
||||
On="uninstall" />
|
||||
|
||||
<RegistryValue Root="HKCU"
|
||||
Key="Software\\{{manufacturer}}\\{{product_name}}"
|
||||
Name="Uninstaller Shortcut"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
<DirectoryRef Id="ApplicationProgramsFolder">
|
||||
<Component Id="ApplicationShortcut" Guid="*">
|
||||
<Shortcut Id="ApplicationStartMenuShortcut"
|
||||
Name="{{product_name}}"
|
||||
Description="Runs {{product_name}}"
|
||||
Target="[!Path]"
|
||||
Icon="ProductIcon"
|
||||
WorkingDirectory="INSTALLDIR">
|
||||
<ShortcutProperty Key="System.AppUserModel.ID" Value="{{bundle_id}}"/>
|
||||
</Shortcut>
|
||||
<RemoveFolder Id="ApplicationProgramsFolder" On="uninstall"/>
|
||||
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Start Menu Shortcut" Type="integer" Value="1" KeyPath="yes"/>
|
||||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
{{#each merge_modules as |msm| ~}}
|
||||
<DirectoryRef Id="TARGETDIR">
|
||||
<Merge Id="{{ msm.name }}" SourceFile="{{ msm.path }}" DiskId="1" Language="!(loc.TauriLanguage)" />
|
||||
</DirectoryRef>
|
||||
|
||||
<Feature Id="{{ msm.name }}" Title="{{ msm.name }}" AllowAdvertise="no" Display="hidden" Level="1">
|
||||
<MergeRef Id="{{ msm.name }}"/>
|
||||
</Feature>
|
||||
{{/each~}}
|
||||
|
||||
<Feature
|
||||
Id="MainProgram"
|
||||
Title="Application"
|
||||
Description="!(loc.InstallAppFeature)"
|
||||
Level="1"
|
||||
ConfigurableDirectory="INSTALLDIR"
|
||||
AllowAdvertise="no"
|
||||
Display="expand"
|
||||
Absent="disallow">
|
||||
|
||||
<ComponentRef Id="RegistryEntries"/>
|
||||
|
||||
{{#each resource_file_ids as |resource_file_id| ~}}
|
||||
<ComponentRef Id="{{ resource_file_id }}"/>
|
||||
{{/each~}}
|
||||
|
||||
{{#if enable_elevated_update_task}}
|
||||
<ComponentRef Id="UpdateTask" />
|
||||
<ComponentRef Id="UpdateTaskInstaller" />
|
||||
<ComponentRef Id="UpdateTaskUninstaller" />
|
||||
{{/if}}
|
||||
|
||||
<Feature Id="ShortcutsFeature"
|
||||
Title="Shortcuts"
|
||||
Level="1">
|
||||
<ComponentRef Id="Path"/>
|
||||
<ComponentRef Id="CMP_UninstallShortcut" />
|
||||
<ComponentRef Id="ApplicationShortcut" />
|
||||
<ComponentRef Id="ApplicationShortcutDesktop" />
|
||||
</Feature>
|
||||
|
||||
<Feature
|
||||
Id="Environment"
|
||||
Title="PATH Environment Variable"
|
||||
Description="!(loc.PathEnvVarFeature)"
|
||||
Level="1"
|
||||
Absent="allow">
|
||||
<ComponentRef Id="Path"/>
|
||||
{{#each binaries as |bin| ~}}
|
||||
<ComponentRef Id="{{ bin.id }}"/>
|
||||
{{/each~}}
|
||||
</Feature>
|
||||
</Feature>
|
||||
|
||||
<Feature Id="External" AllowAdvertise="no" Absent="disallow">
|
||||
{{#each component_group_refs as |id| ~}}
|
||||
<ComponentGroupRef Id="{{ id }}"/>
|
||||
{{/each~}}
|
||||
{{#each component_refs as |id| ~}}
|
||||
<ComponentRef Id="{{ id }}"/>
|
||||
{{/each~}}
|
||||
{{#each feature_group_refs as |id| ~}}
|
||||
<FeatureGroupRef Id="{{ id }}"/>
|
||||
{{/each~}}
|
||||
{{#each feature_refs as |id| ~}}
|
||||
<FeatureRef Id="{{ id }}"/>
|
||||
{{/each~}}
|
||||
{{#each merge_refs as |id| ~}}
|
||||
<MergeRef Id="{{ id }}"/>
|
||||
{{/each~}}
|
||||
</Feature>
|
||||
|
||||
{{#if install_webview}}
|
||||
<!-- WebView2 -->
|
||||
<Property Id="WVRTINSTALLED">
|
||||
<RegistrySearch Id="WVRTInstalledSystem" Root="HKLM" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw" Win64="no" />
|
||||
<RegistrySearch Id="WVRTInstalledUser" Root="HKCU" Key="SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Name="pv" Type="raw"/>
|
||||
</Property>
|
||||
|
||||
{{#if download_bootstrapper}}
|
||||
<CustomAction Id='DownloadAndInvokeBootstrapper' Directory="INSTALLDIR" Execute="deferred" ExeCommand='powershell.exe -NoProfile -windowstyle hidden try [\{] [\[]Net.ServicePointManager[\]]::SecurityProtocol = [\[]Net.SecurityProtocolType[\]]::Tls12 [\}] catch [\{][\}]; Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" ; Start-Process -FilePath "$env:TEMP\MicrosoftEdgeWebview2Setup.exe" -ArgumentList ({{webview_installer_args}} '/install') -Wait' Return='check'/>
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action='DownloadAndInvokeBootstrapper' Before='InstallFinalize'>
|
||||
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
|
||||
</Custom>
|
||||
</InstallExecuteSequence>
|
||||
{{/if}}
|
||||
|
||||
<!-- Embedded webview bootstrapper mode -->
|
||||
{{#if webview2_bootstrapper_path}}
|
||||
<Binary Id="MicrosoftEdgeWebview2Setup.exe" SourceFile="{{webview2_bootstrapper_path}}"/>
|
||||
<CustomAction Id='InvokeBootstrapper' BinaryKey='MicrosoftEdgeWebview2Setup.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action='InvokeBootstrapper' Before='InstallFinalize'>
|
||||
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
|
||||
</Custom>
|
||||
</InstallExecuteSequence>
|
||||
{{/if}}
|
||||
|
||||
<!-- Embedded offline installer -->
|
||||
{{#if webview2_installer_path}}
|
||||
<Binary Id="MicrosoftEdgeWebView2RuntimeInstaller.exe" SourceFile="{{webview2_installer_path}}"/>
|
||||
<CustomAction Id='InvokeStandalone' BinaryKey='MicrosoftEdgeWebView2RuntimeInstaller.exe' Execute="deferred" ExeCommand='{{webview_installer_args}} /install' Return='check' />
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action='InvokeStandalone' Before='InstallFinalize'>
|
||||
<![CDATA[NOT(REMOVE OR WVRTINSTALLED)]]>
|
||||
</Custom>
|
||||
</InstallExecuteSequence>
|
||||
{{/if}}
|
||||
|
||||
{{/if}}
|
||||
|
||||
{{#if enable_elevated_update_task}}
|
||||
<!-- Install an elevated update task within Windows Task Scheduler -->
|
||||
<CustomAction
|
||||
Id="CreateUpdateTask"
|
||||
Return="check"
|
||||
Directory="INSTALLDIR"
|
||||
Execute="commit"
|
||||
Impersonate="yes"
|
||||
ExeCommand="powershell.exe -WindowStyle hidden .\install-task.ps1" />
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action='CreateUpdateTask' Before='InstallFinalize'>
|
||||
NOT(REMOVE)
|
||||
</Custom>
|
||||
</InstallExecuteSequence>
|
||||
<!-- Remove elevated update task during uninstall -->
|
||||
<CustomAction
|
||||
Id="DeleteUpdateTask"
|
||||
Return="check"
|
||||
Directory="INSTALLDIR"
|
||||
ExeCommand="powershell.exe -WindowStyle hidden .\uninstall-task.ps1" />
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="DeleteUpdateTask" Before='InstallFinalize'>
|
||||
(REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE
|
||||
</Custom>
|
||||
</InstallExecuteSequence>
|
||||
{{/if}}
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="LaunchApplication" After="InstallFinalize">AUTOLAUNCHAPP AND NOT Installed</Custom>
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLDIR]" After="CostFinalize"/>
|
||||
</Product>
|
||||
</Wix>
|
||||
187
src/App.tsx
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "./types";
|
||||
import { AppType } from "./lib/tauri-api";
|
||||
import ProviderList from "./components/ProviderList";
|
||||
@@ -7,18 +8,21 @@ import EditProviderModal from "./components/EditProviderModal";
|
||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
||||
import { AppSwitcher } from "./components/AppSwitcher";
|
||||
import SettingsModal from "./components/SettingsModal";
|
||||
import { UpdateBadge } from "./components/UpdateBadge";
|
||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
||||
import { buttonStyles } from "./lib/styles";
|
||||
import { useDarkMode } from "./hooks/useDarkMode";
|
||||
import { extractErrorMessage } from "./utils/errorUtils";
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation();
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
const [activeApp, setActiveApp] = useState<AppType>("claude");
|
||||
const [providers, setProviders] = useState<Record<string, Provider>>({});
|
||||
const [currentProviderId, setCurrentProviderId] = useState<string>("");
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [editingProviderId, setEditingProviderId] = useState<string | null>(
|
||||
null,
|
||||
null
|
||||
);
|
||||
const [notification, setNotification] = useState<{
|
||||
message: string;
|
||||
@@ -38,7 +42,7 @@ function App() {
|
||||
const showNotification = (
|
||||
message: string,
|
||||
type: "success" | "error",
|
||||
duration = 3000,
|
||||
duration = 3000
|
||||
) => {
|
||||
// 清除之前的定时器
|
||||
if (timeoutRef.current) {
|
||||
@@ -74,22 +78,29 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听托盘切换事件
|
||||
// 监听托盘切换事件(包括菜单切换)
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
unlisten = await window.api.onProviderSwitched(async (data) => {
|
||||
console.log("收到供应商切换事件:", data);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(t("console.providerSwitchReceived"), data);
|
||||
}
|
||||
|
||||
// 如果当前应用类型匹配,则重新加载数据
|
||||
if (data.appType === activeApp) {
|
||||
await loadProviders();
|
||||
}
|
||||
|
||||
// 若为 Claude,则同步插件配置
|
||||
if (data.appType === "claude") {
|
||||
await syncClaudePlugin(data.providerId, true);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("设置供应商切换监听器失败:", error);
|
||||
console.error(t("console.setupListenerFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,7 +112,7 @@ function App() {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
}, [activeApp]); // 依赖activeApp,切换应用时重新设置监听器
|
||||
}, [activeApp]);
|
||||
|
||||
const loadProviders = async () => {
|
||||
const loadedProviders = await window.api.getProviders(activeApp);
|
||||
@@ -115,7 +126,6 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return crypto.randomUUID();
|
||||
@@ -140,13 +150,17 @@ function App() {
|
||||
await loadProviders();
|
||||
setEditingProviderId(null);
|
||||
// 显示编辑成功提示
|
||||
showNotification("供应商配置已保存", "success", 2000);
|
||||
showNotification(t("notifications.providerSaved"), "success", 2000);
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
} catch (error) {
|
||||
console.error("更新供应商失败:", error);
|
||||
console.error(t("console.updateProviderFailed"), error);
|
||||
setEditingProviderId(null);
|
||||
showNotification("保存失败,请重试", "error");
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
const message = errorMessage
|
||||
? t("notifications.saveFailed", { error: errorMessage })
|
||||
: t("notifications.saveFailedGeneric");
|
||||
showNotification(message, "error", errorMessage ? 6000 : 3000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -154,34 +168,73 @@ function App() {
|
||||
const provider = providers[id];
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: "删除供应商",
|
||||
message: `确定要删除供应商 "${provider?.name}" 吗?此操作无法撤销。`,
|
||||
title: t("confirm.deleteProvider"),
|
||||
message: t("confirm.deleteProviderMessage", { name: provider?.name }),
|
||||
onConfirm: async () => {
|
||||
await window.api.deleteProvider(id, activeApp);
|
||||
await loadProviders();
|
||||
setConfirmDialog(null);
|
||||
showNotification("供应商删除成功", "success");
|
||||
showNotification(t("notifications.providerDeleted"), "success");
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 同步 Claude 插件配置(写入/移除固定 JSON)
|
||||
const syncClaudePlugin = async (providerId: string, silent = false) => {
|
||||
try {
|
||||
const provider = providers[providerId];
|
||||
if (!provider) return;
|
||||
const isOfficial = provider.category === "official";
|
||||
await window.api.applyClaudePluginConfig({ official: isOfficial });
|
||||
if (!silent) {
|
||||
showNotification(
|
||||
isOfficial
|
||||
? t("notifications.removedFromClaudePlugin")
|
||||
: t("notifications.appliedToClaudePlugin"),
|
||||
"success",
|
||||
2000,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("同步 Claude 插件失败:", error);
|
||||
if (!silent) {
|
||||
const message =
|
||||
error?.message || t("notifications.syncClaudePluginFailed");
|
||||
showNotification(message, "error", 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchProvider = async (id: string) => {
|
||||
const success = await window.api.switchProvider(id, activeApp);
|
||||
if (success) {
|
||||
setCurrentProviderId(id);
|
||||
// 显示重启提示
|
||||
const appName = activeApp === "claude" ? "Claude Code" : "Codex";
|
||||
const appName = t(`apps.${activeApp}`);
|
||||
showNotification(
|
||||
`切换成功!请重启 ${appName} 终端以生效`,
|
||||
t("notifications.switchSuccess", { appName }),
|
||||
"success",
|
||||
2000,
|
||||
2000
|
||||
);
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
|
||||
if (activeApp === "claude") {
|
||||
await syncClaudePlugin(id, true);
|
||||
}
|
||||
} else {
|
||||
showNotification("切换失败,请检查配置", "error");
|
||||
showNotification(t("notifications.switchFailed"), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportSuccess = async () => {
|
||||
await loadProviders();
|
||||
try {
|
||||
await window.api.updateTrayMenu();
|
||||
} catch (error) {
|
||||
console.error("[App] Failed to refresh tray menu after import", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,41 +245,53 @@ function App() {
|
||||
|
||||
if (result.success) {
|
||||
await loadProviders();
|
||||
showNotification("已从现有配置创建默认供应商", "success", 3000);
|
||||
showNotification(t("notifications.autoImported"), "success", 3000);
|
||||
// 更新托盘菜单
|
||||
await window.api.updateTrayMenu();
|
||||
}
|
||||
// 如果导入失败(比如没有现有配置),静默处理,不显示错误
|
||||
} catch (error) {
|
||||
console.error("自动导入默认配置失败:", error);
|
||||
console.error(t("console.autoImportFailed"), error);
|
||||
// 静默处理,不影响用户体验
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
||||
{/* Linear 风格的顶部导航 */}
|
||||
<header className="bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
|
||||
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
||||
{/* 顶部导航区域 - 固定高度 */}
|
||||
<header className="flex-shrink-0 bg-white border-b border-gray-200 dark:bg-gray-900 dark:border-gray-800 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-semibold text-blue-500 dark:text-blue-400">
|
||||
<a
|
||||
href="https://github.com/farion1231/cc-switch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xl font-semibold text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 transition-colors"
|
||||
title={t("header.viewOnGithub")}
|
||||
>
|
||||
CC Switch
|
||||
</h1>
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className={buttonStyles.icon}
|
||||
title={isDarkMode ? "切换到亮色模式" : "切换到暗色模式"}
|
||||
title={
|
||||
isDarkMode
|
||||
? t("header.toggleLightMode")
|
||||
: t("header.toggleDarkMode")
|
||||
}
|
||||
>
|
||||
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className={buttonStyles.icon}
|
||||
title="设置"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className={buttonStyles.icon}
|
||||
title={t("common.settings")}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -237,36 +302,39 @@ function App() {
|
||||
className={`inline-flex items-center gap-2 ${buttonStyles.primary}`}
|
||||
>
|
||||
<Plus size={16} />
|
||||
添加供应商
|
||||
{t("header.addProvider")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<main className="flex-1 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 通知组件 */}
|
||||
{notification && (
|
||||
<div
|
||||
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
|
||||
notification.type === "error"
|
||||
? "bg-red-500 text-white"
|
||||
: "bg-green-500 text-white"
|
||||
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
|
||||
>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
onSwitch={handleSwitchProvider}
|
||||
onDelete={handleDeleteProvider}
|
||||
onEdit={setEditingProviderId}
|
||||
/>
|
||||
{/* 主内容区域 - 独立滚动 */}
|
||||
<main className="flex-1 overflow-y-scroll">
|
||||
<div className="pt-3 px-6 pb-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 通知组件 - 相对于视窗定位 */}
|
||||
{notification && (
|
||||
<div
|
||||
className={`fixed top-20 left-1/2 transform -translate-x-1/2 z-50 px-4 py-3 rounded-lg shadow-lg transition-all duration-300 ${
|
||||
notification.type === "error"
|
||||
? "bg-red-500 text-white"
|
||||
: "bg-green-500 text-white"
|
||||
} ${isNotificationVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`}
|
||||
>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
onSwitch={handleSwitchProvider}
|
||||
onDelete={handleDeleteProvider}
|
||||
onEdit={setEditingProviderId}
|
||||
appType={activeApp}
|
||||
onNotify={showNotification}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -298,7 +366,10 @@ function App() {
|
||||
)}
|
||||
|
||||
{isSettingsOpen && (
|
||||
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
|
||||
<SettingsModal
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
1
src/assets/icons/chatgpt.svg
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
1
src/assets/icons/claude.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1757750114641" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1475" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M202.112 678.656l200.64-112.64 3.392-9.792-3.392-5.44h-9.792l-33.6-2.048-114.624-3.072-99.456-4.224-96.384-5.12-24.192-5.12-22.72-29.952 2.304-14.976 20.48-13.696 29.12 2.56 64.576 4.416 96.832 6.72 70.208 4.096 104.064 10.88h16.576l2.304-6.72-5.696-4.16-4.352-4.096-100.224-67.968-108.48-71.744-56.768-41.344-30.72-20.928-15.488-19.584-6.72-42.88 27.84-30.72 37.504 2.56 9.536 2.56 37.952 29.184 81.088 62.784 105.856 77.952 15.488 12.928 6.208-4.352 0.768-3.136L395.264 360l-57.6-104.064-61.44-105.92-27.392-43.904-7.168-26.304c-2.56-10.88-4.48-19.904-4.48-30.976l31.808-43.136L286.592 0l42.304 5.696 17.856 15.488 26.304 60.16 42.624 94.72 66.112 128.896 19.392 38.208 10.24 35.392 3.904 10.88h6.72v-6.208l5.44-72.576 10.048-89.088 9.856-114.688 3.328-32.256 16-38.72 31.808-20.928 24.768 11.904 20.416 29.184-2.88 18.816-12.16 78.72-23.68 123.52-15.552 82.56h9.088l10.304-10.24 41.856-55.552 70.208-87.808 30.976-34.88 36.16-38.464 23.232-18.368h43.904l32.32 48.064-14.464 49.6-45.184 57.28-37.44 48.576-53.76 72.32-33.536 57.856 3.072 4.608 8-0.768 121.408-25.792 65.6-11.904 78.208-13.44 35.392 16.512 3.84 16.832-13.952 34.304-83.648 20.672-98.112 19.648-146.176 34.56-1.792 1.28 2.048 2.56 65.92 6.272 28.096 1.536h68.928l128.384 9.6 33.536 22.144 20.16 27.136-3.392 20.672-51.648 26.304-69.696-16.512-162.688-38.72-55.744-13.952h-7.744v4.672l46.464 45.44 85.184 76.928 106.688 99.2 5.376 24.512-13.632 19.328-14.464-2.048-93.76-70.464-36.16-31.808-81.856-68.928h-5.44v7.232l18.88 27.648 99.648 149.76 5.184 45.952-7.232 14.976-25.856 9.024-28.352-5.12L673.408 856l-60.16-92.16-48.576-82.624-5.952 3.392-28.672 308.544-13.44 15.744-30.976 11.904-25.792-19.648-13.696-31.744 13.696-62.72 16.512-81.92 13.44-65.024 12.16-80.832 7.232-26.88-0.512-1.792-5.952 0.768-60.928 83.648-92.736 125.248-73.344 78.528-17.536 6.976-30.464-15.808 2.816-28.16 17.024-24.96 101.504-129.152 61.184-80 39.552-46.272-0.256-6.72h-2.368L177.6 789.44l-48 6.144-20.736-19.328 2.56-31.744 9.856-10.368 81.088-55.744-0.256 0.256z" p-id="1476" fill="#bfbfbf"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
@@ -14,11 +15,13 @@ const AddProviderModal: React.FC<AddProviderModalProps> = ({
|
||||
onAdd,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title="添加新供应商"
|
||||
submitText="添加"
|
||||
title={t("provider.addNewProvider")}
|
||||
submitText={t("common.add")}
|
||||
showPresets={true}
|
||||
onSubmit={onAdd}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import { Terminal, Code2 } from "lucide-react";
|
||||
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppType;
|
||||
@@ -17,14 +17,21 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSwitch("claude")}
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
activeApp === "claude"
|
||||
? "bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-gray-100 dark:shadow-none"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<Code2 size={16} />
|
||||
<span>Claude Code</span>
|
||||
<ClaudeIcon
|
||||
size={16}
|
||||
className={
|
||||
activeApp === "claude"
|
||||
? "text-[#D97757] dark:text-[#D97757] transition-colors duration-200"
|
||||
: "text-gray-500 dark:text-gray-400 group-hover:text-[#D97757] dark:group-hover:text-[#D97757] transition-colors duration-200"
|
||||
}
|
||||
/>
|
||||
<span>Claude</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -36,7 +43,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-white/50 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800/60"
|
||||
}`}
|
||||
>
|
||||
<Terminal size={16} />
|
||||
<CodexIcon size={16} />
|
||||
<span>Codex</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
34
src/components/BrandIcons.tsx
Normal file
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import { isLinux } from "../lib/platform";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,18 +17,20 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = "确定",
|
||||
cancelText = "取消",
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
className={`absolute inset-0 bg-black/50${isLinux() ? "" : " backdrop-blur-sm"}`}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
@@ -64,13 +68,13 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
autoFocus
|
||||
>
|
||||
{cancelText}
|
||||
{cancelText || t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm font-medium bg-red-500 text-white hover:bg-red-500/90 rounded-md transition-colors"
|
||||
>
|
||||
{confirmText}
|
||||
{confirmText || t("common.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../types";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
import ProviderForm from "./ProviderForm";
|
||||
@@ -16,6 +17,8 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (data: Omit<Provider, "id">) => {
|
||||
onSave({
|
||||
...provider,
|
||||
@@ -26,8 +29,8 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
|
||||
return (
|
||||
<ProviderForm
|
||||
appType={appType}
|
||||
title="编辑供应商"
|
||||
submitText="保存"
|
||||
title={t("common.edit")}
|
||||
submitText={t("common.save")}
|
||||
initialData={provider}
|
||||
showPresets={false}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
103
src/components/ImportProgressModal.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect } from "react";
|
||||
import { CheckCircle, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ImportProgressModalProps {
|
||||
status: 'importing' | 'success' | 'error';
|
||||
message?: string;
|
||||
backupId?: string;
|
||||
onComplete?: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function ImportProgressModal({
|
||||
status,
|
||||
message,
|
||||
backupId,
|
||||
onComplete,
|
||||
onSuccess
|
||||
}: ImportProgressModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'success') {
|
||||
console.log('[ImportProgressModal] Success detected, starting 2 second countdown');
|
||||
// 成功后等待2秒自动关闭并刷新数据
|
||||
const timer = setTimeout(() => {
|
||||
console.log('[ImportProgressModal] 2 seconds elapsed, calling callbacks...');
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
console.log('[ImportProgressModal] Cleanup timer');
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [status, onComplete, onSuccess]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl p-8 max-w-md w-full mx-4">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{status === 'importing' && (
|
||||
<>
|
||||
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("settings.importing")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("settings.importSuccess")}
|
||||
</h3>
|
||||
{backupId && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{t("settings.backupId")}: {backupId}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("settings.autoReload")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("settings.importFailed")}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{message || t("settings.configCorrupted")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{t("common.close")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import React, { useRef, useEffect, useMemo } from "react";
|
||||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { placeholder } from "@codemirror/view";
|
||||
import { linter, Diagnostic } from "@codemirror/lint";
|
||||
|
||||
interface JsonEditorProps {
|
||||
value: string;
|
||||
@@ -11,6 +12,7 @@ interface JsonEditorProps {
|
||||
placeholder?: string;
|
||||
darkMode?: boolean;
|
||||
rows?: number;
|
||||
showValidation?: boolean;
|
||||
}
|
||||
|
||||
const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
@@ -19,10 +21,50 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
placeholder: placeholderText = "",
|
||||
darkMode = false,
|
||||
rows = 12,
|
||||
showValidation = true,
|
||||
}) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
// JSON linter 函数
|
||||
const jsonLinter = useMemo(
|
||||
() =>
|
||||
linter((view) => {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
if (!showValidation) return diagnostics;
|
||||
|
||||
const doc = view.state.doc.toString();
|
||||
if (!doc.trim()) return diagnostics;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(doc);
|
||||
// 检查是否是JSON对象
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
// 格式正确
|
||||
} else {
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
severity: "error",
|
||||
message: "配置必须是JSON对象,不能是数组或其他类型",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 简单处理JSON解析错误
|
||||
const message = e instanceof SyntaxError ? e.message : "JSON格式错误";
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: doc.length,
|
||||
severity: "error",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}),
|
||||
[showValidation],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
|
||||
@@ -43,6 +85,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
json(),
|
||||
placeholder(placeholderText || ""),
|
||||
sizingTheme,
|
||||
jsonLinter,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const newValue = update.state.doc.toString();
|
||||
@@ -75,7 +118,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [darkMode, rows]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||
}, [darkMode, rows, jsonLinter]); // 依赖项中不包含 onChange 和 placeholder,避免不必要的重建
|
||||
|
||||
// 当 value 从外部改变时更新编辑器内容
|
||||
useEffect(() => {
|
||||
|
||||
@@ -28,15 +28,15 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
|
||||
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
||||
disabled
|
||||
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed"
|
||||
: "border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
: "border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="block text-sm font-medium text-gray-900"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{label} {required && "*"}
|
||||
</label>
|
||||
@@ -56,7 +56,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleShowKey}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 hover:text-gray-900 transition-colors"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
aria-label={showKey ? "隐藏API Key" : "显示API Key"}
|
||||
>
|
||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
|
||||
@@ -1,52 +1,201 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import JsonEditor from "../JsonEditor";
|
||||
import { X, Save } from "lucide-react";
|
||||
import { isLinux } from "../../lib/platform";
|
||||
|
||||
interface ClaudeConfigEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disableCoAuthored: boolean;
|
||||
onCoAuthoredToggle: (checked: boolean) => void;
|
||||
useCommonConfig: boolean;
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
commonConfigSnippet: string;
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
commonConfigError: string;
|
||||
configError: string;
|
||||
}
|
||||
|
||||
const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disableCoAuthored,
|
||||
onCoAuthoredToggle,
|
||||
useCommonConfig,
|
||||
onCommonConfigToggle,
|
||||
commonConfigSnippet,
|
||||
onCommonConfigSnippetChange,
|
||||
commonConfigError,
|
||||
configError,
|
||||
}) => {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 检测暗色模式
|
||||
const checkDarkMode = () => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
// 监听暗色模式变化
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === "class") {
|
||||
checkDarkMode();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
if (!isCommonConfigModalOpen) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [isCommonConfigModalOpen]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCommonConfigModalOpen(false);
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="settingsConfig"
|
||||
className="block text-sm font-medium text-gray-900"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
Claude Code 配置 (JSON) *
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 cursor-pointer">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableCoAuthored}
|
||||
onChange={(e) => onCoAuthoredToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white border-gray-200 rounded focus:ring-blue-500 focus:ring-2"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
禁止 Claude Code 签名
|
||||
写入通用配置
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
编辑通用配置
|
||||
</button>
|
||||
</div>
|
||||
{commonConfigError && !isCommonConfigModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
|
||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||
}
|
||||
}`}
|
||||
rows={12}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
{configError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{configError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
完整的 Claude Code settings.json 配置内容
|
||||
</p>
|
||||
{isCommonConfigModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop - 统一背景样式 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
||||
isLinux() ? "" : " backdrop-blur-sm"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Modal - 统一窗口样式 */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header - 统一标题栏样式 */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
编辑通用配置片段
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - 统一内容区域样式 */}
|
||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
该片段会在勾选"写入通用配置"时合并到 settings.json 中
|
||||
</p>
|
||||
<JsonEditor
|
||||
value={commonConfigSnippet}
|
||||
onChange={onCommonConfigSnippetChange}
|
||||
darkMode={isDarkMode}
|
||||
rows={12}
|
||||
/>
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - 统一底部按钮样式 */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,65 +1,666 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
import { X, Save } from "lucide-react";
|
||||
|
||||
import { isLinux } from "../../lib/platform";
|
||||
|
||||
import {
|
||||
generateThirdPartyAuth,
|
||||
generateThirdPartyConfig,
|
||||
} from "../../config/codexProviderPresets";
|
||||
|
||||
interface CodexConfigEditorProps {
|
||||
authValue: string;
|
||||
|
||||
configValue: string;
|
||||
|
||||
onAuthChange: (value: string) => void;
|
||||
|
||||
onConfigChange: (value: string) => void;
|
||||
|
||||
onAuthBlur?: () => void;
|
||||
|
||||
useCommonConfig: boolean;
|
||||
|
||||
onCommonConfigToggle: (checked: boolean) => void;
|
||||
|
||||
commonConfigSnippet: string;
|
||||
|
||||
onCommonConfigSnippetChange: (value: string) => void;
|
||||
|
||||
commonConfigError: string;
|
||||
|
||||
authError: string;
|
||||
|
||||
isCustomMode?: boolean; // 新增:是否为自定义模式
|
||||
|
||||
onWebsiteUrlChange?: (url: string) => void; // 新增:更新网址回调
|
||||
|
||||
isTemplateModalOpen?: boolean; // 新增:模态框状态
|
||||
|
||||
setIsTemplateModalOpen?: (open: boolean) => void; // 新增:设置模态框状态
|
||||
|
||||
onNameChange?: (name: string) => void; // 新增:更新供应商名称回调
|
||||
}
|
||||
|
||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||
authValue,
|
||||
|
||||
configValue,
|
||||
|
||||
onAuthChange,
|
||||
|
||||
onConfigChange,
|
||||
|
||||
onAuthBlur,
|
||||
|
||||
useCommonConfig,
|
||||
|
||||
onCommonConfigToggle,
|
||||
|
||||
commonConfigSnippet,
|
||||
|
||||
onCommonConfigSnippetChange,
|
||||
|
||||
commonConfigError,
|
||||
|
||||
authError,
|
||||
|
||||
onWebsiteUrlChange,
|
||||
|
||||
onNameChange,
|
||||
|
||||
isTemplateModalOpen: externalTemplateModalOpen,
|
||||
|
||||
setIsTemplateModalOpen: externalSetTemplateModalOpen,
|
||||
}) => {
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
// 使用内部状态或外部状态
|
||||
|
||||
const [internalTemplateModalOpen, setInternalTemplateModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const isTemplateModalOpen =
|
||||
externalTemplateModalOpen ?? internalTemplateModalOpen;
|
||||
|
||||
const setIsTemplateModalOpen =
|
||||
externalSetTemplateModalOpen ?? setInternalTemplateModalOpen;
|
||||
|
||||
const [templateApiKey, setTemplateApiKey] = useState("");
|
||||
|
||||
const [templateProviderName, setTemplateProviderName] = useState("");
|
||||
|
||||
const [templateBaseUrl, setTemplateBaseUrl] = useState("");
|
||||
|
||||
const [templateWebsiteUrl, setTemplateWebsiteUrl] = useState("");
|
||||
|
||||
const [templateModelName, setTemplateModelName] = useState("gpt-5-codex");
|
||||
const apiKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const baseUrlInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const modelNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const displayNameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 移除自动填充逻辑,因为现在在点击自定义按钮时就已经填充
|
||||
|
||||
const [templateDisplayName, setTemplateDisplayName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
||||
setIsCommonConfigModalOpen(true);
|
||||
}
|
||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCommonConfigModalOpen) return;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [isCommonConfigModalOpen]);
|
||||
|
||||
const closeModal = () => {
|
||||
setIsCommonConfigModalOpen(false);
|
||||
};
|
||||
|
||||
const closeTemplateModal = () => {
|
||||
setIsTemplateModalOpen(false);
|
||||
};
|
||||
|
||||
const applyTemplate = () => {
|
||||
const requiredInputs = [
|
||||
displayNameInputRef.current,
|
||||
apiKeyInputRef.current,
|
||||
baseUrlInputRef.current,
|
||||
modelNameInputRef.current,
|
||||
];
|
||||
|
||||
for (const input of requiredInputs) {
|
||||
if (input && !input.checkValidity()) {
|
||||
input.reportValidity();
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedKey = templateApiKey.trim();
|
||||
|
||||
const trimmedBaseUrl = templateBaseUrl.trim();
|
||||
|
||||
const trimmedModel = templateModelName.trim();
|
||||
|
||||
const auth = generateThirdPartyAuth(trimmedKey);
|
||||
|
||||
const config = generateThirdPartyConfig(
|
||||
templateProviderName || "custom",
|
||||
|
||||
trimmedBaseUrl,
|
||||
|
||||
trimmedModel
|
||||
);
|
||||
|
||||
onAuthChange(JSON.stringify(auth, null, 2));
|
||||
|
||||
onConfigChange(config);
|
||||
|
||||
if (onWebsiteUrlChange) {
|
||||
const trimmedWebsite = templateWebsiteUrl.trim();
|
||||
|
||||
if (trimmedWebsite) {
|
||||
onWebsiteUrlChange(trimmedWebsite);
|
||||
}
|
||||
}
|
||||
|
||||
if (onNameChange) {
|
||||
const trimmedName = templateDisplayName.trim();
|
||||
if (trimmedName) {
|
||||
onNameChange(trimmedName);
|
||||
}
|
||||
}
|
||||
|
||||
setTemplateApiKey("");
|
||||
|
||||
setTemplateProviderName("");
|
||||
|
||||
setTemplateBaseUrl("");
|
||||
|
||||
setTemplateWebsiteUrl("");
|
||||
|
||||
setTemplateModelName("gpt-5-codex");
|
||||
|
||||
setTemplateDisplayName("");
|
||||
|
||||
closeTemplateModal();
|
||||
};
|
||||
|
||||
const handleTemplateInputKeyDown = (
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
applyTemplate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthChange = (value: string) => {
|
||||
onAuthChange(value);
|
||||
};
|
||||
|
||||
const handleConfigChange = (value: string) => {
|
||||
onConfigChange(value);
|
||||
};
|
||||
|
||||
const handleCommonConfigSnippetChange = (value: string) => {
|
||||
onCommonConfigSnippetChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexAuth"
|
||||
className="block text-sm font-medium text-gray-900"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
auth.json (JSON) *
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="codexAuth"
|
||||
value={authValue}
|
||||
onChange={(e) => onAuthChange(e.target.value)}
|
||||
onChange={(e) => handleAuthChange(e.target.value)}
|
||||
onBlur={onAuthBlur}
|
||||
placeholder={`{
|
||||
"OPENAI_API_KEY": "sk-your-api-key-here"
|
||||
}`}
|
||||
rows={6}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors resize-y min-h-[8rem]"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[8rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
|
||||
{authError && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400">{authError}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Codex auth.json 配置内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-gray-900"
|
||||
>
|
||||
config.toml (TOML)
|
||||
</label>
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="codexConfig"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
config.toml (TOML)
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useCommonConfig}
|
||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
||||
/>
|
||||
写入通用配置
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCommonConfigModalOpen(true)}
|
||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
编辑通用配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{commonConfigError && !isCommonConfigModalOpen && (
|
||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
id="codexConfig"
|
||||
value={configValue}
|
||||
onChange={(e) => onConfigChange(e.target.value)}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
placeholder=""
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors resize-y min-h-[10rem]"
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y min-h-[10rem]"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Codex config.toml 配置内容
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isTemplateModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
closeTemplateModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
||||
isLinux() ? "" : " backdrop-blur-sm"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white shadow-lg dark:bg-gray-900">
|
||||
<div className="flex h-full min-h-0 flex-col" role="form">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
快速配置向导
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeTemplateModal}
|
||||
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 space-y-4 overflow-auto p-6">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
输入关键参数,系统将自动生成标准的 auth.json 和 config.toml
|
||||
配置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
API 密钥 *
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={templateApiKey}
|
||||
ref={apiKeyInputRef}
|
||||
onChange={(e) => setTemplateApiKey(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
pattern=".*\S.*"
|
||||
title="请输入有效的内容"
|
||||
placeholder="sk-your-api-key-here"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
供应商名称 *
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={templateDisplayName}
|
||||
ref={displayNameInputRef}
|
||||
onChange={(e) => {
|
||||
setTemplateDisplayName(e.target.value);
|
||||
if (onNameChange) {
|
||||
onNameChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
placeholder="例如:Codex 官方"
|
||||
required
|
||||
pattern=".*\S.*"
|
||||
title="请输入有效的内容"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
将显示在供应商列表中,可使用中文
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
供应商代号(英文)
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={templateProviderName}
|
||||
onChange={(e) => setTemplateProviderName(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
placeholder="custom(可选)"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
将用作配置文件中的标识符,默认为 custom
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
API 请求地址 *
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="url"
|
||||
value={templateBaseUrl}
|
||||
ref={baseUrlInputRef}
|
||||
onChange={(e) => setTemplateBaseUrl(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
placeholder="https://your-api-endpoint.com/v1"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
官网地址
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="url"
|
||||
value={templateWebsiteUrl}
|
||||
onChange={(e) => setTemplateWebsiteUrl(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
placeholder="https://example.com"
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
官方网站地址(可选)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
模型名称 *
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={templateModelName}
|
||||
ref={modelNameInputRef}
|
||||
onChange={(e) => setTemplateModelName(e.target.value)}
|
||||
onKeyDown={handleTemplateInputKeyDown}
|
||||
pattern=".*\S.*"
|
||||
title="请输入有效的内容"
|
||||
placeholder="gpt-5-codex"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(templateApiKey ||
|
||||
templateProviderName ||
|
||||
templateBaseUrl) && (
|
||||
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
配置预览
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
auth.json
|
||||
</label>
|
||||
|
||||
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{JSON.stringify(
|
||||
generateThirdPartyAuth(templateApiKey),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
config.toml
|
||||
</label>
|
||||
|
||||
<pre className="whitespace-pre-wrap rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{templateProviderName && templateBaseUrl
|
||||
? generateThirdPartyConfig(
|
||||
templateProviderName,
|
||||
|
||||
templateBaseUrl,
|
||||
|
||||
templateModelName
|
||||
)
|
||||
: ""}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-gray-200 bg-gray-100 p-6 dark:border-gray-800 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeTemplateModal}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
applyTemplate();
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
应用配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCommonConfigModalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop - 统一背景样式 */}
|
||||
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
||||
isLinux() ? "" : " backdrop-blur-sm"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Modal - 统一窗口样式 */}
|
||||
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header - 统一标题栏样式 */}
|
||||
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
编辑 Codex 通用配置片段
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - 统一内容区域样式 */}
|
||||
|
||||
<div className="flex-1 overflow-auto p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
该片段会在勾选"写入通用配置"时追加到 config.toml 末尾
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={commonConfigSnippet}
|
||||
onChange={(e) =>
|
||||
handleCommonConfigSnippetChange(e.target.value)
|
||||
}
|
||||
placeholder={`# Common Codex config
|
||||
|
||||
|
||||
|
||||
# Add your common TOML configuration here`}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-y"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
lang="en"
|
||||
inputMode="text"
|
||||
data-gramm="false"
|
||||
data-gramm_editor="false"
|
||||
data-enable-grammarly="false"
|
||||
/>
|
||||
|
||||
{commonConfigError && (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{commonConfigError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - 统一底部按钮样式 */}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-white dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
602
src/components/ProviderForm/EndpointSpeedTest.tsx
Normal file
@@ -0,0 +1,602 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Zap, Loader2, Plus, X, AlertCircle } from "lucide-react";
|
||||
import { isLinux } from "../../lib/platform";
|
||||
|
||||
import type { AppType } from "../../lib/tauri-api";
|
||||
|
||||
export interface EndpointCandidate {
|
||||
id?: string;
|
||||
url: string;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
interface EndpointSpeedTestProps {
|
||||
appType: AppType;
|
||||
providerId?: string;
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
initialEndpoints: EndpointCandidate[];
|
||||
visible?: boolean;
|
||||
onClose: () => void;
|
||||
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
|
||||
onCustomEndpointsChange?: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
interface EndpointEntry extends EndpointCandidate {
|
||||
id: string;
|
||||
latency: number | null;
|
||||
status?: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const randomId = () => `ep_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
const normalizeEndpointUrl = (url: string): string =>
|
||||
url.trim().replace(/\/+$/, "");
|
||||
|
||||
const buildInitialEntries = (
|
||||
candidates: EndpointCandidate[],
|
||||
selected: string,
|
||||
): EndpointEntry[] => {
|
||||
const map = new Map<string, EndpointEntry>();
|
||||
const addCandidate = (candidate: EndpointCandidate) => {
|
||||
const sanitized = candidate.url ? normalizeEndpointUrl(candidate.url) : "";
|
||||
if (!sanitized) return;
|
||||
if (map.has(sanitized)) return;
|
||||
|
||||
map.set(sanitized, {
|
||||
id: candidate.id ?? randomId(),
|
||||
url: sanitized,
|
||||
isCustom: candidate.isCustom ?? false,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
candidates.forEach(addCandidate);
|
||||
|
||||
const selectedUrl = normalizeEndpointUrl(selected);
|
||||
if (selectedUrl && !map.has(selectedUrl)) {
|
||||
addCandidate({ url: selectedUrl, isCustom: true });
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
|
||||
appType,
|
||||
providerId,
|
||||
value,
|
||||
onChange,
|
||||
initialEndpoints,
|
||||
visible = true,
|
||||
onClose,
|
||||
onCustomEndpointsChange,
|
||||
}) => {
|
||||
const [entries, setEntries] = useState<EndpointEntry[]>(() =>
|
||||
buildInitialEntries(initialEndpoints, value),
|
||||
);
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
const normalizedSelected = normalizeEndpointUrl(value);
|
||||
|
||||
const hasEndpoints = entries.length > 0;
|
||||
|
||||
// 加载保存的自定义端点(按正在编辑的供应商)
|
||||
useEffect(() => {
|
||||
const loadCustomEndpoints = async () => {
|
||||
try {
|
||||
if (!providerId) return;
|
||||
const customEndpoints = await window.api.getCustomEndpoints(
|
||||
appType,
|
||||
providerId,
|
||||
);
|
||||
const candidates: EndpointCandidate[] = customEndpoints.map((ep) => ({
|
||||
url: ep.url,
|
||||
isCustom: true,
|
||||
}));
|
||||
|
||||
setEntries((prev) => {
|
||||
const map = new Map<string, EndpointEntry>();
|
||||
|
||||
// 先添加现有端点
|
||||
prev.forEach((entry) => {
|
||||
map.set(entry.url, entry);
|
||||
});
|
||||
|
||||
// 合并自定义端点
|
||||
candidates.forEach((candidate) => {
|
||||
const sanitized = normalizeEndpointUrl(candidate.url);
|
||||
if (sanitized && !map.has(sanitized)) {
|
||||
map.set(sanitized, {
|
||||
id: randomId(),
|
||||
url: sanitized,
|
||||
isCustom: true,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("加载自定义端点失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (visible) {
|
||||
loadCustomEndpoints();
|
||||
}
|
||||
}, [appType, visible, providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
setEntries((prev) => {
|
||||
const map = new Map<string, EndpointEntry>();
|
||||
prev.forEach((entry) => {
|
||||
map.set(entry.url, entry);
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
||||
const mergeCandidate = (candidate: EndpointCandidate) => {
|
||||
const sanitized = candidate.url
|
||||
? normalizeEndpointUrl(candidate.url)
|
||||
: "";
|
||||
if (!sanitized) return;
|
||||
const existing = map.get(sanitized);
|
||||
if (existing) return;
|
||||
|
||||
map.set(sanitized, {
|
||||
id: candidate.id ?? randomId(),
|
||||
url: sanitized,
|
||||
isCustom: candidate.isCustom ?? false,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
});
|
||||
changed = true;
|
||||
};
|
||||
|
||||
initialEndpoints.forEach(mergeCandidate);
|
||||
|
||||
if (normalizedSelected && !map.has(normalizedSelected)) {
|
||||
mergeCandidate({ url: normalizedSelected, isCustom: true });
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
});
|
||||
}, [initialEndpoints, normalizedSelected]);
|
||||
|
||||
// 将自定义端点变化透传给父组件(仅限 isCustom)
|
||||
useEffect(() => {
|
||||
if (!onCustomEndpointsChange) return;
|
||||
try {
|
||||
const customUrls = Array.from(
|
||||
new Set(
|
||||
entries
|
||||
.filter((e) => e.isCustom)
|
||||
.map((e) => (e.url ? normalizeEndpointUrl(e.url) : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
onCustomEndpointsChange(customUrls);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
// 仅在 entries 变化时同步
|
||||
}, [entries, onCustomEndpointsChange]);
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
return entries.slice().sort((a, b) => {
|
||||
const aLatency = a.latency ?? Number.POSITIVE_INFINITY;
|
||||
const bLatency = b.latency ?? Number.POSITIVE_INFINITY;
|
||||
if (aLatency === bLatency) {
|
||||
return a.url.localeCompare(b.url);
|
||||
}
|
||||
return aLatency - bLatency;
|
||||
});
|
||||
}, [entries]);
|
||||
|
||||
const handleAddEndpoint = useCallback(
|
||||
async () => {
|
||||
const candidate = customUrl.trim();
|
||||
let errorMsg: string | null = null;
|
||||
|
||||
if (!candidate) {
|
||||
errorMsg = "请输入有效的 URL";
|
||||
}
|
||||
|
||||
let parsed: URL | null = null;
|
||||
if (!errorMsg) {
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
errorMsg = "URL 格式不正确";
|
||||
}
|
||||
}
|
||||
|
||||
if (!errorMsg && parsed && !parsed.protocol.startsWith("http")) {
|
||||
errorMsg = "仅支持 HTTP/HTTPS";
|
||||
}
|
||||
|
||||
let sanitized = "";
|
||||
if (!errorMsg && parsed) {
|
||||
sanitized = normalizeEndpointUrl(parsed.toString());
|
||||
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
|
||||
const isDuplicate = entries.some((entry) => entry.url === sanitized);
|
||||
if (isDuplicate) {
|
||||
errorMsg = "该地址已存在";
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
setAddError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setAddError(null);
|
||||
|
||||
// 保存到后端
|
||||
try {
|
||||
if (providerId) {
|
||||
await window.api.addCustomEndpoint(appType, providerId, sanitized);
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setEntries((prev) => {
|
||||
if (prev.some((e) => e.url === sanitized)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: randomId(),
|
||||
url: sanitized,
|
||||
isCustom: true,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (!normalizedSelected) {
|
||||
onChange(sanitized);
|
||||
}
|
||||
|
||||
setCustomUrl("");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setAddError(message || "保存失败,请重试");
|
||||
console.error("添加自定义端点失败:", error);
|
||||
}
|
||||
},
|
||||
[customUrl, entries, normalizedSelected, onChange, appType, providerId],
|
||||
);
|
||||
|
||||
const handleRemoveEndpoint = useCallback(
|
||||
async (entry: EndpointEntry) => {
|
||||
// 如果是自定义端点,尝试从后端删除(无 providerId 则仅本地删除)
|
||||
if (entry.isCustom && providerId) {
|
||||
try {
|
||||
await window.api.removeCustomEndpoint(appType, providerId, entry.url);
|
||||
} catch (error) {
|
||||
console.error("删除自定义端点失败:", error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新本地状态
|
||||
setEntries((prev) => {
|
||||
const next = prev.filter((item) => item.id !== entry.id);
|
||||
if (entry.url === normalizedSelected) {
|
||||
const fallback = next[0];
|
||||
onChange(fallback ? fallback.url : "");
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[normalizedSelected, onChange, appType, providerId],
|
||||
);
|
||||
|
||||
const runSpeedTest = useCallback(async () => {
|
||||
const urls = entries.map((entry) => entry.url);
|
||||
if (urls.length === 0) {
|
||||
setLastError("请先添加端点");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined" || !window.api?.testApiEndpoints) {
|
||||
setLastError("测速功能不可用");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setLastError(null);
|
||||
|
||||
try {
|
||||
const results = await window.api.testApiEndpoints(urls, {
|
||||
timeoutSecs: appType === "codex" ? 12 : 8,
|
||||
});
|
||||
const resultMap = new Map(
|
||||
results.map((item) => [normalizeEndpointUrl(item.url), item]),
|
||||
);
|
||||
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => {
|
||||
const match = resultMap.get(entry.url);
|
||||
if (!match) {
|
||||
return {
|
||||
...entry,
|
||||
latency: null,
|
||||
status: undefined,
|
||||
error: "未返回结果",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
latency:
|
||||
typeof match.latency === "number" ? Math.round(match.latency) : null,
|
||||
status: match.status,
|
||||
error: match.error ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (autoSelect) {
|
||||
const successful = results
|
||||
.filter((item) => typeof item.latency === "number" && item.latency !== null)
|
||||
.sort((a, b) => (a.latency! || 0) - (b.latency! || 0));
|
||||
const best = successful[0];
|
||||
if (best && best.url && best.url !== normalizedSelected) {
|
||||
onChange(best.url);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : `测速失败: ${String(error)}`;
|
||||
setLastError(message);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [entries, autoSelect, appType, normalizedSelected, onChange]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (url: string) => {
|
||||
if (!url || url === normalizedSelected) return;
|
||||
|
||||
// 更新最后使用时间(对自定义端点)
|
||||
const entry = entries.find((e) => e.url === url);
|
||||
if (entry?.isCustom && providerId) {
|
||||
await window.api.updateEndpointLastUsed(appType, providerId, url);
|
||||
}
|
||||
|
||||
onChange(url);
|
||||
},
|
||||
[normalizedSelected, onChange, appType, entries, providerId],
|
||||
);
|
||||
|
||||
// 支持按下 ESC 关闭弹窗
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
||||
isLinux() ? "" : " backdrop-blur-sm"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||
请求地址管理
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
|
||||
{/* 测速控制栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{entries.length} 个端点
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSelect}
|
||||
onChange={(event) => setAutoSelect(event.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
自动选择
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runSpeedTest}
|
||||
disabled={isTesting || !hasEndpoints}
|
||||
className="flex h-7 items-center gap-1.5 rounded-md bg-blue-500 px-2.5 text-xs font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
测速中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
测速
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加输入 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={customUrl}
|
||||
placeholder="https://api.example.com"
|
||||
onChange={(event) => setCustomUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleAddEndpoint();
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 transition focus:border-gray-400 focus:outline-none dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-gray-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddEndpoint}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 transition hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600 dark:hover:bg-gray-800"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
{addError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{addError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 端点列表 */}
|
||||
{hasEndpoints ? (
|
||||
<div className="space-y-2">
|
||||
{sortedEntries.map((entry) => {
|
||||
const isSelected = normalizedSelected === entry.url;
|
||||
const latency = entry.latency;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
onClick={() => handleSelect(entry.url)}
|
||||
className={`group flex cursor-pointer items-center justify-between px-3 py-2.5 rounded-lg border transition ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-500 dark:bg-blue-900/20"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:hover:bg-gray-850"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{/* 选择指示器 */}
|
||||
<div
|
||||
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full transition ${
|
||||
isSelected
|
||||
? "bg-blue-500 dark:bg-blue-400"
|
||||
: "bg-gray-300 dark:bg-gray-700"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-gray-900 dark:text-gray-100">
|
||||
{entry.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧信息 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{latency !== null ? (
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{latency}ms
|
||||
</div>
|
||||
</div>
|
||||
) : isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
) : entry.error ? (
|
||||
<div className="text-xs text-gray-400">失败</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">—</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveEndpoint(entry);
|
||||
}}
|
||||
className="opacity-0 transition hover:text-red-600 group-hover:opacity-100 dark:hover:text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-gray-200 bg-gray-50 py-8 text-center text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
|
||||
暂无端点
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{lastError && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{lastError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-blue-500 dark:bg-blue-600 text-white rounded-lg hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointSpeedTest;
|
||||
@@ -86,10 +86,10 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
}
|
||||
}, [debouncedKey]);
|
||||
|
||||
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white ${
|
||||
const selectClass = `w-full px-3 py-2 border rounded-lg text-sm transition-colors appearance-none bg-white dark:bg-gray-800 ${
|
||||
disabled
|
||||
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed"
|
||||
: "border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
||||
? "bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
: "border-gray-200 dark:border-gray-700 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-blue-500 dark:focus:border-blue-400"
|
||||
}`;
|
||||
|
||||
const ModelSelect: React.FC<{
|
||||
@@ -98,7 +98,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
onChange: (value: string) => void;
|
||||
}> = ({ label, value, onChange }) => (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-900">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -123,7 +123,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
</select>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 pointer-events-none"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,14 +132,14 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
模型配置
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
|
||||
disabled={disabled || loading || !debouncedKey}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
刷新模型列表
|
||||
@@ -147,23 +147,23 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-100 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 p-3 bg-red-100 dark:bg-red-900/20 border border-red-500/20 dark:border-red-500/30 rounded-lg">
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-red-500 flex-shrink-0"
|
||||
className="text-red-500 dark:text-red-400 flex-shrink-0"
|
||||
/>
|
||||
<p className="text-red-500 text-xs">{error}</p>
|
||||
<p className="text-red-500 dark:text-red-400 text-xs">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ModelSelect
|
||||
label="主模型 (ANTHROPIC_MODEL)"
|
||||
label="主模型"
|
||||
value={anthropicModel}
|
||||
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
|
||||
/>
|
||||
<ModelSelect
|
||||
label="快速模型 (ANTHROPIC_SMALL_FAST_MODEL)"
|
||||
label="快速模型"
|
||||
value={anthropicSmallFastModel}
|
||||
onChange={(value) =>
|
||||
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
|
||||
@@ -172,9 +172,9 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
||||
</div>
|
||||
|
||||
{!apiKey.trim() && (
|
||||
<div className="p-3 bg-gray-100 border border-gray-200 rounded-lg">
|
||||
<p className="text-xs text-gray-500">
|
||||
📝 请先填写 API Key(格式:sk-xxx-api-key-here)以获取可用模型列表
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
💡 填写 API Key 后将自动获取可用模型列表
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from "react";
|
||||
import { Zap } from "lucide-react";
|
||||
import { ProviderCategory } from "../../types";
|
||||
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
|
||||
|
||||
interface Preset {
|
||||
name: string;
|
||||
isOfficial?: boolean;
|
||||
category?: ProviderCategory;
|
||||
}
|
||||
|
||||
interface PresetSelectorProps {
|
||||
@@ -13,6 +16,7 @@ interface PresetSelectorProps {
|
||||
onSelectPreset: (index: number) => void;
|
||||
onCustomClick: () => void;
|
||||
customLabel?: string;
|
||||
renderCustomDescription?: () => React.ReactNode; // 新增:自定义描述渲染
|
||||
}
|
||||
|
||||
const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
@@ -22,30 +26,41 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
onSelectPreset,
|
||||
onCustomClick,
|
||||
customLabel = "自定义",
|
||||
renderCustomDescription,
|
||||
}) => {
|
||||
const getButtonClass = (index: number, isOfficial?: boolean) => {
|
||||
const getButtonClass = (index: number, preset?: Preset) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const baseClass =
|
||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||
|
||||
if (isSelected) {
|
||||
return isOfficial
|
||||
? `${baseClass} bg-amber-500 text-white`
|
||||
: `${baseClass} bg-blue-500 text-white`;
|
||||
if (preset?.isOfficial || preset?.category === "official") {
|
||||
// Codex 官方使用黑色背景
|
||||
if (preset?.name.includes("Codex")) {
|
||||
return `${baseClass} bg-gray-900 text-white`;
|
||||
}
|
||||
// Claude 官方使用品牌色背景
|
||||
return `${baseClass} bg-[#D97757] text-white`;
|
||||
}
|
||||
return `${baseClass} bg-blue-500 text-white`;
|
||||
}
|
||||
|
||||
return `${baseClass} bg-gray-100 text-gray-500 hover:bg-gray-200`;
|
||||
return `${baseClass} bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700`;
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
if (selectedIndex === -1) {
|
||||
// 如果提供了自定义描述渲染函数,使用它
|
||||
if (renderCustomDescription) {
|
||||
return renderCustomDescription();
|
||||
}
|
||||
return "手动配置供应商,需要填写完整的配置信息";
|
||||
}
|
||||
|
||||
if (selectedIndex !== null && selectedIndex >= 0) {
|
||||
const preset = presets[selectedIndex];
|
||||
return preset?.isOfficial
|
||||
? "Claude 官方登录,不需要填写 API Key"
|
||||
return preset?.isOfficial || preset?.category === "official"
|
||||
? "官方登录,不需要填写 API Key"
|
||||
: "使用预设配置,只需填写 API Key";
|
||||
}
|
||||
|
||||
@@ -55,13 +70,13 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 mb-3">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{title}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? '' : ''}`}
|
||||
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
|
||||
onClick={onCustomClick}
|
||||
>
|
||||
{customLabel}
|
||||
@@ -70,19 +85,29 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={getButtonClass(index, preset.isOfficial)}
|
||||
className={getButtonClass(index, preset)}
|
||||
onClick={() => onSelectPreset(index)}
|
||||
>
|
||||
{preset.isOfficial && <Zap size={14} />}
|
||||
{(preset.isOfficial || preset.category === "official") && (
|
||||
<>
|
||||
{preset.name.includes("Claude") ? (
|
||||
<ClaudeIcon size={14} />
|
||||
) : preset.name.includes("Codex") ? (
|
||||
<CodexIcon size={14} />
|
||||
) : (
|
||||
<Zap size={14} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{getDescription() && (
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{getDescription()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../types";
|
||||
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
|
||||
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
|
||||
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
||||
import { AppType } from "../lib/tauri-api";
|
||||
// 不再在列表中显示分类徽章,避免造成困惑
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Record<string, Provider>;
|
||||
@@ -9,6 +12,12 @@ interface ProviderListProps {
|
||||
onSwitch: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (id: string) => void;
|
||||
appType?: AppType;
|
||||
onNotify?: (
|
||||
message: string,
|
||||
type: "success" | "error",
|
||||
duration?: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ProviderList: React.FC<ProviderListProps> = ({
|
||||
@@ -17,7 +26,10 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
onSwitch,
|
||||
onDelete,
|
||||
onEdit,
|
||||
appType,
|
||||
onNotify,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// 提取API地址(兼容不同供应商配置:Claude env / Codex TOML)
|
||||
const getApiUrl = (provider: Provider): string => {
|
||||
try {
|
||||
@@ -28,12 +40,13 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
}
|
||||
// Codex: 从 TOML 配置中解析 base_url
|
||||
if (typeof cfg?.config === "string" && cfg.config.includes("base_url")) {
|
||||
const match = cfg.config.match(/base_url\s*=\s*"([^"]+)"/);
|
||||
if (match && match[1]) return match[1];
|
||||
// 支持单/双引号
|
||||
const match = cfg.config.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||
if (match && match[2]) return match[2];
|
||||
}
|
||||
return "未配置官网地址";
|
||||
return t("provider.notConfigured");
|
||||
} catch {
|
||||
return "配置错误";
|
||||
return t("provider.configError");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,7 +54,57 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
try {
|
||||
await window.api.openExternal(url);
|
||||
} catch (error) {
|
||||
console.error("打开链接失败:", error);
|
||||
console.error(t("console.openLinkFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const [claudeApplied, setClaudeApplied] = useState<boolean>(false);
|
||||
|
||||
// 检查 Claude 插件配置是否已应用
|
||||
useEffect(() => {
|
||||
const checkClaude = async () => {
|
||||
if (appType !== "claude" || !currentProviderId) {
|
||||
setClaudeApplied(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const applied = await window.api.isClaudePluginApplied();
|
||||
setClaudeApplied(applied);
|
||||
} catch (error) {
|
||||
console.error("检测 Claude 插件配置失败:", error);
|
||||
setClaudeApplied(false);
|
||||
}
|
||||
};
|
||||
checkClaude();
|
||||
}, [appType, currentProviderId, providers]);
|
||||
|
||||
const handleApplyToClaudePlugin = async () => {
|
||||
try {
|
||||
await window.api.applyClaudePluginConfig({ official: false });
|
||||
onNotify?.(t("notifications.appliedToClaudePlugin"), "success", 3000);
|
||||
setClaudeApplied(true);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
const msg =
|
||||
error && error.message
|
||||
? error.message
|
||||
: t("notifications.syncClaudePluginFailed");
|
||||
onNotify?.(msg, "error", 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromClaudePlugin = async () => {
|
||||
try {
|
||||
await window.api.applyClaudePluginConfig({ official: true });
|
||||
onNotify?.(t("notifications.removedFromClaudePlugin"), "success", 3000);
|
||||
setClaudeApplied(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
const msg =
|
||||
error && error.message
|
||||
? error.message
|
||||
: t("notifications.syncClaudePluginFailed");
|
||||
onNotify?.(msg, "error", 5000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,16 +115,16 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
// 有时间戳的按时间升序排列
|
||||
const timeA = a.createdAt || 0;
|
||||
const timeB = b.createdAt || 0;
|
||||
|
||||
|
||||
// 如果都没有时间戳,按名称排序
|
||||
if (timeA === 0 && timeB === 0) {
|
||||
return a.name.localeCompare(b.name, 'zh-CN');
|
||||
return a.name.localeCompare(b.name, "zh-CN");
|
||||
}
|
||||
|
||||
|
||||
// 如果只有一个没有时间戳,没有时间戳的排在前面
|
||||
if (timeA === 0) return -1;
|
||||
if (timeB === 0) return 1;
|
||||
|
||||
|
||||
// 都有时间戳,按时间升序
|
||||
return timeA - timeB;
|
||||
});
|
||||
@@ -74,10 +137,10 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
<Users size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
还没有添加任何供应商
|
||||
{t("provider.noProviders")}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
点击右上角的"添加供应商"按钮开始配置您的第一个API供应商
|
||||
{t("provider.noProvidersDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -99,12 +162,16 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{provider.name}
|
||||
</h3>
|
||||
{isCurrent && (
|
||||
<div className={badgeStyles.success}>
|
||||
<CheckCircle2 size={12} />
|
||||
当前使用
|
||||
</div>
|
||||
)}
|
||||
{/* 分类徽章已移除 */}
|
||||
<div
|
||||
className={cn(
|
||||
badgeStyles.success,
|
||||
!isCurrent && "invisible"
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 size={12} />
|
||||
{t("provider.currentlyUsing")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
@@ -131,24 +198,52 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{appType === "claude" ? (
|
||||
<div className="flex-shrink-0">
|
||||
{provider.category !== "official" && isCurrent && (
|
||||
<button
|
||||
onClick={() =>
|
||||
claudeApplied
|
||||
? handleRemoveFromClaudePlugin()
|
||||
: handleApplyToClaudePlugin()
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-full whitespace-nowrap justify-center",
|
||||
claudeApplied
|
||||
? "border border-gray-300 text-gray-600 hover:border-red-300 hover:text-red-600 hover:bg-red-50 dark:border-gray-600 dark:text-gray-400 dark:hover:border-red-800 dark:hover:text-red-400 dark:hover:bg-red-900/20"
|
||||
: "border border-gray-300 text-gray-700 hover:border-green-300 hover:text-green-600 hover:bg-green-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-green-700 dark:hover:text-green-400 dark:hover:bg-green-900/20"
|
||||
)}
|
||||
title={
|
||||
claudeApplied
|
||||
? t("provider.removeFromClaudePlugin")
|
||||
: t("provider.applyToClaudePlugin")
|
||||
}
|
||||
>
|
||||
{claudeApplied
|
||||
? t("provider.removeFromClaudePlugin")
|
||||
: t("provider.applyToClaudePlugin")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => onSwitch(provider.id)}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
||||
"inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors w-[90px] justify-center whitespace-nowrap",
|
||||
isCurrent
|
||||
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
|
||||
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
<Play size={14} />
|
||||
{isCurrent ? "使用中" : "启用"}
|
||||
{isCurrent ? <Check size={14} /> : <Play size={14} />}
|
||||
{isCurrent ? t("provider.inUse") : t("provider.enable")}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onEdit(provider.id)}
|
||||
className={buttonStyles.icon}
|
||||
title="编辑供应商"
|
||||
title={t("provider.editProvider")}
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
</button>
|
||||
@@ -162,7 +257,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-500 hover:text-red-500 hover:bg-red-100 dark:text-gray-400 dark:hover:text-red-400 dark:hover:bg-red-500/10"
|
||||
)}
|
||||
title="删除供应商"
|
||||
title={t("provider.deleteProvider")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,26 +1,83 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Info, RefreshCw, FolderOpen } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
X,
|
||||
RefreshCw,
|
||||
FolderOpen,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Check,
|
||||
Undo2,
|
||||
FolderSearch,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { ImportProgressModal } from "./ImportProgressModal";
|
||||
import { homeDir, join } from "@tauri-apps/api/path";
|
||||
import "../lib/tauri-api";
|
||||
import { runUpdateFlow } from "../lib/updater";
|
||||
import { relaunchApp } from "../lib/updater";
|
||||
import { useUpdate } from "../contexts/UpdateContext";
|
||||
import type { Settings } from "../types";
|
||||
import type { AppType } from "../lib/tauri-api";
|
||||
import { isLinux } from "../lib/platform";
|
||||
|
||||
interface SettingsModalProps {
|
||||
onClose: () => void;
|
||||
onImportSuccess?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
export default function SettingsModal({ onClose, onImportSuccess }: SettingsModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
|
||||
lang === "en" ? "en" : "zh";
|
||||
|
||||
const readPersistedLanguage = (): "zh" | "en" => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = window.localStorage.getItem("language");
|
||||
if (stored === "en" || stored === "zh") {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return normalizeLanguage(i18n.language);
|
||||
};
|
||||
|
||||
const persistedLanguage = readPersistedLanguage();
|
||||
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
showInDock: true,
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
claudeConfigDir: undefined,
|
||||
codexConfigDir: undefined,
|
||||
language: persistedLanguage,
|
||||
});
|
||||
const [initialLanguage, setInitialLanguage] = useState<"zh" | "en">(
|
||||
persistedLanguage,
|
||||
);
|
||||
const [configPath, setConfigPath] = useState<string>("");
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [showUpToDate, setShowUpToDate] = useState(false);
|
||||
const [resolvedClaudeDir, setResolvedClaudeDir] = useState<string>("");
|
||||
const [resolvedCodexDir, setResolvedCodexDir] = useState<string>("");
|
||||
const [isPortable, setIsPortable] = useState(false);
|
||||
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
|
||||
useUpdate();
|
||||
|
||||
// 导入/导出相关状态
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importStatus, setImportStatus] = useState<'idle' | 'importing' | 'success' | 'error'>('idle');
|
||||
const [importError, setImportError] = useState<string>("");
|
||||
const [importBackupId, setImportBackupId] = useState<string>("");
|
||||
const [selectedImportFile, setSelectedImportFile] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
loadConfigPath();
|
||||
loadVersion();
|
||||
loadResolvedDirs();
|
||||
loadPortableFlag();
|
||||
}, []);
|
||||
|
||||
const loadVersion = async () => {
|
||||
@@ -28,19 +85,48 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
const appVersion = await getVersion();
|
||||
setVersion(appVersion);
|
||||
} catch (error) {
|
||||
console.error("获取版本信息失败:", error);
|
||||
setVersion("3.1.1"); // 降级使用默认版本
|
||||
console.error(t("console.getVersionFailed"), error);
|
||||
// 失败时不硬编码版本号,显示为未知
|
||||
setVersion(t("common.unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const loadedSettings = await window.api.getSettings();
|
||||
if (loadedSettings?.showInDock !== undefined) {
|
||||
setSettings({ showInDock: loadedSettings.showInDock });
|
||||
const showInTray =
|
||||
(loadedSettings as any)?.showInTray ??
|
||||
(loadedSettings as any)?.showInDock ??
|
||||
true;
|
||||
const minimizeToTrayOnClose =
|
||||
(loadedSettings as any)?.minimizeToTrayOnClose ??
|
||||
(loadedSettings as any)?.minimize_to_tray_on_close ??
|
||||
true;
|
||||
const storedLanguage = normalizeLanguage(
|
||||
typeof (loadedSettings as any)?.language === "string"
|
||||
? (loadedSettings as any).language
|
||||
: persistedLanguage,
|
||||
);
|
||||
|
||||
setSettings({
|
||||
showInTray,
|
||||
minimizeToTrayOnClose,
|
||||
claudeConfigDir:
|
||||
typeof (loadedSettings as any)?.claudeConfigDir === "string"
|
||||
? (loadedSettings as any).claudeConfigDir
|
||||
: undefined,
|
||||
codexConfigDir:
|
||||
typeof (loadedSettings as any)?.codexConfigDir === "string"
|
||||
? (loadedSettings as any).codexConfigDir
|
||||
: undefined,
|
||||
language: storedLanguage,
|
||||
});
|
||||
setInitialLanguage(storedLanguage);
|
||||
if (i18n.language !== storedLanguage) {
|
||||
void i18n.changeLanguage(storedLanguage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载设置失败:", error);
|
||||
console.error(t("console.loadSettingsFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,29 +137,129 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
setConfigPath(path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取配置路径失败:", error);
|
||||
console.error(t("console.getConfigPathFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadResolvedDirs = async () => {
|
||||
try {
|
||||
const [claudeDir, codexDir] = await Promise.all([
|
||||
window.api.getConfigDir("claude"),
|
||||
window.api.getConfigDir("codex"),
|
||||
]);
|
||||
setResolvedClaudeDir(claudeDir || "");
|
||||
setResolvedCodexDir(codexDir || "");
|
||||
} catch (error) {
|
||||
console.error(t("console.getConfigDirFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPortableFlag = async () => {
|
||||
try {
|
||||
const portable = await window.api.isPortable();
|
||||
setIsPortable(portable);
|
||||
} catch (error) {
|
||||
console.error(t("console.detectPortableFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await window.api.saveSettings(settings);
|
||||
const selectedLanguage = settings.language === "en" ? "en" : "zh";
|
||||
const payload: Settings = {
|
||||
...settings,
|
||||
claudeConfigDir:
|
||||
settings.claudeConfigDir && settings.claudeConfigDir.trim() !== ""
|
||||
? settings.claudeConfigDir.trim()
|
||||
: undefined,
|
||||
codexConfigDir:
|
||||
settings.codexConfigDir && settings.codexConfigDir.trim() !== ""
|
||||
? settings.codexConfigDir.trim()
|
||||
: undefined,
|
||||
language: selectedLanguage,
|
||||
};
|
||||
await window.api.saveSettings(payload);
|
||||
setSettings(payload);
|
||||
try {
|
||||
window.localStorage.setItem("language", selectedLanguage);
|
||||
} catch (error) {
|
||||
console.warn("[Settings] Failed to persist language preference", error);
|
||||
}
|
||||
setInitialLanguage(selectedLanguage);
|
||||
if (i18n.language !== selectedLanguage) {
|
||||
void i18n.changeLanguage(selectedLanguage);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("保存设置失败:", error);
|
||||
console.error(t("console.saveSettingsFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: "zh" | "en") => {
|
||||
setSettings((prev) => ({ ...prev, language: lang }));
|
||||
if (i18n.language !== lang) {
|
||||
void i18n.changeLanguage(lang);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (settings.language !== initialLanguage) {
|
||||
setSettings((prev) => ({ ...prev, language: initialLanguage }));
|
||||
if (i18n.language !== initialLanguage) {
|
||||
void i18n.changeLanguage(initialLanguage);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setIsCheckingUpdate(true);
|
||||
try {
|
||||
// 优先使用 Tauri Updater 流程;失败时回退到打开 Releases 页面
|
||||
await runUpdateFlow({ timeout: 30000 });
|
||||
} catch (error) {
|
||||
console.error("检查更新失败,回退到 Releases 页面:", error);
|
||||
await window.api.checkForUpdates();
|
||||
} finally {
|
||||
setIsCheckingUpdate(false);
|
||||
if (hasUpdate && updateHandle) {
|
||||
if (isPortable) {
|
||||
await window.api.checkForUpdates();
|
||||
return;
|
||||
}
|
||||
// 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
resetDismiss();
|
||||
await updateHandle.downloadAndInstall();
|
||||
await relaunchApp();
|
||||
} catch (error) {
|
||||
console.error(t("console.updateFailed"), error);
|
||||
// 更新失败时回退到打开 Releases 页面
|
||||
await window.api.checkForUpdates();
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
} else {
|
||||
// 尚未检测到更新:先检查
|
||||
setIsCheckingUpdate(true);
|
||||
setShowUpToDate(false);
|
||||
try {
|
||||
const hasNewUpdate = await checkUpdate();
|
||||
// 检查完成后,如果没有更新,显示"已是最新"
|
||||
if (!hasNewUpdate) {
|
||||
setShowUpToDate(true);
|
||||
// 3秒后恢复按钮文字
|
||||
setTimeout(() => {
|
||||
setShowUpToDate(false);
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t("console.checkUpdateFailed"), error);
|
||||
// 在开发模式下,模拟已是最新版本的响应
|
||||
if (import.meta.env.DEV) {
|
||||
setShowUpToDate(true);
|
||||
setTimeout(() => {
|
||||
setShowUpToDate(false);
|
||||
}, 3000);
|
||||
} else {
|
||||
// 生产环境下如果更新插件不可用,回退到打开 Releases 页面
|
||||
await window.api.checkForUpdates();
|
||||
}
|
||||
} finally {
|
||||
setIsCheckingUpdate(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,20 +267,174 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
try {
|
||||
await window.api.openAppConfigFolder();
|
||||
} catch (error) {
|
||||
console.error("打开配置文件夹失败:", error);
|
||||
console.error(t("console.openConfigFolderFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrowseConfigDir = async (app: AppType) => {
|
||||
try {
|
||||
const currentResolved =
|
||||
app === "claude"
|
||||
? (settings.claudeConfigDir ?? resolvedClaudeDir)
|
||||
: (settings.codexConfigDir ?? resolvedCodexDir);
|
||||
|
||||
const selected = await window.api.selectConfigDirectory(currentResolved);
|
||||
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = selected.trim();
|
||||
|
||||
if (sanitized === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (app === "claude") {
|
||||
setSettings((prev) => ({ ...prev, claudeConfigDir: sanitized }));
|
||||
setResolvedClaudeDir(sanitized);
|
||||
} else {
|
||||
setSettings((prev) => ({ ...prev, codexConfigDir: sanitized }));
|
||||
setResolvedCodexDir(sanitized);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t("console.selectConfigDirFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const computeDefaultConfigDir = async (app: AppType) => {
|
||||
try {
|
||||
const home = await homeDir();
|
||||
const folder = app === "claude" ? ".claude" : ".codex";
|
||||
return await join(home, folder);
|
||||
} catch (error) {
|
||||
console.error(t("console.getDefaultConfigDirFailed"), error);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetConfigDir = async (app: AppType) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
...(app === "claude"
|
||||
? { claudeConfigDir: undefined }
|
||||
: { codexConfigDir: undefined }),
|
||||
}));
|
||||
|
||||
const defaultDir = await computeDefaultConfigDir(app);
|
||||
if (!defaultDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (app === "claude") {
|
||||
setResolvedClaudeDir(defaultDir);
|
||||
} else {
|
||||
setResolvedCodexDir(defaultDir);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenReleaseNotes = async () => {
|
||||
try {
|
||||
const targetVersion = updateInfo?.availableVersion || version;
|
||||
const unknownLabel = t("common.unknown");
|
||||
// 如果未知或为空,回退到 releases 首页
|
||||
if (!targetVersion || targetVersion === unknownLabel) {
|
||||
await window.api.openExternal(
|
||||
"https://github.com/farion1231/cc-switch/releases"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const tag = targetVersion.startsWith("v")
|
||||
? targetVersion
|
||||
: `v${targetVersion}`;
|
||||
await window.api.openExternal(
|
||||
`https://github.com/farion1231/cc-switch/releases/tag/${tag}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(t("console.openReleaseNotesFailed"), error);
|
||||
}
|
||||
};
|
||||
|
||||
// 导出配置处理函数
|
||||
const handleExportConfig = async () => {
|
||||
try {
|
||||
const defaultName = `cc-switch-config-${new Date().toISOString().split('T')[0]}.json`;
|
||||
const filePath = await window.api.saveFileDialog(defaultName);
|
||||
|
||||
if (!filePath) return; // 用户取消了
|
||||
|
||||
const result = await window.api.exportConfigToFile(filePath);
|
||||
|
||||
if (result.success) {
|
||||
alert(`${t("settings.configExported")}\n${result.filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("导出配置失败:", error);
|
||||
alert(`${t("settings.exportFailed")}: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 选择要导入的文件
|
||||
const handleSelectImportFile = async () => {
|
||||
try {
|
||||
const filePath = await window.api.openFileDialog();
|
||||
if (filePath) {
|
||||
setSelectedImportFile(filePath);
|
||||
setImportStatus('idle'); // 重置状态
|
||||
setImportError('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择文件失败:', error);
|
||||
alert(`${t("settings.selectFileFailed")}: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 执行导入
|
||||
const handleExecuteImport = async () => {
|
||||
if (!selectedImportFile || isImporting) return;
|
||||
|
||||
setIsImporting(true);
|
||||
setImportStatus('importing');
|
||||
|
||||
try {
|
||||
const result = await window.api.importConfigFromFile(selectedImportFile);
|
||||
|
||||
if (result.success) {
|
||||
setImportBackupId(result.backupId || '');
|
||||
setImportStatus('success');
|
||||
// ImportProgressModal 会在2秒后触发数据刷新回调
|
||||
} else {
|
||||
setImportError(result.message || t("settings.configCorrupted"));
|
||||
setImportStatus('error');
|
||||
}
|
||||
} catch (error) {
|
||||
setImportError(String(error));
|
||||
setImportStatus('error');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) handleCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 dark:bg-black/70${
|
||||
isLinux() ? "" : " backdrop-blur-sm"
|
||||
}`}
|
||||
/>
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-blue-500 dark:text-blue-400">
|
||||
设置
|
||||
{t("settings.title")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
onClick={handleCancel}
|
||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
@@ -102,42 +442,83 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
|
||||
{/* 设置内容 */}
|
||||
<div className="px-6 py-4 space-y-6">
|
||||
{/* 显示设置 - 功能还未实现 */}
|
||||
{/* <div>
|
||||
<div className="px-6 py-4 space-y-6 overflow-y-auto flex-1">
|
||||
{/* 语言设置 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
显示设置
|
||||
{t("settings.language")}
|
||||
</h3>
|
||||
<label className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
在 Dock 中显示(macOS)
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showInDock}
|
||||
onChange={(e) =>
|
||||
setSettings({ ...settings, showInDock: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
|
||||
/>
|
||||
</label>
|
||||
</div> */}
|
||||
<div className="inline-flex p-0.5 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLanguageChange("zh")}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
|
||||
(settings.language ?? "zh") === "zh"
|
||||
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{t("settings.languageOptionChinese")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLanguageChange("en")}
|
||||
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all min-w-[80px] ${
|
||||
settings.language === "en"
|
||||
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
|
||||
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{t("settings.languageOptionEnglish")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 窗口行为设置 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{t("settings.windowBehavior")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||||
{t("settings.minimizeToTray")}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t("settings.minimizeToTrayDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.minimizeToTrayOnClose}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
minimizeToTrayOnClose: e.target.checked,
|
||||
}))
|
||||
}
|
||||
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置文件位置 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
配置文件位置
|
||||
{t("settings.configFileLocation")}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
|
||||
{configPath || "加载中..."}
|
||||
{configPath || t("common.loading")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenConfigFolder}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title="打开文件夹"
|
||||
title={t("settings.openFolder")}
|
||||
>
|
||||
<FolderOpen
|
||||
size={18}
|
||||
@@ -147,45 +528,211 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置目录覆盖 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("settings.configDirectoryOverride")}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
|
||||
{t("settings.configDirectoryDescription")}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t("settings.claudeConfigDir")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.claudeConfigDir ?? resolvedClaudeDir ?? ""}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
claudeConfigDir: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("settings.browsePlaceholderClaude")}
|
||||
className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBrowseConfigDir("claude")}
|
||||
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title={t("settings.browseDirectory")}
|
||||
>
|
||||
<FolderSearch size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResetConfigDir("claude")}
|
||||
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title={t("settings.resetDefault")}
|
||||
>
|
||||
<Undo2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{t("settings.codexConfigDir")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.codexConfigDir ?? resolvedCodexDir ?? ""}
|
||||
onChange={(e) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
codexConfigDir: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("settings.browsePlaceholderCodex")}
|
||||
className="flex-1 px-3 py-2 text-xs font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBrowseConfigDir("codex")}
|
||||
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title={t("settings.browseDirectory")}
|
||||
>
|
||||
<FolderSearch size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleResetConfigDir("codex")}
|
||||
className="px-2 py-2 text-xs text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
title={t("settings.resetDefault")}
|
||||
>
|
||||
<Undo2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导入导出 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{t("settings.importExport")}
|
||||
</h3>
|
||||
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div className="space-y-3">
|
||||
{/* 导出按钮 */}
|
||||
<button
|
||||
onClick={handleExportConfig}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
|
||||
>
|
||||
<Save size={12} />
|
||||
{t("settings.exportConfig")}
|
||||
</button>
|
||||
|
||||
{/* 导入区域 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSelectImportFile}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
|
||||
>
|
||||
<FolderOpen size={12} />
|
||||
{t("settings.selectConfigFile")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecuteImport}
|
||||
disabled={!selectedImportFile || isImporting}
|
||||
className={`px-3 py-2 text-xs font-medium rounded-lg transition-colors text-white ${
|
||||
!selectedImportFile || isImporting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isImporting ? t("settings.importing") : t("settings.import")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 显示选择的文件 */}
|
||||
{selectedImportFile && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 px-2 py-1 bg-gray-50 dark:bg-gray-900 rounded break-all">
|
||||
{selectedImportFile.split('/').pop() || selectedImportFile.split('\\').pop() || selectedImportFile}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关于 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
关于
|
||||
{t("common.about")}
|
||||
</h3>
|
||||
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info
|
||||
size={18}
|
||||
className="text-gray-500 mt-0.5"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||
CC Switch
|
||||
</p>
|
||||
<p className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
版本 {version}
|
||||
{t("common.version")} {version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={isCheckingUpdate}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
|
||||
isCheckingUpdate
|
||||
? "bg-white dark:bg-gray-700 text-gray-400 dark:text-gray-500"
|
||||
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{isCheckingUpdate ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
检查中...
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleOpenReleaseNotes}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 rounded-lg hover:bg-blue-500/10 transition-colors"
|
||||
title={
|
||||
hasUpdate
|
||||
? t("settings.viewReleaseNotes")
|
||||
: t("settings.viewCurrentReleaseNotes")
|
||||
}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ExternalLink size={12} />
|
||||
{t("settings.releaseNotes")}
|
||||
</span>
|
||||
) : (
|
||||
"检查更新"
|
||||
)}
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCheckUpdate}
|
||||
disabled={isCheckingUpdate || isDownloading}
|
||||
className={`min-w-[88px] px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
|
||||
isCheckingUpdate || isDownloading
|
||||
? "bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed border border-transparent"
|
||||
: hasUpdate
|
||||
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white border border-transparent"
|
||||
: showUpToDate
|
||||
? "bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800"
|
||||
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400 border border-gray-200 dark:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Download size={12} className="animate-pulse" />
|
||||
{t("settings.updating")}
|
||||
</span>
|
||||
) : isCheckingUpdate ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
{t("settings.checking")}
|
||||
</span>
|
||||
) : hasUpdate ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Download size={12} />
|
||||
{t("settings.updateTo", {
|
||||
version: updateInfo?.availableVersion ?? "",
|
||||
})}
|
||||
</span>
|
||||
) : showUpToDate ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Check size={12} />
|
||||
{t("settings.upToDate")}
|
||||
</span>
|
||||
) : (
|
||||
t("settings.checkForUpdates")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,19 +741,42 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<button
|
||||
onClick={onClose}
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-lg transition-colors"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
保存
|
||||
<Save size={16} />
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Progress Modal */}
|
||||
{importStatus !== 'idle' && (
|
||||
<ImportProgressModal
|
||||
status={importStatus}
|
||||
message={importError}
|
||||
backupId={importBackupId}
|
||||
onComplete={() => {
|
||||
setImportStatus('idle');
|
||||
setImportError('');
|
||||
setSelectedImportFile('');
|
||||
}}
|
||||
onSuccess={() => {
|
||||
if (onImportSuccess) {
|
||||
void onImportSuccess();
|
||||
}
|
||||
void window.api
|
||||
.updateTrayMenu()
|
||||
.catch((error) => console.error("[SettingsModal] Failed to refresh tray menu", error));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
61
src/components/UpdateBadge.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { X, Download } from "lucide-react";
|
||||
import { useUpdate } from "../contexts/UpdateContext";
|
||||
|
||||
interface UpdateBadgeProps {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
||||
const { hasUpdate, updateInfo, isDismissed, dismissUpdate } = useUpdate();
|
||||
|
||||
// 如果没有更新或已关闭,不显示
|
||||
if (!hasUpdate || isDismissed || !updateInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-1.5 px-2.5 py-1
|
||||
bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
rounded-lg text-xs
|
||||
shadow-sm
|
||||
transition-all duration-200
|
||||
${onClick ? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750" : ""}
|
||||
${className}
|
||||
`}
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : -1}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (!onClick) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="w-3 h-3 text-blue-500 dark:text-blue-400" />
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">
|
||||
v{updateInfo.availableVersion}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
dismissUpdate();
|
||||
}}
|
||||
className="
|
||||
ml-1 -mr-0.5 p-0.5 rounded
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
transition-colors
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500/20
|
||||
"
|
||||
aria-label="关闭更新提醒"
|
||||
>
|
||||
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,55 @@
|
||||
/**
|
||||
* Codex 预设供应商配置模板
|
||||
*/
|
||||
import { ProviderCategory } from "../types";
|
||||
|
||||
export interface CodexProviderPreset {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
// 第三方供应商可提供单独的获取 API Key 链接
|
||||
apiKeyUrl?: string;
|
||||
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
|
||||
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
category?: ProviderCategory; // 新增:分类
|
||||
isCustomTemplate?: boolean; // 标识是否为自定义模板
|
||||
// 新增:请求地址候选列表(用于地址管理/测速)
|
||||
endpointCandidates?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成第三方供应商的 auth.json
|
||||
*/
|
||||
export function generateThirdPartyAuth(apiKey: string): Record<string, any> {
|
||||
return {
|
||||
OPENAI_API_KEY: apiKey || "sk-your-api-key-here",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成第三方供应商的 config.toml
|
||||
*/
|
||||
export function generateThirdPartyConfig(
|
||||
providerName: string,
|
||||
baseUrl: string,
|
||||
modelName = "gpt-5-codex"
|
||||
): string {
|
||||
// 清理供应商名称,确保符合TOML键名规范
|
||||
const cleanProviderName =
|
||||
providerName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "custom";
|
||||
|
||||
return `model_provider = "${cleanProviderName}"
|
||||
model = "${modelName}"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.${cleanProviderName}]
|
||||
name = "${cleanProviderName}"
|
||||
base_url = "${baseUrl}"
|
||||
wire_api = "responses"`;
|
||||
}
|
||||
|
||||
export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
@@ -14,7 +57,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
name: "Codex官方",
|
||||
websiteUrl: "https://chatgpt.com/codex",
|
||||
isOfficial: true,
|
||||
// 官方的 key 为null
|
||||
category: "official",
|
||||
auth: {
|
||||
OPENAI_API_KEY: null,
|
||||
},
|
||||
@@ -23,19 +66,18 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://codex.packycode.com/",
|
||||
// PackyCode 一般通过 API Key;请将占位符替换为你的实际 key
|
||||
auth: {
|
||||
OPENAI_API_KEY: "sk-your-api-key-here",
|
||||
},
|
||||
config: `model_provider = "packycode"
|
||||
model = "gpt-5"
|
||||
model_reasoning_effort = "high"
|
||||
disable_response_storage = true
|
||||
|
||||
[model_providers.packycode]
|
||||
name = "packycode"
|
||||
base_url = "https://codex-api.packycode.com/v1"
|
||||
wire_api = "responses"
|
||||
env_key = "packycode"`,
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth("sk-your-api-key-here"),
|
||||
config: generateThirdPartyConfig(
|
||||
"packycode",
|
||||
"https://codex-api.packycode.com/v1",
|
||||
"gpt-5-codex"
|
||||
),
|
||||
// Codex 请求地址候选(用于地址管理/测速)
|
||||
endpointCandidates: [
|
||||
"https://codex-api.packycode.com/v1",
|
||||
"https://codex-api-hk-cn2.packycode.com/v1",
|
||||
"https://codex-api-hk-cdn.packycode.com/v1",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,33 +1,51 @@
|
||||
/**
|
||||
* 预设供应商配置模板
|
||||
*/
|
||||
import { ProviderCategory } from "../types";
|
||||
|
||||
export interface TemplateValueConfig {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
defaultValue?: string;
|
||||
editorValue: string;
|
||||
}
|
||||
|
||||
export interface ProviderPreset {
|
||||
name: string;
|
||||
websiteUrl: string;
|
||||
// 新增:第三方/聚合等可单独配置获取 API Key 的链接
|
||||
apiKeyUrl?: string;
|
||||
settingsConfig: object;
|
||||
isOfficial?: boolean; // 标识是否为官方预设
|
||||
category?: ProviderCategory; // 新增:分类
|
||||
// 新增:模板变量定义,用于动态替换配置中的值
|
||||
templateValues?: Record<string, TemplateValueConfig>; // editorValue 存储编辑器中的实时输入值
|
||||
// 新增:请求地址候选列表(用于地址管理/测速)
|
||||
endpointCandidates?: string[];
|
||||
}
|
||||
|
||||
export const providerPresets: ProviderPreset[] = [
|
||||
{
|
||||
name: "Claude官方登录",
|
||||
name: "Claude官方",
|
||||
websiteUrl: "https://www.anthropic.com/claude-code",
|
||||
settingsConfig: {
|
||||
env: {},
|
||||
},
|
||||
isOfficial: true, // 明确标识为官方预设
|
||||
category: "official",
|
||||
},
|
||||
{
|
||||
name: "DeepSeek v3.1",
|
||||
name: "DeepSeek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "deepseek-chat",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
|
||||
ANTHROPIC_MODEL: "DeepSeek-V3.2-Exp",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "DeepSeek-V3.2-Exp",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "智谱GLM",
|
||||
@@ -36,19 +54,29 @@ export const providerPresets: ProviderPreset[] = [
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
// 兼容旧键名,保持前端读取一致
|
||||
ANTHROPIC_MODEL: "GLM-4.6",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "glm-4.5-air",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "千问Qwen-Coder",
|
||||
name: "Qwen-Coder",
|
||||
websiteUrl: "https://bailian.console.aliyun.com",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL:
|
||||
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "qwen3-max",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-max",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "Kimi k2",
|
||||
@@ -61,27 +89,61 @@ export const providerPresets: ProviderPreset[] = [
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
},
|
||||
{
|
||||
name: "魔搭",
|
||||
websiteUrl: "https://modelscope.cn",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: "ms-your-api-key",
|
||||
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
|
||||
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.6",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.6",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://www.packycode.com",
|
||||
apiKeyUrl: "https://www.packycode.com/?aff=rlo54mgz",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.packycode.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
// 请求地址候选(用于地址管理/测速)
|
||||
endpointCandidates: [
|
||||
"https://api.packycode.com",
|
||||
"https://api-hk-cn2.packycode.com",
|
||||
"https://api-hk-g.packycode.com",
|
||||
"https://api-us-cn2.packycode.com",
|
||||
"https://api-cf-pro.packycode.com",
|
||||
],
|
||||
category: "third_party",
|
||||
},
|
||||
{
|
||||
name: "KAT-Coder 官方",
|
||||
websiteUrl: "https://console.streamlake.ai/wanqing/",
|
||||
apiKeyUrl: "https://console.streamlake.ai/console/wanqing/api-key",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/claude-code-proxy",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "KAT-Coder",
|
||||
ANTHROPIC_SMALL_FAST_MODEL: "KAT-Coder",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
templateValues: {
|
||||
ENDPOINT_ID: {
|
||||
label: "Vanchin Endpoint ID",
|
||||
placeholder: "ep-xxx-xxx",
|
||||
defaultValue: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
155
src/contexts/UpdateContext.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { UpdateInfo, UpdateHandle } from "../lib/updater";
|
||||
import { checkForUpdate } from "../lib/updater";
|
||||
|
||||
interface UpdateContextValue {
|
||||
// 更新状态
|
||||
hasUpdate: boolean;
|
||||
updateInfo: UpdateInfo | null;
|
||||
updateHandle: UpdateHandle | null;
|
||||
isChecking: boolean;
|
||||
error: string | null;
|
||||
|
||||
// 提示状态
|
||||
isDismissed: boolean;
|
||||
dismissUpdate: () => void;
|
||||
|
||||
// 操作方法
|
||||
checkUpdate: () => Promise<boolean>;
|
||||
resetDismiss: () => void;
|
||||
}
|
||||
|
||||
const UpdateContext = createContext<UpdateContextValue | undefined>(undefined);
|
||||
|
||||
export function UpdateProvider({ children }: { children: React.ReactNode }) {
|
||||
const DISMISSED_VERSION_KEY = "ccswitch:update:dismissedVersion";
|
||||
const LEGACY_DISMISSED_KEY = "dismissedUpdateVersion"; // 兼容旧键
|
||||
|
||||
const [hasUpdate, setHasUpdate] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [updateHandle, setUpdateHandle] = useState<UpdateHandle | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
// 从 localStorage 读取已关闭的版本
|
||||
useEffect(() => {
|
||||
const current = updateInfo?.availableVersion;
|
||||
if (!current) return;
|
||||
|
||||
// 读取新键;若不存在,尝试迁移旧键
|
||||
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
|
||||
if (!dismissedVersion) {
|
||||
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
|
||||
if (legacy) {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
|
||||
localStorage.removeItem(LEGACY_DISMISSED_KEY);
|
||||
dismissedVersion = legacy;
|
||||
}
|
||||
}
|
||||
|
||||
setIsDismissed(dismissedVersion === current);
|
||||
}, [updateInfo?.availableVersion]);
|
||||
|
||||
const isCheckingRef = useRef(false);
|
||||
|
||||
const checkUpdate = useCallback(async () => {
|
||||
if (isCheckingRef.current) return false;
|
||||
isCheckingRef.current = true;
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await checkForUpdate({ timeout: 30000 });
|
||||
|
||||
if (result.status === "available") {
|
||||
setHasUpdate(true);
|
||||
setUpdateInfo(result.info);
|
||||
setUpdateHandle(result.update);
|
||||
|
||||
// 检查是否已经关闭过这个版本的提醒
|
||||
let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY);
|
||||
if (!dismissedVersion) {
|
||||
const legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
|
||||
if (legacy) {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, legacy);
|
||||
localStorage.removeItem(LEGACY_DISMISSED_KEY);
|
||||
dismissedVersion = legacy;
|
||||
}
|
||||
}
|
||||
setIsDismissed(dismissedVersion === result.info.availableVersion);
|
||||
return true; // 有更新
|
||||
} else {
|
||||
setHasUpdate(false);
|
||||
setUpdateInfo(null);
|
||||
setUpdateHandle(null);
|
||||
setIsDismissed(false);
|
||||
return false; // 已是最新
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("检查更新失败:", err);
|
||||
setError(err instanceof Error ? err.message : "检查更新失败");
|
||||
setHasUpdate(false);
|
||||
throw err; // 抛出错误让调用方处理
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
isCheckingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissUpdate = useCallback(() => {
|
||||
setIsDismissed(true);
|
||||
if (updateInfo?.availableVersion) {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, updateInfo.availableVersion);
|
||||
// 清理旧键
|
||||
localStorage.removeItem(LEGACY_DISMISSED_KEY);
|
||||
}
|
||||
}, [updateInfo?.availableVersion]);
|
||||
|
||||
const resetDismiss = useCallback(() => {
|
||||
setIsDismissed(false);
|
||||
localStorage.removeItem(DISMISSED_VERSION_KEY);
|
||||
localStorage.removeItem(LEGACY_DISMISSED_KEY);
|
||||
}, []);
|
||||
|
||||
// 应用启动时自动检查更新
|
||||
useEffect(() => {
|
||||
// 延迟1秒后检查,避免影响启动体验
|
||||
const timer = setTimeout(() => {
|
||||
checkUpdate().catch(console.error);
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkUpdate]);
|
||||
|
||||
const value: UpdateContextValue = {
|
||||
hasUpdate,
|
||||
updateInfo,
|
||||
updateHandle,
|
||||
isChecking,
|
||||
error,
|
||||
isDismissed,
|
||||
dismissUpdate,
|
||||
checkUpdate,
|
||||
resetDismiss,
|
||||
};
|
||||
|
||||
return (
|
||||
<UpdateContext.Provider value={value}>{children}</UpdateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdate() {
|
||||
const context = useContext(UpdateContext);
|
||||
if (!context) {
|
||||
throw new Error("useUpdate must be used within UpdateProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,55 +1,60 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDarkMode() {
|
||||
// 初始设为 false,挂载后在 useEffect 中加载真实值
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
// 组件挂载后加载初始值(兼容 Tauri 环境)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
// 尝试读取已保存的偏好
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
const saved = localStorage.getItem("darkMode");
|
||||
if (saved !== null) {
|
||||
const savedBool = saved === 'true';
|
||||
const savedBool = saved === "true";
|
||||
setIsDarkMode(savedBool);
|
||||
console.log('[DarkMode] Loaded from localStorage:', savedBool);
|
||||
if (isDev)
|
||||
console.log("[DarkMode] Loaded from localStorage:", savedBool);
|
||||
} else {
|
||||
// 回退到系统偏好
|
||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const prefersDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setIsDarkMode(prefersDark);
|
||||
console.log('[DarkMode] Using system preference:', prefersDark);
|
||||
if (isDev)
|
||||
console.log("[DarkMode] Using system preference:", prefersDark);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DarkMode] Error loading preference:', error);
|
||||
console.error("[DarkMode] Error loading preference:", error);
|
||||
setIsDarkMode(false);
|
||||
}
|
||||
|
||||
|
||||
setIsInitialized(true);
|
||||
}, []); // 仅在首次挂载时运行
|
||||
|
||||
// 将 dark 类应用到文档根节点
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
|
||||
// 添加短暂延迟以确保 Tauri 中 DOM 已就绪
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
console.log('[DarkMode] Added dark class to document');
|
||||
document.documentElement.classList.add("dark");
|
||||
if (isDev) console.log("[DarkMode] Added dark class to document");
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
console.log('[DarkMode] Removed dark class from document');
|
||||
document.documentElement.classList.remove("dark");
|
||||
if (isDev) console.log("[DarkMode] Removed dark class from document");
|
||||
}
|
||||
|
||||
|
||||
// 检查类名是否已成功应用
|
||||
const hasClass = document.documentElement.classList.contains('dark');
|
||||
console.log('[DarkMode] Document has dark class:', hasClass);
|
||||
const hasClass = document.documentElement.classList.contains("dark");
|
||||
if (isDev) console.log("[DarkMode] Document has dark class:", hasClass);
|
||||
} catch (error) {
|
||||
console.error('[DarkMode] Error applying dark class:', error);
|
||||
console.error("[DarkMode] Error applying dark class:", error);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
@@ -59,19 +64,19 @@ export function useDarkMode() {
|
||||
// 将偏好保存到 localStorage
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
|
||||
|
||||
try {
|
||||
localStorage.setItem('darkMode', isDarkMode.toString());
|
||||
console.log('[DarkMode] Saved to localStorage:', isDarkMode);
|
||||
localStorage.setItem("darkMode", isDarkMode.toString());
|
||||
if (isDev) console.log("[DarkMode] Saved to localStorage:", isDarkMode);
|
||||
} catch (error) {
|
||||
console.error('[DarkMode] Error saving preference:', error);
|
||||
console.error("[DarkMode] Error saving preference:", error);
|
||||
}
|
||||
}, [isDarkMode, isInitialized]);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setIsDarkMode(prev => {
|
||||
setIsDarkMode((prev) => {
|
||||
const newValue = !prev;
|
||||
console.log('[DarkMode] Toggling from', prev, 'to', newValue);
|
||||
if (isDev) console.log("[DarkMode] Toggling from", prev, "to", newValue);
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
59
src/i18n/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import en from "./locales/en.json";
|
||||
import zh from "./locales/zh.json";
|
||||
|
||||
const DEFAULT_LANGUAGE: "zh" | "en" = "zh";
|
||||
|
||||
const getInitialLanguage = (): "zh" | "en" => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const stored = window.localStorage.getItem("language");
|
||||
if (stored === "zh" || stored === "en") {
|
||||
return stored;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[i18n] Failed to read stored language preference", error);
|
||||
}
|
||||
}
|
||||
|
||||
const navigatorLang =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.language?.toLowerCase() ?? navigator.languages?.[0]?.toLowerCase()
|
||||
: undefined;
|
||||
|
||||
if (navigatorLang?.startsWith("zh")) {
|
||||
return "zh";
|
||||
}
|
||||
|
||||
if (navigatorLang?.startsWith("en")) {
|
||||
return "en";
|
||||
}
|
||||
|
||||
return DEFAULT_LANGUAGE;
|
||||
};
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
zh: {
|
||||
translation: zh,
|
||||
},
|
||||
};
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: getInitialLanguage(), // 根据本地存储或系统语言选择默认语言
|
||||
fallbackLng: "en", // 如果缺少中文翻译则退回英文
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React 已经默认转义
|
||||
},
|
||||
|
||||
// 开发模式下显示调试信息
|
||||
debug: false,
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
124
src/i18n/locales/en.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "CC Switch",
|
||||
"description": "Claude Code & Codex Provider Switching Tool"
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"close": "Close",
|
||||
"settings": "Settings",
|
||||
"about": "About",
|
||||
"version": "Version",
|
||||
"loading": "Loading...",
|
||||
"success": "Success",
|
||||
"error": "Error",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "View on GitHub",
|
||||
"toggleDarkMode": "Switch to Dark Mode",
|
||||
"toggleLightMode": "Switch to Light Mode",
|
||||
"addProvider": "Add Provider",
|
||||
"switchToChinese": "Switch to Chinese",
|
||||
"switchToEnglish": "Switch to English"
|
||||
},
|
||||
"provider": {
|
||||
"noProviders": "No providers added yet",
|
||||
"noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider",
|
||||
"currentlyUsing": "Currently Using",
|
||||
"enable": "Enable",
|
||||
"inUse": "In Use",
|
||||
"editProvider": "Edit Provider",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"addNewProvider": "Add New Provider",
|
||||
"configError": "Configuration Error",
|
||||
"notConfigured": "Not configured for official website",
|
||||
"applyToClaudePlugin": "Apply to Claude plugin",
|
||||
"removeFromClaudePlugin": "Remove from Claude plugin"
|
||||
},
|
||||
"notifications": {
|
||||
"providerSaved": "Provider configuration saved",
|
||||
"providerDeleted": "Provider deleted successfully",
|
||||
"switchSuccess": "Switch successful! Please restart {{appName}} terminal to take effect",
|
||||
"switchFailed": "Switch failed, please check configuration",
|
||||
"autoImported": "Default provider created from existing configuration",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"saveFailedGeneric": "Save failed, please try again",
|
||||
"appliedToClaudePlugin": "Applied to Claude plugin",
|
||||
"removedFromClaudePlugin": "Removed from Claude plugin",
|
||||
"syncClaudePluginFailed": "Sync Claude plugin failed"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProviderMessage": "Are you sure you want to delete provider \"{{name}}\"? This action cannot be undone."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"general": "General",
|
||||
"language": "Language",
|
||||
"importExport": "Import/Export Config",
|
||||
"exportConfig": "Export Config to File",
|
||||
"selectConfigFile": "Select Config File",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"importSuccess": "Import Successful!",
|
||||
"importFailed": "Import Failed",
|
||||
"configExported": "Config exported to:",
|
||||
"exportFailed": "Export failed",
|
||||
"selectFileFailed": "Failed to select file",
|
||||
"configCorrupted": "Config file may be corrupted or invalid",
|
||||
"backupId": "Backup ID",
|
||||
"autoReload": "Data will refresh automatically in 2 seconds...",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionEnglish": "English",
|
||||
"windowBehavior": "Window Behavior",
|
||||
"minimizeToTray": "Minimize to tray on close",
|
||||
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
||||
"configFileLocation": "Configuration File Location",
|
||||
"openFolder": "Open Folder",
|
||||
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
||||
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory in WSL to keep provider data consistent with the main environment.",
|
||||
"claudeConfigDir": "Claude Code Configuration Directory",
|
||||
"codexConfigDir": "Codex Configuration Directory",
|
||||
"browsePlaceholderClaude": "e.g., /home/<your-username>/.claude",
|
||||
"browsePlaceholderCodex": "e.g., /home/<your-username>/.codex",
|
||||
"browseDirectory": "Browse Directory",
|
||||
"resetDefault": "Reset to default directory (takes effect after saving)",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"updateTo": "Update to v{{version}}",
|
||||
"updating": "Updating...",
|
||||
"checking": "Checking...",
|
||||
"upToDate": "Up to Date",
|
||||
"releaseNotes": "Release Notes",
|
||||
"viewReleaseNotes": "View release notes for this version",
|
||||
"viewCurrentReleaseNotes": "View current version release notes"
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "Received provider switch event:",
|
||||
"setupListenerFailed": "Failed to setup provider switch listener:",
|
||||
"updateProviderFailed": "Update provider failed:",
|
||||
"autoImportFailed": "Auto import default configuration failed:",
|
||||
"openLinkFailed": "Failed to open link:",
|
||||
"getVersionFailed": "Failed to get version info:",
|
||||
"loadSettingsFailed": "Failed to load settings:",
|
||||
"getConfigPathFailed": "Failed to get config path:",
|
||||
"getConfigDirFailed": "Failed to get config directory:",
|
||||
"detectPortableFailed": "Failed to detect portable mode:",
|
||||
"saveSettingsFailed": "Failed to save settings:",
|
||||
"updateFailed": "Update failed:",
|
||||
"checkUpdateFailed": "Check for updates failed:",
|
||||
"openConfigFolderFailed": "Failed to open config folder:",
|
||||
"selectConfigDirFailed": "Failed to select config directory:",
|
||||
"getDefaultConfigDirFailed": "Failed to get default config directory:",
|
||||
"openReleaseNotesFailed": "Failed to open release notes:"
|
||||
}
|
||||
}
|
||||
124
src/i18n/locales/zh.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "CC Switch",
|
||||
"description": "Claude Code & Codex 供应商切换工具"
|
||||
},
|
||||
"common": {
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"confirm": "确定",
|
||||
"close": "关闭",
|
||||
"settings": "设置",
|
||||
"about": "关于",
|
||||
"version": "版本",
|
||||
"loading": "加载中...",
|
||||
"success": "成功",
|
||||
"error": "错误",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"header": {
|
||||
"viewOnGithub": "在 GitHub 上查看",
|
||||
"toggleDarkMode": "切换到暗色模式",
|
||||
"toggleLightMode": "切换到亮色模式",
|
||||
"addProvider": "添加供应商",
|
||||
"switchToChinese": "切换到中文",
|
||||
"switchToEnglish": "切换到英文"
|
||||
},
|
||||
"provider": {
|
||||
"noProviders": "还没有添加任何供应商",
|
||||
"noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商",
|
||||
"currentlyUsing": "当前使用",
|
||||
"enable": "启用",
|
||||
"inUse": "使用中",
|
||||
"editProvider": "编辑供应商",
|
||||
"deleteProvider": "删除供应商",
|
||||
"addNewProvider": "添加新供应商",
|
||||
"configError": "配置错误",
|
||||
"notConfigured": "未配置官网地址",
|
||||
"applyToClaudePlugin": "应用到 Claude 插件",
|
||||
"removeFromClaudePlugin": "从 Claude 插件移除"
|
||||
},
|
||||
"notifications": {
|
||||
"providerSaved": "供应商配置已保存",
|
||||
"providerDeleted": "供应商删除成功",
|
||||
"switchSuccess": "切换成功!请重启 {{appName}} 终端以生效",
|
||||
"switchFailed": "切换失败,请检查配置",
|
||||
"autoImported": "已从现有配置创建默认供应商",
|
||||
"saveFailed": "保存失败:{{error}}",
|
||||
"saveFailedGeneric": "保存失败,请重试",
|
||||
"appliedToClaudePlugin": "已应用到 Claude 插件",
|
||||
"removedFromClaudePlugin": "已从 Claude 插件移除",
|
||||
"syncClaudePluginFailed": "同步 Claude 插件失败"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
"deleteProviderMessage": "确定要删除供应商 \"{{name}}\" 吗?此操作无法撤销。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"general": "通用",
|
||||
"language": "界面语言",
|
||||
"importExport": "导入导出配置",
|
||||
"exportConfig": "导出配置到文件",
|
||||
"selectConfigFile": "选择配置文件",
|
||||
"import": "导入",
|
||||
"importing": "导入中...",
|
||||
"importSuccess": "导入成功!",
|
||||
"importFailed": "导入失败",
|
||||
"configExported": "配置已导出到:",
|
||||
"exportFailed": "导出失败",
|
||||
"selectFileFailed": "选择文件失败",
|
||||
"configCorrupted": "配置文件可能已损坏或格式不正确",
|
||||
"backupId": "备份ID",
|
||||
"autoReload": "数据将在2秒后自动刷新...",
|
||||
"languageOptionChinese": "中文",
|
||||
"languageOptionEnglish": "English",
|
||||
"windowBehavior": "窗口行为",
|
||||
"minimizeToTray": "关闭时最小化到托盘",
|
||||
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
||||
"configFileLocation": "配置文件位置",
|
||||
"openFolder": "打开文件夹",
|
||||
"configDirectoryOverride": "配置目录覆盖(高级)",
|
||||
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
||||
"claudeConfigDir": "Claude Code 配置目录",
|
||||
"codexConfigDir": "Codex 配置目录",
|
||||
"browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude",
|
||||
"browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex",
|
||||
"browseDirectory": "浏览目录",
|
||||
"resetDefault": "恢复默认目录(需保存后生效)",
|
||||
"checkForUpdates": "检查更新",
|
||||
"updateTo": "更新到 v{{version}}",
|
||||
"updating": "更新中...",
|
||||
"checking": "检查中...",
|
||||
"upToDate": "已是最新",
|
||||
"releaseNotes": "更新日志",
|
||||
"viewReleaseNotes": "查看该版本更新日志",
|
||||
"viewCurrentReleaseNotes": "查看当前版本更新日志"
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude Code",
|
||||
"codex": "Codex"
|
||||
},
|
||||
"console": {
|
||||
"providerSwitchReceived": "收到供应商切换事件:",
|
||||
"setupListenerFailed": "设置供应商切换监听器失败:",
|
||||
"updateProviderFailed": "更新供应商失败:",
|
||||
"autoImportFailed": "自动导入默认配置失败:",
|
||||
"openLinkFailed": "打开链接失败:",
|
||||
"getVersionFailed": "获取版本信息失败:",
|
||||
"loadSettingsFailed": "加载设置失败:",
|
||||
"getConfigPathFailed": "获取配置路径失败:",
|
||||
"getConfigDirFailed": "获取配置目录失败:",
|
||||
"detectPortableFailed": "检测便携模式失败:",
|
||||
"saveSettingsFailed": "保存设置失败:",
|
||||
"updateFailed": "更新失败:",
|
||||
"checkUpdateFailed": "检查更新失败:",
|
||||
"openConfigFolderFailed": "打开配置文件夹失败:",
|
||||
"selectConfigDirFailed": "选择配置目录失败:",
|
||||
"getDefaultConfigDirFailed": "获取默认配置目录失败:",
|
||||
"openReleaseNotesFailed": "打开更新日志失败:"
|
||||
}
|
||||
}
|
||||
@@ -21,23 +21,33 @@ body {
|
||||
}
|
||||
|
||||
/* 暗色模式下启用暗色原生控件/滚动条配色 */
|
||||
html.dark { color-scheme: dark; }
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
/* 滚动条样式(避免在伪元素中使用自定义 dark 变体,消除构建警告) */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5 h-1.5;
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 dark:bg-gray-800;
|
||||
background-color: #f4f4f5;
|
||||
}
|
||||
html.dark ::-webkit-scrollbar-track {
|
||||
background-color: #27272a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded dark:bg-gray-600;
|
||||
background-color: #d4d4d8;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
html.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: #52525b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400 dark:bg-gray-500;
|
||||
background-color: #a1a1aa;
|
||||
}
|
||||
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #71717a;
|
||||
}
|
||||
|
||||
/* 焦点样式 */
|
||||
|
||||
30
src/lib/platform.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// 轻量平台检测,避免在 SSR 或无 navigator 的环境报错
|
||||
export const isMac = (): boolean => {
|
||||
try {
|
||||
const ua = navigator.userAgent || "";
|
||||
const plat = (navigator.platform || "").toLowerCase();
|
||||
return /mac/i.test(ua) || plat.includes("mac");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isWindows = (): boolean => {
|
||||
try {
|
||||
const ua = navigator.userAgent || "";
|
||||
return /windows|win32|win64/i.test(ua);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isLinux = (): boolean => {
|
||||
try {
|
||||
const ua = navigator.userAgent || "";
|
||||
// WebKitGTK/Chromium 在 Linux/Wayland/X11 下 UA 通常包含 Linux 或 X11
|
||||
return /linux|x11/i.test(ua) && !/android/i.test(ua) && !isMac() && !isWindows();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,20 +5,24 @@
|
||||
// 按钮样式
|
||||
export const buttonStyles = {
|
||||
// 主按钮:蓝底白字
|
||||
primary: "px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium",
|
||||
|
||||
primary:
|
||||
"px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 transition-colors text-sm font-medium",
|
||||
|
||||
// 次按钮:灰背景,深色文本
|
||||
secondary: "px-4 py-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium",
|
||||
|
||||
secondary:
|
||||
"px-4 py-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 rounded-lg transition-colors text-sm font-medium",
|
||||
|
||||
// 危险按钮:用于不可撤销/破坏性操作
|
||||
danger: "px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 transition-colors text-sm font-medium",
|
||||
|
||||
danger:
|
||||
"px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 transition-colors text-sm font-medium",
|
||||
|
||||
// 幽灵按钮:无背景,仅悬浮反馈
|
||||
ghost: "px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-sm font-medium",
|
||||
|
||||
ghost:
|
||||
"px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-sm font-medium",
|
||||
|
||||
// 图标按钮:小尺寸,仅图标
|
||||
icon: "p-1.5 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors",
|
||||
|
||||
|
||||
// 禁用态:可与其他样式组合
|
||||
disabled: "opacity-50 cursor-not-allowed pointer-events-none",
|
||||
} as const;
|
||||
@@ -27,42 +31,49 @@ export const buttonStyles = {
|
||||
export const cardStyles = {
|
||||
// 基础卡片容器
|
||||
base: "bg-white rounded-lg border border-gray-200 p-4 dark:bg-gray-900 dark:border-gray-700",
|
||||
|
||||
|
||||
// 带悬浮效果的卡片
|
||||
interactive: "bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-all duration-200",
|
||||
|
||||
interactive:
|
||||
"bg-white rounded-lg border border-gray-200 p-4 hover:border-gray-300 hover:shadow-sm dark:bg-gray-900 dark:border-gray-700 dark:hover:border-gray-600 transition-[border-color,box-shadow] duration-200",
|
||||
|
||||
// 选中/激活态卡片
|
||||
selected: "bg-white rounded-lg border border-blue-500 ring-1 ring-blue-500/20 bg-blue-500/5 p-4 dark:bg-gray-900 dark:border-blue-400 dark:ring-blue-400/20 dark:bg-blue-400/10",
|
||||
selected:
|
||||
"bg-white rounded-lg border border-blue-500 shadow-sm bg-blue-50 p-4 dark:bg-gray-900 dark:border-blue-400 dark:bg-blue-400/10",
|
||||
} as const;
|
||||
|
||||
// 输入控件样式
|
||||
export const inputStyles = {
|
||||
// 文本输入框
|
||||
text: "w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
|
||||
|
||||
|
||||
// 下拉选择框
|
||||
select: "w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
|
||||
|
||||
select:
|
||||
"w-full px-3 py-2 border border-gray-200 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 outline-none bg-white dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-400/20 transition-colors",
|
||||
|
||||
// 复选框
|
||||
checkbox: "w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20 border-gray-300 dark:border-gray-600 dark:bg-gray-800",
|
||||
checkbox:
|
||||
"w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20 border-gray-300 dark:border-gray-600 dark:bg-gray-800",
|
||||
} as const;
|
||||
|
||||
// 徽标(Badge)样式
|
||||
export const badgeStyles = {
|
||||
// 成功徽标
|
||||
success: "inline-flex items-center gap-1 px-2 py-1 bg-green-500/10 text-green-500 rounded-md text-xs font-medium",
|
||||
|
||||
success:
|
||||
"inline-flex items-center gap-1 px-2 py-1 bg-green-500/10 text-green-500 rounded-md text-xs font-medium",
|
||||
|
||||
// 信息徽标
|
||||
info: "inline-flex items-center gap-1 px-2 py-1 bg-blue-500/10 text-blue-500 rounded-md text-xs font-medium",
|
||||
|
||||
|
||||
// 警告徽标
|
||||
warning: "inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-500 rounded-md text-xs font-medium",
|
||||
|
||||
warning:
|
||||
"inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-500 rounded-md text-xs font-medium",
|
||||
|
||||
// 错误徽标
|
||||
error: "inline-flex items-center gap-1 px-2 py-1 bg-red-500/10 text-red-500 rounded-md text-xs font-medium",
|
||||
error:
|
||||
"inline-flex items-center gap-1 px-2 py-1 bg-red-500/10 text-red-500 rounded-md text-xs font-medium",
|
||||
} as const;
|
||||
|
||||
// 组合类名的工具函数
|
||||
export function cn(...classes: (string | undefined | false)[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { Provider, Settings } from "../types";
|
||||
import { Provider, Settings, CustomEndpoint } from "../types";
|
||||
|
||||
// 应用类型
|
||||
export type AppType = "claude" | "codex";
|
||||
@@ -18,6 +18,13 @@ interface ImportResult {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface EndpointLatencyResult {
|
||||
url: string;
|
||||
latency: number | null;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Tauri API 封装,提供统一的全局 API 接口
|
||||
export const tauriAPI = {
|
||||
// 获取所有供应商
|
||||
@@ -122,40 +129,32 @@ export const tauriAPI = {
|
||||
}
|
||||
},
|
||||
|
||||
// 获取 Claude Code 配置状态
|
||||
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
|
||||
// 获取当前生效的配置目录
|
||||
getConfigDir: async (app?: AppType): Promise<string> => {
|
||||
try {
|
||||
return await invoke("get_claude_config_status");
|
||||
return await invoke("get_config_dir", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("获取配置状态失败:", error);
|
||||
return {
|
||||
exists: false,
|
||||
path: "",
|
||||
error: String(error),
|
||||
};
|
||||
console.error("获取配置目录失败:", error);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
// 获取应用配置状态(通用)
|
||||
getConfigStatus: async (app?: AppType): Promise<ConfigStatus> => {
|
||||
try {
|
||||
return await invoke("get_config_status", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("获取配置状态失败:", error);
|
||||
return {
|
||||
exists: false,
|
||||
path: "",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// 打开配置文件夹
|
||||
// 打开配置目录(按应用类型)
|
||||
openConfigFolder: async (app?: AppType): Promise<void> => {
|
||||
try {
|
||||
await invoke("open_config_folder", { app_type: app, app });
|
||||
} catch (error) {
|
||||
console.error("打开配置文件夹失败:", error);
|
||||
console.error("打开配置目录失败:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// 选择配置目录(可选默认路径)
|
||||
selectConfigDirectory: async (defaultPath?: string): Promise<string | null> => {
|
||||
try {
|
||||
return await invoke("pick_directory", { defaultPath });
|
||||
} catch (error) {
|
||||
console.error("选择配置目录失败:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -171,37 +170,20 @@ export const tauriAPI = {
|
||||
// 更新托盘菜单
|
||||
updateTrayMenu: async (): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke("update_tray_menu");
|
||||
return await invoke<boolean>("update_tray_menu");
|
||||
} catch (error) {
|
||||
console.error("更新托盘菜单失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 监听供应商切换事件
|
||||
onProviderSwitched: async (
|
||||
callback: (data: { appType: string; providerId: string }) => void,
|
||||
): Promise<UnlistenFn> => {
|
||||
return await listen("provider-switched", (event) => {
|
||||
callback(event.payload as { appType: string; providerId: string });
|
||||
});
|
||||
},
|
||||
|
||||
// (保留空位,取消迁移提示)
|
||||
|
||||
// 选择配置文件(Tauri 暂不实现,保留接口兼容性)
|
||||
selectConfigFile: async (): Promise<string | null> => {
|
||||
console.warn("selectConfigFile 在 Tauri 版本中暂不支持");
|
||||
return null;
|
||||
},
|
||||
|
||||
// 获取设置
|
||||
// 获取应用设置
|
||||
getSettings: async (): Promise<Settings> => {
|
||||
try {
|
||||
return await invoke("get_settings");
|
||||
} catch (error) {
|
||||
console.error("获取设置失败:", error);
|
||||
return { showInDock: true };
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -224,6 +206,16 @@ export const tauriAPI = {
|
||||
}
|
||||
},
|
||||
|
||||
// 判断是否为便携模式
|
||||
isPortable: async (): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke<boolean>("is_portable_mode");
|
||||
} catch (error) {
|
||||
console.error("检测便携模式失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取应用配置文件路径
|
||||
getAppConfigPath: async (): Promise<string> => {
|
||||
try {
|
||||
@@ -242,6 +234,215 @@ export const tauriAPI = {
|
||||
console.error("打开应用配置文件夹失败:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// Claude 插件:获取 ~/.claude/config.json 状态
|
||||
getClaudePluginStatus: async (): Promise<ConfigStatus> => {
|
||||
try {
|
||||
return await invoke<ConfigStatus>("get_claude_plugin_status");
|
||||
} catch (error) {
|
||||
console.error("获取 Claude 插件状态失败:", error);
|
||||
return { exists: false, path: "", error: String(error) };
|
||||
}
|
||||
},
|
||||
|
||||
// Claude 插件:读取配置内容
|
||||
readClaudePluginConfig: async (): Promise<string | null> => {
|
||||
try {
|
||||
return await invoke<string | null>("read_claude_plugin_config");
|
||||
} catch (error) {
|
||||
throw new Error(`读取 Claude 插件配置失败: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Claude 插件:应用或移除固定配置
|
||||
applyClaudePluginConfig: async (options: {
|
||||
official: boolean;
|
||||
}): Promise<boolean> => {
|
||||
const { official } = options;
|
||||
try {
|
||||
return await invoke<boolean>("apply_claude_plugin_config", { official });
|
||||
} catch (error) {
|
||||
throw new Error(`写入 Claude 插件配置失败: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Claude 插件:检测是否已应用目标配置
|
||||
isClaudePluginApplied: async (): Promise<boolean> => {
|
||||
try {
|
||||
return await invoke<boolean>("is_claude_plugin_applied");
|
||||
} catch (error) {
|
||||
throw new Error(`检测 Claude 插件配置失败: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
// ours: 第三方/自定义供应商——测速与端点管理
|
||||
// 第三方/自定义供应商:批量测试端点延迟
|
||||
testApiEndpoints: async (
|
||||
urls: string[],
|
||||
options?: { timeoutSecs?: number },
|
||||
): Promise<EndpointLatencyResult[]> => {
|
||||
try {
|
||||
return await invoke<EndpointLatencyResult[]>("test_api_endpoints", {
|
||||
urls,
|
||||
timeout_secs: options?.timeoutSecs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("测速调用失败:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 获取自定义端点列表
|
||||
getCustomEndpoints: async (
|
||||
appType: AppType,
|
||||
providerId: string,
|
||||
): Promise<CustomEndpoint[]> => {
|
||||
try {
|
||||
return await invoke<CustomEndpoint[]>("get_custom_endpoints", {
|
||||
// 兼容不同后端参数命名
|
||||
app_type: appType,
|
||||
app: appType,
|
||||
appType: appType,
|
||||
provider_id: providerId,
|
||||
providerId: providerId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("获取自定义端点列表失败:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// 添加自定义端点
|
||||
addCustomEndpoint: async (
|
||||
appType: AppType,
|
||||
providerId: string,
|
||||
url: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await invoke("add_custom_endpoint", {
|
||||
app_type: appType,
|
||||
app: appType,
|
||||
appType: appType,
|
||||
provider_id: providerId,
|
||||
providerId: providerId,
|
||||
url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("添加自定义端点失败:", error);
|
||||
// 尽量抛出可读信息
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new Error(String(error));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 删除自定义端点
|
||||
removeCustomEndpoint: async (
|
||||
appType: AppType,
|
||||
providerId: string,
|
||||
url: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await invoke("remove_custom_endpoint", {
|
||||
app_type: appType,
|
||||
app: appType,
|
||||
appType: appType,
|
||||
provider_id: providerId,
|
||||
providerId: providerId,
|
||||
url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("删除自定义端点失败:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 更新端点最后使用时间
|
||||
updateEndpointLastUsed: async (
|
||||
appType: AppType,
|
||||
providerId: string,
|
||||
url: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await invoke("update_endpoint_last_used", {
|
||||
app_type: appType,
|
||||
app: appType,
|
||||
appType: appType,
|
||||
provider_id: providerId,
|
||||
providerId: providerId,
|
||||
url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("更新端点最后使用时间失败:", error);
|
||||
// 不抛出错误,因为这不是关键操作
|
||||
}
|
||||
},
|
||||
|
||||
// theirs: 导入导出与文件对话框
|
||||
// 导出配置到文件
|
||||
exportConfigToFile: async (filePath: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
filePath: string;
|
||||
}> => {
|
||||
try {
|
||||
return await invoke("export_config_to_file", { filePath });
|
||||
} catch (error) {
|
||||
throw new Error(`导出配置失败: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 从文件导入配置
|
||||
importConfigFromFile: async (filePath: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
backupId?: string;
|
||||
}> => {
|
||||
try {
|
||||
return await invoke("import_config_from_file", { filePath });
|
||||
} catch (error) {
|
||||
throw new Error(`导入配置失败: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 保存文件对话框
|
||||
saveFileDialog: async (defaultName: string): Promise<string | null> => {
|
||||
try {
|
||||
const result = await invoke<string | null>("save_file_dialog", { defaultName });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("打开保存对话框失败:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// 打开文件对话框
|
||||
openFileDialog: async (): Promise<string | null> => {
|
||||
try {
|
||||
const result = await invoke<string | null>("open_file_dialog");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("打开文件对话框失败:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// 监听供应商切换事件
|
||||
onProviderSwitched: async (
|
||||
callback: (data: { appType: string; providerId: string }) => void,
|
||||
): Promise<UnlistenFn> => {
|
||||
const unlisten = await listen("provider-switched", (event) => {
|
||||
try {
|
||||
// 事件 payload 形如 { appType: string, providerId: string }
|
||||
callback(event.payload as any);
|
||||
} catch (e) {
|
||||
console.error("处理 provider-switched 事件失败: ", e);
|
||||
}
|
||||
});
|
||||
return unlisten;
|
||||
},
|
||||
};
|
||||
|
||||
// 创建全局 API 对象,兼容现有代码
|
||||
|
||||
@@ -122,25 +122,5 @@ export async function relaunchApp(): Promise<void> {
|
||||
await relaunch();
|
||||
}
|
||||
|
||||
export async function runUpdateFlow(
|
||||
opts: CheckOptions = {},
|
||||
): Promise<{ status: "up-to-date" | "done" }> {
|
||||
const result = await checkForUpdate(opts);
|
||||
if (result.status === "up-to-date") return result;
|
||||
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
await result.update.downloadAndInstall((e) => {
|
||||
if (e.event === "Started") {
|
||||
total = e.total ?? 0;
|
||||
downloaded = 0;
|
||||
} else if (e.event === "Progress") {
|
||||
downloaded += e.downloaded ?? 0;
|
||||
// 调用方可监听此处并更新 UI(目前设置页仅显示加载态)
|
||||
console.debug("update progress", { downloaded, total });
|
||||
}
|
||||
});
|
||||
|
||||
await relaunchApp();
|
||||
return { status: "done" };
|
||||
}
|
||||
// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
|
||||
// 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { UpdateProvider } from "./contexts/UpdateContext";
|
||||
import "./index.css";
|
||||
// 导入 Tauri API(自动绑定到 window.api)
|
||||
import "./lib/tauri-api";
|
||||
// 导入国际化配置
|
||||
import "./i18n";
|
||||
|
||||
// 根据平台添加 body class,便于平台特定样式
|
||||
try {
|
||||
@@ -19,6 +22,8 @@ try {
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
<UpdateProvider>
|
||||
<App />
|
||||
</UpdateProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
39
src/types.ts
@@ -1,9 +1,20 @@
|
||||
export type ProviderCategory =
|
||||
| "official" // 官方
|
||||
| "cn_official" // 国产官方
|
||||
| "aggregator" // 聚合网站
|
||||
| "third_party" // 第三方供应商
|
||||
| "custom"; // 自定义
|
||||
|
||||
export interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
settingsConfig: Record<string, any>; // 应用配置对象:Claude 为 settings.json;Codex 为 { auth, config }
|
||||
websiteUrl?: string;
|
||||
// 新增:供应商分类(用于差异化提示/能力开关)
|
||||
category?: ProviderCategory;
|
||||
createdAt?: number; // 添加时间戳(毫秒)
|
||||
// 可选:供应商元数据(仅存于 ~/.cc-switch/config.json,不写入 live 配置)
|
||||
meta?: ProviderMeta;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
@@ -11,7 +22,33 @@ export interface AppConfig {
|
||||
current: string;
|
||||
}
|
||||
|
||||
// 自定义端点配置
|
||||
export interface CustomEndpoint {
|
||||
url: string;
|
||||
addedAt: number;
|
||||
lastUsed?: number;
|
||||
}
|
||||
|
||||
// 供应商元数据(字段名与后端一致,保持 snake_case)
|
||||
export interface ProviderMeta {
|
||||
// 自定义端点:以 URL 为键,值为端点信息
|
||||
custom_endpoints?: Record<string, CustomEndpoint>;
|
||||
}
|
||||
|
||||
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
||||
export interface Settings {
|
||||
showInDock: boolean;
|
||||
// 是否在系统托盘(macOS 菜单栏)显示图标
|
||||
showInTray: boolean;
|
||||
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
|
||||
minimizeToTrayOnClose: boolean;
|
||||
// 覆盖 Claude Code 配置目录(可选)
|
||||
claudeConfigDir?: string;
|
||||
// 覆盖 Codex 配置目录(可选)
|
||||
codexConfigDir?: string;
|
||||
// 首选语言(可选,默认中文)
|
||||
language?: "en" | "zh";
|
||||
// Claude 自定义端点列表
|
||||
customEndpointsClaude?: Record<string, CustomEndpoint>;
|
||||
// Codex 自定义端点列表
|
||||
customEndpointsCodex?: Record<string, CustomEndpoint>;
|
||||
}
|
||||
|
||||
38
src/utils/errorUtils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 从各种错误对象中提取错误信息
|
||||
* @param error 错误对象
|
||||
* @returns 提取的错误信息字符串
|
||||
*/
|
||||
export const extractErrorMessage = (error: unknown): string => {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObject = error as Record<string, unknown>;
|
||||
|
||||
const candidate = errObject.message ?? errObject.error ?? errObject.detail;
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const payload = errObject.payload;
|
||||
if (typeof payload === "string" && payload.trim()) {
|
||||
return payload;
|
||||
}
|
||||
if (payload && typeof payload === "object") {
|
||||
const payloadObj = payload as Record<string, unknown>;
|
||||
const payloadCandidate =
|
||||
payloadObj.message ?? payloadObj.error ?? payloadObj.detail;
|
||||
if (typeof payloadCandidate === "string" && payloadCandidate.trim()) {
|
||||
return payloadCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
@@ -1,33 +1,164 @@
|
||||
// 供应商配置处理工具函数
|
||||
|
||||
// 处理includeCoAuthoredBy字段的添加/删除
|
||||
export const updateCoAuthoredSetting = (
|
||||
jsonString: string,
|
||||
disable: boolean,
|
||||
): string => {
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
import type { TemplateValueConfig } from "../config/providerPresets";
|
||||
|
||||
if (disable) {
|
||||
// 添加或更新includeCoAuthoredBy字段
|
||||
config.includeCoAuthoredBy = false;
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
||||
return Object.prototype.toString.call(value) === "[object Object]";
|
||||
};
|
||||
|
||||
const deepMerge = (
|
||||
target: Record<string, any>,
|
||||
source: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (isPlainObject(value)) {
|
||||
if (!isPlainObject(target[key])) {
|
||||
target[key] = {};
|
||||
}
|
||||
deepMerge(target[key], value);
|
||||
} else {
|
||||
// 删除includeCoAuthoredBy字段
|
||||
delete config.includeCoAuthoredBy;
|
||||
// 直接覆盖非对象字段(数组/基础类型)
|
||||
target[key] = value;
|
||||
}
|
||||
});
|
||||
return target;
|
||||
};
|
||||
|
||||
return JSON.stringify(config, null, 2);
|
||||
} catch (err) {
|
||||
// 如果JSON解析失败,返回原始字符串
|
||||
return jsonString;
|
||||
const deepRemove = (
|
||||
target: Record<string, any>,
|
||||
source: Record<string, any>,
|
||||
) => {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (!(key in target)) return;
|
||||
|
||||
if (isPlainObject(value) && isPlainObject(target[key])) {
|
||||
// 只移除完全匹配的嵌套属性
|
||||
deepRemove(target[key], value);
|
||||
if (Object.keys(target[key]).length === 0) {
|
||||
delete target[key];
|
||||
}
|
||||
} else if (isSubset(target[key], value)) {
|
||||
// 只有当值完全匹配时才删除
|
||||
delete target[key];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const isSubset = (target: any, source: any): boolean => {
|
||||
if (isPlainObject(source)) {
|
||||
if (!isPlainObject(target)) return false;
|
||||
return Object.entries(source).every(([key, value]) =>
|
||||
isSubset(target[key], value),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
if (!Array.isArray(target) || target.length !== source.length) return false;
|
||||
return source.every((item, index) => isSubset(target[index], item));
|
||||
}
|
||||
|
||||
return target === source;
|
||||
};
|
||||
|
||||
// 深拷贝函数
|
||||
const deepClone = <T>(obj: T): T => {
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
||||
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
|
||||
if (obj instanceof Object) {
|
||||
const clonedObj = {} as T;
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
export interface UpdateCommonConfigResult {
|
||||
updatedConfig: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 验证JSON配置格式
|
||||
export const validateJsonConfig = (
|
||||
value: string,
|
||||
fieldName: string = "配置",
|
||||
): string => {
|
||||
if (!value.trim()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return `${fieldName}必须是 JSON 对象`;
|
||||
}
|
||||
return "";
|
||||
} catch {
|
||||
return `${fieldName}JSON格式错误,请检查语法`;
|
||||
}
|
||||
};
|
||||
|
||||
// 从JSON配置中检查是否包含includeCoAuthoredBy设置
|
||||
export const checkCoAuthoredSetting = (jsonString: string): boolean => {
|
||||
// 将通用配置片段写入/移除 settingsConfig
|
||||
export const updateCommonConfigSnippet = (
|
||||
jsonString: string,
|
||||
snippetString: string,
|
||||
enabled: boolean,
|
||||
): UpdateCommonConfigResult => {
|
||||
let config: Record<string, any>;
|
||||
try {
|
||||
const config = JSON.parse(jsonString);
|
||||
return config.includeCoAuthoredBy === false;
|
||||
config = jsonString ? JSON.parse(jsonString) : {};
|
||||
} catch (err) {
|
||||
return {
|
||||
updatedConfig: jsonString,
|
||||
error: "配置 JSON 解析失败,无法写入通用配置",
|
||||
};
|
||||
}
|
||||
|
||||
if (!snippetString.trim()) {
|
||||
return {
|
||||
updatedConfig: JSON.stringify(config, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
// 使用统一的验证函数
|
||||
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
|
||||
if (snippetError) {
|
||||
return {
|
||||
updatedConfig: JSON.stringify(config, null, 2),
|
||||
error: snippetError,
|
||||
};
|
||||
}
|
||||
|
||||
const snippet = JSON.parse(snippetString) as Record<string, any>;
|
||||
|
||||
if (enabled) {
|
||||
const merged = deepMerge(deepClone(config), snippet);
|
||||
return {
|
||||
updatedConfig: JSON.stringify(merged, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
const cloned = deepClone(config);
|
||||
deepRemove(cloned, snippet);
|
||||
return {
|
||||
updatedConfig: JSON.stringify(cloned, null, 2),
|
||||
};
|
||||
};
|
||||
|
||||
// 检查当前配置是否已包含通用配置片段
|
||||
export const hasCommonConfigSnippet = (
|
||||
jsonString: string,
|
||||
snippetString: string,
|
||||
): boolean => {
|
||||
try {
|
||||
if (!snippetString.trim()) return false;
|
||||
const config = jsonString ? JSON.parse(jsonString) : {};
|
||||
const snippet = JSON.parse(snippetString);
|
||||
if (!isPlainObject(snippet)) return false;
|
||||
return isSubset(config, snippet);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
@@ -44,6 +175,51 @@ export const getApiKeyFromConfig = (jsonString: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// 模板变量替换
|
||||
export const applyTemplateValues = (
|
||||
config: any,
|
||||
templateValues: Record<string, TemplateValueConfig> | undefined
|
||||
): any => {
|
||||
const resolvedValues = Object.fromEntries(
|
||||
Object.entries(templateValues ?? {}).map(([key, value]) => {
|
||||
const resolvedValue =
|
||||
value.editorValue !== undefined
|
||||
? value.editorValue
|
||||
: value.defaultValue ?? "";
|
||||
return [key, resolvedValue];
|
||||
})
|
||||
);
|
||||
|
||||
const replaceInString = (str: string): string => {
|
||||
return Object.entries(resolvedValues).reduce((acc, [key, value]) => {
|
||||
const placeholder = `\${${key}}`;
|
||||
if (!acc.includes(placeholder)) {
|
||||
return acc;
|
||||
}
|
||||
return acc.split(placeholder).join(value ?? "");
|
||||
}, str);
|
||||
};
|
||||
|
||||
const traverse = (obj: any): any => {
|
||||
if (typeof obj === "string") {
|
||||
return replaceInString(obj);
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(traverse);
|
||||
}
|
||||
if (obj && typeof obj === "object") {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = traverse(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
return traverse(config);
|
||||
};
|
||||
|
||||
// 判断配置中是否存在 API Key 字段
|
||||
export const hasApiKeyField = (jsonString: string): boolean => {
|
||||
try {
|
||||
@@ -79,3 +255,135 @@ export const setApiKeyInConfig = (
|
||||
return jsonString;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== TOML Config Utilities ==========
|
||||
|
||||
export interface UpdateTomlCommonConfigResult {
|
||||
updatedConfig: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 保存之前的通用配置片段,用于替换操作
|
||||
let previousCommonSnippet = "";
|
||||
|
||||
// 将通用配置片段写入/移除 TOML 配置
|
||||
export const updateTomlCommonConfigSnippet = (
|
||||
tomlString: string,
|
||||
snippetString: string,
|
||||
enabled: boolean,
|
||||
): UpdateTomlCommonConfigResult => {
|
||||
if (!snippetString.trim()) {
|
||||
// 如果片段为空,直接返回原始配置
|
||||
return {
|
||||
updatedConfig: tomlString,
|
||||
};
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
// 添加通用配置
|
||||
// 先移除旧的通用配置(如果有)
|
||||
let updatedConfig = tomlString;
|
||||
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
|
||||
updatedConfig = tomlString.replace(previousCommonSnippet, "");
|
||||
}
|
||||
|
||||
// 在文件末尾添加新的通用配置
|
||||
// 确保有适当的换行
|
||||
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
|
||||
updatedConfig =
|
||||
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
|
||||
|
||||
// 保存当前通用配置片段
|
||||
previousCommonSnippet = snippetString;
|
||||
|
||||
return {
|
||||
updatedConfig: updatedConfig.trim() + "\n",
|
||||
};
|
||||
} else {
|
||||
// 移除通用配置
|
||||
if (tomlString.includes(snippetString)) {
|
||||
const updatedConfig = tomlString.replace(snippetString, "");
|
||||
// 清理多余的空行
|
||||
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
// 清空保存的状态
|
||||
previousCommonSnippet = "";
|
||||
|
||||
return {
|
||||
updatedConfig: cleaned ? cleaned + "\n" : "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
updatedConfig: tomlString,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 检查 TOML 配置是否已包含通用配置片段
|
||||
export const hasTomlCommonConfigSnippet = (
|
||||
tomlString: string,
|
||||
snippetString: string,
|
||||
): boolean => {
|
||||
if (!snippetString.trim()) return false;
|
||||
|
||||
// 简单检查配置是否包含片段内容
|
||||
// 去除空白字符后比较,避免格式差异影响
|
||||
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
|
||||
|
||||
return normalizeWhitespace(tomlString).includes(
|
||||
normalizeWhitespace(snippetString),
|
||||
);
|
||||
};
|
||||
|
||||
// ========== Codex base_url utils ==========
|
||||
|
||||
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
||||
export const extractCodexBaseUrl = (
|
||||
configText: string | undefined | null,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const text = typeof configText === "string" ? configText : "";
|
||||
if (!text) return undefined;
|
||||
const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
|
||||
return m && m[2] ? m[2] : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 从 Provider 对象中提取 Codex base_url(当 settingsConfig.config 为 TOML 字符串时)
|
||||
export const getCodexBaseUrl = (
|
||||
provider: { settingsConfig?: Record<string, any> } | undefined | null,
|
||||
): string | undefined => {
|
||||
try {
|
||||
const text =
|
||||
typeof provider?.settingsConfig?.config === "string"
|
||||
? (provider as any).settingsConfig.config
|
||||
: "";
|
||||
return extractCodexBaseUrl(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 在 Codex 的 TOML 配置文本中写入或更新 base_url 字段
|
||||
export const setCodexBaseUrl = (
|
||||
configText: string,
|
||||
baseUrl: string,
|
||||
): string => {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) {
|
||||
return configText;
|
||||
}
|
||||
|
||||
const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, "");
|
||||
const replacementLine = `base_url = "${normalizedUrl}"`;
|
||||
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
|
||||
|
||||
if (pattern.test(configText)) {
|
||||
return configText.replace(pattern, replacementLine);
|
||||
}
|
||||
|
||||
const prefix = configText && !configText.endsWith("\n") ? `${configText}\n` : configText;
|
||||
return `${prefix}${replacementLine}\n`;
|
||||
};
|
||||
|
||||
54
src/vite-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { Provider, Settings } from "./types";
|
||||
import { Provider, Settings, CustomEndpoint } from "./types";
|
||||
import { AppType } from "./lib/tauri-api";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
@@ -28,7 +28,20 @@ declare global {
|
||||
getClaudeCodeConfigPath: () => Promise<string>;
|
||||
getClaudeConfigStatus: () => Promise<ConfigStatus>;
|
||||
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
|
||||
selectConfigFile: () => Promise<string | null>;
|
||||
getConfigDir: (app?: AppType) => Promise<string>;
|
||||
saveFileDialog: (defaultName: string) => Promise<string | null>;
|
||||
openFileDialog: () => Promise<string | null>;
|
||||
exportConfigToFile: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
filePath: string;
|
||||
}>;
|
||||
importConfigFromFile: (filePath: string) => Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
backupId?: string;
|
||||
}>;
|
||||
selectConfigDirectory: (defaultPath?: string) => Promise<string | null>;
|
||||
openConfigFolder: (app?: AppType) => Promise<void>;
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
updateTrayMenu: () => Promise<boolean>;
|
||||
@@ -38,8 +51,45 @@ declare global {
|
||||
getSettings: () => Promise<Settings>;
|
||||
saveSettings: (settings: Settings) => Promise<boolean>;
|
||||
checkForUpdates: () => Promise<void>;
|
||||
isPortable: () => Promise<boolean>;
|
||||
getAppConfigPath: () => Promise<string>;
|
||||
openAppConfigFolder: () => Promise<void>;
|
||||
// Claude 插件配置能力
|
||||
getClaudePluginStatus: () => Promise<ConfigStatus>;
|
||||
readClaudePluginConfig: () => Promise<string | null>;
|
||||
applyClaudePluginConfig: (options: {
|
||||
official: boolean;
|
||||
}) => Promise<boolean>;
|
||||
isClaudePluginApplied: () => Promise<boolean>;
|
||||
testApiEndpoints: (
|
||||
urls: string[],
|
||||
options?: { timeoutSecs?: number },
|
||||
) => Promise<Array<{
|
||||
url: string;
|
||||
latency: number | null;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}>>;
|
||||
// 自定义端点管理
|
||||
getCustomEndpoints: (
|
||||
appType: AppType,
|
||||
providerId: string
|
||||
) => Promise<CustomEndpoint[]>;
|
||||
addCustomEndpoint: (
|
||||
appType: AppType,
|
||||
providerId: string,
|
||||
url: string
|
||||
) => Promise<void>;
|
||||
removeCustomEndpoint: (
|
||||
appType: AppType,
|
||||
providerId: string,
|
||||
url: string
|
||||
) => Promise<void>;
|
||||
updateEndpointLastUsed: (
|
||||
appType: AppType,
|
||||
providerId: string,
|
||||
url: string
|
||||
) => Promise<void>;
|
||||
};
|
||||
platform: {
|
||||
isMac: boolean;
|
||||
|
||||