feat: complete stage 3 settings refactor
This commit is contained in:
@@ -67,7 +67,7 @@ src/
|
|||||||
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
- ✅ EditProviderModal.tsx - 编辑供应商弹窗
|
||||||
- ✅ ProviderList.tsx - 供应商列表
|
- ✅ ProviderList.tsx - 供应商列表
|
||||||
- ✅ LanguageSwitcher.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% |
|
| App.tsx | 412 | ~100 | -76% |
|
||||||
| tauri-api.ts | 712 | ~50 | -93% |
|
| tauri-api.ts | 712 | ~50 | -93% |
|
||||||
| ProviderForm.tsx | 271 | ~150 | -45% |
|
| ProviderForm.tsx | 271 | ~150 | -45% |
|
||||||
| SettingsModal.tsx | 643 | ~400 (拆分) | -38% |
|
| settings 模块 | 1046 | ~470 (拆分) | -55% |
|
||||||
| **总计** | 2038 | ~700 | **-66%** |
|
| **总计** | 2038 | ~700 | **-66%** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -872,7 +872,7 @@ export function useDragSort(
|
|||||||
| **阶段 0** | 准备环境 | 1 天 | 依赖安装、配置完成 |
|
| **阶段 0** | 准备环境 | 1 天 | 依赖安装、配置完成 |
|
||||||
| **阶段 1** | 搭建基础设施(✅ 已完成) | 2-3 天 | API 层、Query Hooks 完成 |
|
| **阶段 1** | 搭建基础设施(✅ 已完成) | 2-3 天 | API 层、Query Hooks 完成 |
|
||||||
| **阶段 2** | 重构核心功能(✅ 已完成) | 3-4 天 | App.tsx、ProviderList 完成 |
|
| **阶段 2** | 重构核心功能(✅ 已完成) | 3-4 天 | App.tsx、ProviderList 完成 |
|
||||||
| **阶段 3** | 重构设置和辅助 | 2-3 天 | SettingsDialog、通知系统完成 |
|
| **阶段 3** | 重构设置和辅助(✅ 已完成) | 2-3 天 | SettingsDialog、通知系统完成 |
|
||||||
| **阶段 4** | 清理和优化 | 1-2 天 | 旧代码删除、优化完成 |
|
| **阶段 4** | 清理和优化 | 1-2 天 | 旧代码删除、优化完成 |
|
||||||
| **阶段 5** | 测试和修复 | 2-3 天 | 测试通过、Bug 修复 |
|
| **阶段 5** | 测试和修复 | 2-3 天 | 测试通过、Bug 修复 |
|
||||||
| **总计** | - | **11-16 天** | v4.0.0 发布 |
|
| **总计** | - | **11-16 天** | v4.0.0 发布 |
|
||||||
@@ -1488,15 +1488,15 @@ export const useTheme = () => {
|
|||||||
|
|
||||||
### 阶段 3: 设置和辅助功能 (2-3天)
|
### 阶段 3: 设置和辅助功能 (2-3天)
|
||||||
|
|
||||||
**目标**: 重构 SettingsModal 和通知系统
|
**目标**: 重构设置模块和通知系统
|
||||||
|
|
||||||
#### 任务清单
|
#### 任务清单
|
||||||
|
|
||||||
- [ ] 拆分 SettingsDialog (7个组件)
|
- [x] 拆分 SettingsDialog (7个组件)
|
||||||
- [ ] 创建 `useSettings` Hook
|
- [x] 创建 `useSettings` Hook
|
||||||
- [ ] 创建 `useImportExport` Hook
|
- [x] 创建 `useImportExport` Hook
|
||||||
- [ ] 替换通知系统为 Sonner
|
- [x] 替换通知系统为 Sonner
|
||||||
- [ ] 重构 ConfirmDialog
|
- [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 { AddProviderDialog } from "@/components/providers/AddProviderDialog";
|
||||||
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
|
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import SettingsModal from "@/components/SettingsModal";
|
import { SettingsDialog } from "@/components/settings/SettingsDialog";
|
||||||
import { UpdateBadge } from "@/components/UpdateBadge";
|
import { UpdateBadge } from "@/components/UpdateBadge";
|
||||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||||
import McpPanel from "@/components/mcp/McpPanel";
|
import McpPanel from "@/components/mcp/McpPanel";
|
||||||
@@ -325,13 +325,11 @@ function App() {
|
|||||||
onCancel={() => setConfirmDelete(null)}
|
onCancel={() => setConfirmDelete(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isSettingsOpen && (
|
<SettingsDialog
|
||||||
<SettingsModal
|
open={isSettingsOpen}
|
||||||
onClose={() => setIsSettingsOpen(false)}
|
onOpenChange={setIsSettingsOpen}
|
||||||
onImportSuccess={handleImportSuccess}
|
onImportSuccess={handleImportSuccess}
|
||||||
onNotify={handleNotify}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isMcpOpen && (
|
{isMcpOpen && (
|
||||||
<McpPanel
|
<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 { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, X } from "lucide-react";
|
|
||||||
import { isLinux } from "../lib/platform";
|
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,7 +20,7 @@ interface ConfirmDialogProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
export function ConfirmDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
@@ -21,63 +28,37 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|||||||
cancelText,
|
cancelText,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}) => {
|
}: ConfirmDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<Dialog
|
||||||
{/* Backdrop */}
|
open={isOpen}
|
||||||
<div
|
onOpenChange={(open) => {
|
||||||
className={`absolute inset-0 bg-black/50${isLinux() ? "" : " backdrop-blur-sm"}`}
|
if (!open) {
|
||||||
onClick={onCancel}
|
onCancel();
|
||||||
/>
|
}
|
||||||
|
}}
|
||||||
{/* Dialog */}
|
>
|
||||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-md w-full mx-4 overflow-hidden">
|
<DialogContent className="max-w-sm">
|
||||||
{/* Header */}
|
<DialogHeader className="space-y-3">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||||
<div className="flex items-center gap-3">
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
<div className="w-10 h-10 bg-red-100 dark:bg-red-500/10 rounded-full flex items-center justify-center">
|
{title}
|
||||||
<AlertTriangle size={20} className="text-red-500" />
|
</DialogTitle>
|
||||||
</div>
|
<DialogDescription className="whitespace-pre-line text-sm leading-relaxed">
|
||||||
<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">
|
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</DialogDescription>
|
||||||
</div>
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
{/* Actions */}
|
<Button variant="outline" onClick={onCancel}>
|
||||||
<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
|
|
||||||
>
|
|
||||||
{cancelText || t("common.cancel")}
|
{cancelText || t("common.cancel")}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
{confirmText || t("common.confirm")}
|
{confirmText || t("common.confirm")}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
</DialogContent>
|
||||||
</div>
|
</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 { Settings } from "@/types";
|
||||||
import type { AppType } from "./types";
|
import type { AppType } from "./types";
|
||||||
|
|
||||||
|
export interface ConfigTransferResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
filePath?: string;
|
||||||
|
backupId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
async get(): Promise<Settings> {
|
async get(): Promise<Settings> {
|
||||||
return await invoke("get_settings");
|
return await invoke("get_settings");
|
||||||
@@ -49,4 +56,48 @@ export const settingsApi = {
|
|||||||
async openAppConfigFolder(): Promise<void> {
|
async openAppConfigFolder(): Promise<void> {
|
||||||
await invoke("open_app_config_folder");
|
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;
|
usage_script?: UsageScript;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用设置类型(用于 SettingsModal 与 Tauri API)
|
// 应用设置类型(用于设置对话框与 Tauri API)
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
// 是否在系统托盘(macOS 菜单栏)显示图标
|
// 是否在系统托盘(macOS 菜单栏)显示图标
|
||||||
showInTray: boolean;
|
showInTray: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user