45 Commits

Author SHA1 Message Date
Jason
7ccef5f385 fix(ui): add dark mode support for Kimi model selector
- Update select dropdown with dark mode background and text colors
- Add dark mode styles to labels and refresh button
- Apply dark mode colors to error messages (red theme)
- Ensure hint box uses consistent amber theme in dark mode
- Fix chevron icon visibility in dark mode

This ensures the Kimi provider configuration UI is properly visible
and maintains consistency with other provider modals in dark mode.
2025-09-13 21:48:18 +08:00
Jason
85ba24f1c3 fix(ui): add dark mode support for provider modals
- Add dark mode styles to ProviderForm modal (background, borders, text)
- Update ApiKeyInput component with dark mode colors
- Add dark mode detection to ClaudeConfigEditor for JSON editor
- Apply dark mode styles to CodexConfigEditor textareas
- Update PresetSelector buttons for dark mode
- Ensure consistent amber color scheme for all hint boxes in dark mode

This ensures proper visibility and readability of add/edit provider dialogs
when dark mode is enabled.
2025-09-13 21:45:34 +08:00
Jason
0d2dedbb6d fix(migration): ensure config directory exists before writing marker
- Create parent directory before checking/writing migration marker
- Prevents runtime error for fresh installations without config directory
- Defensive fix for edge case where ~/.cc-switch/ doesn't exist yet
2025-09-13 21:03:48 +08:00
Jason
d76c675feb docs: update README and screenshots for v3.2.0 release
- Update README with v3.2.0 features and UI improvements
- Replace screenshots with new UI design
- Clarify SSOT architecture and one-time migration details
- Update version badge to 3.2.0
2025-09-13 17:54:21 +08:00
Jason
9372ecd3c6 feat(ui): enhance provider form with brand icons and colors
- Add Save icon to submit buttons in provider forms
- Replace generic Zap icon with brand-specific icons (ClaudeIcon, CodexIcon)
- Update selected state colors: Claude uses brand color #D97757, Codex uses black
- Maintain visual consistency with AppSwitcher component
2025-09-13 17:04:46 +08:00
Jason
d0b654f63e feat(ui): replace generic icons with official brand icons
- Add Claude and ChatGPT/Codex official brand SVG icons
- Create BrandIcons component with proper currentColor support
- Update AppSwitcher to use brand icons with Claude's official color (#D97757)
- Icons now dynamically change color based on active state
- Improve brand recognition and visual consistency
2025-09-13 16:21:15 +08:00
Jason
f035796654 chore(release): polish 3.2.0 changes\n\n- Docs: add 3.2.0 release notes to CHANGELOG\n- UI: silence debug logs in production via import.meta.env.DEV\n- CSS: replace pseudo-element Tailwind @apply with explicit selectors to fix minifier warnings 2025-09-13 15:48:14 +08:00
Jason
160da2729e fix(css): eliminate minifier warnings for scrollbar styles\n\n- Replace Tailwind @apply + custom dark variant on pseudo-elements\n- Use explicit selectors (html.dark) and hex colors for track/thumb\n- Prevent :where() empty selector warnings during production build 2025-09-13 15:38:23 +08:00
Jason
14db6b8a8f refactor(tauri): replace println/eprintln with structured logging\n\n- Use log::info/warn/error instead of println!/eprintln!\n- Keep behavior identical; improve log integration with tauri-plugin-log\n- Reduce stdout noise in production builds 2025-09-13 15:38:01 +08:00
Jason
d91bbb122c refactor(ui): silence debug logs in production\n\n- Wrap dark mode and event logs with import.meta.env.DEV\n- Keep error logging for failures intact\n- Reduce console noise in release builds 2025-09-13 15:37:39 +08:00
Jason
6df5dfc123 style: format codebase with Prettier\n\n- Apply Prettier across src to ensure consistent styling\n- No functional changes; whitespace and ordering only\n- Unblocks format:check for release pipeline 2025-09-13 15:36:43 +08:00
Jason
c8327f7632 feat: add API key links for third-party providers and simplify Kimi model labels
- Add "Get API Key" link support for third-party providers (e.g., PackyCode)
- Simplify Kimi model selector labels by removing technical field names
  - Changed "主模型 (ANTHROPIC_MODEL)" to "主模型"
  - Changed "快速模型 (ANTHROPIC_SMALL_FAST_MODEL)" to "快速模型"
- Improve user experience with cleaner, more intuitive interface labels
2025-09-13 13:23:32 +08:00
Jason
4a0e63d0b7 style: improve "Get API Key" link styling
- Change link color to light blue (text-blue-400)
- Remove arrow symbol from link text
- Apply to domestic official and aggregator provider API key links
2025-09-12 21:36:32 +08:00
Jason
e63b4e069b feat: enhance provider configuration UX with custom URL support and API key links
- Add custom base URL input for custom providers
  - New "Request URL" field appears only in custom mode
  - Automatically syncs with ANTHROPIC_BASE_URL in config
  - Includes helpful amber-styled hint about Claude API compatibility

- Add "Get API Key" links for non-official providers
  - Shows for cn_official, aggregator, and third_party categories
  - Links point to provider's official website
  - Styled as subtle helper text (text-xs, gray-500)
  - Positioned closely under API key input for better visual grouping

- Improve UI consistency and hints
  - Unify all hint boxes to use amber color scheme (amber-50/amber-200/amber-600)
  - Update model placeholders to latest versions (GLM-4.5, GLM-4.5-Air)
  - Simplify provider names (remove version numbers and redundant text)

- Update provider presets
  - GLM models: glm-4-plus → GLM-4.5, glm-4-flash → GLM-4.5-Air
  - Qwen models: qwen-coder-turbo → qwen3-coder-plus
  - Cleaner naming: "Claude官方登录" → "Claude官方", "DeepSeek v3.1" → "DeepSeek"

- Fix Kimi model selector behavior
  - Remove API key requirement for displaying selector
  - Avoid showing duplicate model input fields for Kimi preset
  - Improve hint message clarity
2025-09-12 20:14:59 +08:00
Jason
687c7de111 feat: improve custom provider configuration UX
- Show API key input field for custom mode
- Initialize default custom mode with JSON template on modal open
- Change default API key from placeholder to empty string
- Remove Save icon from submit button for cleaner UI
- Ensure consistent behavior between default and manually selected custom mode
2025-09-12 15:20:49 +08:00
Jason
876605e983 feat: require API key for non-official Claude providers
- Add required asterisk to API key input label for non-official providers
- Pre-fill API key placeholder when switching to custom mode
- Ensure consistent validation across Claude and Codex providers
2025-09-12 12:15:09 +08:00
Jason
442b05507c feat: simplify Claude provider configuration form
- Add optional model input fields (ANTHROPIC_MODEL, ANTHROPIC_SMALL_FAST_MODEL)
- Place model inputs in a single row for better space utilization
- Move website URL field above API configuration section
- Add JSON template for custom mode to guide users
- Simplify field labels and remove redundant descriptions
- Keep JSON editor for advanced configuration flexibility
2025-09-12 12:04:19 +08:00
Jason
eca9c02147 feat(providers): add provider categorization system
- Add ProviderCategory type with official, cn_official, aggregator, third_party, and custom categories
- Update Provider interface and Rust struct to include optional category field
- Enhance ProviderForm to automatically sync category when selecting presets
- Improve PresetSelector to show category-based styling and hints
- Add category classification to all provider presets
- Support differentiated interactions (e.g., hide API key input for official providers)
- Maintain backward compatibility with existing configurations
2025-09-11 22:33:55 +08:00
Jason
9fbce5d0cf refactor(settings): rename Dock setting to system tray (showInDock → showInTray)
- compat: map legacy showInDock to showInTray when loading settings
- ui(copy): clarify “system tray (menu bar)” vs Dock in SettingsModal
- tauri(settings): return showInTray in get_settings; adjust default fallback
- docs(comment): align comments to “system tray” terminology across code
- note: no functional change yet; tray visibility toggle remains unimplemented
2025-09-11 20:20:27 +08:00
farion1231
c597b9b122 feat(ui): add "up-to-date" feedback for update check button
- Show green "Already up-to-date" state with check icon when no updates available
- Button changes color and text temporarily (3 seconds) to provide clear feedback
- Fix TypeScript type for checkUpdate to return Promise<boolean>
- Handle dev mode gracefully - show up-to-date instead of opening release page
- Simplify previous complex notification UI to inline button state change
2025-09-11 15:13:33 +08:00
Jason
54b88d9c89 refactor(ui): redesign update notification to match Linear design system
- Replace gradient background with solid colors and subtle borders
- Remove decorative Info icon from settings panel that had no functionality
- Change update badge icon from Sparkles to Download for better clarity
- Simplify update button states with consistent blue primary color
- Remove all gradient effects in favor of flat design
- Unify hover states and transitions across all update-related UI

The update notification now seamlessly integrates with the app's Linear-inspired
aesthetic, providing a clean and non-intrusive user experience.
2025-09-11 12:06:49 +08:00
Jason
319e5fa61a build(rust): optimize release binary size (thin LTO, strip symbols, s-level, codegen-units=1, panic=abort)\n\n- Helps reduce final AppImage size by trimming Rust binary 2025-09-11 10:17:35 +08:00
Jason
310086d5c9 ci(release): refine artifacts\n\n- macOS: add .app zip (plus tar.gz + .sig for updater)\n- Windows: only MSI + .sig; add portable zip\n- Linux: always include .deb; keep AppImage + .sig for updater 2025-09-11 10:17:35 +08:00
Jason
4297703ebe ci(release): pin runners for compatibility and update cache action\n\n- matrix: windows-2022 / ubuntu-22.04 / macos-14\n- assemble-latest-json runs on ubuntu-22.04\n- actions/cache bumped to v4 2025-09-11 09:33:49 +08:00
Jason
ca7ce99702 chore(version): bump app version to 3.2.0\n\n- package.json → 3.2.0\n- src-tauri/Cargo.toml → 3.2.0\n- src-tauri/tauri.conf.json → 3.2.0\n- src-tauri/Cargo.lock → 3.2.0 2025-09-11 09:33:49 +08:00
Jason
af8b9289fe feat(updater): 优化更新体验与 UI
- ui: UpdateBadge 使用 Tailwind 内置过渡,支持点击打开设置,保留图标动画

- updater: 新增 UpdateContext 首启延迟检查,忽略版本键名命名空间化(含旧键迁移),并发保护

- settings: 去除版本硬编码回退;检测到更新时复用 updateHandle 下载并安装,并新增常显“更新日志”入口

- a11y: 更新徽标支持键盘触达(Enter/Space)

- refactor: 移除未使用的 runUpdateFlow 导出

- chore: 类型检查通过,整体行为与权限边界未改变
2025-09-10 19:46:38 +08:00
Jason
bf7e13d4e9 chore: update repository URLs from jasonyoung to farion1231
- Update GitHub repository URLs in README.md badges
- Update repository URL in Cargo.toml metadata
- Update updater endpoint URL in tauri.conf.json for auto-updates
2025-09-10 15:26:21 +08:00
Jason
b015af173a feat(updater): refactor release workflow for proper Tauri updater support
- Switch macOS packaging from .app to .tar.gz updater artifacts
- Add automatic latest.json generation workflow
- Include signature file handling for all platforms
- Remove manual latest.json in favor of automated generation
- Improve updater artifact detection and path handling
2025-09-10 09:20:14 +08:00
Jason
4a4779a7e7 enhance(ci): improve release workflow with concurrency control and updated dependencies
- Add concurrency control to prevent multiple simultaneous releases
- Upgrade softprops/action-gh-release from v1 to v2 for better reliability
- Add docs/ directory to .gitignore to exclude documentation build artifacts
2025-09-10 08:10:01 +08:00
Jason
92a39a1a34 enhance(ci): implement cross-platform base64 encoding for private key
- Add support for multiple base64 encoders (base64, openssl, node.js)
- Encode complete private key file content as single-line base64
- Implement fallback chain for maximum platform compatibility
- Simplify environment variable handling with encoded content
2025-09-10 07:05:02 +08:00
Jason
ea56794a37 refactor(ci): use complete private key content with heredoc syntax
- Switch to passing complete two-line private key content instead of base64 only
- Use GitHub Actions heredoc syntax (<<'EOF') for proper multiline handling
- Preserve original minisign private key format with comment and base64 lines
- Improve compatibility with Tauri CLI's private key parsing
2025-09-09 22:52:34 +08:00
Jason
fd4864115c enhance(ci): improve Tauri signing key handling with direct base64 content
- Switch from file path to direct base64 content for better compatibility
- Extract private key base64 from second line for stable parsing
- Enhance error handling for key extraction process
- Improve cross-version compatibility for different Tauri CLI versions
2025-09-09 22:40:26 +08:00
Jason
74d4b42936 refactor(ci): standardize Tauri signing key variable and update pubkey
- Update CI workflow to use TAURI_SIGNING_PRIVATE_KEY consistently
- Simplify key handling logic and add password support
- Update pubkey in tauri.conf.json to match new signing key
2025-09-09 22:26:37 +08:00
Jason
a95f974787 refactor(ci): simplify Tauri signing key handling to use file path only
- Remove redundant environment variables for key content export
- Focus on providing proper key file path to Tauri CLI to avoid decoding ambiguity
- Maintain support for all three key formats (two-line, base64-wrapped, single base64)
- Improve reliability by standardizing on file-based key passing approach
2025-09-09 21:50:37 +08:00
Jason
29057c1fe0 refactor: optimize key handling to export base64 line only with multiple env var formats 2025-09-09 21:38:24 +08:00
Jason
63285acba8 enhance: improve private key handling with better base64 compatibility and single-line support 2025-09-09 21:21:30 +08:00
Jason
f99b614888 enhance: improve updater workflow with latest.json collection and prerelease flag 2025-09-09 21:14:44 +08:00
Jason
41f3aa7d76 fix: correct Tauri signing key environment variable usage 2025-09-09 16:38:59 +08:00
Jason
f23898a5c9 fix: correct GitHub Secret name to TAURI_PRIVATE_KEY 2025-09-09 16:29:42 +08:00
Jason
664391568c fix: add debug output for GitHub Secret issues 2025-09-09 16:28:12 +08:00
Jason
081aabe10f fix: add Tauri signing key decoding in GitHub Actions 2025-09-09 16:25:49 +08:00
Jason
036069a5c1 update pub key again 2025-09-09 16:06:14 +08:00
Jason
9b7091ba88 update pub key 2025-09-09 15:36:04 +08:00
Jason
2357d976dc chore: bump version to 3.1.2 for auto-updater testing 2025-09-09 15:21:49 +08:00
Jason Young
df43692bb9 Merge pull request #16 from farion1231/feat/auto-update
feat: Add auto-updater support with GitHub releases
2025-09-09 15:16:19 +08:00
38 changed files with 1384 additions and 405 deletions

View File

@@ -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
View File

@@ -9,3 +9,4 @@ release/
.npmrc .npmrc
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
docs/

View File

@@ -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

View File

@@ -1,26 +1,28 @@
# Claude Code & Codex 供应商切换器 # Claude Code & Codex 供应商切换器
[![Version](https://img.shields.io/badge/version-3.0.0-blue.svg)](https://github.com/jasonyoung/cc-switch/releases) [![Version](https://img.shields.io/badge/version-3.2.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/jasonyoung/cc-switch/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202.0-orange.svg)](https://tauri.app/) [![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](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/)** - 极速的前端构建工具

View File

@@ -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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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);
} }

View File

@@ -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,
} }
} }
} }

View File

@@ -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"
] ]
} }
} }

View File

@@ -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>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

View 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

View File

@@ -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>

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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} />}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 Keysk-xxx-api-key-here 💡 API Key
</p> </p>
</div> </div>
)} )}

View File

@@ -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>
)} )}

View File

@@ -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="删除供应商"
> >

View File

@@ -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>

View 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>
);
}

View File

@@ -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.tomlTOML 字符串) config: string; // 将写入 ~/.codex/config.tomlTOML 字符串)
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",

View File

@@ -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",
}, },
]; ];

View 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;
}

View File

@@ -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;
}); });
}; };

View File

@@ -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;
} }
/* 焦点样式 */ /* 焦点样式 */

View File

@@ -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(" ");
} }

View File

@@ -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 };
} }
}, },

View File

@@ -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" };
}

View File

@@ -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>,
); );

View File

@@ -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.jsonCodex 为 { auth, config } settingsConfig: Record<string, any>; // 应用配置对象Claude 为 settings.jsonCodex 为 { 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;
} }