refactor(settings): migrate from dialog to full-screen page layout

Complete migration of settings from modal dialog to dedicated full-screen
page, improving UX and providing more space for configuration options.

Changes:
- Remove SettingsDialog component (legacy modal-based interface)
- Add SettingsPage component with full-screen layout using FullScreenPanel
- Refactor App.tsx routing to support dedicated settings page
  * Add settings route handler
  * Update navigation logic from dialog-based to page-based
  * Integrate with existing app switcher and provider management
- Update ImportExportSection to work with new page layout
  * Improve spacing and layout for better readability
  * Enhanced error handling and user feedback
  * Better integration with page-level actions
- Enhance useSettings hook to support page-based workflow
  * Add navigation state management
  * Improve settings persistence logic
  * Better error boundary handling

Benefits:
- More intuitive navigation with dedicated settings page
- Better use of screen space for complex configurations
- Improved accessibility with clearer visual hierarchy
- Consistent with modern desktop application patterns
- Easier to extend with new settings sections

This change is part of the larger UI refactoring initiative to modernize
the application interface and improve user experience.
This commit is contained in:
YoVinchen
2025-11-21 09:28:11 +08:00
parent d802b7bf61
commit 764ba81ea6
5 changed files with 655 additions and 471 deletions

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Plus, Settings, Edit3 } from "lucide-react";
import { Plus, Settings, ArrowLeft, Bot, Book, Wrench, Server, RefreshCw } from "lucide-react";
import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery } from "@/lib/query";
@@ -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 { SettingsDialog } from "@/components/settings/SettingsDialog";
import { SettingsPage } from "@/components/settings/SettingsPage";
import { UpdateBadge } from "@/components/UpdateBadge";
import { EnvWarningBanner } from "@/components/env/EnvWarningBanner";
import UsageScriptModal from "@/components/UsageScriptModal";
@@ -27,34 +27,35 @@ import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
type View = 'providers' | 'settings' | 'prompts' | 'skills' | 'mcp' | 'agents';
function App() {
const { t } = useTranslation();
const [activeApp, setActiveApp] = useState<AppId>("claude");
const [isEditMode, setIsEditMode] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [currentView, setCurrentView] = useState<View>('providers');
const [isAddOpen, setIsAddOpen] = useState(false);
const [isMcpOpen, setIsMcpOpen] = useState(false);
const [isPromptOpen, setIsPromptOpen] = useState(false);
const [isSkillsOpen, setIsSkillsOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
const [confirmDelete, setConfirmDelete] = useState<Provider | null>(null);
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
const addActionButtonClass =
"bg-primary hover:bg-primary/90 text-primary-foreground shadow-lg shadow-primary/20 rounded-full w-8 h-8";
const { data, isLoading, refetch } = useProvidersQuery(activeApp);
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const isClaudeApp = activeApp === "claude";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -98,7 +99,10 @@ function App() {
if (flatConflicts.length > 0) {
setEnvConflicts(flatConflicts);
setShowEnvBanner(true);
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
}
} catch (error) {
console.error(
@@ -128,7 +132,10 @@ function App() {
);
return [...prev, ...newConflicts];
});
setShowEnvBanner(true);
const dismissed = sessionStorage.getItem("env_banner_dismissed");
if (!dismissed) {
setShowEnvBanner(true);
}
}
} catch (error) {
console.error(
@@ -229,13 +236,77 @@ function App() {
}
};
const renderContent = () => {
switch (currentView) {
case 'settings':
return (
<SettingsPage
open={true}
onOpenChange={() => setCurrentView('providers')}
onImportSuccess={handleImportSuccess}
/>
);
case 'prompts':
return (
<PromptPanel
ref={promptPanelRef}
open={true}
onOpenChange={() => setCurrentView('providers')}
appId={activeApp}
/>
);
case 'skills':
return <SkillsPage ref={skillsPageRef} onClose={() => setCurrentView('providers')} />;
case 'mcp':
return (
<UnifiedMcpPanel
ref={mcpPanelRef}
onOpenChange={() => setCurrentView('providers')}
/>
);
case 'agents':
return (
<AgentsPanel
onOpenChange={() => setCurrentView('providers')}
/>
);
default:
return (
<div className="mx-auto max-w-5xl space-y-4">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
);
}
};
return (
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
<div className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30" style={{ overflowX: "hidden" }}>
{/* 全局拖拽区域(顶部 4px避免上边框无法拖动 */}
<div
className="fixed top-0 left-0 right-0 h-4 z-[60]"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" }}
/>
{/* 环境变量警告横幅 */}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
conflicts={envConflicts}
onDismiss={() => setShowEnvBanner(false)}
onDismiss={() => {
setShowEnvBanner(false);
sessionStorage.setItem("env_banner_dismissed", "true");
}}
onDeleted={async () => {
// 删除后重新检测
try {
@@ -255,92 +326,172 @@ function App() {
/>
)}
<header className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-4 dark:border-gray-800 dark:bg-gray-900">
<div className="flex flex-wrap items-center justify-between gap-2">
<header
className="glass-header fixed top-0 z-50 w-full px-6 py-3 transition-all duration-300"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag" }}
>
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
<div
className="flex flex-wrap items-center justify-between gap-2"
style={{ WebkitAppRegion: "no-drag" }}
>
<div className="flex items-center gap-1">
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
CC Switch
</a>
<Button
variant="ghost"
size="icon"
onClick={() => setIsSettingsOpen(true)}
title={t("common.settings")}
className="ml-2"
>
<Settings className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setIsEditMode(!isEditMode)}
title={t(
isEditMode ? "header.exitEditMode" : "header.enterEditMode",
)}
className={
isEditMode
? "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
: ""
}
>
<Edit3 className="h-4 w-4" />
</Button>
<UpdateBadge onClick={() => setIsSettingsOpen(true)} />
{currentView !== 'providers' ? (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView('providers')}
className="mr-1 hover:bg-black/5 dark:hover:bg-white/5 -ml-2"
>
<ArrowLeft className="h-5 w-5 mr-1" />
{t("common.back")}
</Button>
<h1 className="text-lg font-semibold">
{currentView === 'settings' && t("settings.title")}
{currentView === 'prompts' && t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === 'skills' && t("skills.title")}
{currentView === 'mcp' && t("mcp.unifiedPanel.title")}
{currentView === 'agents' && "Agents"}
</h1>
</div>
) : (
<>
<a
href="https://github.com/farion1231/cc-switch"
target="_blank"
rel="noreferrer"
className="text-xl font-semibold text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
CC Switch
</a>
<Button
variant="ghost"
size="icon"
onClick={() => setCurrentView('settings')}
title={t("common.settings")}
className="ml-2 hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="h-4 w-4" />
</Button>
<UpdateBadge onClick={() => setCurrentView('settings')} />
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<Button
variant="mcp"
onClick={() => setIsPromptOpen(true)}
className="min-w-[80px]"
>
{t("prompts.manage")}
</Button>
<Button
variant="mcp"
onClick={() => setIsMcpOpen(true)}
className="min-w-[80px]"
>
MCP
</Button>
<Button
variant="mcp"
onClick={() => setIsSkillsOpen(true)}
className="min-w-[80px]"
>
{t("skills.manage")}
</Button>
<Button onClick={() => setIsAddOpen(true)}>
<Plus className="h-4 w-4" />
{t("header.addProvider")}
</Button>
<div className="flex items-center gap-2">
{currentView === 'prompts' && (
<Button
size="icon"
onClick={() => promptPanelRef.current?.openAdd()}
className={addActionButtonClass}
title={t("prompts.add")}
>
<Plus className="h-5 w-5" />
</Button>
)}
{currentView === 'mcp' && (
<Button
size="icon"
onClick={() => mcpPanelRef.current?.openAdd()}
className={addActionButtonClass}
title={t("mcp.unifiedPanel.addServer")}
>
<Plus className="h-5 w-5" />
</Button>
)}
{currentView === 'skills' && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.refresh()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<RefreshCw className="h-4 w-4 mr-2" />
{t("skills.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skillsPageRef.current?.openRepoManager()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Settings className="h-4 w-4 mr-2" />
{t("skills.repoManager")}
</Button>
</>
)}
{currentView === 'providers' && (
<>
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
<div className="h-8 w-[1px] bg-black/10 dark:bg-white/10 mx-1" />
<div className="glass p-1 rounded-xl flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView('prompts')}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.manage")}
>
<Book className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView('skills')}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title={t("skills.manage")}
>
<Wrench className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView('mcp')}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="MCP"
>
<Server className="h-4 w-4" />
</Button>
{isClaudeApp && (
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView('agents')}
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
title="Agents"
>
<Bot className="h-4 w-4" />
</Button>
)}
</div>
<Button
onClick={() => setIsAddOpen(true)}
size="icon"
className={`ml-2 ${addActionButtonClass}`}
>
<Plus className="h-5 w-5" />
</Button>
</>
)}
</div>
</div>
</header>
<main className="flex-1 overflow-y-scroll">
<div className="mx-auto max-w-4xl px-6 py-6">
<ProviderList
providers={providers}
currentProviderId={currentProviderId}
appId={activeApp}
isLoading={isLoading}
isEditMode={isEditMode}
onSwitch={switchProvider}
onEdit={setEditingProvider}
onDelete={setConfirmDelete}
onDuplicate={handleDuplicateProvider}
onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)}
/>
</div>
<main
className={`flex-1 overflow-y-auto pb-12 px-6 animate-fade-in scroll-overlay ${
currentView === 'providers' ? "pt-24" : "pt-20"
}`}
style={{ overflowX: "hidden" }}
>
{renderContent()}
</main>
<AddProviderDialog
@@ -380,38 +531,14 @@ function App() {
message={
confirmDelete
? t("confirm.deleteProviderMessage", {
name: confirmDelete.name,
})
name: confirmDelete.name,
})
: ""
}
onConfirm={() => void handleConfirmDelete()}
onCancel={() => setConfirmDelete(null)}
/>
<SettingsDialog
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
onImportSuccess={handleImportSuccess}
/>
<PromptPanel
open={isPromptOpen}
onOpenChange={setIsPromptOpen}
appId={activeApp}
/>
<UnifiedMcpPanel open={isMcpOpen} onOpenChange={setIsMcpOpen} />
<Dialog open={isSkillsOpen} onOpenChange={setIsSkillsOpen}>
<DialogContent className="max-w-4xl max-h-[85vh] min-h-[600px] flex flex-col p-0">
<DialogHeader className="sr-only">
<VisuallyHidden>
<DialogTitle>{t("skills.title")}</DialogTitle>
</VisuallyHidden>
</DialogHeader>
<SkillsPage onClose={() => setIsSkillsOpen(false)} />
</DialogContent>
</Dialog>
<DeepLinkImportDialog />
</div>
);

View File

@@ -44,30 +44,45 @@ export function ImportExportSection({
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">
<header className="space-y-2">
<h3 className="text-base font-semibold text-foreground">{t("settings.importExport")}</h3>
<p className="text-sm text-muted-foreground">
{t("settings.importExportHint")}
</p>
</header>
<div className="space-y-3 rounded-lg border border-border-default 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-4 rounded-xl glass-card p-6 border border-white/10">
{/* Export Section */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Save className="h-4 w-4" />
<span></span>
</div>
<Button
type="button"
className="w-full bg-primary/90 hover:bg-primary text-primary-foreground shadow-lg shadow-primary/20"
onClick={onExport}
>
<Save className="mr-2 h-4 w-4" />
{t("settings.exportConfig")}
</Button>
</div>
{/* Divider */}
<div className="border-t border-white/10" />
{/* Import Section */}
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<FolderOpen className="h-4 w-4" />
<span></span>
</div>
<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]"
className="flex-1 min-w-[180px] hover:bg-black/5 dark:hover:bg-white/5 border-white/10"
onClick={onSelectFile}
>
<FolderOpen className="mr-2 h-4 w-4" />
@@ -76,6 +91,7 @@ export function ImportExportSection({
<Button
type="button"
disabled={!selectedFile || isImporting}
className="bg-primary hover:bg-primary/90"
onClick={onImport}
>
{isImporting ? (
@@ -84,11 +100,19 @@ export function ImportExportSection({
{t("settings.importing")}
</span>
) : (
t("settings.import")
<>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("settings.import")}
</>
)}
</Button>
{selectedFile ? (
<Button type="button" variant="ghost" onClick={onClear}>
<Button
type="button"
variant="ghost"
onClick={onClear}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<XCircle className="mr-2 h-4 w-4" />
{t("common.clear")}
</Button>
@@ -96,13 +120,17 @@ export function ImportExportSection({
</div>
{selectedFile ? (
<p className="truncate rounded-md bg-muted/40 px-3 py-2 text-xs font-mono text-muted-foreground">
{selectedFileName}
</p>
<div className="glass rounded-lg border border-white/10 p-3">
<p className="text-xs font-mono text-foreground/80 truncate">
📄 {selectedFileName}
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
{t("settings.noFileSelected")}
</p>
<div className="glass rounded-lg border border-white/10 p-3">
<p className="text-xs text-muted-foreground italic">
{t("settings.noFileSelected")}
</p>
</div>
)}
</div>
@@ -134,15 +162,15 @@ function ImportStatusMessage({
}
const baseClass =
"flex items-start gap-2 rounded-md border px-3 py-2 text-xs leading-relaxed";
"flex items-start gap-3 rounded-xl border p-4 text-sm leading-relaxed backdrop-blur-sm";
if (status === "importing") {
return (
<div className={`${baseClass} border-border-default bg-muted/40`}>
<Loader2 className="mt-0.5 h-4 w-4 animate-spin text-muted-foreground" />
<div className={`${baseClass} border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400`}>
<Loader2 className="mt-0.5 h-5 w-5 flex-shrink-0 animate-spin" />
<div>
<p className="font-medium">{t("settings.importing")}</p>
<p className="text-muted-foreground">{t("common.loading")}</p>
<p className="font-semibold">{t("settings.importing")}</p>
<p className="text-blue-600/80 dark:text-blue-400/80">{t("common.loading")}</p>
</div>
</div>
);
@@ -151,17 +179,17 @@ function ImportStatusMessage({
if (status === "success") {
return (
<div
className={`${baseClass} border-green-200 bg-green-100/70 text-green-700`}
className={`${baseClass} border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-400`}
>
<CheckCircle2 className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">{t("settings.importSuccess")}</p>
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importSuccess")}</p>
{backupId ? (
<p className="text-xs">
<p className="text-xs text-green-600/80 dark:text-green-400/80">
{t("settings.backupId")}: {backupId}
</p>
) : null}
<p>{t("settings.autoReload")}</p>
<p className="text-green-600/80 dark:text-green-400/80">{t("settings.autoReload")}</p>
</div>
</div>
);
@@ -170,12 +198,12 @@ function ImportStatusMessage({
if (status === "partial-success") {
return (
<div
className={`${baseClass} border-yellow-200 bg-yellow-100/70 text-yellow-700`}
className={`${baseClass} border-yellow-500/30 bg-yellow-500/10 text-yellow-700 dark:text-yellow-400`}
>
<AlertCircle className="mt-0.5 h-4 w-4" />
<div className="space-y-1">
<p className="font-medium">{t("settings.importPartialSuccess")}</p>
<p>{t("settings.importPartialHint")}</p>
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importPartialSuccess")}</p>
<p className="text-yellow-600/80 dark:text-yellow-400/80">{t("settings.importPartialHint")}</p>
</div>
</div>
);
@@ -184,11 +212,11 @@ function ImportStatusMessage({
const message = errorMessage || t("settings.importFailed");
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")}</p>
<p>{message}</p>
<div className={`${baseClass} border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400`}>
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="space-y-1.5">
<p className="font-semibold">{t("settings.importFailed")}</p>
<p className="text-red-600/80 dark:text-red-400/80">{message}</p>
</div>
</div>
);

View File

@@ -1,295 +0,0 @@
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 { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
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,
appConfigDir,
resolvedDirs,
updateSettings,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
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 closeAfterSave = useCallback(() => {
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
acknowledgeRestart();
clearSelection();
resetStatus();
onOpenChange(false);
}, [acknowledgeRestart, clearSelection, onOpenChange, 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;
}
closeAfterSave();
} catch (error) {
console.error("[SettingsDialog] Failed to save settings", error);
}
}, [closeDialog, saveSettings]);
const handleRestartLater = useCallback(() => {
setShowRestartPrompt(false);
closeAfterSave();
}, [closeAfterSave]);
const handleRestartNow = useCallback(async () => {
setShowRestartPrompt(false);
if (import.meta.env.DEV) {
toast.success(t("settings.devModeRestartHint"));
closeAfterSave();
return;
}
try {
await settingsApi.restart();
} catch (error) {
console.error("[SettingsDialog] Failed to restart app", error);
toast.error(t("settings.restartFailed"));
} finally {
closeAfterSave();
}
}, [closeAfterSave, t]);
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<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-1 overflow-y-auto px-6 py-4">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col h-full"
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
<TabsContent
value="general"
className="space-y-6 mt-6 min-h-[400px]"
>
{settings ? (
<>
<LanguageSettings
value={settings.language}
onChange={(lang) => updateSettings({ language: lang })}
/>
<ThemeSettings />
<WindowSettings
settings={settings}
onChange={updateSettings}
/>
</>
) : null}
</TabsContent>
<TabsContent
value="advanced"
className="space-y-6 mt-6 min-h-[400px]"
>
{settings ? (
<>
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
<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="mt-6 min-h-[400px]">
<AboutSection isPortable={isPortable} />
</TabsContent>
</Tabs>
</div>
)}
<DialogFooter>
<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")}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
<Dialog
open={showRestartPrompt}
onOpenChange={(open) => !open && handleRestartLater()}
>
<DialogContent zIndex="alert" className="max-w-md">
<DialogHeader>
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
</DialogHeader>
<div className="px-6">
<p className="text-sm text-muted-foreground">
{t("settings.restartRequiredMessage")}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleRestartLater}>
{t("settings.restartLater")}
</Button>
<Button onClick={handleRestartNow}>
{t("settings.restartNow")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Dialog>
);
}

View File

@@ -0,0 +1,302 @@
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 { ThemeSettings } from "@/components/settings/ThemeSettings";
import { WindowSettings } from "@/components/settings/WindowSettings";
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";
import type { SettingsFormState } from "@/hooks/useSettings";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImportSuccess?: () => void | Promise<void>;
}
export function SettingsPage({
open,
onOpenChange,
onImportSuccess,
}: SettingsDialogProps) {
const { t } = useTranslation();
const {
settings,
isLoading,
isSaving,
isPortable,
appConfigDir,
resolvedDirs,
updateSettings,
updateDirectory,
updateAppConfigDir,
browseDirectory,
browseAppConfigDir,
resetDirectory,
resetAppConfigDir,
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 closeAfterSave = useCallback(() => {
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
acknowledgeRestart();
clearSelection();
resetStatus();
onOpenChange(false);
}, [acknowledgeRestart, clearSelection, onOpenChange, resetStatus]);
const handleSave = useCallback(async () => {
try {
const result = await saveSettings(undefined, { silent: false });
if (!result) return;
if (result.requiresRestart) {
setShowRestartPrompt(true);
return;
}
closeAfterSave();
} catch (error) {
console.error("[SettingsPage] Failed to save settings", error);
}
}, [closeDialog, saveSettings]);
const handleRestartLater = useCallback(() => {
setShowRestartPrompt(false);
closeAfterSave();
}, [closeAfterSave]);
const handleRestartNow = useCallback(async () => {
setShowRestartPrompt(false);
if (import.meta.env.DEV) {
toast.success(t("settings.devModeRestartHint"));
closeAfterSave();
return;
}
try {
await settingsApi.restart();
} catch (error) {
console.error("[SettingsPage] Failed to restart app", error);
toast.error(t("settings.restartFailed"));
} finally {
closeAfterSave();
}
}, [closeAfterSave, t]);
// 通用设置即时保存(无需手动点击)
const handleAutoSave = useCallback(
async (updates: Partial<SettingsFormState>) => {
if (!settings) return;
updateSettings(updates);
try {
const result = await saveSettings(updates, { silent: true });
if (result?.requiresRestart) {
setShowRestartPrompt(true);
}
} catch (error) {
console.error("[SettingsPage] Failed to autosave settings", error);
toast.error(
t("settings.saveFailedGeneric", {
defaultValue: "保存失败,请重试",
}),
);
}
},
[saveSettings, settings, t, updateSettings],
);
const isBusy = useMemo(() => isLoading && !settings, [isLoading, settings]);
return (
<div className="mx-auto max-w-5xl flex flex-col h-[calc(100vh-8rem)]">
{isBusy ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col h-full"
>
<TabsList className="grid w-full grid-cols-3 mb-6 glass rounded-xl">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto pr-2">
<TabsContent
value="general"
className="space-y-6 mt-0"
>
{settings ? (
<>
<LanguageSettings
value={settings.language}
onChange={(lang) => handleAutoSave({ language: lang })}
/>
<ThemeSettings />
<WindowSettings
settings={settings}
onChange={handleAutoSave}
/>
</>
) : null}
</TabsContent>
<TabsContent
value="advanced"
className="space-y-6 mt-0"
>
{settings ? (
<>
<DirectorySettings
appConfigDir={appConfigDir}
resolvedDirs={resolvedDirs}
onAppConfigChange={updateAppConfigDir}
onBrowseAppConfig={browseAppConfigDir}
onResetAppConfig={resetAppConfigDir}
claudeDir={settings.claudeConfigDir}
codexDir={settings.codexConfigDir}
geminiDir={settings.geminiConfigDir}
onDirectoryChange={updateDirectory}
onBrowseDirectory={browseDirectory}
onResetDirectory={resetDirectory}
/>
<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="mt-0">
<AboutSection isPortable={isPortable} />
</TabsContent>
</div>
{activeTab === "advanced" ? (
<div className="flex-shrink-0 pt-6 border-t border-white/5 sticky bottom-0 bg-background/95 backdrop-blur-sm">
<Button
onClick={handleSave}
className="w-full bg-primary hover:bg-primary/90"
disabled={isSaving}
>
{isSaving ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("settings.saving")}
</span>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t("common.save")}
</>
)}
</Button>
</div>
) : null}
</Tabs>
)}
<Dialog
open={showRestartPrompt}
onOpenChange={(open) => !open && handleRestartLater()}
>
<DialogContent zIndex="alert" className="max-w-md glass-card border-white/10">
<DialogHeader>
<DialogTitle>{t("settings.restartRequired")}</DialogTitle>
</DialogHeader>
<div className="px-6">
<p className="text-sm text-muted-foreground">
{t("settings.restartRequiredMessage")}
</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleRestartLater} className="hover:bg-white/5">
{t("settings.restartLater")}
</Button>
<Button onClick={handleRestartNow} className="bg-primary hover:bg-primary/90">
{t("settings.restartNow")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -33,7 +33,10 @@ export interface UseSettingsResult {
browseAppConfigDir: () => Promise<void>;
resetDirectory: (app: AppId) => Promise<void>;
resetAppConfigDir: () => Promise<void>;
saveSettings: () => Promise<SaveResult | null>;
saveSettings: (
overrides?: Partial<SettingsFormState>,
options?: { silent?: boolean },
) => Promise<SaveResult | null>;
resetSettings: () => void;
acknowledgeRestart: () => void;
}
@@ -115,24 +118,29 @@ export function useSettings(): UseSettingsResult {
]);
// 保存设置
const saveSettings = useCallback(async (): Promise<SaveResult | null> => {
if (!settings) return null;
const saveSettings = useCallback(
async (
overrides?: Partial<SettingsFormState>,
options?: { silent?: boolean },
): Promise<SaveResult | null> => {
const mergedSettings = settings ? { ...settings, ...overrides } : null;
if (!mergedSettings) return null;
try {
const sanitizedAppDir = sanitizeDir(appConfigDir);
const sanitizedClaudeDir = sanitizeDir(settings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(settings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(settings.geminiConfigDir);
const sanitizedClaudeDir = sanitizeDir(mergedSettings.claudeConfigDir);
const sanitizedCodexDir = sanitizeDir(mergedSettings.codexConfigDir);
const sanitizedGeminiDir = sanitizeDir(mergedSettings.geminiConfigDir);
const previousAppDir = initialAppConfigDir;
const previousClaudeDir = sanitizeDir(data?.claudeConfigDir);
const previousCodexDir = sanitizeDir(data?.codexConfigDir);
const previousGeminiDir = sanitizeDir(data?.geminiConfigDir);
const payload: Settings = {
...settings,
...mergedSettings,
claudeConfigDir: sanitizedClaudeDir,
codexConfigDir: sanitizedCodexDir,
geminiConfigDir: sanitizedGeminiDir,
language: settings.language,
language: mergedSettings.language,
};
await saveMutation.mutateAsync(payload);
@@ -191,9 +199,23 @@ export function useSettings(): UseSettingsResult {
const appDirChanged = sanitizedAppDir !== (previousAppDir ?? undefined);
setRequiresRestart(appDirChanged);
if (!options?.silent) {
toast.success(
t("notifications.settingsSaved", {
defaultValue: "设置已保存",
}),
);
}
return { requiresRestart: appDirChanged };
} catch (error) {
console.error("[useSettings] Failed to save settings", error);
toast.error(
t("notifications.settingsSaveFailed", {
defaultValue: "保存设置失败: {{error}}",
error: (error as Error)?.message ?? String(error),
}),
);
throw error;
}
}, [