Compare commits
45 Commits
feat/auto-
...
v3.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
255
.github/workflows/release.yml
vendored
255
.github/workflows/release.yml
vendored
@@ -8,15 +8,19 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: windows-latest
|
- os: windows-2022
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-22.04
|
||||||
- os: macos-latest
|
- os: macos-14
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -74,7 +78,7 @@ jobs:
|
|||||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.path }}
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
@@ -83,22 +87,72 @@ jobs:
|
|||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: pnpm install --frozen-lockfile
|
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)
|
- name: Build Tauri App (macOS)
|
||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
run: pnpm tauri build --target universal-apple-darwin
|
run: pnpm tauri build --target universal-apple-darwin
|
||||||
|
|
||||||
- name: Build Tauri App (Windows)
|
- name: Build Tauri App (Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
run: pnpm tauri build
|
run: pnpm tauri build
|
||||||
|
|
||||||
- name: Build Tauri App (Linux)
|
- name: Build Tauri App (Linux)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
env:
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
||||||
run: pnpm tauri build
|
run: pnpm tauri build
|
||||||
|
|
||||||
- name: Prepare macOS Assets
|
- name: Prepare macOS Assets
|
||||||
@@ -107,29 +161,34 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
mkdir -p release-assets
|
mkdir -p release-assets
|
||||||
echo "Looking for .app bundle..."
|
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
||||||
APP_PATH=""
|
TAR_GZ=""; APP_PATH=""
|
||||||
for path in \
|
for path in \
|
||||||
"src-tauri/target/release/bundle/macos" \
|
|
||||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||||
"src-tauri/target/aarch64-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
|
if [ -d "$path" ]; then
|
||||||
APP_PATH=$(find "$path" -name "*.app" -type d | head -1)
|
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
||||||
[ -n "$APP_PATH" ] && break
|
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$APP_PATH" ]; then
|
if [ -z "$TAR_GZ" ]; then
|
||||||
echo "No .app found" >&2
|
echo "No macOS .tar.gz updater artifact found" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
APP_DIR=$(dirname "$APP_PATH")
|
cp "$TAR_GZ" release-assets/
|
||||||
APP_NAME=$(basename "$APP_PATH")
|
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" release-assets/ || echo ".sig for macOS not found yet"
|
||||||
cd "$APP_DIR"
|
echo "macOS updater artifact copied: $(basename "$TAR_GZ")"
|
||||||
# 使用 ditto 打包更兼容资源分叉
|
if [ -n "$APP_PATH" ]; then
|
||||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "CC-Switch-macOS.zip"
|
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
||||||
mv "CC-Switch-macOS.zip" "$GITHUB_WORKSPACE/release-assets/"
|
cd "$APP_DIR"
|
||||||
echo "macOS zip ready"
|
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
|
- name: Prepare Windows Assets
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
@@ -137,18 +196,27 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||||
# 安装器(优先 NSIS,其次 MSI)
|
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
||||||
$installer = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.exe,*.msi -ErrorAction SilentlyContinue |
|
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
Where-Object { $_.FullName -match '\\bundle\\(nsis|msi)\\' } |
|
if ($null -eq $msi) {
|
||||||
Select-Object -First 1
|
# 兜底:全局搜索 .msi
|
||||||
if ($null -ne $installer) {
|
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
$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'
|
|
||||||
}
|
}
|
||||||
# 绿色版(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 = @(
|
$exeCandidates = @(
|
||||||
'src-tauri/target/release/cc-switch.exe',
|
'src-tauri/target/release/cc-switch.exe',
|
||||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||||
@@ -171,14 +239,22 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
mkdir -p release-assets
|
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)
|
DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true)
|
||||||
if [ -n "$DEB" ]; then
|
if [ -n "$DEB" ]; then
|
||||||
cp "$DEB" release-assets/
|
cp "$DEB" release-assets/
|
||||||
echo "Deb package copied"
|
echo "Deb package copied"
|
||||||
else
|
else
|
||||||
echo "No .deb found" >&2
|
echo "No .deb found (optional)"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: List prepared assets
|
- name: List prepared assets
|
||||||
@@ -189,18 +265,16 @@ jobs:
|
|||||||
- name: Collect Signatures
|
- name: Collect Signatures
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# 查找并复制签名文件到 release-assets
|
set -euo pipefail
|
||||||
find src-tauri/target -name "*.sig" -type f 2>/dev/null | while read sig; do
|
echo "Collected signatures (if any alongside artifacts):"
|
||||||
cp "$sig" release-assets/ || true
|
|
||||||
done
|
|
||||||
echo "Collected signatures:"
|
|
||||||
ls -la release-assets/*.sig || echo "No signatures found"
|
ls -la release-assets/*.sig || echo "No signatures found"
|
||||||
|
|
||||||
- name: Upload Release Assets
|
- name: Upload Release Assets
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
name: CC Switch ${{ github.ref_name }}
|
name: CC Switch ${{ github.ref_name }}
|
||||||
|
prerelease: true
|
||||||
body: |
|
body: |
|
||||||
## CC Switch ${{ github.ref_name }}
|
## CC Switch ${{ github.ref_name }}
|
||||||
|
|
||||||
@@ -209,7 +283,7 @@ jobs:
|
|||||||
### 下载
|
### 下载
|
||||||
|
|
||||||
- macOS: `CC-Switch-macOS.zip`(解压即用)
|
- 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 安装包)
|
- Linux: `*.deb`(Debian/Ubuntu 安装包)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -224,3 +298,92 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Listing bundles in src-tauri/target..."
|
echo "Listing bundles in src-tauri/target..."
|
||||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
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
1
.gitignore
vendored
@@ -9,3 +9,4 @@ release/
|
|||||||
.npmrc
|
.npmrc
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
docs/
|
||||||
|
|||||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -5,6 +5,33 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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
|
## [3.1.1] - 2025-09-03
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|||||||
89
README.md
89
README.md
@@ -1,26 +1,28 @@
|
|||||||
# Claude Code & Codex 供应商切换器
|
# Claude Code & Codex 供应商切换器
|
||||||
|
|
||||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://github.com/jasonyoung/cc-switch/releases)
|
[](https://github.com/farion1231/cc-switch/releases)
|
||||||
[](https://tauri.app/)
|
[](https://tauri.app/)
|
||||||
|
|
||||||
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
一个用于管理和切换 Claude Code 与 Codex 不同供应商配置的桌面应用。
|
||||||
|
|
||||||
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文““迁移与备份”)。
|
> v3.2.0 重点:全新 UI、macOS系统托盘、内置更新器、原子写入与回滚、改进暗色样式、单一事实源(SSOT)与一次性迁移/归档(详见下文“迁移与归档 v3.2.0”)。
|
||||||
|
|
||||||
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积减少 85%(从 ~80MB 降至 ~12MB),启动速度提升 10 倍!
|
> v3.1.0 :新增 Codex 供应商管理与一键切换,支持导入当前 Codex 配置为默认供应商,并在内部配置从 v1 → v2 迁移前自动备份(详见下文“迁移与归档”)。
|
||||||
|
|
||||||
## 功能特性
|
> v3.0.0 重大更新:从 Electron 完全迁移到 Tauri 2.0,应用体积显著降低、启动性能大幅提升。
|
||||||
|
|
||||||
- **极速启动** - 基于 Tauri 2.0,原生性能,秒开应用
|
## 功能特性(v3.2.0)
|
||||||
- 一键切换不同供应商
|
|
||||||
- 同时支持 Claude Code 与 Codex 的供应商切换与导入
|
- **全新 UI**:感谢 [TinsFox](https://github.com/TinsFox) 大佬设计的全新 UI
|
||||||
- Qwen coder、kimi k2、智谱 GLM、DeepSeek v3.1、packycode 等预设供应商只需要填写 key 即可一键配置
|
- **系统托盘(菜单栏)快速切换**:按应用分组(Claude / Codex),勾选态展示当前供应商
|
||||||
- 支持添加自定义供应商
|
- **内置更新器**:集成 Tauri Updater,支持检测/下载/安装与一键重启
|
||||||
- 随时切换官方登录
|
- **单一事实源(SSOT)**:不再写每个供应商的“副本文件”,统一存于 `~/.cc-switch/config.json`
|
||||||
- 简洁美观的图形界面
|
- **一次性迁移/归档**:首次升级自动导入旧副本并归档原文件,之后不再持续归档
|
||||||
- 信息存储在本地 ~/.cc-switch/config.json,无隐私风险
|
- **原子写入与回滚**:写入 `auth.json`/`config.toml`/`settings.json` 时避免半写状态
|
||||||
- 超小体积 - 仅 ~5MB 安装包
|
- **深色模式优化**:Tailwind v4 适配与选择器修正
|
||||||
|
- **丰富预设与自定义**:Qwen coder、Kimi、GLM、DeepSeek、PackyCode 等;可自定义 Base URL
|
||||||
|
- **本地优先与隐私**:全部信息存储在本地 `~/.cc-switch/config.json`
|
||||||
|
|
||||||
## 界面预览
|
## 界面预览
|
||||||
|
|
||||||
@@ -57,35 +59,52 @@
|
|||||||
## 使用说明
|
## 使用说明
|
||||||
|
|
||||||
1. 点击"添加供应商"添加你的 API 配置
|
1. 点击"添加供应商"添加你的 API 配置
|
||||||
2. 选择要使用的供应商,点击单选按钮切换
|
2. 切换方式:
|
||||||
3. 配置会自动保存到对应应用的配置文件中
|
- 在主界面选择供应商后点击切换
|
||||||
4. 重启或者新打开终端以生效
|
- 或通过“系统托盘(菜单栏)”直接选择目标供应商,立即生效
|
||||||
5. 如果需要切回 Claude 官方登录,可以添加预设供应商里的“Claude 官方登录”并切换,重启终端后即可进行正常的 /login 登录
|
3. 切换会写入对应应用的“live 配置文件”(Claude:`settings.json`;Codex:`auth.json` + `config.toml`)
|
||||||
|
4. 重启或新开终端以确保生效
|
||||||
|
5. 若需切回官方登录,在预设中选择“官方登录”并切换即可;重启终端后按官方流程登录
|
||||||
|
|
||||||
### Codex 说明
|
### 检查更新
|
||||||
|
|
||||||
|
- 在“设置”中点击“检查更新”,若内置 Updater 配置可用将直接检测与下载;否则会回退打开 Releases 页面
|
||||||
|
|
||||||
|
### Codex 说明(v3.2.0 SSOT)
|
||||||
|
|
||||||
- 配置目录:`~/.codex/`
|
- 配置目录:`~/.codex/`
|
||||||
- 主配置文件:`auth.json`(必需)、`config.toml`(可为空)
|
- live 主配置:`auth.json`(必需)、`config.toml`(可为空)
|
||||||
- 供应商副本:`auth-<name>.json`、`config-<name>.toml`
|
|
||||||
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
- API Key 字段:`auth.json` 中使用 `OPENAI_API_KEY`
|
||||||
- 切换策略:将选中供应商的副本覆盖到主配置(`auth.json`、`config.toml`)。若供应商没有 `config-*.toml`,会创建空的 `config.toml`。
|
- 切换行为(不再写“副本文件”):
|
||||||
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前;`config.toml` 不存在时按空处理。
|
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||||
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后可选择使用 ChatGPT 账号完成登录。
|
- 切换时将目标供应商写回 live 文件(`auth.json` + `config.toml`)
|
||||||
|
- 采用“原子写入 + 失败回滚”,避免半写状态;`config.toml` 可为空
|
||||||
|
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||||
|
- 官方登录:可切换到预设“Codex 官方登录”,重启终端后按官方流程登录
|
||||||
|
|
||||||
### Claude Code 说明
|
### Claude Code 说明(v3.2.0 SSOT)
|
||||||
|
|
||||||
- 配置目录:`~/.claude/`
|
- 配置目录:`~/.claude/`
|
||||||
- 主配置文件:`settings.json`(推荐)或 `claude.json`(旧版兼容,若存在则继续使用)
|
- live 主配置:`settings.json`(优先)或历史兼容 `claude.json`
|
||||||
- 供应商副本:`settings-<name>.json`
|
|
||||||
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
- API Key 字段:`env.ANTHROPIC_AUTH_TOKEN`
|
||||||
- 切换策略:将选中供应商的副本覆盖到主配置(`settings.json`/`claude.json`)。如当前有配置且存在“当前供应商”,会先将主配置备份回该供应商的副本文件。
|
- 切换行为(不再写“副本文件”):
|
||||||
- 导入默认:仅当该应用无任何供应商时,从现有主配置创建一条默认项并设为当前。
|
- 供应商配置统一保存在 `~/.cc-switch/config.json`
|
||||||
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录。
|
- 切换时将目标供应商 JSON 直接写入 live 文件(优先 `settings.json`)
|
||||||
|
- 编辑当前供应商时,先写 live 成功,再更新应用主配置,保证一致性
|
||||||
|
- 导入默认:当该应用无任何供应商时,从现有 live 主配置创建一条默认项并设为当前
|
||||||
|
- 官方登录:可切换到预设“Claude 官方登录”,重启终端后可使用 `/login` 完成登录
|
||||||
|
|
||||||
### 迁移与备份
|
### 迁移与归档(v3.2.0)
|
||||||
|
|
||||||
- cc-switch 自身配置从 v1 → v2 迁移时,将在 `~/.cc-switch/` 目录自动创建时间戳备份:`config.v1.backup.<timestamp>.json`。
|
- 一次性迁移:首次启动 3.2.0 会扫描旧的“副本文件”并合并到 `~/.cc-switch/config.json`
|
||||||
- 实际生效的应用配置文件(如 `~/.claude/settings.json`、`~/.codex/auth.json`/`config.toml`)不会被修改,切换仅在用户点击“切换”时按副本覆盖到主配置。
|
- 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 +157,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/)** - 用户界面库
|
- **[React 18](https://react.dev/)** - 用户界面库
|
||||||
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
- **[TypeScript](https://www.typescriptlang.org/)** - 类型安全的 JavaScript
|
||||||
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
- **[Vite](https://vitejs.dev/)** - 极速的前端构建工具
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.1.1",
|
"version": "3.2.0",
|
||||||
"description": "Claude Code & Codex 供应商切换工具",
|
"description": "Claude Code & Codex 供应商切换工具",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm tauri dev",
|
"dev": "pnpm tauri dev",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 203 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 162 KiB |
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -559,7 +559,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.1.1"
|
version = "3.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.1.1"
|
version = "3.2.0"
|
||||||
description = "Claude Code & Codex 供应商配置管理工具"
|
description = "Claude Code & Codex 供应商配置管理工具"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/jasonyoung/cc-switch"
|
repository = "https://github.com/farion1231/cc-switch"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85.0"
|
rust-version = "1.85.0"
|
||||||
|
|
||||||
@@ -32,3 +32,11 @@ toml = "0.8"
|
|||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.5"
|
objc2 = "0.5"
|
||||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
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"
|
||||||
|
|||||||
@@ -567,9 +567,9 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
|
|||||||
/// 获取设置
|
/// 获取设置
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||||
// 暂时返回默认设置
|
// 暂时返回默认设置:系统托盘(菜单栏)显示开关
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"showInDock": true
|
"showInTray": true
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,7 +579,7 @@ pub async fn save_settings(
|
|||||||
_state: State<'_, AppState>,
|
_state: State<'_, AppState>,
|
||||||
settings: serde_json::Value,
|
settings: serde_json::Value,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
// TODO: 实现设置保存逻辑
|
// TODO: 实现系统托盘显示开关的保存与应用(显示/隐藏菜单栏托盘图标)
|
||||||
log::info!("保存设置: {:?}", settings);
|
log::info!("保存设置: {:?}", settings);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,16 +109,16 @@ fn create_tray_menu(
|
|||||||
|
|
||||||
/// 处理托盘菜单事件
|
/// 处理托盘菜单事件
|
||||||
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||||
println!("处理托盘菜单事件: {}", event_id);
|
log::info!("处理托盘菜单事件: {}", event_id);
|
||||||
|
|
||||||
match event_id {
|
match event_id {
|
||||||
"quit" => {
|
"quit" => {
|
||||||
println!("退出应用");
|
log::info!("退出应用");
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
}
|
}
|
||||||
id if id.starts_with("claude_") => {
|
id if id.starts_with("claude_") => {
|
||||||
let provider_id = id.strip_prefix("claude_").unwrap();
|
let provider_id = id.strip_prefix("claude_").unwrap();
|
||||||
println!("切换到Claude供应商: {}", provider_id);
|
log::info!("切换到Claude供应商: {}", provider_id);
|
||||||
|
|
||||||
// 执行切换
|
// 执行切换
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
@@ -130,14 +130,12 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
provider_id,
|
provider_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{ log::error!("切换Claude供应商失败: {}", e); }
|
||||||
eprintln!("切换Claude供应商失败: {}", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
id if id.starts_with("codex_") => {
|
id if id.starts_with("codex_") => {
|
||||||
let provider_id = id.strip_prefix("codex_").unwrap();
|
let provider_id = id.strip_prefix("codex_").unwrap();
|
||||||
println!("切换到Codex供应商: {}", provider_id);
|
log::info!("切换到Codex供应商: {}", provider_id);
|
||||||
|
|
||||||
// 执行切换
|
// 执行切换
|
||||||
let app_handle = app.clone();
|
let app_handle = app.clone();
|
||||||
@@ -149,13 +147,11 @@ fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
|||||||
provider_id,
|
provider_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{ log::error!("切换Codex供应商失败: {}", e); }
|
||||||
eprintln!("切换Codex供应商失败: {}", e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!("未处理的菜单事件: {}", event_id);
|
log::warn!("未处理的菜单事件: {}", event_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +180,7 @@ async fn switch_provider_internal(
|
|||||||
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
if let Ok(new_menu) = create_tray_menu(app, app_state.inner()) {
|
||||||
if let Some(tray) = app.tray_by_id("main") {
|
if let Some(tray) = app.tray_by_id("main") {
|
||||||
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
if let Err(e) = tray.set_menu(Some(new_menu)) {
|
||||||
eprintln!("更新托盘菜单失败: {}", e);
|
log::error!("更新托盘菜单失败: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +191,7 @@ async fn switch_provider_internal(
|
|||||||
"providerId": provider_id_clone
|
"providerId": provider_id_clone
|
||||||
});
|
});
|
||||||
if let Err(e) = app.emit("provider-switched", event_data) {
|
if let Err(e) = app.emit("provider-switched", event_data) {
|
||||||
eprintln!("发射供应商切换事件失败: {}", e);
|
log::error!("发射供应商切换事件失败: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -301,7 +297,7 @@ pub fn run() {
|
|||||||
button_state: MouseButtonState::Up,
|
button_state: MouseButtonState::Up,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
println!("left click pressed and released");
|
log::info!("left click pressed and released");
|
||||||
// 在这个例子中,当点击托盘图标时,将展示并聚焦于主窗口
|
// 在这个例子中,当点击托盘图标时,将展示并聚焦于主窗口
|
||||||
let app = tray.app_handle();
|
let app = tray.app_handle();
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
@@ -311,7 +307,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
println!("unhandled event {event:?}");
|
log::debug!("unhandled event {event:?}");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.menu(&menu)
|
.menu(&menu)
|
||||||
|
|||||||
@@ -145,8 +145,12 @@ fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)>
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> {
|
pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result<bool, String> {
|
||||||
// 如果已迁移过则跳过
|
// 如果已迁移过则跳过;若目录不存在则先创建,避免新装用户写入标记时失败
|
||||||
let marker = get_marker_path();
|
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() {
|
if marker.exists() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub struct Provider {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "websiteUrl")]
|
#[serde(rename = "websiteUrl")]
|
||||||
pub website_url: Option<String>,
|
pub website_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub category: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Provider {
|
impl Provider {
|
||||||
@@ -29,6 +31,7 @@ impl Provider {
|
|||||||
name,
|
name,
|
||||||
settings_config,
|
settings_config,
|
||||||
website_url,
|
website_url,
|
||||||
|
category: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.1.1",
|
"version": "3.2.0",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -42,9 +42,9 @@
|
|||||||
,
|
,
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRERTRCNEUxQUE3MDA4QTYKUldTbUNIQ3E0YlRrVFF2cnFVVE1jczlNZFlmemxXd0h6cTdibXRJWjBDSytQODdZOTYvR3d3d2oK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4MDI4QzlBNTczOTI4RTMKUldUaktEbFhtb3dDeUM5US9kT0FmdGR5Ti9vQzcwa2dTMlpibDVDUmQ2M0VGTzVOWnd0SGpFVlEK",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
"https://github.com/jasonyoung/cc-switch/releases/latest/download/latest.json"
|
"https://github.com/farion1231/cc-switch/releases/latest/download/latest.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/App.tsx
25
src/App.tsx
@@ -7,6 +7,7 @@ import EditProviderModal from "./components/EditProviderModal";
|
|||||||
import { ConfirmDialog } from "./components/ConfirmDialog";
|
import { ConfirmDialog } from "./components/ConfirmDialog";
|
||||||
import { AppSwitcher } from "./components/AppSwitcher";
|
import { AppSwitcher } from "./components/AppSwitcher";
|
||||||
import SettingsModal from "./components/SettingsModal";
|
import SettingsModal from "./components/SettingsModal";
|
||||||
|
import { UpdateBadge } from "./components/UpdateBadge";
|
||||||
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
import { Plus, Settings, Moon, Sun } from "lucide-react";
|
||||||
import { buttonStyles } from "./lib/styles";
|
import { buttonStyles } from "./lib/styles";
|
||||||
import { useDarkMode } from "./hooks/useDarkMode";
|
import { useDarkMode } from "./hooks/useDarkMode";
|
||||||
@@ -81,7 +82,9 @@ function App() {
|
|||||||
const setupListener = async () => {
|
const setupListener = async () => {
|
||||||
try {
|
try {
|
||||||
unlisten = await window.api.onProviderSwitched(async (data) => {
|
unlisten = await window.api.onProviderSwitched(async (data) => {
|
||||||
console.log("收到供应商切换事件:", data);
|
if (import.meta.env.DEV) {
|
||||||
|
console.log("收到供应商切换事件:", data);
|
||||||
|
}
|
||||||
|
|
||||||
// 如果当前应用类型匹配,则重新加载数据
|
// 如果当前应用类型匹配,则重新加载数据
|
||||||
if (data.appType === activeApp) {
|
if (data.appType === activeApp) {
|
||||||
@@ -115,7 +118,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 生成唯一ID
|
// 生成唯一ID
|
||||||
const generateId = () => {
|
const generateId = () => {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -203,7 +205,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-gray-950">
|
||||||
{/* Linear 风格的顶部导航 */}
|
{/* Linear 风格的顶部导航 */}
|
||||||
@@ -220,13 +221,16 @@ function App() {
|
|||||||
>
|
>
|
||||||
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
{isDarkMode ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
<button
|
||||||
className={buttonStyles.icon}
|
onClick={() => setIsSettingsOpen(true)}
|
||||||
title="设置"
|
className={buttonStyles.icon}
|
||||||
>
|
title="设置"
|
||||||
<Settings size={18} />
|
>
|
||||||
</button>
|
<Settings size={18} />
|
||||||
|
</button>
|
||||||
|
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -266,7 +270,6 @@ function App() {
|
|||||||
onDelete={handleDeleteProvider}
|
onDelete={handleDeleteProvider}
|
||||||
onEdit={setEditingProviderId}
|
onEdit={setEditingProviderId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
1
src/assets/icons/chatgpt.svg
Normal file
1
src/assets/icons/chatgpt.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.8 KiB |
1
src/assets/icons/claude.svg
Normal file
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,5 +1,5 @@
|
|||||||
import { AppType } from "../lib/tauri-api";
|
import { AppType } from "../lib/tauri-api";
|
||||||
import { Terminal, Code2 } from "lucide-react";
|
import { ClaudeIcon, CodexIcon } from "./BrandIcons";
|
||||||
|
|
||||||
interface AppSwitcherProps {
|
interface AppSwitcherProps {
|
||||||
activeApp: AppType;
|
activeApp: AppType;
|
||||||
@@ -23,8 +23,13 @@ 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"
|
: "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} />
|
<ClaudeIcon
|
||||||
<span>Claude Code</span>
|
size={16}
|
||||||
|
className={
|
||||||
|
activeApp === "claude" ? "text-[#D97757] dark:text-[#D97757]" : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Claude</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -36,7 +41,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"
|
: "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>
|
<span>Codex</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
34
src/components/BrandIcons.tsx
Normal file
34
src/components/BrandIcons.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Provider } from "../types";
|
import { Provider, ProviderCategory } from "../types";
|
||||||
import { AppType } from "../lib/tauri-api";
|
import { AppType } from "../lib/tauri-api";
|
||||||
import {
|
import {
|
||||||
updateCoAuthoredSetting,
|
updateCoAuthoredSetting,
|
||||||
@@ -16,6 +16,7 @@ import ClaudeConfigEditor from "./ProviderForm/ClaudeConfigEditor";
|
|||||||
import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
|
import CodexConfigEditor from "./ProviderForm/CodexConfigEditor";
|
||||||
import KimiModelSelector from "./ProviderForm/KimiModelSelector";
|
import KimiModelSelector from "./ProviderForm/KimiModelSelector";
|
||||||
import { X, AlertCircle, Save } from "lucide-react";
|
import { X, AlertCircle, Save } from "lucide-react";
|
||||||
|
// 分类仅用于控制少量交互(如官方禁用 API Key),不显示介绍组件
|
||||||
|
|
||||||
interface ProviderFormProps {
|
interface ProviderFormProps {
|
||||||
appType?: AppType;
|
appType?: AppType;
|
||||||
@@ -46,6 +47,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||||||
: "",
|
: "",
|
||||||
});
|
});
|
||||||
|
const [category, setCategory] = useState<ProviderCategory | undefined>(
|
||||||
|
initialData?.category,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Claude 模型配置状态
|
||||||
|
const [claudeModel, setClaudeModel] = useState("");
|
||||||
|
const [claudeSmallFastModel, setClaudeSmallFastModel] = useState("");
|
||||||
|
const [baseUrl, setBaseUrl] = useState(""); // 新增:基础 URL 状态
|
||||||
|
|
||||||
// Codex 特有的状态
|
// Codex 特有的状态
|
||||||
const [codexAuth, setCodexAuth] = useState("");
|
const [codexAuth, setCodexAuth] = useState("");
|
||||||
@@ -88,6 +97,33 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
|
const [kimiAnthropicSmallFastModel, setKimiAnthropicSmallFastModel] =
|
||||||
useState("");
|
useState("");
|
||||||
|
|
||||||
|
// 初始化自定义模式的默认配置
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
showPresets &&
|
||||||
|
selectedPreset === -1 &&
|
||||||
|
!initialData &&
|
||||||
|
formData.settingsConfig === ""
|
||||||
|
) {
|
||||||
|
// 设置自定义模板
|
||||||
|
const customTemplate = {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: "https://your-api-endpoint.com",
|
||||||
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
|
// 可选配置
|
||||||
|
// ANTHROPIC_MODEL: "your-model-name",
|
||||||
|
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
settingsConfig: JSON.stringify(customTemplate, null, 2),
|
||||||
|
}));
|
||||||
|
setApiKey("");
|
||||||
|
}
|
||||||
|
}, []); // 只在组件挂载时执行一次
|
||||||
|
|
||||||
// 初始化时检查禁用签名状态
|
// 初始化时检查禁用签名状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
@@ -95,7 +131,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
||||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||||
|
|
||||||
// 初始化 Kimi 模型选择(编辑模式)
|
// 初始化模型配置(编辑模式)
|
||||||
if (
|
if (
|
||||||
initialData.settingsConfig &&
|
initialData.settingsConfig &&
|
||||||
typeof initialData.settingsConfig === "object"
|
typeof initialData.settingsConfig === "object"
|
||||||
@@ -104,6 +140,11 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
env?: Record<string, any>;
|
env?: Record<string, any>;
|
||||||
};
|
};
|
||||||
if (config.env) {
|
if (config.env) {
|
||||||
|
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
|
||||||
|
setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || "");
|
||||||
|
setBaseUrl(config.env.ANTHROPIC_BASE_URL || ""); // 初始化基础 URL
|
||||||
|
|
||||||
|
// 初始化 Kimi 模型选择
|
||||||
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
||||||
setKimiAnthropicSmallFastModel(
|
setKimiAnthropicSmallFastModel(
|
||||||
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
||||||
@@ -113,6 +154,30 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [initialData]);
|
}, [initialData]);
|
||||||
|
|
||||||
|
// 当选择预设变化时,同步类别
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPresets) return;
|
||||||
|
if (!isCodex) {
|
||||||
|
if (selectedPreset !== null && selectedPreset >= 0) {
|
||||||
|
const preset = providerPresets[selectedPreset];
|
||||||
|
setCategory(
|
||||||
|
preset?.category || (preset?.isOfficial ? "official" : undefined),
|
||||||
|
);
|
||||||
|
} else if (selectedPreset === -1) {
|
||||||
|
setCategory("custom");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
|
||||||
|
const preset = codexProviderPresets[selectedCodexPreset];
|
||||||
|
setCategory(
|
||||||
|
preset?.category || (preset?.isOfficial ? "official" : undefined),
|
||||||
|
);
|
||||||
|
} else if (selectedCodexPreset === -1) {
|
||||||
|
setCategory("custom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showPresets, isCodex, selectedPreset, selectedCodexPreset]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
@@ -177,6 +242,8 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
name: formData.name,
|
name: formData.name,
|
||||||
websiteUrl: formData.websiteUrl,
|
websiteUrl: formData.websiteUrl,
|
||||||
settingsConfig,
|
settingsConfig,
|
||||||
|
// 仅在用户选择了预设或手动选择“自定义”时持久化分类
|
||||||
|
...(category ? { category } : {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,48 +297,70 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
websiteUrl: preset.websiteUrl,
|
websiteUrl: preset.websiteUrl,
|
||||||
settingsConfig: configString,
|
settingsConfig: configString,
|
||||||
});
|
});
|
||||||
|
setCategory(
|
||||||
|
preset.category || (preset.isOfficial ? "official" : undefined),
|
||||||
|
);
|
||||||
|
|
||||||
// 设置选中的预设
|
// 设置选中的预设
|
||||||
setSelectedPreset(index);
|
setSelectedPreset(index);
|
||||||
|
|
||||||
// 清空 API Key 输入框,让用户重新输入
|
// 清空 API Key 输入框,让用户重新输入
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
|
setBaseUrl(""); // 清空基础 URL
|
||||||
|
|
||||||
// 同步选择框状态
|
// 同步选择框状态
|
||||||
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
const hasCoAuthoredDisabled = checkCoAuthoredSetting(configString);
|
||||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||||
|
|
||||||
// 如果是 Kimi 预设,初始化模型选择
|
// 如果预设包含模型配置,初始化模型输入框
|
||||||
if (
|
if (preset.settingsConfig && typeof preset.settingsConfig === "object") {
|
||||||
preset.name?.includes("Kimi") &&
|
|
||||||
preset.settingsConfig &&
|
|
||||||
typeof preset.settingsConfig === "object"
|
|
||||||
) {
|
|
||||||
const config = preset.settingsConfig as { env?: Record<string, any> };
|
const config = preset.settingsConfig as { env?: Record<string, any> };
|
||||||
if (config.env) {
|
if (config.env) {
|
||||||
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
setClaudeModel(config.env.ANTHROPIC_MODEL || "");
|
||||||
setKimiAnthropicSmallFastModel(
|
setClaudeSmallFastModel(config.env.ANTHROPIC_SMALL_FAST_MODEL || "");
|
||||||
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
|
||||||
);
|
// 如果是 Kimi 预设,同步 Kimi 模型选择
|
||||||
|
if (preset.name?.includes("Kimi")) {
|
||||||
|
setKimiAnthropicModel(config.env.ANTHROPIC_MODEL || "");
|
||||||
|
setKimiAnthropicSmallFastModel(
|
||||||
|
config.env.ANTHROPIC_SMALL_FAST_MODEL || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setClaudeModel("");
|
||||||
|
setClaudeSmallFastModel("");
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setKimiAnthropicModel("");
|
|
||||||
setKimiAnthropicSmallFastModel("");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理点击自定义按钮
|
// 处理点击自定义按钮
|
||||||
const handleCustomClick = () => {
|
const handleCustomClick = () => {
|
||||||
setSelectedPreset(-1);
|
setSelectedPreset(-1);
|
||||||
|
|
||||||
|
// 设置自定义模板
|
||||||
|
const customTemplate = {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: "https://your-api-endpoint.com",
|
||||||
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
|
// 可选配置
|
||||||
|
// ANTHROPIC_MODEL: "your-model-name",
|
||||||
|
// ANTHROPIC_SMALL_FAST_MODEL: "your-fast-model-name"
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
name: "",
|
name: "",
|
||||||
websiteUrl: "",
|
websiteUrl: "",
|
||||||
settingsConfig: "",
|
settingsConfig: JSON.stringify(customTemplate, null, 2),
|
||||||
});
|
});
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
|
setBaseUrl("https://your-api-endpoint.com"); // 设置默认的基础 URL
|
||||||
setDisableCoAuthored(false);
|
setDisableCoAuthored(false);
|
||||||
|
setClaudeModel("");
|
||||||
|
setClaudeSmallFastModel("");
|
||||||
setKimiAnthropicModel("");
|
setKimiAnthropicModel("");
|
||||||
setKimiAnthropicSmallFastModel("");
|
setKimiAnthropicSmallFastModel("");
|
||||||
|
setCategory("custom");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Codex: 应用预设
|
// Codex: 应用预设
|
||||||
@@ -290,6 +379,9 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
setSelectedCodexPreset(index);
|
setSelectedCodexPreset(index);
|
||||||
|
setCategory(
|
||||||
|
preset.category || (preset.isOfficial ? "official" : undefined),
|
||||||
|
);
|
||||||
|
|
||||||
// 清空 API Key,让用户重新输入
|
// 清空 API Key,让用户重新输入
|
||||||
setCodexApiKey("");
|
setCodexApiKey("");
|
||||||
@@ -306,6 +398,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
setCodexAuth("");
|
setCodexAuth("");
|
||||||
setCodexConfig("");
|
setCodexConfig("");
|
||||||
setCodexApiKey("");
|
setCodexApiKey("");
|
||||||
|
setCategory("custom");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理 API Key 输入并自动更新配置
|
// 处理 API Key 输入并自动更新配置
|
||||||
@@ -329,6 +422,26 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
setDisableCoAuthored(hasCoAuthoredDisabled);
|
setDisableCoAuthored(hasCoAuthoredDisabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理基础 URL 变化
|
||||||
|
const handleBaseUrlChange = (url: string) => {
|
||||||
|
setBaseUrl(url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(formData.settingsConfig || "{}");
|
||||||
|
if (!config.env) {
|
||||||
|
config.env = {};
|
||||||
|
}
|
||||||
|
config.env.ANTHROPIC_BASE_URL = url.trim();
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
settingsConfig: JSON.stringify(config, null, 2),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Codex: 处理 API Key 输入并写回 auth.json
|
// Codex: 处理 API Key 输入并写回 auth.json
|
||||||
const handleCodexApiKeyChange = (key: string) => {
|
const handleCodexApiKeyChange = (key: string) => {
|
||||||
setCodexApiKey(key);
|
setCodexApiKey(key);
|
||||||
@@ -342,16 +455,18 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 根据当前配置决定是否展示 API Key 输入框
|
// 根据当前配置决定是否展示 API Key 输入框
|
||||||
// 自定义模式(-1)不显示独立的 API Key 输入框
|
// 自定义模式(-1)也需要显示 API Key 输入框
|
||||||
const showApiKey =
|
const showApiKey =
|
||||||
(selectedPreset !== null && selectedPreset !== -1) ||
|
selectedPreset !== null ||
|
||||||
(!showPresets && hasApiKeyField(formData.settingsConfig));
|
(!showPresets && hasApiKeyField(formData.settingsConfig));
|
||||||
|
|
||||||
// 判断当前选中的预设是否是官方
|
// 判断当前选中的预设是否是官方
|
||||||
const isOfficialPreset =
|
const isOfficialPreset =
|
||||||
selectedPreset !== null &&
|
(selectedPreset !== null &&
|
||||||
selectedPreset >= 0 &&
|
selectedPreset >= 0 &&
|
||||||
providerPresets[selectedPreset]?.isOfficial === true;
|
(providerPresets[selectedPreset]?.isOfficial === true ||
|
||||||
|
providerPresets[selectedPreset]?.category === "official")) ||
|
||||||
|
category === "official";
|
||||||
|
|
||||||
// 判断当前选中的预设是否是 Kimi
|
// 判断当前选中的预设是否是 Kimi
|
||||||
const isKimiPreset =
|
const isKimiPreset =
|
||||||
@@ -370,6 +485,38 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
// 综合判断是否应该显示 Kimi 模型选择器
|
// 综合判断是否应该显示 Kimi 模型选择器
|
||||||
const shouldShowKimiSelector = isKimiPreset || isEditingKimi;
|
const shouldShowKimiSelector = isKimiPreset || isEditingKimi;
|
||||||
|
|
||||||
|
// 判断是否显示基础 URL 输入框(仅自定义模式显示)
|
||||||
|
const showBaseUrlInput = selectedPreset === -1 && !isCodex;
|
||||||
|
|
||||||
|
// 判断是否显示"获取 API Key"链接(国产官方、聚合站和第三方显示)
|
||||||
|
const shouldShowApiKeyLink =
|
||||||
|
!isCodex &&
|
||||||
|
!isOfficialPreset &&
|
||||||
|
(category === "cn_official" ||
|
||||||
|
category === "aggregator" ||
|
||||||
|
category === "third_party" ||
|
||||||
|
(selectedPreset !== null &&
|
||||||
|
selectedPreset >= 0 &&
|
||||||
|
(providerPresets[selectedPreset]?.category === "cn_official" ||
|
||||||
|
providerPresets[selectedPreset]?.category === "aggregator" ||
|
||||||
|
providerPresets[selectedPreset]?.category === "third_party")));
|
||||||
|
|
||||||
|
// 获取当前供应商的网址
|
||||||
|
const getCurrentWebsiteUrl = () => {
|
||||||
|
if (selectedPreset !== null && selectedPreset >= 0) {
|
||||||
|
return providerPresets[selectedPreset]?.websiteUrl || "";
|
||||||
|
}
|
||||||
|
return formData.websiteUrl || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取 Codex 当前供应商的网址
|
||||||
|
const getCurrentCodexWebsiteUrl = () => {
|
||||||
|
if (selectedCodexPreset !== null && selectedCodexPreset >= 0) {
|
||||||
|
return codexProviderPresets[selectedCodexPreset]?.websiteUrl || "";
|
||||||
|
}
|
||||||
|
return formData.websiteUrl || "";
|
||||||
|
};
|
||||||
|
|
||||||
// Codex: 控制显示 API Key 与官方标记
|
// Codex: 控制显示 API Key 与官方标记
|
||||||
const getCodexAuthApiKey = (authString: string): string => {
|
const getCodexAuthApiKey = (authString: string): string => {
|
||||||
try {
|
try {
|
||||||
@@ -385,10 +532,63 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
(selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
|
(selectedCodexPreset !== null && selectedCodexPreset !== -1) ||
|
||||||
(!showPresets && getCodexAuthApiKey(codexAuth) !== "");
|
(!showPresets && getCodexAuthApiKey(codexAuth) !== "");
|
||||||
|
|
||||||
|
// 不再渲染分类介绍组件,避免造成干扰
|
||||||
|
|
||||||
const isCodexOfficialPreset =
|
const isCodexOfficialPreset =
|
||||||
selectedCodexPreset !== null &&
|
(selectedCodexPreset !== null &&
|
||||||
selectedCodexPreset >= 0 &&
|
selectedCodexPreset >= 0 &&
|
||||||
codexProviderPresets[selectedCodexPreset]?.isOfficial === true;
|
(codexProviderPresets[selectedCodexPreset]?.isOfficial === true ||
|
||||||
|
codexProviderPresets[selectedCodexPreset]?.category === "official")) ||
|
||||||
|
category === "official";
|
||||||
|
|
||||||
|
// 判断是否显示 Codex 的"获取 API Key"链接
|
||||||
|
const shouldShowCodexApiKeyLink =
|
||||||
|
isCodex &&
|
||||||
|
!isCodexOfficialPreset &&
|
||||||
|
(category === "cn_official" ||
|
||||||
|
category === "aggregator" ||
|
||||||
|
category === "third_party" ||
|
||||||
|
(selectedCodexPreset !== null &&
|
||||||
|
selectedCodexPreset >= 0 &&
|
||||||
|
(codexProviderPresets[selectedCodexPreset]?.category ===
|
||||||
|
"cn_official" ||
|
||||||
|
codexProviderPresets[selectedCodexPreset]?.category ===
|
||||||
|
"aggregator" ||
|
||||||
|
codexProviderPresets[selectedCodexPreset]?.category ===
|
||||||
|
"third_party")));
|
||||||
|
|
||||||
|
// 处理模型输入变化,自动更新 JSON 配置
|
||||||
|
const handleModelChange = (
|
||||||
|
field: "ANTHROPIC_MODEL" | "ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
if (field === "ANTHROPIC_MODEL") {
|
||||||
|
setClaudeModel(value);
|
||||||
|
} else {
|
||||||
|
setClaudeSmallFastModel(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 JSON 配置
|
||||||
|
try {
|
||||||
|
const currentConfig = formData.settingsConfig
|
||||||
|
? JSON.parse(formData.settingsConfig)
|
||||||
|
: { env: {} };
|
||||||
|
if (!currentConfig.env) currentConfig.env = {};
|
||||||
|
|
||||||
|
if (value.trim()) {
|
||||||
|
currentConfig.env[field] = value.trim();
|
||||||
|
} else {
|
||||||
|
delete currentConfig.env[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
settingsConfig: JSON.stringify(currentConfig, null, 2),
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
// 如果 JSON 解析失败,不做处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Kimi 模型选择处理函数
|
// Kimi 模型选择处理函数
|
||||||
const handleKimiModelChange = (
|
const handleKimiModelChange = (
|
||||||
@@ -419,14 +619,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
|
|
||||||
// 初始时从配置中同步 API Key(编辑模式)
|
// 初始时从配置中同步 API Key(编辑模式)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (!initialData) return;
|
||||||
const parsedKey = getApiKeyFromConfig(
|
const parsedKey = getApiKeyFromConfig(
|
||||||
JSON.stringify(initialData.settingsConfig),
|
JSON.stringify(initialData.settingsConfig),
|
||||||
);
|
);
|
||||||
if (parsedKey) setApiKey(parsedKey);
|
if (parsedKey) setApiKey(parsedKey);
|
||||||
}
|
}, [initialData]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 支持按下 ESC 关闭弹窗
|
// 支持按下 ESC 关闭弹窗
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -448,19 +646,19 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="relative bg-white rounded-xl shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
<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">
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
|
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="关闭"
|
aria-label="关闭"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -470,12 +668,12 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-3 p-4 bg-red-100 border border-red-500/20 rounded-lg">
|
<div className="flex items-center gap-3 p-4 bg-red-100 dark:bg-red-900/20 border border-red-500/20 dark:border-red-500/30 rounded-lg">
|
||||||
<AlertCircle
|
<AlertCircle
|
||||||
size={20}
|
size={20}
|
||||||
className="text-red-500 flex-shrink-0"
|
className="text-red-500 dark:text-red-400 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<p className="text-red-500 text-sm font-medium">
|
<p className="text-red-500 dark:text-red-400 text-sm font-medium">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,7 +704,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="name"
|
htmlFor="name"
|
||||||
className="block text-sm font-medium text-gray-900"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
供应商名称 *
|
供应商名称 *
|
||||||
</label>
|
</label>
|
||||||
@@ -519,59 +717,14 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
placeholder="例如:Anthropic 官方"
|
placeholder="例如:Anthropic 官方"
|
||||||
required
|
required
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
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 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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isCodex && showApiKey && (
|
|
||||||
<ApiKeyInput
|
|
||||||
value={apiKey}
|
|
||||||
onChange={handleApiKeyChange}
|
|
||||||
placeholder={
|
|
||||||
isOfficialPreset
|
|
||||||
? "官方登录无需填写 API Key,直接保存即可"
|
|
||||||
: shouldShowKimiSelector
|
|
||||||
? "sk-xxx-api-key-here (填写后可获取模型列表)"
|
|
||||||
: "只需要填这里,下方配置会自动填充"
|
|
||||||
}
|
|
||||||
disabled={isOfficialPreset}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCodex && shouldShowKimiSelector && apiKey.trim() && (
|
|
||||||
<KimiModelSelector
|
|
||||||
apiKey={apiKey}
|
|
||||||
anthropicModel={kimiAnthropicModel}
|
|
||||||
anthropicSmallFastModel={kimiAnthropicSmallFastModel}
|
|
||||||
onModelChange={handleKimiModelChange}
|
|
||||||
disabled={isOfficialPreset}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCodex && showCodexApiKey && (
|
|
||||||
<ApiKeyInput
|
|
||||||
id="codexApiKey"
|
|
||||||
label="API Key"
|
|
||||||
value={codexApiKey}
|
|
||||||
onChange={handleCodexApiKeyChange}
|
|
||||||
placeholder={
|
|
||||||
isCodexOfficialPreset
|
|
||||||
? "官方无需填写 API Key,直接保存即可"
|
|
||||||
: "只需要填这里,下方 auth.json 会自动填充"
|
|
||||||
}
|
|
||||||
disabled={isCodexOfficialPreset}
|
|
||||||
required={
|
|
||||||
selectedCodexPreset !== null &&
|
|
||||||
selectedCodexPreset >= 0 &&
|
|
||||||
!isCodexOfficialPreset
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="websiteUrl"
|
htmlFor="websiteUrl"
|
||||||
className="block text-sm font-medium text-gray-900"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
官网地址
|
官网地址
|
||||||
</label>
|
</label>
|
||||||
@@ -583,10 +736,110 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="https://example.com(可选)"
|
placeholder="https://example.com(可选)"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
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 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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isCodex && showApiKey && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ApiKeyInput
|
||||||
|
value={apiKey}
|
||||||
|
onChange={handleApiKeyChange}
|
||||||
|
required={!isOfficialPreset}
|
||||||
|
placeholder={
|
||||||
|
isOfficialPreset
|
||||||
|
? "官方登录无需填写 API Key,直接保存即可"
|
||||||
|
: shouldShowKimiSelector
|
||||||
|
? "填写后可获取模型列表"
|
||||||
|
: "只需要填这里,下方配置会自动填充"
|
||||||
|
}
|
||||||
|
disabled={isOfficialPreset}
|
||||||
|
/>
|
||||||
|
{shouldShowApiKeyLink && getCurrentWebsiteUrl() && (
|
||||||
|
<div className="-mt-1 pl-1">
|
||||||
|
<a
|
||||||
|
href={getCurrentWebsiteUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
获取 API Key
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 基础 URL 输入框 - 仅在自定义模式下显示 */}
|
||||||
|
{!isCodex && showBaseUrlInput && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="baseUrl"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
请求地址
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="baseUrl"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
||||||
|
placeholder="https://your-api-endpoint.com"
|
||||||
|
autoComplete="off"
|
||||||
|
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 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"
|
||||||
|
/>
|
||||||
|
<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">
|
||||||
|
💡 填写兼容 Claude API 的服务端点地址
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCodex && shouldShowKimiSelector && (
|
||||||
|
<KimiModelSelector
|
||||||
|
apiKey={apiKey}
|
||||||
|
anthropicModel={kimiAnthropicModel}
|
||||||
|
anthropicSmallFastModel={kimiAnthropicSmallFastModel}
|
||||||
|
onModelChange={handleKimiModelChange}
|
||||||
|
disabled={isOfficialPreset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCodex && showCodexApiKey && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ApiKeyInput
|
||||||
|
id="codexApiKey"
|
||||||
|
label="API Key"
|
||||||
|
value={codexApiKey}
|
||||||
|
onChange={handleCodexApiKeyChange}
|
||||||
|
placeholder={
|
||||||
|
isCodexOfficialPreset
|
||||||
|
? "官方无需填写 API Key,直接保存即可"
|
||||||
|
: "只需要填这里,下方 auth.json 会自动填充"
|
||||||
|
}
|
||||||
|
disabled={isCodexOfficialPreset}
|
||||||
|
required={
|
||||||
|
selectedCodexPreset !== null &&
|
||||||
|
selectedCodexPreset >= 0 &&
|
||||||
|
!isCodexOfficialPreset
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{shouldShowCodexApiKeyLink && getCurrentCodexWebsiteUrl() && (
|
||||||
|
<div className="-mt-1 pl-1">
|
||||||
|
<a
|
||||||
|
href={getCurrentCodexWebsiteUrl()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
获取 API Key
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Claude 或 Codex 的配置部分 */}
|
{/* Claude 或 Codex 的配置部分 */}
|
||||||
{isCodex ? (
|
{isCodex ? (
|
||||||
<CodexConfigEditor
|
<CodexConfigEditor
|
||||||
@@ -608,33 +861,91 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ClaudeConfigEditor
|
<>
|
||||||
value={formData.settingsConfig}
|
{/* 可选的模型配置输入框 - 仅在非官方且非 Kimi 时显示 */}
|
||||||
onChange={(value) =>
|
{!isOfficialPreset && !shouldShowKimiSelector && (
|
||||||
handleChange({
|
<div className="space-y-4">
|
||||||
target: { name: "settingsConfig", value },
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
} as React.ChangeEvent<HTMLTextAreaElement>)
|
<div className="space-y-2">
|
||||||
}
|
<label
|
||||||
disableCoAuthored={disableCoAuthored}
|
htmlFor="anthropicModel"
|
||||||
onCoAuthoredToggle={handleCoAuthoredToggle}
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
/>
|
>
|
||||||
|
主模型 (可选)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="anthropicModel"
|
||||||
|
value={claudeModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleModelChange("ANTHROPIC_MODEL", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="例如: GLM-4.5"
|
||||||
|
autoComplete="off"
|
||||||
|
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 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="anthropicSmallFastModel"
|
||||||
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
快速模型 (可选)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="anthropicSmallFastModel"
|
||||||
|
value={claudeSmallFastModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleModelChange(
|
||||||
|
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="例如: GLM-4.5-Air"
|
||||||
|
autoComplete="off"
|
||||||
|
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 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
💡 留空将使用供应商的默认模型
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ClaudeConfigEditor
|
||||||
|
value={formData.settingsConfig}
|
||||||
|
onChange={(value) =>
|
||||||
|
handleChange({
|
||||||
|
target: { name: "settingsConfig", value },
|
||||||
|
} as React.ChangeEvent<HTMLTextAreaElement>)
|
||||||
|
}
|
||||||
|
disableCoAuthored={disableCoAuthored}
|
||||||
|
onCoAuthoredToggle={handleCoAuthoredToggle}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-100">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white rounded-lg transition-colors"
|
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors text-sm font-medium"
|
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 size={16} />
|
<Save className="w-4 h-4" />
|
||||||
{submitText}
|
{submitText}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 ${
|
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
||||||
disabled
|
disabled
|
||||||
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed"
|
? "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 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
: "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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor={id}
|
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} {required && "*"}
|
||||||
</label>
|
</label>
|
||||||
@@ -56,7 +56,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleShowKey}
|
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"}
|
aria-label={showKey ? "隐藏API Key" : "显示API Key"}
|
||||||
>
|
>
|
||||||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import JsonEditor from "../JsonEditor";
|
import JsonEditor from "../JsonEditor";
|
||||||
|
|
||||||
interface ClaudeConfigEditorProps {
|
interface ClaudeConfigEditorProps {
|
||||||
@@ -14,21 +14,47 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
disableCoAuthored,
|
disableCoAuthored,
|
||||||
onCoAuthoredToggle,
|
onCoAuthoredToggle,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isDarkMode, setIsDarkMode] = 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();
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label
|
<label
|
||||||
htmlFor="settingsConfig"
|
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) *
|
Claude Code 配置 (JSON) *
|
||||||
</label>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={disableCoAuthored}
|
checked={disableCoAuthored}
|
||||||
onChange={(e) => onCoAuthoredToggle(e.target.checked)}
|
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"
|
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 签名
|
禁止 Claude Code 签名
|
||||||
</label>
|
</label>
|
||||||
@@ -36,15 +62,16 @@ const ClaudeConfigEditor: React.FC<ClaudeConfigEditorProps> = ({
|
|||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
darkMode={isDarkMode}
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
||||||
"ANTHROPIC_AUTH_TOKEN": "sk-your-api-key-here"
|
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||||
}
|
}
|
||||||
}`}
|
}`}
|
||||||
rows={12}
|
rows={12}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
完整的 Claude Code settings.json 配置内容
|
完整的 Claude Code settings.json 配置内容
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="codexAuth"
|
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) *
|
auth.json (JSON) *
|
||||||
</label>
|
</label>
|
||||||
@@ -34,9 +34,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
rows={6}
|
rows={6}
|
||||||
required
|
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]"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Codex auth.json 配置内容
|
Codex auth.json 配置内容
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,7 +44,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
htmlFor="codexConfig"
|
htmlFor="codexConfig"
|
||||||
className="block text-sm font-medium text-gray-900"
|
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
>
|
>
|
||||||
config.toml (TOML)
|
config.toml (TOML)
|
||||||
</label>
|
</label>
|
||||||
@@ -54,9 +54,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onChange={(e) => onConfigChange(e.target.value)}
|
onChange={(e) => onConfigChange(e.target.value)}
|
||||||
placeholder=""
|
placeholder=""
|
||||||
rows={8}
|
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]"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Codex config.toml 配置内容
|
Codex config.toml 配置内容
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
}, [debouncedKey]);
|
}, [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
|
disabled
|
||||||
? "bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed"
|
? "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 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
: "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<{
|
const ModelSelect: React.FC<{
|
||||||
@@ -98,7 +98,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}> = ({ label, value, onChange }) => (
|
}> = ({ label, value, onChange }) => (
|
||||||
<div className="space-y-2">
|
<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}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -123,7 +123,7 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={16}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,14 +132,14 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
|
onClick={() => debouncedKey && fetchModelsWithKey(debouncedKey)}
|
||||||
disabled={disabled || loading || !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" : ""} />
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
刷新模型列表
|
刷新模型列表
|
||||||
@@ -147,23 +147,23 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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
|
<AlertCircle
|
||||||
size={16}
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<ModelSelect
|
<ModelSelect
|
||||||
label="主模型 (ANTHROPIC_MODEL)"
|
label="主模型"
|
||||||
value={anthropicModel}
|
value={anthropicModel}
|
||||||
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
|
onChange={(value) => onModelChange("ANTHROPIC_MODEL", value)}
|
||||||
/>
|
/>
|
||||||
<ModelSelect
|
<ModelSelect
|
||||||
label="快速模型 (ANTHROPIC_SMALL_FAST_MODEL)"
|
label="快速模型"
|
||||||
value={anthropicSmallFastModel}
|
value={anthropicSmallFastModel}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
|
onModelChange("ANTHROPIC_SMALL_FAST_MODEL", value)
|
||||||
@@ -172,9 +172,9 @@ const KimiModelSelector: React.FC<KimiModelSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!apiKey.trim() && (
|
{!apiKey.trim() && (
|
||||||
<div className="p-3 bg-gray-100 border border-gray-200 rounded-lg">
|
<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-gray-500">
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
📝 请先填写 API Key(格式:sk-xxx-api-key-here)以获取可用模型列表
|
💡 填写 API Key 后将自动获取可用模型列表
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Zap } from "lucide-react";
|
import { Zap } from "lucide-react";
|
||||||
|
import { ProviderCategory } from "../../types";
|
||||||
|
import { ClaudeIcon, CodexIcon } from "../BrandIcons";
|
||||||
|
|
||||||
interface Preset {
|
interface Preset {
|
||||||
name: string;
|
name: string;
|
||||||
isOfficial?: boolean;
|
isOfficial?: boolean;
|
||||||
|
category?: ProviderCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PresetSelectorProps {
|
interface PresetSelectorProps {
|
||||||
@@ -23,18 +26,24 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
|||||||
onCustomClick,
|
onCustomClick,
|
||||||
customLabel = "自定义",
|
customLabel = "自定义",
|
||||||
}) => {
|
}) => {
|
||||||
const getButtonClass = (index: number, isOfficial?: boolean) => {
|
const getButtonClass = (index: number, preset?: Preset) => {
|
||||||
const isSelected = selectedIndex === index;
|
const isSelected = selectedIndex === index;
|
||||||
const baseClass =
|
const baseClass =
|
||||||
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
"inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return isOfficial
|
if (preset?.isOfficial || preset?.category === "official") {
|
||||||
? `${baseClass} bg-amber-500 text-white`
|
// Codex 官方使用黑色背景
|
||||||
: `${baseClass} bg-blue-500 text-white`;
|
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 = () => {
|
const getDescription = () => {
|
||||||
@@ -44,8 +53,8 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
|||||||
|
|
||||||
if (selectedIndex !== null && selectedIndex >= 0) {
|
if (selectedIndex !== null && selectedIndex >= 0) {
|
||||||
const preset = presets[selectedIndex];
|
const preset = presets[selectedIndex];
|
||||||
return preset?.isOfficial
|
return preset?.isOfficial || preset?.category === "official"
|
||||||
? "Claude 官方登录,不需要填写 API Key"
|
? "官方登录,不需要填写 API Key"
|
||||||
: "使用预设配置,只需填写 API Key";
|
: "使用预设配置,只需填写 API Key";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +64,13 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<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}
|
{title}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? '' : ''}`}
|
className={`${getButtonClass(-1)} ${selectedIndex === -1 ? "" : ""}`}
|
||||||
onClick={onCustomClick}
|
onClick={onCustomClick}
|
||||||
>
|
>
|
||||||
{customLabel}
|
{customLabel}
|
||||||
@@ -70,17 +79,27 @@ const PresetSelector: React.FC<PresetSelectorProps> = ({
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
type="button"
|
||||||
className={getButtonClass(index, preset.isOfficial)}
|
className={getButtonClass(index, preset)}
|
||||||
onClick={() => onSelectPreset(index)}
|
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}
|
{preset.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{getDescription() && (
|
{getDescription() && (
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{getDescription()}
|
{getDescription()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import { Provider } from "../types";
|
import { Provider } from "../types";
|
||||||
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
|
import { Play, Edit3, Trash2, CheckCircle2, Users } from "lucide-react";
|
||||||
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
|
||||||
|
// 不再在列表中显示分类徽章,避免造成困惑
|
||||||
|
|
||||||
interface ProviderListProps {
|
interface ProviderListProps {
|
||||||
providers: Record<string, Provider>;
|
providers: Record<string, Provider>;
|
||||||
@@ -55,7 +56,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
|
|
||||||
// 如果都没有时间戳,按名称排序
|
// 如果都没有时间戳,按名称排序
|
||||||
if (timeA === 0 && timeB === 0) {
|
if (timeA === 0 && timeB === 0) {
|
||||||
return a.name.localeCompare(b.name, 'zh-CN');
|
return a.name.localeCompare(b.name, "zh-CN");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果只有一个没有时间戳,没有时间戳的排在前面
|
// 如果只有一个没有时间戳,没有时间戳的排在前面
|
||||||
@@ -90,7 +91,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
isCurrent ? cardStyles.selected : cardStyles.interactive
|
isCurrent ? cardStyles.selected : cardStyles.interactive,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -99,6 +100,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
{/* 分类徽章已移除 */}
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<div className={badgeStyles.success}>
|
<div className={badgeStyles.success}>
|
||||||
<CheckCircle2 size={12} />
|
<CheckCircle2 size={12} />
|
||||||
@@ -138,7 +140,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
"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 px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
||||||
isCurrent
|
isCurrent
|
||||||
? "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500 cursor-not-allowed"
|
? "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"
|
: "bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Play size={14} />
|
<Play size={14} />
|
||||||
@@ -160,7 +162,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
|
|||||||
buttonStyles.icon,
|
buttonStyles.icon,
|
||||||
isCurrent
|
isCurrent
|
||||||
? "text-gray-400 cursor-not-allowed"
|
? "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"
|
: "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="删除供应商"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { X, Info, RefreshCw, FolderOpen } from "lucide-react";
|
import {
|
||||||
|
X,
|
||||||
|
RefreshCw,
|
||||||
|
FolderOpen,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
Check,
|
||||||
|
} from "lucide-react";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import "../lib/tauri-api";
|
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 { Settings } from "../types";
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
@@ -11,11 +19,15 @@ interface SettingsModalProps {
|
|||||||
|
|
||||||
export default function SettingsModal({ onClose }: SettingsModalProps) {
|
export default function SettingsModal({ onClose }: SettingsModalProps) {
|
||||||
const [settings, setSettings] = useState<Settings>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
showInDock: true,
|
showInTray: true,
|
||||||
});
|
});
|
||||||
const [configPath, setConfigPath] = useState<string>("");
|
const [configPath, setConfigPath] = useState<string>("");
|
||||||
const [version, setVersion] = useState<string>("");
|
const [version, setVersion] = useState<string>("");
|
||||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [showUpToDate, setShowUpToDate] = useState(false);
|
||||||
|
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
|
||||||
|
useUpdate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -29,15 +41,19 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
setVersion(appVersion);
|
setVersion(appVersion);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取版本信息失败:", error);
|
console.error("获取版本信息失败:", error);
|
||||||
setVersion("3.1.1"); // 降级使用默认版本
|
// 失败时不硬编码版本号,显示为未知
|
||||||
|
setVersion("未知");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const loadedSettings = await window.api.getSettings();
|
const loadedSettings = await window.api.getSettings();
|
||||||
if (loadedSettings?.showInDock !== undefined) {
|
if ((loadedSettings as any)?.showInTray !== undefined) {
|
||||||
setSettings({ showInDock: loadedSettings.showInDock });
|
setSettings({ showInTray: (loadedSettings as any).showInTray });
|
||||||
|
} else if ((loadedSettings as any)?.showInDock !== undefined) {
|
||||||
|
// 向后兼容:若历史上有 showInDock,则映射为 showInTray
|
||||||
|
setSettings({ showInTray: (loadedSettings as any).showInDock });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("加载设置失败:", error);
|
console.error("加载设置失败:", error);
|
||||||
@@ -65,15 +81,49 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckUpdate = async () => {
|
const handleCheckUpdate = async () => {
|
||||||
setIsCheckingUpdate(true);
|
if (hasUpdate && updateHandle) {
|
||||||
try {
|
// 已检测到更新:直接复用 updateHandle 下载并安装,避免重复检查
|
||||||
// 优先使用 Tauri Updater 流程;失败时回退到打开 Releases 页面
|
setIsDownloading(true);
|
||||||
await runUpdateFlow({ timeout: 30000 });
|
try {
|
||||||
} catch (error) {
|
resetDismiss();
|
||||||
console.error("检查更新失败,回退到 Releases 页面:", error);
|
await updateHandle.downloadAndInstall();
|
||||||
await window.api.checkForUpdates();
|
await relaunchApp();
|
||||||
} finally {
|
} catch (error) {
|
||||||
setIsCheckingUpdate(false);
|
console.error("更新失败:", 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("检查更新失败:", error);
|
||||||
|
// 在开发模式下,模拟已是最新版本的响应
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
setShowUpToDate(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowUpToDate(false);
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
// 生产环境下如果更新插件不可用,回退到打开 Releases 页面
|
||||||
|
await window.api.checkForUpdates();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsCheckingUpdate(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +135,27 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenReleaseNotes = async () => {
|
||||||
|
try {
|
||||||
|
const targetVersion = updateInfo?.availableVersion || version;
|
||||||
|
// 如果未知或为空,回退到 releases 首页
|
||||||
|
if (!targetVersion || targetVersion === "未知") {
|
||||||
|
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("打开更新日志失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50">
|
<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="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-[500px] overflow-hidden">
|
||||||
@@ -103,20 +174,21 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
{/* 设置内容 */}
|
{/* 设置内容 */}
|
||||||
<div className="px-6 py-4 space-y-6">
|
<div className="px-6 py-4 space-y-6">
|
||||||
{/* 显示设置 - 功能还未实现 */}
|
{/* 系统托盘设置(未实现)
|
||||||
|
说明:此开关用于控制是否在系统托盘/菜单栏显示应用图标。 */}
|
||||||
{/* <div>
|
{/* <div>
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||||
显示设置
|
显示设置(系统托盘)
|
||||||
</h3>
|
</h3>
|
||||||
<label className="flex items-center justify-between">
|
<label className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
在 Dock 中显示(macOS)
|
在菜单栏显示图标(系统托盘)
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.showInDock}
|
checked={settings.showInTray}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings({ ...settings, showInDock: e.target.checked })
|
setSettings({ ...settings, showInTray: e.target.checked })
|
||||||
}
|
}
|
||||||
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
|
className="w-4 h-4 text-blue-500 rounded focus:ring-blue-500/20"
|
||||||
/>
|
/>
|
||||||
@@ -154,11 +226,7 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
<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 justify-between">
|
||||||
<div className="flex items-start gap-3">
|
<div>
|
||||||
<Info
|
|
||||||
size={18}
|
|
||||||
className="text-gray-500 mt-0.5"
|
|
||||||
/>
|
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
<p className="font-medium text-gray-900 dark:text-gray-100">
|
||||||
CC Switch
|
CC Switch
|
||||||
@@ -168,24 +236,57 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleCheckUpdate}
|
<button
|
||||||
disabled={isCheckingUpdate}
|
onClick={handleOpenReleaseNotes}
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
|
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"
|
||||||
isCheckingUpdate
|
title={
|
||||||
? "bg-white dark:bg-gray-700 text-gray-400 dark:text-gray-500"
|
hasUpdate ? "查看该版本更新日志" : "查看当前版本更新日志"
|
||||||
: "bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 text-blue-500 dark:text-blue-400"
|
}
|
||||||
}`}
|
>
|
||||||
>
|
<span className="inline-flex items-center gap-1">
|
||||||
{isCheckingUpdate ? (
|
<ExternalLink size={12} />
|
||||||
<span className="flex items-center gap-1">
|
更新日志
|
||||||
<RefreshCw size={12} className="animate-spin" />
|
|
||||||
检查中...
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
</button>
|
||||||
"检查更新"
|
<button
|
||||||
)}
|
onClick={handleCheckUpdate}
|
||||||
</button>
|
disabled={isCheckingUpdate || isDownloading}
|
||||||
|
className={`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"
|
||||||
|
: hasUpdate
|
||||||
|
? "bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white"
|
||||||
|
: 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" />
|
||||||
|
更新中...
|
||||||
|
</span>
|
||||||
|
) : isCheckingUpdate ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<RefreshCw size={12} className="animate-spin" />
|
||||||
|
检查中...
|
||||||
|
</span>
|
||||||
|
) : hasUpdate ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download size={12} />
|
||||||
|
更新到 v{updateInfo?.availableVersion}
|
||||||
|
</span>
|
||||||
|
) : showUpToDate ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Check size={12} />
|
||||||
|
已是最新
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"检查更新"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
61
src/components/UpdateBadge.tsx
Normal file
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,15 @@
|
|||||||
/**
|
/**
|
||||||
* Codex 预设供应商配置模板
|
* Codex 预设供应商配置模板
|
||||||
*/
|
*/
|
||||||
|
import { ProviderCategory } from "../types";
|
||||||
|
|
||||||
export interface CodexProviderPreset {
|
export interface CodexProviderPreset {
|
||||||
name: string;
|
name: string;
|
||||||
websiteUrl: string;
|
websiteUrl: string;
|
||||||
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
|
auth: Record<string, any>; // 将写入 ~/.codex/auth.json
|
||||||
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
config: string; // 将写入 ~/.codex/config.toml(TOML 字符串)
|
||||||
isOfficial?: boolean; // 标识是否为官方预设
|
isOfficial?: boolean; // 标识是否为官方预设
|
||||||
|
category?: ProviderCategory; // 新增:分类
|
||||||
}
|
}
|
||||||
|
|
||||||
export const codexProviderPresets: CodexProviderPreset[] = [
|
export const codexProviderPresets: CodexProviderPreset[] = [
|
||||||
@@ -14,6 +17,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
|||||||
name: "Codex官方",
|
name: "Codex官方",
|
||||||
websiteUrl: "https://chatgpt.com/codex",
|
websiteUrl: "https://chatgpt.com/codex",
|
||||||
isOfficial: true,
|
isOfficial: true,
|
||||||
|
category: "official",
|
||||||
// 官方的 key 为null
|
// 官方的 key 为null
|
||||||
auth: {
|
auth: {
|
||||||
OPENAI_API_KEY: null,
|
OPENAI_API_KEY: null,
|
||||||
@@ -23,6 +27,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
|||||||
{
|
{
|
||||||
name: "PackyCode",
|
name: "PackyCode",
|
||||||
websiteUrl: "https://codex.packycode.com/",
|
websiteUrl: "https://codex.packycode.com/",
|
||||||
|
category: "third_party",
|
||||||
// PackyCode 一般通过 API Key;请将占位符替换为你的实际 key
|
// PackyCode 一般通过 API Key;请将占位符替换为你的实际 key
|
||||||
auth: {
|
auth: {
|
||||||
OPENAI_API_KEY: "sk-your-api-key-here",
|
OPENAI_API_KEY: "sk-your-api-key-here",
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* 预设供应商配置模板
|
* 预设供应商配置模板
|
||||||
*/
|
*/
|
||||||
|
import { ProviderCategory } from "../types";
|
||||||
|
|
||||||
export interface ProviderPreset {
|
export interface ProviderPreset {
|
||||||
name: string;
|
name: string;
|
||||||
websiteUrl: string;
|
websiteUrl: string;
|
||||||
settingsConfig: object;
|
settingsConfig: object;
|
||||||
isOfficial?: boolean; // 标识是否为官方预设
|
isOfficial?: boolean; // 标识是否为官方预设
|
||||||
|
category?: ProviderCategory; // 新增:分类
|
||||||
}
|
}
|
||||||
|
|
||||||
export const providerPresets: ProviderPreset[] = [
|
export const providerPresets: ProviderPreset[] = [
|
||||||
{
|
{
|
||||||
name: "Claude官方登录",
|
name: "Claude官方",
|
||||||
websiteUrl: "https://www.anthropic.com/claude-code",
|
websiteUrl: "https://www.anthropic.com/claude-code",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {},
|
env: {},
|
||||||
},
|
},
|
||||||
isOfficial: true, // 明确标识为官方预设
|
isOfficial: true, // 明确标识为官方预设
|
||||||
|
category: "official",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DeepSeek v3.1",
|
name: "DeepSeek",
|
||||||
websiteUrl: "https://platform.deepseek.com",
|
websiteUrl: "https://platform.deepseek.com",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
@@ -28,6 +32,7 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
|
ANTHROPIC_SMALL_FAST_MODEL: "deepseek-chat",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
category: "cn_official",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "智谱GLM",
|
name: "智谱GLM",
|
||||||
@@ -36,19 +41,25 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic",
|
||||||
ANTHROPIC_AUTH_TOKEN: "",
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
|
ANTHROPIC_MODEL: "GLM-4.5",
|
||||||
|
ANTHROPIC_SMALL_FAST_MODEL: "GLM-4.5-Air",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
category: "cn_official",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "千问Qwen-Coder",
|
name: "Qwen-Coder",
|
||||||
websiteUrl: "https://bailian.console.aliyun.com",
|
websiteUrl: "https://bailian.console.aliyun.com",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL:
|
ANTHROPIC_BASE_URL:
|
||||||
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
|
"https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy",
|
||||||
ANTHROPIC_AUTH_TOKEN: "",
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
|
ANTHROPIC_MODEL: "qwen3-coder-plus",
|
||||||
|
ANTHROPIC_SMALL_FAST_MODEL: "qwen3-coder-plus",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
category: "cn_official",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Kimi k2",
|
name: "Kimi k2",
|
||||||
@@ -61,18 +72,20 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
|
ANTHROPIC_SMALL_FAST_MODEL: "kimi-k2-turbo-preview",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
category: "cn_official",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "魔搭",
|
name: "魔搭",
|
||||||
websiteUrl: "https://modelscope.cn",
|
websiteUrl: "https://modelscope.cn",
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_AUTH_TOKEN: "ms-your-api-key",
|
|
||||||
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
|
ANTHROPIC_BASE_URL: "https://api-inference.modelscope.cn",
|
||||||
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
|
ANTHROPIC_MODEL: "ZhipuAI/GLM-4.5",
|
||||||
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
|
ANTHROPIC_SMALL_FAST_MODEL: "ZhipuAI/GLM-4.5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
category: "aggregator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "PackyCode",
|
name: "PackyCode",
|
||||||
@@ -83,5 +96,6 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
ANTHROPIC_AUTH_TOKEN: "",
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
category: "third_party",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
155
src/contexts/UpdateContext.tsx
Normal file
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,29 +1,34 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export function useDarkMode() {
|
export function useDarkMode() {
|
||||||
// 初始设为 false,挂载后在 useEffect 中加载真实值
|
// 初始设为 false,挂载后在 useEffect 中加载真实值
|
||||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
|
const [isDarkMode, setIsDarkMode] = useState<boolean>(false);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
// 组件挂载后加载初始值(兼容 Tauri 环境)
|
// 组件挂载后加载初始值(兼容 Tauri 环境)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 尝试读取已保存的偏好
|
// 尝试读取已保存的偏好
|
||||||
const saved = localStorage.getItem('darkMode');
|
const saved = localStorage.getItem("darkMode");
|
||||||
if (saved !== null) {
|
if (saved !== null) {
|
||||||
const savedBool = saved === 'true';
|
const savedBool = saved === "true";
|
||||||
setIsDarkMode(savedBool);
|
setIsDarkMode(savedBool);
|
||||||
console.log('[DarkMode] Loaded from localStorage:', savedBool);
|
if (isDev)
|
||||||
|
console.log("[DarkMode] Loaded from localStorage:", savedBool);
|
||||||
} else {
|
} 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);
|
setIsDarkMode(prefersDark);
|
||||||
console.log('[DarkMode] Using system preference:', prefersDark);
|
if (isDev)
|
||||||
|
console.log("[DarkMode] Using system preference:", prefersDark);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DarkMode] Error loading preference:', error);
|
console.error("[DarkMode] Error loading preference:", error);
|
||||||
setIsDarkMode(false);
|
setIsDarkMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,18 +43,18 @@ export function useDarkMode() {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add("dark");
|
||||||
console.log('[DarkMode] Added dark class to document');
|
if (isDev) console.log("[DarkMode] Added dark class to document");
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove("dark");
|
||||||
console.log('[DarkMode] Removed dark class from document');
|
if (isDev) console.log("[DarkMode] Removed dark class from document");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查类名是否已成功应用
|
// 检查类名是否已成功应用
|
||||||
const hasClass = document.documentElement.classList.contains('dark');
|
const hasClass = document.documentElement.classList.contains("dark");
|
||||||
console.log('[DarkMode] Document has dark class:', hasClass);
|
if (isDev) console.log("[DarkMode] Document has dark class:", hasClass);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DarkMode] Error applying dark class:', error);
|
console.error("[DarkMode] Error applying dark class:", error);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
@@ -61,17 +66,17 @@ export function useDarkMode() {
|
|||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('darkMode', isDarkMode.toString());
|
localStorage.setItem("darkMode", isDarkMode.toString());
|
||||||
console.log('[DarkMode] Saved to localStorage:', isDarkMode);
|
if (isDev) console.log("[DarkMode] Saved to localStorage:", isDarkMode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DarkMode] Error saving preference:', error);
|
console.error("[DarkMode] Error saving preference:", error);
|
||||||
}
|
}
|
||||||
}, [isDarkMode, isInitialized]);
|
}, [isDarkMode, isInitialized]);
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const toggleDarkMode = () => {
|
||||||
setIsDarkMode(prev => {
|
setIsDarkMode((prev) => {
|
||||||
const newValue = !prev;
|
const newValue = !prev;
|
||||||
console.log('[DarkMode] Toggling from', prev, 'to', newValue);
|
if (isDev) console.log("[DarkMode] Toggling from", prev, "to", newValue);
|
||||||
return newValue;
|
return newValue;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,23 +21,33 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 暗色模式下启用暗色原生控件/滚动条配色 */
|
/* 暗色模式下启用暗色原生控件/滚动条配色 */
|
||||||
html.dark { color-scheme: dark; }
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
/* 滚动条样式 */
|
/* 滚动条样式(避免在伪元素中使用自定义 dark 变体,消除构建警告) */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@apply w-1.5 h-1.5;
|
width: 0.375rem;
|
||||||
|
height: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-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 {
|
::-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 {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
@apply bg-gray-400 dark:bg-gray-500;
|
background-color: #a1a1aa;
|
||||||
|
}
|
||||||
|
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #71717a;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 焦点样式 */
|
/* 焦点样式 */
|
||||||
|
|||||||
@@ -5,16 +5,20 @@
|
|||||||
// 按钮样式
|
// 按钮样式
|
||||||
export const buttonStyles = {
|
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",
|
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",
|
||||||
@@ -29,10 +33,12 @@ export const cardStyles = {
|
|||||||
base: "bg-white rounded-lg border border-gray-200 p-4 dark:bg-gray-900 dark:border-gray-700",
|
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-all 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 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",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 输入控件样式
|
// 输入控件样式
|
||||||
@@ -41,28 +47,33 @@ 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",
|
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;
|
} as const;
|
||||||
|
|
||||||
// 徽标(Badge)样式
|
// 徽标(Badge)样式
|
||||||
export const badgeStyles = {
|
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",
|
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;
|
} as const;
|
||||||
|
|
||||||
// 组合类名的工具函数
|
// 组合类名的工具函数
|
||||||
export function cn(...classes: (string | undefined | false)[]) {
|
export function cn(...classes: (string | undefined | false)[]) {
|
||||||
return classes.filter(Boolean).join(' ');
|
return classes.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export const tauriAPI = {
|
|||||||
return await invoke("get_settings");
|
return await invoke("get_settings");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取设置失败:", error);
|
console.error("获取设置失败:", error);
|
||||||
return { showInDock: true };
|
return { showInTray: true };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -122,25 +122,5 @@ export async function relaunchApp(): Promise<void> {
|
|||||||
await relaunch();
|
await relaunch();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runUpdateFlow(
|
// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
|
||||||
opts: CheckOptions = {},
|
// 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用
|
||||||
): 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" };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { UpdateProvider } from "./contexts/UpdateContext";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
// 导入 Tauri API(自动绑定到 window.api)
|
// 导入 Tauri API(自动绑定到 window.api)
|
||||||
import "./lib/tauri-api";
|
import "./lib/tauri-api";
|
||||||
@@ -19,6 +20,8 @@ try {
|
|||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<UpdateProvider>
|
||||||
|
<App />
|
||||||
|
</UpdateProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
12
src/types.ts
12
src/types.ts
@@ -1,8 +1,17 @@
|
|||||||
|
export type ProviderCategory =
|
||||||
|
| "official" // 官方
|
||||||
|
| "cn_official" // 国产官方
|
||||||
|
| "aggregator" // 聚合网站
|
||||||
|
| "third_party" // 第三方供应商
|
||||||
|
| "custom"; // 自定义
|
||||||
|
|
||||||
export interface Provider {
|
export interface Provider {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
settingsConfig: Record<string, any>; // 应用配置对象:Claude 为 settings.json;Codex 为 { auth, config }
|
settingsConfig: Record<string, any>; // 应用配置对象:Claude 为 settings.json;Codex 为 { auth, config }
|
||||||
websiteUrl?: string;
|
websiteUrl?: string;
|
||||||
|
// 新增:供应商分类(用于差异化提示/能力开关)
|
||||||
|
category?: ProviderCategory;
|
||||||
createdAt?: number; // 添加时间戳(毫秒)
|
createdAt?: number; // 添加时间戳(毫秒)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,5 +22,6 @@ export interface AppConfig {
|
|||||||
|
|
||||||
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
showInDock: boolean;
|
// 是否在系统托盘(macOS 菜单栏)显示图标
|
||||||
|
showInTray: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user