feat(settings): enhance settings page with accordion layout and tool versions

Major settings page improvements:

AboutSection:
- Add local tool version detection (Claude, Codex, Gemini)
- Display installed vs latest version comparison with visual indicators
- Show update availability badges and environment check cards

SettingsPage:
- Reorganize advanced settings into collapsible accordion sections
- Add proxy control panel with inline status toggle
- Integrate auto-failover configuration with accordion UI
- Add database and cost calculation config sections

DirectorySettings & WindowSettings:
- Minor styling adjustments for consistency

settings.ts API:
- Add getToolVersions() wrapper for new backend command
This commit is contained in:
YoVinchen
2025-12-08 17:05:48 +08:00
parent cfbbc9485f
commit d83312ad3f
5 changed files with 411 additions and 106 deletions
+115 -29
View File
@@ -1,5 +1,14 @@
import { useCallback, useEffect, useState } from "react";
import { Download, ExternalLink, Info, Loader2, RefreshCw } from "lucide-react";
import {
Download,
ExternalLink,
Info,
Loader2,
RefreshCw,
Terminal,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -7,16 +16,27 @@ 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";
interface AboutSectionProps {
isPortable: boolean;
}
interface ToolVersion {
name: string;
version: string | null;
latest_version: string | null;
error: string | null;
}
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,
@@ -31,18 +51,24 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
let active = true;
const load = async () => {
try {
const loaded = await getVersion();
const [appVersion, tools] = await Promise.all([
getVersion(),
settingsApi.getToolVersions(),
]);
if (active) {
setVersion(loaded);
setVersion(appVersion);
setToolVersions(tools);
}
} catch (error) {
console.error("[AboutSection] Failed to get version", error);
console.error("[AboutSection] Failed to load info", error);
if (active) {
setVersion(null);
}
} finally {
if (active) {
setIsLoadingVersion(false);
setIsLoadingTools(false);
}
}
};
@@ -53,6 +79,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
};
}, []);
// ... (handlers like handleOpenReleaseNotes, handleCheckUpdate) ...
const handleOpenReleaseNotes = useCallback(async () => {
try {
const targetVersion = updateInfo?.availableVersion ?? version ?? "";
@@ -125,7 +153,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const displayVersion = version ?? t("common.unknown");
return (
<section className="space-y-4">
<section 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">
@@ -133,24 +161,28 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</p>
</header>
<div className="space-y-4 rounded-lg border border-border-default p-4">
<div className="rounded-xl border border-border bg-card/50 p-6 space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">CC Switch</p>
<p className="text-xs text-muted-foreground">
{t("common.version")}{" "}
{isLoadingVersion ? (
<Loader2 className="inline h-3 w-3 animate-spin" />
) : (
`v${displayVersion}`
<div className="space-y-2">
<h4 className="text-lg font-semibold text-foreground">CC Switch</h4>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1.5 bg-background">
<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>
)}
</p>
{isPortable ? (
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Info className="h-3 w-3" />
{t("settings.portableMode")}
</p>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -159,6 +191,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
variant="outline"
size="sm"
onClick={handleOpenReleaseNotes}
className="h-9"
>
<ExternalLink className="mr-2 h-4 w-4" />
{t("settings.releaseNotes")}
@@ -168,7 +201,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
size="sm"
onClick={handleCheckUpdate}
disabled={isChecking || isDownloading}
className="min-w-[140px]"
className="min-w-[140px] h-9"
>
{isDownloading ? (
<span className="inline-flex items-center gap-2">
@@ -194,18 +227,71 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</div>
</div>
{hasUpdate && updateInfo ? (
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<p>
{hasUpdate && updateInfo && (
<div 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="mt-1 line-clamp-3">{updateInfo.notes}</p>
) : null}
{updateInfo.notes && (
<p className="text-muted-foreground line-clamp-3 leading-relaxed">
{updateInfo.notes}
</p>
)}
</div>
) : null}
)}
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground px-1">
</h4>
<div className="grid gap-3 sm:grid-cols-3">
{isLoadingTools
? Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-20 rounded-xl border border-border bg-card/50 animate-pulse"
/>
))
: toolVersions.map((tool) => (
<div
key={tool.name}
className="flex flex-col gap-2 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50"
>
<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">
{tool.name}
</span>
</div>
{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">
Update: {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="flex flex-col gap-0.5">
<div
className="text-xs font-mono truncate"
title={tool.version || tool.error || "Unknown"}
>
{tool.version ? tool.version : tool.error || "未安装"}
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
@@ -50,7 +50,7 @@ export function DirectorySettings({
<Input
value={appConfigDir ?? resolvedDirs.appConfig ?? ""}
placeholder={t("settings.browsePlaceholderApp")}
className="font-mono text-xs"
className="text-xs"
onChange={(event) => onAppConfigChange(event.target.value)}
/>
<Button
@@ -161,7 +161,7 @@ function DirectoryInput({
<Input
value={displayValue}
placeholder={placeholder}
className="font-mono text-xs"
className="text-xs"
onChange={(event) => onChange(event.target.value)}
/>
<Button
+239 -44
View File
@@ -1,5 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save } from "lucide-react";
import {
Loader2,
Save,
FolderSearch,
Activity,
Coins,
Database,
Server,
} from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
@@ -8,6 +16,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
@@ -26,6 +40,9 @@ import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useProxyStatus } from "@/hooks/useProxyStatus";
interface SettingsDialogProps {
open: boolean;
@@ -155,6 +172,26 @@ export function SettingsPage({
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
const {
isRunning,
start: startProxy,
stop: stopProxy,
isPending: isProxyPending,
} = useProxyStatus();
const [failoverEnabled, setFailoverEnabled] = useState(true);
const handleToggleProxy = async (checked: boolean) => {
try {
if (!checked) {
await stopProxy();
} else {
await startProxy();
}
} catch (error) {
console.error("Toggle proxy failed:", error);
}
};
return (
<div className="mx-auto max-w-[56rem] flex flex-col h-[calc(100vh-8rem)] overflow-hidden px-6">
{isBusy ? (
@@ -199,64 +236,225 @@ export function SettingsPage({
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
{settings ? (
<>
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
<div className="space-y-4">
<Accordion
type="multiple"
defaultValue={[]}
className="w-full space-y-4"
>
<AccordionItem
value="directory"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<FolderSearch className="h-5 w-5 text-primary" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
ClaudeCodex Gemini
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
</AccordionContent>
</AccordionItem>
{/* 代理服务面板 */}
<ProxyPanel />
<AccordionItem
value="proxy"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex flex-1 items-center justify-between pr-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-green-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
</p>
</div>
</div>
<div
className="flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<Badge
variant={isRunning ? "default" : "secondary"}
className="gap-1.5 h-6"
>
<Activity
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
/>
{isRunning ? "运行中" : "已停止"}
</Badge>
<Switch
checked={isRunning}
onCheckedChange={handleToggleProxy}
disabled={isProxyPending}
/>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-0 border-t border-border/50">
<ProxyPanel />
</AccordionContent>
</AccordionItem>
{/* 模型定价配置 */}
<PricingConfigPanel />
<AccordionItem
value="test"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-indigo-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
使
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ModelTestConfigPanel />
</AccordionContent>
</AccordionItem>
{/* 模型测试配置 */}
<ModelTestConfigPanel />
<AccordionItem
value="failover"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex flex-1 items-center justify-between pr-4">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-orange-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
</p>
</div>
</div>
<div
className="flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
{/* Removed status text as requested */}
<Switch
checked={failoverEnabled}
onCheckedChange={setFailoverEnabled}
/>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<AutoFailoverConfigPanel
enabled={failoverEnabled}
onEnabledChange={setFailoverEnabled}
/>
</AccordionContent>
</AccordionItem>
{/* 自动故障转移配置 */}
<AutoFailoverConfigPanel />
<AccordionItem
value="pricing"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Coins className="h-5 w-5 text-yellow-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
Token
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
errorMessage={errorMessage}
backupId={backupId}
isImporting={isImporting}
onSelectFile={selectImportFile}
onImport={importConfig}
onExport={exportConfig}
onClear={clearSelection}
/>
<div className="pt-6 border-t border-gray-200 dark:border-white/10">
<AccordionItem
value="data"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Database className="h-5 w-5 text-blue-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
</h3>
<p className="text-sm text-muted-foreground font-normal">
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
errorMessage={errorMessage}
backupId={backupId}
isImporting={isImporting}
onSelectFile={selectImportFile}
onImport={importConfig}
onExport={exportConfig}
onClear={clearSelection}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="pt-4">
<Button
onClick={handleSave}
className="w-full"
className="w-full h-12 text-base font-medium"
disabled={isSaving}
>
{isSaving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="h-5 w-5 animate-spin" />
{t("settings.saving")}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
<Save className="mr-2 h-5 w-5" />
{t("common.save")}
</>
)}
</Button>
</div>
</>
</div>
) : null}
</TabsContent>
@@ -275,10 +473,7 @@ export function SettingsPage({
open={showRestartPrompt}
onOpenChange={(open) => !open && handleRestartLater()}
>
<DialogContent
zIndex="alert"
className="max-w-md glass border-white/10"
>
<DialogContent zIndex="alert" className="max-w-md glass border-border">
<DialogHeader>
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
</DialogHeader>
@@ -291,7 +486,7 @@ export function SettingsPage({
<Button
variant="ghost"
onClick={handleRestartLater}
className="hover:bg-white/5"
className="hover:bg-muted/50"
>
{t("settings.restartLater")}
</Button>
+44 -31
View File
@@ -1,6 +1,7 @@
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power } from "lucide-react";
interface WindowSettingsProps {
settings: SettingsFormState;
@@ -12,40 +13,46 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
return (
<section className="space-y-4">
<header className="space-y-1">
<div className="flex items-center gap-2 pb-2 border-b border-border/40">
<AppWindow className="h-4 w-4 text-primary" />
<h3 className="text-sm font-medium">{t("settings.windowBehavior")}</h3>
<p className="text-xs text-muted-foreground">
{t("settings.windowBehaviorHint")}
</p>
</header>
</div>
<ToggleRow
title={t("settings.launchOnStartup")}
description={t("settings.launchOnStartupDescription")}
checked={!!settings.launchOnStartup}
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
/>
<div className="space-y-3">
<ToggleRow
icon={<Power className="h-4 w-4 text-orange-500" />}
title={t("settings.launchOnStartup")}
description={t("settings.launchOnStartupDescription")}
checked={!!settings.launchOnStartup}
onCheckedChange={(value) => onChange({ launchOnStartup: value })}
/>
<ToggleRow
title={t("settings.minimizeToTray")}
description={t("settings.minimizeToTrayDescription")}
checked={settings.minimizeToTrayOnClose}
onCheckedChange={(value) => onChange({ minimizeToTrayOnClose: value })}
/>
<ToggleRow
icon={<AppWindow className="h-4 w-4 text-blue-500" />}
title={t("settings.minimizeToTray")}
description={t("settings.minimizeToTrayDescription")}
checked={settings.minimizeToTrayOnClose}
onCheckedChange={(value) =>
onChange({ minimizeToTrayOnClose: value })
}
/>
<ToggleRow
title={t("settings.enableClaudePluginIntegration")}
description={t("settings.enableClaudePluginIntegrationDescription")}
checked={!!settings.enableClaudePluginIntegration}
onCheckedChange={(value) =>
onChange({ enableClaudePluginIntegration: value })
}
/>
<ToggleRow
icon={<MonitorUp className="h-4 w-4 text-purple-500" />}
title={t("settings.enableClaudePluginIntegration")}
description={t("settings.enableClaudePluginIntegrationDescription")}
checked={!!settings.enableClaudePluginIntegration}
onCheckedChange={(value) =>
onChange({ enableClaudePluginIntegration: value })
}
/>
</div>
</section>
);
}
interface ToggleRowProps {
icon: React.ReactNode;
title: string;
description?: string;
checked: boolean;
@@ -53,18 +60,24 @@ interface ToggleRowProps {
}
function ToggleRow({
icon,
title,
description,
checked,
onCheckedChange,
}: ToggleRowProps) {
return (
<div className="flex items-start justify-between gap-4 rounded-lg border border-border-default p-4">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{title}</p>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
<div className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-background ring-1 ring-border">
{icon}
</div>
<div className="space-y-1">
<p className="text-sm font-medium leading-none">{title}</p>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
</div>
<Switch
checked={checked}
+11
View File
@@ -115,4 +115,15 @@ export const settingsApi = {
async getAutoLaunchStatus(): Promise<boolean> {
return await invoke("get_auto_launch_status");
},
async getToolVersions(): Promise<
Array<{
name: string;
version: string | null;
latest_version: string | null;
error: string | null;
}>
> {
return await invoke("get_tool_versions");
},
};