mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
cce6ae86a5
Add horizontal padding to the grid container to accommodate the scale transform effect when hovering over environment check cards.
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import {
|
|
Download,
|
|
Copy,
|
|
ExternalLink,
|
|
Info,
|
|
Loader2,
|
|
RefreshCw,
|
|
Terminal,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
Sparkles,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { getVersion } from "@tauri-apps/api/app";
|
|
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;
|
|
}
|
|
|
|
interface ToolVersion {
|
|
name: string;
|
|
version: string | null;
|
|
latest_version: string | null;
|
|
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();
|
|
const [version, setVersion] = useState<string | null>(null);
|
|
const [isLoadingVersion, setIsLoadingVersion] = useState(true);
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
const [toolVersions, setToolVersions] = useState<ToolVersion[]>([]);
|
|
const [isLoadingTools, setIsLoadingTools] = useState(true);
|
|
|
|
const {
|
|
hasUpdate,
|
|
updateInfo,
|
|
updateHandle,
|
|
checkUpdate,
|
|
resetDismiss,
|
|
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 () => {
|
|
try {
|
|
const [appVersion, tools] = await Promise.all([
|
|
getVersion(),
|
|
settingsApi.getToolVersions(),
|
|
]);
|
|
|
|
if (active) {
|
|
setVersion(appVersion);
|
|
setToolVersions(tools);
|
|
}
|
|
} catch (error) {
|
|
console.error("[AboutSection] Failed to load info", error);
|
|
if (active) {
|
|
setVersion(null);
|
|
}
|
|
} finally {
|
|
if (active) {
|
|
setIsLoadingVersion(false);
|
|
setIsLoadingTools(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
void load();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, []);
|
|
|
|
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
|
|
|
|
const handleOpenReleaseNotes = useCallback(async () => {
|
|
try {
|
|
const targetVersion = updateInfo?.availableVersion ?? version ?? "";
|
|
const displayVersion = targetVersion.startsWith("v")
|
|
? targetVersion
|
|
: targetVersion
|
|
? `v${targetVersion}`
|
|
: "";
|
|
|
|
if (!displayVersion) {
|
|
await settingsApi.openExternal(
|
|
"https://github.com/farion1231/cc-switch/releases",
|
|
);
|
|
return;
|
|
}
|
|
|
|
await settingsApi.openExternal(
|
|
`https://github.com/farion1231/cc-switch/releases/tag/${displayVersion}`,
|
|
);
|
|
} catch (error) {
|
|
console.error("[AboutSection] Failed to open release notes", error);
|
|
toast.error(t("settings.openReleaseNotesFailed"));
|
|
}
|
|
}, [t, updateInfo?.availableVersion, version]);
|
|
|
|
const handleCheckUpdate = useCallback(async () => {
|
|
if (hasUpdate && updateHandle) {
|
|
if (isPortable) {
|
|
try {
|
|
await settingsApi.checkUpdates();
|
|
} catch (error) {
|
|
console.error("[AboutSection] Portable update failed", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setIsDownloading(true);
|
|
try {
|
|
resetDismiss();
|
|
await updateHandle.downloadAndInstall();
|
|
await relaunchApp();
|
|
} catch (error) {
|
|
console.error("[AboutSection] Update failed", error);
|
|
toast.error(t("settings.updateFailed"));
|
|
try {
|
|
await settingsApi.checkUpdates();
|
|
} catch (fallbackError) {
|
|
console.error(
|
|
"[AboutSection] Failed to open fallback updater",
|
|
fallbackError,
|
|
);
|
|
}
|
|
} finally {
|
|
setIsDownloading(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const available = await checkUpdate();
|
|
if (!available) {
|
|
toast.success(t("settings.upToDate"), { closeButton: true });
|
|
}
|
|
} catch (error) {
|
|
console.error("[AboutSection] Check update failed", error);
|
|
toast.error(t("settings.checkUpdateFailed"));
|
|
}
|
|
}, [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 (
|
|
<motion.section
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="space-y-6"
|
|
>
|
|
<header className="space-y-1">
|
|
<h3 className="text-sm font-medium">{t("common.about")}</h3>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.aboutHint")}
|
|
</p>
|
|
</header>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.98 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.3, delay: 0.1 }}
|
|
className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-6 space-y-5 shadow-sm"
|
|
>
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5 text-primary" />
|
|
<h4 className="text-lg font-semibold text-foreground">
|
|
CC Switch
|
|
</h4>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="gap-1.5 bg-background/80">
|
|
<span className="text-muted-foreground">
|
|
{t("common.version")}
|
|
</span>
|
|
{isLoadingVersion ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<span className="font-medium">{`v${displayVersion}`}</span>
|
|
)}
|
|
</Badge>
|
|
{isPortable && (
|
|
<Badge variant="secondary" className="gap-1.5">
|
|
<Info className="h-3 w-3" />
|
|
{t("settings.portableMode")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleOpenReleaseNotes}
|
|
className="h-8 gap-1.5 text-xs"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
{t("settings.releaseNotes")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={handleCheckUpdate}
|
|
disabled={isChecking || isDownloading}
|
|
className="h-8 gap-1.5 text-xs"
|
|
>
|
|
{isDownloading ? (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t("settings.updating")}
|
|
</>
|
|
) : hasUpdate ? (
|
|
<>
|
|
<Download className="h-3.5 w-3.5" />
|
|
{t("settings.updateTo", {
|
|
version: updateInfo?.availableVersion ?? "",
|
|
})}
|
|
</>
|
|
) : isChecking ? (
|
|
<>
|
|
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
|
{t("settings.checking")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
{t("settings.checkForUpdates")}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{hasUpdate && updateInfo && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm"
|
|
>
|
|
<p className="font-medium text-primary mb-1">
|
|
{t("settings.updateAvailable", {
|
|
version: updateInfo.availableVersion,
|
|
})}
|
|
</p>
|
|
{updateInfo.notes && (
|
|
<p className="text-muted-foreground line-clamp-3 leading-relaxed">
|
|
{updateInfo.notes}
|
|
</p>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</motion.div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between px-1">
|
|
<h3 className="text-sm font-medium">{t("settings.localEnvCheck")}</h3>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 gap-1.5 text-xs"
|
|
onClick={loadToolVersions}
|
|
disabled={isLoadingTools}
|
|
>
|
|
<RefreshCw
|
|
className={
|
|
isLoadingTools ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"
|
|
}
|
|
/>
|
|
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
|
</Button>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-3 px-1">
|
|
{["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 (
|
|
<motion.div
|
|
key={toolName}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3, delay: 0.15 + index * 0.05 }}
|
|
whileHover={{ scale: 1.02 }}
|
|
className="flex flex-col gap-2 rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 shadow-sm transition-colors hover:border-primary/30"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Terminal className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium capitalize">
|
|
{displayName}
|
|
</span>
|
|
</div>
|
|
{isLoadingTools ? (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
) : tool?.version ? (
|
|
<div className="flex items-center gap-1.5">
|
|
{tool.latest_version &&
|
|
tool.version !== tool.latest_version && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
|
|
{tool.latest_version}
|
|
</span>
|
|
)}
|
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
</div>
|
|
) : (
|
|
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
|
)}
|
|
</div>
|
|
<div
|
|
className="text-xs font-mono text-muted-foreground truncate"
|
|
title={title}
|
|
>
|
|
{isLoadingTools
|
|
? t("common.loading")
|
|
: tool?.version
|
|
? tool.version
|
|
: tool?.error || t("common.notInstalled")}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3, delay: 0.3 }}
|
|
className="space-y-3"
|
|
>
|
|
<h3 className="text-sm font-medium px-1">
|
|
{t("settings.oneClickInstall")}
|
|
</h3>
|
|
<div className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("settings.oneClickInstallHint")}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleCopyInstallCommands}
|
|
className="h-7 gap-1.5 text-xs"
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
{t("common.copy")}
|
|
</Button>
|
|
</div>
|
|
<pre className="text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto">
|
|
{ONE_CLICK_INSTALL_COMMANDS}
|
|
</pre>
|
|
</div>
|
|
</motion.div>
|
|
</motion.section>
|
|
);
|
|
}
|