From 5bce6d602079b9a316a4f16454cfd26f421c6624 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Fri, 19 Dec 2025 20:40:11 +0800 Subject: [PATCH] Fix/about section UI (#419) * fix(ui): improve AboutSection styling and version detection - Add framer-motion animations for smooth page transitions - Unify button sizes and add icons for consistency - Add gradient backgrounds and hover effects to cards - Add notInstalled i18n translations (zh/en/ja) - Fix version detection when stdout/stderr is empty * fix(proxy): persist per-app takeover state across app restarts - Fix proxy toggle color to reflect current app's takeover state only - Restore proxy service on startup if Live config is still in takeover state - Preserve per-app backup records instead of clearing all on restart - Only recover Live config when proxy service fails to start --- src-tauri/src/commands/misc.rs | 20 +- src-tauri/src/lib.rs | 91 ++++----- src/App.tsx | 14 +- src/components/settings/AboutSection.tsx | 232 ++++++++++++++++------- src/i18n/locales/en.json | 11 +- src/i18n/locales/ja.json | 11 +- src/i18n/locales/zh.json | 11 +- 7 files changed, 270 insertions(+), 120 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 50f4f7f0f..612cba56f 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -155,11 +155,17 @@ fn try_get_version(tool: &str) -> (Option, Option) { match output { Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); if out.status.success() { - let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); - (Some(extract_version(&raw)), None) + let raw = if stdout.is_empty() { &stderr } else { &stdout }; + if raw.is_empty() { + (None, Some("未安装或无法执行".to_string())) + } else { + (Some(extract_version(raw)), None) + } } else { - let err = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let err = if stderr.is_empty() { stdout } else { stderr }; ( None, Some(if err.is_empty() { @@ -239,9 +245,13 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { .output(); if let Ok(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); if out.status.success() { - let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); - return (Some(extract_version(&raw)), None); + let raw = if stdout.is_empty() { &stderr } else { &stdout }; + if !raw.is_empty() { + return (Some(extract_version(raw)), None); + } } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a4f3a758..b393e4461 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -529,61 +529,64 @@ pub fn run() { tauri::async_runtime::spawn(async move { let state = app_handle.state::(); - // 1. 检测异常退出并恢复 Live 配置 - let is_proxy_running = state.proxy_service.is_running().await; - if !is_proxy_running { - let takeover_flag = match state.db.is_live_takeover_active().await { - Ok(active) => active, - Err(e) => { - log::error!("检查接管状态失败: {e}"); - false - } - }; + // 检查是否有 Live 备份(表示上次有接管状态) + let has_backups = match state.db.has_any_live_backup().await { + Ok(v) => v, + Err(e) => { + log::error!("检查 Live 备份失败: {e}"); + false + } + }; - let has_backups = match state.db.has_any_live_backup().await { - Ok(v) => v, - Err(e) => { - log::error!("检查 Live 备份失败: {e}"); - false - } - }; + // 获取代理配置 + let proxy_config = match state.db.get_proxy_config().await { + Ok(config) => Some(config), + Err(e) => { + log::error!("启动时获取代理配置失败: {e}"); + None + } + }; - // 兜底检测:旧版本/极端窗口期可能出现“标志未写入,但 Live 已被写成占位符”的残留状态。 - // 只有在存在备份时才检查占位符,避免误判覆盖用户正常配置。 - let live_taken_over = - has_backups && state.proxy_service.detect_takeover_in_live_configs(); + if has_backups { + // 有备份说明上次退出时有接管状态 + // 检查 Live 配置是否仍处于被接管状态(包含占位符) + let live_taken_over = state.proxy_service.detect_takeover_in_live_configs(); - if takeover_flag || live_taken_over { - log::warn!("检测到上次异常退出或残留接管状态,正在恢复 Live 配置..."); - if let Err(e) = state.proxy_service.recover_from_crash().await { - log::error!("恢复 Live 配置失败: {e}"); - } else { - log::info!("Live 配置已从异常退出中恢复"); + if live_taken_over { + // Live 配置仍是接管状态,尝试重新启动代理服务以恢复接管 + log::info!("检测到上次接管状态,正在重新启动代理服务..."); + match state.proxy_service.start(false).await { + Ok(info) => { + log::info!("代理服务器已恢复启动: {}:{}", info.address, info.port); + } + Err(e) => { + // 启动失败,恢复 Live 配置 + log::error!("恢复代理服务失败: {e},正在恢复 Live 配置..."); + if let Err(e) = state.proxy_service.recover_from_crash().await { + log::error!("恢复 Live 配置失败: {e}"); + } else { + log::info!("Live 配置已恢复"); + } + } } - } else if has_backups { - // 备份残留但 Live 未处于接管状态:清理敏感备份,避免长期存储 Token + } else { + // Live 配置已经是正常状态,清理残留备份 + log::info!("Live 配置已是正常状态,清理残留备份..."); if let Err(e) = state.db.delete_all_live_backups().await { log::warn!("清理残留 Live 备份失败: {e}"); } } - } - - // 2. 自动启动代理服务器(如果配置为启用) - match state.db.get_proxy_config().await { - Ok(config) => { - if config.enabled { - log::info!("代理服务配置为启用,正在启动..."); - match state.proxy_service.start(true).await { - Ok(info) => log::info!( - "代理服务器自动启动成功: {}:{}", - info.address, - info.port - ), - Err(e) => log::error!("代理服务器自动启动失败: {e}"), + } else if let Some(config) = proxy_config { + // 没有备份,检查是否需要自动启动代理服务器(总开关) + if config.enabled { + log::info!("代理服务配置为启用,正在启动..."); + match state.proxy_service.start(true).await { + Ok(info) => { + log::info!("代理服务器自动启动成功: {}:{}", info.address, info.port) } + Err(e) => log::error!("代理服务器自动启动失败: {e}"), } } - Err(e) => log::error!("启动时获取代理配置失败: {e}"), } }); diff --git a/src/App.tsx b/src/App.tsx index cb418c438..32fbf8d51 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,7 +65,15 @@ function App() { "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; // 获取代理服务状态 - const { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus(); + const { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus(); + // 当前应用的代理是否开启 + const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false; + // 任意应用的代理是否开启 + const isTakeoverActive = + takeoverStatus?.claude || + takeoverStatus?.codex || + takeoverStatus?.gemini || + false; // 获取供应商列表,当代理服务运行时自动刷新 const { data, isLoading, refetch } = useProvidersQuery(activeApp, { @@ -324,7 +332,7 @@ function App() { appId={activeApp} isLoading={isLoading} isProxyRunning={isProxyRunning} - isProxyTakeover={isProxyRunning && isTakeoverActive} + isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive} onSwitch={switchProvider} onEdit={setEditingProvider} onDelete={setConfirmDelete} @@ -421,7 +429,7 @@ function App() { rel="noreferrer" className={cn( "text-xl font-semibold transition-colors", - isProxyRunning && isTakeoverActive + isProxyRunning && isCurrentAppTakeoverActive ? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300" : "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300", )} diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index cf9465ec4..d40233f97 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { Download, + Copy, ExternalLink, Info, Loader2, @@ -8,6 +9,7 @@ import { Terminal, CheckCircle2, AlertCircle, + Sparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslation } from "react-i18next"; @@ -17,6 +19,7 @@ import { settingsApi } from "@/lib/api"; import { useUpdate } from "@/contexts/UpdateContext"; import { relaunchApp } from "@/lib/updater"; import { Badge } from "@/components/ui/badge"; +import { motion } from "framer-motion"; interface AboutSectionProps { isPortable: boolean; @@ -29,6 +32,10 @@ interface ToolVersion { error: string | null; } +const ONE_CLICK_INSTALL_COMMANDS = `npm i -g @anthropic-ai/claude-code@latest +npm i -g @openai/codex@latest +npm i -g @google/gemini-cli@latest`; + export function AboutSection({ isPortable }: AboutSectionProps) { // ... (use hooks as before) ... const { t } = useTranslation(); @@ -47,6 +54,18 @@ export function AboutSection({ isPortable }: AboutSectionProps) { isChecking, } = useUpdate(); + const loadToolVersions = useCallback(async () => { + setIsLoadingTools(true); + try { + const tools = await settingsApi.getToolVersions(); + setToolVersions(tools); + } catch (error) { + console.error("[AboutSection] Failed to load tool versions", error); + } finally { + setIsLoadingTools(false); + } + }, []); + useEffect(() => { let active = true; const load = async () => { @@ -150,10 +169,25 @@ export function AboutSection({ isPortable }: AboutSectionProps) { } }, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]); + const handleCopyInstallCommands = useCallback(async () => { + try { + await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS); + toast.success(t("settings.installCommandsCopied"), { closeButton: true }); + } catch (error) { + console.error("[AboutSection] Failed to copy install commands", error); + toast.error(t("settings.installCommandsCopyFailed")); + } + }, [t]); + const displayVersion = version ?? t("common.unknown"); return ( -
+

{t("common.about")}

@@ -161,12 +195,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {

-
+
-

CC Switch

- + +

+ CC Switch +

+
+
+ {t("common.version")} @@ -185,15 +229,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
-
+
{hasUpdate && updateInfo && ( -
+

{t("settings.updateAvailable", { version: updateInfo.availableVersion, @@ -239,60 +290,111 @@ export function AboutSection({ isPortable }: AboutSectionProps) { {updateInfo.notes}

)} -
+ )} -
+
-

- 本地环境检查 -

+
+

{t("settings.localEnvCheck")}

+ +
- {isLoadingTools - ? Array.from({ length: 3 }).map((_, i) => ( -
- )) - : toolVersions.map((tool) => ( -
-
-
- - - {tool.name} - -
- {tool.version ? ( -
- {tool.latest_version && - tool.version !== tool.latest_version && ( - - Update: {tool.latest_version} - - )} - -
- ) : ( - - )} + {["claude", "codex", "gemini"].map((toolName, index) => { + const tool = toolVersions.find((item) => item.name === toolName); + const displayName = tool?.name ?? toolName; + const title = tool?.version || tool?.error || t("common.unknown"); + + return ( + +
+
+ + + {displayName} +
-
-
- {tool.version ? tool.version : tool.error || "未安装"} + {isLoadingTools ? ( + + ) : tool?.version ? ( +
+ {tool.latest_version && + tool.version !== tool.latest_version && ( + + {tool.latest_version} + + )} +
-
+ ) : ( + + )}
- ))} +
+ {isLoadingTools + ? t("common.loading") + : tool?.version + ? tool.version + : tool?.error || t("common.notInstalled")} +
+ + ); + })}
-
+ + +

+ {t("settings.oneClickInstall")} +

+
+
+

+ {t("settings.oneClickInstallHint")} +

+ +
+
+            {ONE_CLICK_INSTALL_COMMANDS}
+          
+
+
+ ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2226b977b..911e446e9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -17,6 +17,7 @@ "about": "About", "version": "Version", "loading": "Loading...", + "notInstalled": "Not installed", "success": "Success", "error": "Error", "unknown": "Unknown", @@ -28,7 +29,10 @@ "formatError": "Format failed: {{error}}", "copy": "Copy", "view": "View", - "back": "Back" + "back": "Back", + "refresh": "Refresh", + "refreshing": "Refreshing...", + "notInstalled": "Not installed" }, "apiKeyInput": { "placeholder": "Enter API Key", @@ -205,6 +209,11 @@ "releaseNotes": "Release Notes", "viewReleaseNotes": "View release notes for this version", "viewCurrentReleaseNotes": "View current version release notes", + "oneClickInstall": "One-click Install", + "oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI", + "localEnvCheck": "Local environment check", + "installCommandsCopied": "Install commands copied", + "installCommandsCopyFailed": "Copy failed, please copy manually.", "importFailedError": "Import config failed: {{message}}", "exportFailedError": "Export config failed:", "restartRequired": "Restart Required", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 07f1a32ca..6f9ff89f0 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -17,6 +17,7 @@ "about": "バージョン情報", "version": "バージョン", "loading": "読み込み中...", + "notInstalled": "未インストール", "success": "成功", "error": "エラー", "unknown": "不明", @@ -28,7 +29,10 @@ "formatError": "整形に失敗しました: {{error}}", "copy": "コピー", "view": "表示", - "back": "戻る" + "back": "戻る", + "refresh": "更新", + "refreshing": "更新中...", + "notInstalled": "未インストール" }, "apiKeyInput": { "placeholder": "API Key を入力", @@ -205,6 +209,11 @@ "releaseNotes": "リリースノート", "viewReleaseNotes": "このバージョンのリリースノートを見る", "viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る", + "oneClickInstall": "ワンクリックインストール", + "oneClickInstallHint": "Claude Code / Codex / Gemini CLI をインストール", + "localEnvCheck": "ローカル環境チェック", + "installCommandsCopied": "インストールコマンドをコピーしました", + "installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。", "importFailedError": "設定のインポートに失敗しました: {{message}}", "exportFailedError": "設定のエクスポートに失敗しました:", "restartRequired": "再起動が必要です", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c63bbc74c..12eabee5a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -17,6 +17,7 @@ "about": "关于", "version": "版本", "loading": "加载中...", + "notInstalled": "未安装", "success": "成功", "error": "错误", "unknown": "未知", @@ -28,7 +29,10 @@ "formatError": "格式化失败:{{error}}", "copy": "复制", "view": "查看", - "back": "返回" + "back": "返回", + "refresh": "刷新", + "refreshing": "刷新中...", + "notInstalled": "未安装" }, "apiKeyInput": { "placeholder": "请输入API Key", @@ -205,6 +209,11 @@ "releaseNotes": "更新日志", "viewReleaseNotes": "查看该版本更新日志", "viewCurrentReleaseNotes": "查看当前版本更新日志", + "oneClickInstall": "一键安装", + "oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI", + "localEnvCheck": "本地环境检查", + "installCommandsCopied": "安装命令已复制", + "installCommandsCopyFailed": "复制失败,请手动复制。", "importFailedError": "导入配置失败:{{message}}", "exportFailedError": "导出配置失败:", "restartRequired": "需要重启应用",