feat: complete stage 3 settings refactor
This commit is contained in:
@@ -67,7 +67,7 @@ src/
|
||||
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
||||
- ✅ ProviderList.tsx - 供应商列表
|
||||
- ✅ LanguageSwitcher.tsx - 语言切换器
|
||||
- 🔄 SettingsModal.tsx - 设置弹窗(部分完成)
|
||||
- ✅ settings/SettingsDialog.tsx - 设置对话框
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
@@ -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%** |
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
#### 详细步骤
|
||||
|
||||
|
||||
14
src/App.tsx
14
src/App.tsx
@@ -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
|
||||
|
||||
@@ -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
236
src/components/settings/AboutSection.tsx
Normal file
236
src/components/settings/AboutSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/settings/ConfigPathDisplay.tsx
Normal file
41
src/components/settings/ConfigPathDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/components/settings/DirectorySettings.tsx
Normal file
147
src/components/settings/DirectorySettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
src/components/settings/ImportExportSection.tsx
Normal file
189
src/components/settings/ImportExportSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/settings/LanguageSettings.tsx
Normal file
64
src/components/settings/LanguageSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
src/components/settings/SettingsDialog.tsx
Normal file
286
src/components/settings/SettingsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/settings/WindowSettings.tsx
Normal file
75
src/components/settings/WindowSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/hooks/useImportExport.ts
Normal file
187
src/hooks/useImportExport.ts
Normal 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
498
src/hooks/useSettings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface ProviderMeta {
|
||||
usage_script?: UsageScript;
|
||||
}
|
||||
|
||||
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
||||
// 应用设置类型(用于设置对话框与 Tauri API)
|
||||
export interface Settings {
|
||||
// 是否在系统托盘(macOS 菜单栏)显示图标
|
||||
showInTray: boolean;
|
||||
|
||||
Reference in New Issue
Block a user