feat: complete stage 3 settings refactor

This commit is contained in:
Jason
2025-10-16 11:40:02 +08:00
parent b88eb88608
commit 2b45af118f
17 changed files with 1828 additions and 1121 deletions

View File

@@ -67,7 +67,7 @@ src/
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
- ✅ ProviderList.tsx - 供应商列表
- ✅ LanguageSwitcher.tsx - 语言切换器
- 🔄 SettingsModal.tsx - 设置弹窗(部分完成)
- ✅ settings/SettingsDialog.tsx - 设置对话框
## 注意事项

View File

@@ -415,7 +415,7 @@ pnpm add class-variance-authority clsx tailwind-merge tailwindcss-animate
| App.tsx | 412 | ~100 | -76% |
| tauri-api.ts | 712 | ~50 | -93% |
| ProviderForm.tsx | 271 | ~150 | -45% |
| SettingsModal.tsx | 643 | ~400 (拆分) | -38% |
| settings 模块 | 1046 | ~470 (拆分) | -55% |
| **总计** | 2038 | ~700 | **-66%** |
---

View File

@@ -872,7 +872,7 @@ export function useDragSort(
| **阶段 0** | 准备环境 | 1 天 | 依赖安装、配置完成 |
| **阶段 1** | 搭建基础设施(✅ 已完成) | 2-3 天 | API 层、Query Hooks 完成 |
| **阶段 2** | 重构核心功能(✅ 已完成) | 3-4 天 | App.tsx、ProviderList 完成 |
| **阶段 3** | 重构设置和辅助 | 2-3 天 | SettingsDialog、通知系统完成 |
| **阶段 3** | 重构设置和辅助(✅ 已完成) | 2-3 天 | SettingsDialog、通知系统完成 |
| **阶段 4** | 清理和优化 | 1-2 天 | 旧代码删除、优化完成 |
| **阶段 5** | 测试和修复 | 2-3 天 | 测试通过、Bug 修复 |
| **总计** | - | **11-16 天** | v4.0.0 发布 |
@@ -1488,15 +1488,15 @@ export const useTheme = () => {
### 阶段 3: 设置和辅助功能 (2-3天)
**目标**: 重构 SettingsModal 和通知系统
**目标**: 重构设置模块和通知系统
#### 任务清单
- [ ] 拆分 SettingsDialog (7个组件)
- [ ] 创建 `useSettings` Hook
- [ ] 创建 `useImportExport` Hook
- [ ] 替换通知系统为 Sonner
- [ ] 重构 ConfirmDialog
- [x] 拆分 SettingsDialog (7个组件)
- [x] 创建 `useSettings` Hook
- [x] 创建 `useImportExport` Hook
- [x] 替换通知系统为 Sonner
- [x] 重构 ConfirmDialog
#### 详细步骤

View File

@@ -19,7 +19,7 @@ import { ProviderList } from "@/components/providers/ProviderList";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import SettingsModal from "@/components/SettingsModal";
import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { UpdateBadge } from "@/components/UpdateBadge";
import UsageScriptModal from "@/components/UsageScriptModal";
import McpPanel from "@/components/mcp/McpPanel";
@@ -325,13 +325,11 @@ function App() {
onCancel={() => setConfirmDelete(null)}
/>
{isSettingsOpen && (
<SettingsModal
onClose={() => setIsSettingsOpen(false)}
onImportSuccess={handleImportSuccess}
onNotify={handleNotify}
/>
)}
<SettingsDialog
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
onImportSuccess={handleImportSuccess}
/>
{isMcpOpen && (
<McpPanel

View File

@@ -1,7 +1,14 @@
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X } from "lucide-react";
import { isLinux } from "../lib/platform";
interface ConfirmDialogProps {
isOpen: boolean;
@@ -13,7 +20,7 @@ interface ConfirmDialogProps {
onCancel: () => void;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
export function ConfirmDialog({
isOpen,
title,
message,
@@ -21,63 +28,37 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
cancelText,
onConfirm,
onCancel,
}) => {
}: ConfirmDialogProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50${isLinux() ? "" : " backdrop-blur-sm"}`}
onClick={onCancel}
/>
{/* Dialog */}
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-md w-full mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/10 rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{title}
</h3>
</div>
<button
onClick={onCancel}
className="p-1 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"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-6">
<p className="text-gray-500 dark:text-gray-400 leading-relaxed">
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onCancel();
}
}}
>
<DialogContent className="max-w-sm">
<DialogHeader className="space-y-3">
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
<AlertTriangle className="h-5 w-5 text-destructive" />
{title}
</DialogTitle>
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
{message}
</p>
</div>
{/* Actions */}
<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-900">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
autoFocus
>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button variant="outline" onClick={onCancel}>
{cancelText || t("common.cancel")}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium bg-red-500 text-white hover:bg-red-500/90 rounded-md transition-colors"
>
</Button>
<Button variant="destructive" onClick={onConfirm}>
{confirmText || t("common.confirm")}
</button>
</div>
</div>
</div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,236 @@
import { useCallback, useEffect, useState } from "react";
import { Download, ExternalLink, Info, Loader2, RefreshCw } 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";
interface AboutSectionProps {
isPortable: boolean;
}
export function AboutSection({ isPortable }: AboutSectionProps) {
const { t } = useTranslation();
const [version, setVersion] = useState<string | null>(null);
const [isLoadingVersion, setIsLoadingVersion] = useState(true);
const [isDownloading, setIsDownloading] = useState(false);
const {
hasUpdate,
updateInfo,
updateHandle,
checkUpdate,
resetDismiss,
isChecking,
} = useUpdate();
useEffect(() => {
let active = true;
const load = async () => {
try {
const loaded = await getVersion();
if (active) {
setVersion(loaded);
}
} catch (error) {
console.error("[AboutSection] Failed to get version", error);
if (active) {
setVersion(null);
}
} finally {
if (active) {
setIsLoadingVersion(false);
}
}
};
void load();
return () => {
active = false;
};
}, []);
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", {
defaultValue: "打开更新日志失败",
}),
);
}
}, [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", {
defaultValue: "更新安装失败,已尝试打开下载页面。",
}),
);
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", { defaultValue: "已是最新版本" }),
);
}
} catch (error) {
console.error("[AboutSection] Check update failed", error);
toast.error(
t("settings.checkUpdateFailed", {
defaultValue: "检查更新失败,请稍后重试。",
}),
);
}
}, [
checkUpdate,
hasUpdate,
isPortable,
resetDismiss,
t,
updateHandle,
]);
const displayVersion =
version ?? t("common.unknown", { defaultValue: "未知" });
return (
<section className="space-y-4">
<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", {
defaultValue: "查看版本信息与更新状态。",
})}
</p>
</header>
<div className="space-y-4 rounded-lg border border-border p-4">
<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}`
)}
</p>
{isPortable ? (
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Info className="h-3 w-3" />
{t("settings.portableMode", {
defaultValue: "当前为便携版,更新需手动下载。",
})}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleOpenReleaseNotes}
>
<ExternalLink className="mr-2 h-4 w-4" />
{t("settings.releaseNotes")}
</Button>
<Button
type="button"
size="sm"
onClick={handleCheckUpdate}
disabled={isChecking || isDownloading}
>
{isDownloading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.updating", { defaultValue: "安装更新..." })}
</span>
) : hasUpdate ? (
<span className="inline-flex items-center gap-2">
<Download className="h-4 w-4" />
{t("settings.updateTo", {
defaultValue: "更新到 {{version}}",
version: updateInfo?.availableVersion ?? "",
})}
</span>
) : isChecking ? (
<span className="inline-flex items-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin" />
{t("settings.checking", { defaultValue: "检查中..." })}
</span>
) : (
t("settings.checkForUpdates")
)}
</Button>
</div>
</div>
{hasUpdate && updateInfo ? (
<div className="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<p>
{t("settings.updateAvailable", {
defaultValue: "检测到新版本:{{version}}",
version: updateInfo.availableVersion,
})}
</p>
{updateInfo.notes ? (
<p className="mt-1 line-clamp-3">{updateInfo.notes}</p>
) : null}
</div>
) : null}
</div>
</section>
);
}

View File

@@ -0,0 +1,41 @@
import { FolderOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
interface ConfigPathDisplayProps {
path: string;
onOpen: () => Promise<void> | void;
}
export function ConfigPathDisplay({ path, onOpen }: ConfigPathDisplayProps) {
const { t } = useTranslation();
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">
{t("settings.configFileLocation")}
</h3>
<p className="text-xs text-muted-foreground">
{t("settings.configFileLocationHint", {
defaultValue: "显示当前使用的配置文件路径。",
})}
</p>
</header>
<div className="flex items-center gap-2">
<span className="flex-1 truncate rounded-md border border-dashed border-border bg-muted/40 px-3 py-2 text-xs font-mono">
{path || t("common.loading")}
</span>
<Button
type="button"
variant="outline"
size="icon"
onClick={onOpen}
title={t("settings.openFolder")}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</section>
);
}

View File

@@ -0,0 +1,147 @@
import { useMemo } from "react";
import { FolderSearch, Undo2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import type { AppType } from "@/lib/api";
import type { ResolvedDirectories } from "@/hooks/useSettings";
interface DirectorySettingsProps {
appConfigDir?: string;
resolvedDirs: ResolvedDirectories;
onAppConfigChange: (value?: string) => void;
onBrowseAppConfig: () => Promise<void>;
onResetAppConfig: () => Promise<void>;
claudeDir?: string;
codexDir?: string;
onDirectoryChange: (app: AppType, value?: string) => void;
onBrowseDirectory: (app: AppType) => Promise<void>;
onResetDirectory: (app: AppType) => Promise<void>;
}
export function DirectorySettings({
appConfigDir,
resolvedDirs,
onAppConfigChange,
onBrowseAppConfig,
onResetAppConfig,
claudeDir,
codexDir,
onDirectoryChange,
onBrowseDirectory,
onResetDirectory,
}: DirectorySettingsProps) {
const { t } = useTranslation();
return (
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-medium">
{t("settings.configDirectoryOverride")}
</h3>
<p className="text-xs text-muted-foreground">
{t("settings.configDirectoryDescription")}
</p>
</header>
<DirectoryInput
label={t("settings.appConfigDir")}
description={t("settings.appConfigDirDescription")}
value={appConfigDir}
resolvedValue={resolvedDirs.appConfig}
placeholder={t("settings.browsePlaceholderApp")}
onChange={onAppConfigChange}
onBrowse={onBrowseAppConfig}
onReset={onResetAppConfig}
/>
<DirectoryInput
label={t("settings.claudeConfigDir")}
description={t("settings.claudeConfigDirDescription", {
defaultValue: "覆盖 Claude 配置目录 (settings.json)。",
})}
value={claudeDir}
resolvedValue={resolvedDirs.claude}
placeholder={t("settings.browsePlaceholderClaude")}
onChange={(val) => onDirectoryChange("claude", val)}
onBrowse={() => onBrowseDirectory("claude")}
onReset={() => onResetDirectory("claude")}
/>
<DirectoryInput
label={t("settings.codexConfigDir")}
description={t("settings.codexConfigDirDescription", {
defaultValue: "覆盖 Codex 配置目录。",
})}
value={codexDir}
resolvedValue={resolvedDirs.codex}
placeholder={t("settings.browsePlaceholderCodex")}
onChange={(val) => onDirectoryChange("codex", val)}
onBrowse={() => onBrowseDirectory("codex")}
onReset={() => onResetDirectory("codex")}
/>
</section>
);
}
interface DirectoryInputProps {
label: string;
description?: string;
value?: string;
resolvedValue: string;
placeholder?: string;
onChange: (value?: string) => void;
onBrowse: () => Promise<void>;
onReset: () => Promise<void>;
}
function DirectoryInput({
label,
description,
value,
resolvedValue,
placeholder,
onChange,
onBrowse,
onReset,
}: DirectoryInputProps) {
const { t } = useTranslation();
const displayValue = useMemo(() => value ?? resolvedValue ?? "", [value, resolvedValue]);
return (
<div className="space-y-1.5">
<div className="space-y-1">
<p className="text-xs font-medium text-foreground">{label}</p>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<div className="flex items-center gap-2">
<Input
value={displayValue}
placeholder={placeholder}
className="font-mono text-xs"
onChange={(event) => onChange(event.target.value)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={onBrowse}
title={t("settings.browseDirectory")}
>
<FolderSearch className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={onReset}
title={t("settings.resetDefault")}
>
<Undo2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
import { useMemo } from "react";
import {
AlertCircle,
CheckCircle2,
FolderOpen,
Loader2,
Save,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import type { ImportStatus } from "@/hooks/useImportExport";
interface ImportExportSectionProps {
status: ImportStatus;
selectedFile: string;
errorMessage: string | null;
backupId: string | null;
isImporting: boolean;
onSelectFile: () => Promise<void>;
onImport: () => Promise<void>;
onExport: () => Promise<void>;
onClear: () => void;
}
export function ImportExportSection({
status,
selectedFile,
errorMessage,
backupId,
isImporting,
onSelectFile,
onImport,
onExport,
onClear,
}: ImportExportSectionProps) {
const { t } = useTranslation();
const selectedFileName = useMemo(() => {
if (!selectedFile) return "";
const segments = selectedFile.split(/[\\/]/);
return segments[segments.length - 1] || selectedFile;
}, [selectedFile]);
return (
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("settings.importExport")}</h3>
<p className="text-xs text-muted-foreground">
{t("settings.importExportHint", {
defaultValue: "导入导出 cc-switch 配置,便于备份或迁移。",
})}
</p>
</header>
<div className="space-y-3 rounded-lg border border-border p-4">
<Button
type="button"
className="w-full"
variant="secondary"
onClick={onExport}
>
<Save className="mr-2 h-4 w-4" />
{t("settings.exportConfig")}
</Button>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
className="flex-1 min-w-[180px]"
onClick={onSelectFile}
>
<FolderOpen className="mr-2 h-4 w-4" />
{t("settings.selectConfigFile")}
</Button>
<Button
type="button"
disabled={!selectedFile || isImporting}
onClick={onImport}
>
{isImporting ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.importing")}
</span>
) : (
t("settings.import")
)}
</Button>
{selectedFile ? (
<Button type="button" variant="ghost" onClick={onClear}>
<XCircle className="mr-2 h-4 w-4" />
{t("common.clear", { defaultValue: "清除" })}
</Button>
) : null}
</div>
{selectedFile ? (
<p className="truncate rounded-md bg-muted/40 px-3 py-2 text-xs font-mono text-muted-foreground">
{selectedFileName}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t("settings.noFileSelected", {
defaultValue: "尚未选择配置文件。",
})}
</p>
)}
</div>
<ImportStatusMessage
status={status}
errorMessage={errorMessage}
backupId={backupId}
/>
</div>
</section>
);
}
interface ImportStatusMessageProps {
status: ImportStatus;
errorMessage: string | null;
backupId: string | null;
}
function ImportStatusMessage({
status,
errorMessage,
backupId,
}: ImportStatusMessageProps) {
const { t } = useTranslation();
if (status === "idle") {
return null;
}
const baseClass =
"flex items-start gap-2 rounded-md border px-3 py-2 text-xs leading-relaxed";
if (status === "importing") {
return (
<div className={`${baseClass} border-border bg-muted/40`}>
<Loader2 className="mt-0.5 h-4 w-4 animate-spin text-muted-foreground" />
<div>
<p className="font-medium">{t("settings.importing")}</p>
<p className="text-muted-foreground">
{t("common.loading", { defaultValue: "正在处理..." })}
</p>
</div>
</div>
);
}
if (status === "success") {
return (
<div className={`${baseClass} border-green-200 bg-green-100/70 text-green-700`}>
<CheckCircle2 className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">{t("settings.importSuccess")}</p>
{backupId ? (
<p className="text-xs">
{t("settings.backupId", { defaultValue: "备份 ID" })}: {backupId}
</p>
) : null}
<p>{t("settings.autoReload", { defaultValue: "即将刷新列表。" })}</p>
</div>
</div>
);
}
const message =
errorMessage ||
t("settings.importFailed", { defaultValue: "导入失败,请重试。" });
return (
<div className={`${baseClass} border-red-200 bg-red-100/70 text-red-600`}>
<AlertCircle className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">
{t("settings.importFailed", { defaultValue: "导入失败" })}
</p>
<p>{message}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface LanguageSettingsProps {
value: "zh" | "en";
onChange: (value: "zh" | "en") => void;
}
export function LanguageSettings({ value, onChange }: LanguageSettingsProps) {
const { t } = useTranslation();
return (
<section className="space-y-2">
<header className="space-y-1">
<h3 className="text-sm font-medium">{t("settings.language")}</h3>
<p className="text-xs text-muted-foreground">
{t("settings.languageHint", {
defaultValue: "切换后立即预览界面语言,保存后永久生效。",
})}
</p>
</header>
<div className="inline-flex gap-1 rounded-md border border-border bg-background p-1">
<LanguageButton
active={value === "zh"}
onClick={() => onChange("zh")}
>
{t("settings.languageOptionChinese")}
</LanguageButton>
<LanguageButton
active={value === "en"}
onClick={() => onChange("en")}
>
{t("settings.languageOptionEnglish")}
</LanguageButton>
</div>
</section>
);
}
interface LanguageButtonProps {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}
function LanguageButton({ active, onClick, children }: LanguageButtonProps) {
return (
<Button
type="button"
onClick={onClick}
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"min-w-[96px]",
active
? "shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{children}
</Button>
);
}

View File

@@ -0,0 +1,286 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2, Save } from "lucide-react";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { settingsApi } from "@/lib/api";
import { LanguageSettings } from "@/components/settings/LanguageSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
import { ConfigPathDisplay } from "@/components/settings/ConfigPathDisplay";
import { DirectorySettings } from "@/components/settings/DirectorySettings";
import { ImportExportSection } from "@/components/settings/ImportExportSection";
import { AboutSection } from "@/components/settings/AboutSection";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
import { useTranslation } from "react-i18next";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImportSuccess?: () => void | Promise<void>;
}
export function SettingsDialog({
open,
onOpenChange,
onImportSuccess,
}: SettingsDialogProps) {
const { t } = useTranslation();
const {
settings,
isLoading,
isSaving,
isPortable,
configPath,
appConfigDir,
resolvedDirs,
updateSettings,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
openConfigFolder,
saveSettings,
resetSettings,
requiresRestart,
acknowledgeRestart,
} = useSettings();
const {
selectedFile,
status: importStatus,
errorMessage,
backupId,
isImporting,
selectImportFile,
importConfig,
exportConfig,
clearSelection,
resetStatus,
} = useImportExport({ onImportSuccess });
const [activeTab, setActiveTab] = useState<string>("general");
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
useEffect(() => {
if (open) {
setActiveTab("general");
resetStatus();
}
}, [open, resetStatus]);
useEffect(() => {
if (requiresRestart) {
setShowRestartPrompt(true);
}
}, [requiresRestart]);
const closeDialog = useCallback(() => {
resetSettings();
acknowledgeRestart();
clearSelection();
resetStatus();
onOpenChange(false);
}, [acknowledgeRestart, clearSelection, onOpenChange, resetSettings, resetStatus]);
const handleDialogChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
closeDialog();
} else {
onOpenChange(true);
}
},
[closeDialog, onOpenChange],
);
const handleCancel = useCallback(() => {
closeDialog();
}, [closeDialog]);
const handleSave = useCallback(async () => {
try {
const result = await saveSettings();
if (!result) return;
if (result.requiresRestart) {
setShowRestartPrompt(true);
return;
}
closeDialog();
} catch (error) {
console.error("[SettingsDialog] Failed to save settings", error);
}
}, [closeDialog, saveSettings]);
const handleRestartLater = useCallback(() => {
setShowRestartPrompt(false);
closeDialog();
}, [closeDialog]);
const handleRestartNow = useCallback(async () => {
setShowRestartPrompt(false);
if (import.meta.env.DEV) {
toast.success(
t("settings.devModeRestartHint", {
defaultValue: "开发模式下不支持自动重启,请手动重新启动应用。",
}),
);
closeDialog();
return;
}
try {
await settingsApi.restart();
} catch (error) {
console.error("[SettingsDialog] Failed to restart app", error);
toast.error(
t("settings.restartFailed", {
defaultValue: "应用重启失败,请手动关闭后重新打开。",
}),
);
} finally {
closeDialog();
}
}, [closeDialog, t]);
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent className="max-w-3xl gap-6">
<DialogHeader>
<DialogTitle>{t("settings.title")}</DialogTitle>
</DialogHeader>
{isBusy ? (
<div className="flex min-h-[320px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex max-h-[70vh] flex-col gap-6 overflow-hidden">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-1 flex-col"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="general">
{t("settings.tabGeneral", { defaultValue: "通用" })}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced", { defaultValue: "高级" })}
</TabsTrigger>
<TabsTrigger value="about">
{t("common.about")}
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto pr-1">
<TabsContent value="general" className="space-y-6 pt-4">
{settings ? (
<>
<LanguageSettings
value={settings.language}
onChange={(lang) => updateSettings({ language: lang })}
/>
<WindowSettings
settings={settings}
onChange={updateSettings}
/>
<ConfigPathDisplay
path={configPath}
onOpen={openConfigFolder}
/>
</>
) : null}
</TabsContent>
<TabsContent value="advanced" className="space-y-6 pt-4">
{settings ? (
<>
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
<ImportExportSection
status={importStatus}
selectedFile={selectedFile}
errorMessage={errorMessage}
backupId={backupId}
isImporting={isImporting}
onSelectFile={selectImportFile}
onImport={importConfig}
onExport={exportConfig}
onClear={clearSelection}
/>
</>
) : null}
</TabsContent>
<TabsContent value="about" className="pt-4">
<AboutSection isPortable={isPortable} />
</TabsContent>
</div>
</Tabs>
</div>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={isSaving || isBusy}>
{isSaving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.saving", { defaultValue: "正在保存..." })}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
{showRestartPrompt ? (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" />
<div className="relative z-10 w-full max-w-md space-y-4 rounded-lg border border-border bg-background p-6 shadow-xl">
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("settings.restartRequired")}
</h2>
<p className="text-sm text-muted-foreground">
{t("settings.restartRequiredMessage", {
defaultValue: "配置目录已变更,需要重启应用生效。",
})}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleRestartLater}>
{t("settings.restartLater")}
</Button>
<Button onClick={handleRestartNow}>
{t("settings.restartNow")}
</Button>
</div>
</div>
</div>
) : null}
</Dialog>
);
}

View File

@@ -0,0 +1,75 @@
import { Switch } from "@/components/ui/switch";
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
interface WindowSettingsProps {
settings: SettingsFormState;
onChange: (updates: Partial<SettingsFormState>) => void;
}
export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
const { t } = useTranslation();
return (
<section className="space-y-4">
<header className="space-y-1">
<h3 className="text-sm font-medium">
{t("settings.windowBehavior")}
</h3>
<p className="text-xs text-muted-foreground">
{t("settings.windowBehaviorHint", {
defaultValue: "配置窗口最小化与 Claude 插件联动策略。",
})}
</p>
</header>
<ToggleRow
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 })
}
/>
</section>
);
}
interface ToggleRowProps {
title: string;
description?: string;
checked: boolean;
onCheckedChange: (value: boolean) => void;
}
function ToggleRow({
title,
description,
checked,
onCheckedChange,
}: ToggleRowProps) {
return (
<div className="flex items-start justify-between gap-4 rounded-lg border border-border 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>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
aria-label={title}
/>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { settingsApi } from "@/lib/api";
export type ImportStatus = "idle" | "importing" | "success" | "error";
export interface UseImportExportOptions {
onImportSuccess?: () => void | Promise<void>;
}
export interface UseImportExportResult {
selectedFile: string;
status: ImportStatus;
errorMessage: string | null;
backupId: string | null;
isImporting: boolean;
selectImportFile: () => Promise<void>;
clearSelection: () => void;
importConfig: () => Promise<void>;
exportConfig: () => Promise<void>;
resetStatus: () => void;
}
export function useImportExport(
options: UseImportExportOptions = {},
): UseImportExportResult {
const { t } = useTranslation();
const { onImportSuccess } = options;
const [selectedFile, setSelectedFile] = useState("");
const [status, setStatus] = useState<ImportStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [backupId, setBackupId] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const successTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (successTimerRef.current) {
window.clearTimeout(successTimerRef.current);
}
};
}, []);
const clearSelection = useCallback(() => {
setSelectedFile("");
setStatus("idle");
setErrorMessage(null);
setBackupId(null);
}, []);
const selectImportFile = useCallback(async () => {
try {
const filePath = await settingsApi.openFileDialog();
if (filePath) {
setSelectedFile(filePath);
setStatus("idle");
setErrorMessage(null);
}
} catch (error) {
console.error("[useImportExport] Failed to open file dialog", error);
toast.error(
t("settings.selectFileFailed", {
defaultValue: "选择文件失败",
}),
);
}
}, [t]);
const importConfig = useCallback(async () => {
if (!selectedFile) {
toast.error(
t("settings.selectFileFailed", {
defaultValue: "请选择有效的配置文件",
}),
);
return;
}
if (isImporting) return;
setIsImporting(true);
setStatus("importing");
setErrorMessage(null);
try {
const result = await settingsApi.importConfigFromFile(selectedFile);
if (result.success) {
setBackupId(result.backupId ?? null);
setStatus("success");
toast.success(
t("settings.importSuccess", {
defaultValue: "配置导入成功",
}),
);
successTimerRef.current = window.setTimeout(() => {
void onImportSuccess?.();
}, 1500);
} else {
setStatus("error");
const message =
result.message ||
t("settings.configCorrupted", {
defaultValue: "配置文件已损坏或格式不正确",
});
setErrorMessage(message);
toast.error(message);
}
} catch (error) {
console.error("[useImportExport] Failed to import config", error);
setStatus("error");
const message =
error instanceof Error ? error.message : String(error ?? "");
setErrorMessage(message);
toast.error(
t("settings.importFailedError", {
defaultValue: "导入配置失败: {{message}}",
message,
}),
);
} finally {
setIsImporting(false);
}
}, [isImporting, onImportSuccess, selectedFile, t]);
const exportConfig = useCallback(async () => {
try {
const defaultName = `cc-switch-config-${
new Date().toISOString().split("T")[0]
}.json`;
const destination = await settingsApi.saveFileDialog(defaultName);
if (!destination) {
toast.error(
t("settings.selectFileFailed", {
defaultValue: "选择保存位置失败",
}),
);
return;
}
const result = await settingsApi.exportConfigToFile(destination);
if (result.success) {
const displayPath = result.filePath ?? destination;
toast.success(
t("settings.configExported", {
defaultValue: "配置已导出",
}) + `\n${displayPath}`,
);
} else {
toast.error(
t("settings.exportFailed", {
defaultValue: "导出配置失败",
}) + (result.message ? `: ${result.message}` : ""),
);
}
} catch (error) {
console.error("[useImportExport] Failed to export config", error);
toast.error(
t("settings.exportFailedError", {
defaultValue: "导出配置失败: {{message}}",
message: error instanceof Error ? error.message : String(error ?? ""),
}),
);
}
}, [t]);
const resetStatus = useCallback(() => {
setStatus("idle");
setErrorMessage(null);
setBackupId(null);
}, []);
return {
selectedFile,
status,
errorMessage,
backupId,
isImporting,
selectImportFile,
clearSelection,
importConfig,
exportConfig,
resetStatus,
};
}

498
src/hooks/useSettings.ts Normal file
View File

@@ -0,0 +1,498 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { homeDir, join } from "@tauri-apps/api/path";
import { settingsApi, type AppType } from "@/lib/api";
import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query";
import type { Settings } from "@/types";
type Language = "zh" | "en";
export type SettingsFormState = Omit<Settings, "language"> & {
language: Language;
};
type DirectoryKey = "appConfig" | "claude" | "codex";
export interface ResolvedDirectories {
appConfig: string;
claude: string;
codex: string;
}
interface SaveResult {
requiresRestart: boolean;
}
export interface UseSettingsResult {
settings: SettingsFormState | null;
isLoading: boolean;
isSaving: boolean;
isPortable: boolean;
configPath: string;
appConfigDir?: string;
resolvedDirs: ResolvedDirectories;
requiresRestart: boolean;
updateSettings: (updates: Partial<SettingsFormState>) => void;
updateDirectory: (app: AppType, value?: string) => void;
updateAppConfigDir: (value?: string) => void;
browseDirectory: (app: AppType) => Promise<void>;
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppType) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
openConfigFolder: () => Promise<void>;
saveSettings: () => Promise<SaveResult | null>;
resetSettings: () => void;
acknowledgeRestart: () => void;
}
const normalizeLanguage = (lang?: string | null): Language => {
if (!lang) return "zh";
return lang === "en" ? "en" : "zh";
};
const sanitizeDir = (value?: string | null): string | undefined => {
if (!value) return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const computeDefaultAppConfigDir = async (): Promise<string | undefined> => {
try {
const home = await homeDir();
return await join(home, ".cc-switch");
} catch (error) {
console.error("[useSettings] Failed to resolve default app config dir", error);
return undefined;
}
};
const computeDefaultConfigDir = async (app: AppType): Promise<string | undefined> => {
try {
const home = await homeDir();
const folder = app === "claude" ? ".claude" : ".codex";
return await join(home, folder);
} catch (error) {
console.error("[useSettings] Failed to resolve default config dir", error);
return undefined;
}
};
export function useSettings(): UseSettingsResult {
const { t, i18n } = useTranslation();
const { data, isLoading } = useSettingsQuery();
const saveMutation = useSaveSettingsMutation();
const [settingsState, setSettingsState] = useState<SettingsFormState | null>(null);
const [appConfigDir, setAppConfigDir] = useState<string | undefined>(undefined);
const [configPath, setConfigPath] = useState("");
const [isPortable, setIsPortable] = useState(false);
const [requiresRestart, setRequiresRestart] = useState(false);
const [resolvedDirs, setResolvedDirs] = useState<ResolvedDirectories>({
appConfig: "",
claude: "",
codex: "",
});
const [isAuxiliaryLoading, setIsAuxiliaryLoading] = useState(true);
const defaultsRef = useRef<ResolvedDirectories>({
appConfig: "",
claude: "",
codex: "",
});
const initialLanguageRef = useRef<Language>("zh");
const initialAppConfigDirRef = useRef<string | undefined>(undefined);
const readPersistedLanguage = useCallback((): Language => {
if (typeof window !== "undefined") {
const stored = window.localStorage.getItem("language");
if (stored === "en" || stored === "zh") {
return stored;
}
}
return normalizeLanguage(i18n.language);
}, [i18n.language]);
const syncLanguage = useCallback(
(lang: Language) => {
const current = normalizeLanguage(i18n.language);
if (current !== lang) {
void i18n.changeLanguage(lang);
}
},
[i18n],
);
// 初始化设置数据
useEffect(() => {
if (!data) return;
const normalizedLanguage = normalizeLanguage(
data.language ?? readPersistedLanguage(),
);
const normalized: SettingsFormState = {
...data,
showInTray: data.showInTray ?? true,
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration: data.enableClaudePluginIntegration ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
language: normalizedLanguage,
};
setSettingsState(normalized);
initialLanguageRef.current = normalizedLanguage;
syncLanguage(normalizedLanguage);
}, [data, readPersistedLanguage, syncLanguage]);
// 加载辅助信息(目录、配置路径、便携模式)
useEffect(() => {
let active = true;
setIsAuxiliaryLoading(true);
const load = async () => {
try {
const [
overrideRaw,
appConfigPath,
claudeDir,
codexDir,
portable,
defaultAppConfig,
defaultClaudeDir,
defaultCodexDir,
] = await Promise.all([
settingsApi.getAppConfigDirOverride(),
settingsApi.getAppConfigPath(),
settingsApi.getConfigDir("claude"),
settingsApi.getConfigDir("codex"),
settingsApi.isPortable(),
computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
]);
if (!active) return;
const normalizedOverride = sanitizeDir(overrideRaw ?? undefined);
defaultsRef.current = {
appConfig: defaultAppConfig ?? "",
claude: defaultClaudeDir ?? "",
codex: defaultCodexDir ?? "",
};
setAppConfigDir(normalizedOverride);
initialAppConfigDirRef.current = normalizedOverride;
setResolvedDirs({
appConfig: normalizedOverride ?? defaultsRef.current.appConfig,
claude: claudeDir || defaultsRef.current.claude,
codex: codexDir || defaultsRef.current.codex,
});
setConfigPath(appConfigPath || "");
setIsPortable(portable);
} catch (error) {
console.error("[useSettings] Failed to load directory info", error);
} finally {
if (active) {
setIsAuxiliaryLoading(false);
}
}
};
void load();
return () => {
active = false;
};
}, []);
const updateSettings = useCallback(
(updates: Partial<SettingsFormState>) => {
setSettingsState((prev) => {
const base =
prev ??
({
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
language: readPersistedLanguage(),
} as SettingsFormState);
const next: SettingsFormState = {
...base,
...updates,
};
if (updates.language) {
const normalized = normalizeLanguage(updates.language);
next.language = normalized;
syncLanguage(normalized);
}
return next;
});
},
[readPersistedLanguage, syncLanguage],
);
const updateDirectoryState = useCallback(
(key: DirectoryKey, value?: string) => {
const sanitized = sanitizeDir(value);
if (key === "appConfig") {
setAppConfigDir(sanitized);
} else {
setSettingsState((prev) => {
if (!prev) return prev;
if (key === "claude") {
return {
...prev,
claudeConfigDir: sanitized,
};
}
return {
...prev,
codexConfigDir: sanitized,
};
});
}
setResolvedDirs((prev) => ({
...prev,
[key]: sanitized ?? defaultsRef.current[key],
}));
},
[],
);
const updateAppConfigDir = useCallback(
(value?: string) => {
updateDirectoryState("appConfig", value);
},
[updateDirectoryState],
);
const updateDirectory = useCallback(
(app: AppType, value?: string) => {
updateDirectoryState(app === "claude" ? "claude" : "codex", value);
},
[updateDirectoryState],
);
const browseDirectory = useCallback(
async (app: AppType) => {
const key: DirectoryKey = app === "claude" ? "claude" : "codex";
const currentValue =
key === "claude"
? settingsState?.claudeConfigDir ?? resolvedDirs.claude
: settingsState?.codexConfigDir ?? resolvedDirs.codex;
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
const sanitized = sanitizeDir(picked ?? undefined);
if (!sanitized) return;
updateDirectoryState(key, sanitized);
} catch (error) {
console.error("[useSettings] Failed to pick directory", error);
toast.error(
t("settings.selectFileFailed", {
defaultValue: "选择目录失败",
}),
);
}
},
[settingsState, resolvedDirs, t, updateDirectoryState],
);
const browseAppConfigDir = useCallback(async () => {
const currentValue = appConfigDir ?? resolvedDirs.appConfig;
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
const sanitized = sanitizeDir(picked ?? undefined);
if (!sanitized) return;
updateDirectoryState("appConfig", sanitized);
} catch (error) {
console.error("[useSettings] Failed to pick app config directory", error);
toast.error(
t("settings.selectFileFailed", {
defaultValue: "选择目录失败",
}),
);
}
}, [appConfigDir, resolvedDirs.appConfig, t, updateDirectoryState]);
const resetDirectory = useCallback(
async (app: AppType) => {
const key: DirectoryKey = app === "claude" ? "claude" : "codex";
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
if (fallback) {
defaultsRef.current = {
...defaultsRef.current,
[key]: fallback,
};
}
}
updateDirectoryState(key, undefined);
},
[updateDirectoryState],
);
const resetAppConfigDir = useCallback(async () => {
if (!defaultsRef.current.appConfig) {
const fallback = await computeDefaultAppConfigDir();
if (fallback) {
defaultsRef.current = {
...defaultsRef.current,
appConfig: fallback,
};
}
}
updateDirectoryState("appConfig", undefined);
}, [updateDirectoryState]);
const openConfigFolder = useCallback(async () => {
try {
await settingsApi.openAppConfigFolder();
} catch (error) {
console.error("[useSettings] Failed to open config folder", error);
toast.error(
t("settings.openFolderFailed", {
defaultValue: "打开目录失败",
}),
);
}
}, [t]);
const resetSettings = useCallback(() => {
if (!data) return;
const normalizedLanguage = normalizeLanguage(
data.language ?? readPersistedLanguage(),
);
const normalized: SettingsFormState = {
...data,
showInTray: data.showInTray ?? true,
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
enableClaudePluginIntegration: data.enableClaudePluginIntegration ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
language: normalizedLanguage,
};
setSettingsState(normalized);
syncLanguage(initialLanguageRef.current);
setAppConfigDir(initialAppConfigDirRef.current);
setResolvedDirs({
appConfig: initialAppConfigDirRef.current ?? defaultsRef.current.appConfig,
claude: normalized.claudeConfigDir ?? defaultsRef.current.claude,
codex: normalized.codexConfigDir ?? defaultsRef.current.codex,
});
setRequiresRestart(false);
}, [data, readPersistedLanguage, syncLanguage]);
const acknowledgeRestart = useCallback(() => {
setRequiresRestart(false);
}, []);
const saveSettings = useCallback(async (): Promise<SaveResult | null> => {
if (!settingsState) return null;
try {
const sanitizedAppDir = sanitizeDir(appConfigDir);
const sanitizedClaudeDir = sanitizeDir(settingsState.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(settingsState.codexConfigDir);
const previousAppDir = initialAppConfigDirRef.current;
const payload: Settings = {
...settingsState,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
language: settingsState.language,
};
await saveMutation.mutateAsync(payload);
await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null);
try {
if (payload.enableClaudePluginIntegration) {
await settingsApi.applyClaudePluginConfig({ official: false });
} else {
await settingsApi.applyClaudePluginConfig({ official: true });
}
} catch (error) {
console.warn("[useSettings] Failed to sync Claude plugin config", error);
toast.error(
t("notifications.syncClaudePluginFailed", {
defaultValue: "同步 Claude 插件失败",
}),
);
}
try {
if (typeof window !== "undefined") {
window.localStorage.setItem("language", payload.language as Language);
}
} catch (error) {
console.warn("[useSettings] Failed to persist language preference", error);
}
initialLanguageRef.current = payload.language as Language;
setSettingsState((prev) =>
prev
? {
...prev,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
language: payload.language as Language,
}
: prev,
);
setResolvedDirs({
appConfig: sanitizedAppDir ?? defaultsRef.current.appConfig,
claude: sanitizedClaudeDir ?? defaultsRef.current.claude,
codex: sanitizedCodexDir ?? defaultsRef.current.codex,
});
setAppConfigDir(sanitizedAppDir);
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
initialAppConfigDirRef.current = sanitizedAppDir;
setRequiresRestart(appDirChanged);
return { requiresRestart: appDirChanged };
} catch (error) {
console.error("[useSettings] Failed to save settings", error);
throw error;
}
}, [appConfigDir, saveMutation, settingsState, t]);
const isBusy = useMemo(
() => isLoading || isAuxiliaryLoading,
[isLoading, isAuxiliaryLoading],
);
return {
settings: settingsState,
isLoading: isBusy,
isSaving: saveMutation.isPending,
isPortable,
configPath,
appConfigDir,
resolvedDirs,
requiresRestart,
updateSettings,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
openConfigFolder,
saveSettings,
resetSettings,
acknowledgeRestart,
};
}

View File

@@ -2,6 +2,13 @@ import { invoke } from "@tauri-apps/api/core";
import type { Settings } from "@/types";
import type { AppType } from "./types";
export interface ConfigTransferResult {
success: boolean;
message: string;
filePath?: string;
backupId?: string;
}
export const settingsApi = {
async get(): Promise<Settings> {
return await invoke("get_settings");
@@ -49,4 +56,48 @@ export const settingsApi = {
async openAppConfigFolder(): Promise<void> {
await invoke("open_app_config_folder");
},
async getAppConfigDirOverride(): Promise<string | null> {
return await invoke("get_app_config_dir_override");
},
async setAppConfigDirOverride(path: string | null): Promise<boolean> {
return await invoke("set_app_config_dir_override", { path });
},
async applyClaudePluginConfig(options: {
official: boolean;
}): Promise<boolean> {
const { official } = options;
return await invoke("apply_claude_plugin_config", { official });
},
async saveFileDialog(defaultName: string): Promise<string | null> {
return await invoke("save_file_dialog", {
default_name: defaultName,
defaultName,
});
},
async openFileDialog(): Promise<string | null> {
return await invoke("open_file_dialog");
},
async exportConfigToFile(filePath: string): Promise<ConfigTransferResult> {
return await invoke("export_config_to_file", {
file_path: filePath,
filePath,
});
},
async importConfigFromFile(filePath: string): Promise<ConfigTransferResult> {
return await invoke("import_config_from_file", {
file_path: filePath,
filePath,
});
},
async openExternal(url: string): Promise<void> {
await invoke("open_external", { url });
},
};

View File

@@ -65,7 +65,7 @@ export interface ProviderMeta {
usage_script?: UsageScript;
}
// 应用设置类型(用于 SettingsModal 与 Tauri API
// 应用设置类型(用于设置对话框与 Tauri API
export interface Settings {
// 是否在系统托盘macOS 菜单栏)显示图标
showInTray: boolean;