From 764ba81ea61a2bf9b0c779cfa1f64b3441a57099 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Fri, 21 Nov 2025 09:28:11 +0800 Subject: [PATCH] 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. --- src/App.tsx | 375 ++++++++++++------ .../settings/ImportExportSection.tsx | 116 ++++-- src/components/settings/SettingsDialog.tsx | 295 -------------- src/components/settings/SettingsPage.tsx | 302 ++++++++++++++ src/hooks/useSettings.ts | 38 +- 5 files changed, 655 insertions(+), 471 deletions(-) delete mode 100644 src/components/settings/SettingsDialog.tsx create mode 100644 src/components/settings/SettingsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 6402605..c874da8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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("claude"); - const [isEditMode, setIsEditMode] = useState(false); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [currentView, setCurrentView] = useState('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(null); const [usageProvider, setUsageProvider] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [envConflicts, setEnvConflicts] = useState([]); const [showEnvBanner, setShowEnvBanner] = useState(false); + const promptPanelRef = useRef(null); + const mcpPanelRef = useRef(null); + const skillsPageRef = useRef(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 ( + setCurrentView('providers')} + onImportSuccess={handleImportSuccess} + /> + ); + case 'prompts': + return ( + setCurrentView('providers')} + appId={activeApp} + /> + ); + case 'skills': + return setCurrentView('providers')} />; + case 'mcp': + return ( + setCurrentView('providers')} + /> + ); + case 'agents': + return ( + setCurrentView('providers')} + /> + ); + default: + return ( +
+ setIsAddOpen(true)} + /> +
+ ); + } + }; + return ( -
+
+ {/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */} +
{/* 环境变量警告横幅 */} {showEnvBanner && envConflicts.length > 0 && ( setShowEnvBanner(false)} + onDismiss={() => { + setShowEnvBanner(false); + sessionStorage.setItem("env_banner_dismissed", "true"); + }} onDeleted={async () => { // 删除后重新检测 try { @@ -255,92 +326,172 @@ function App() { /> )} -
-
+
+
+
- - CC Switch - - - - setIsSettingsOpen(true)} /> + {currentView !== 'providers' ? ( +
+ +

+ {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"} +

+
+ ) : ( + <> + + CC Switch + + + setCurrentView('settings')} /> + + )}
-
- - - - - +
+ {currentView === 'prompts' && ( + + )} + {currentView === 'mcp' && ( + + )} + {currentView === 'skills' && ( + <> + + + + )} + {currentView === 'providers' && ( + <> + + +
+ +
+ + {isClaudeApp && ( + + )} + + {isClaudeApp && ( + + )} +
+ + + + )}
-
-
- setIsAddOpen(true)} - /> -
+
+ {renderContent()}
void handleConfirmDelete()} onCancel={() => setConfirmDelete(null)} /> - - - - - - - - - - - {t("skills.title")} - - - setIsSkillsOpen(false)} /> - -
); diff --git a/src/components/settings/ImportExportSection.tsx b/src/components/settings/ImportExportSection.tsx index f9c195f..31d9dad 100644 --- a/src/components/settings/ImportExportSection.tsx +++ b/src/components/settings/ImportExportSection.tsx @@ -44,30 +44,45 @@ export function ImportExportSection({ return (
-
-

{t("settings.importExport")}

-

+

+

{t("settings.importExport")}

+

{t("settings.importExportHint")}

-
- +
+ {/* Export Section */} +
+
+ + 导出配置 +
+ +
+ + {/* Divider */} +
+ + {/* Import Section */} +
+
+ + 导入配置 +
-
{selectedFile ? ( - @@ -96,13 +120,17 @@ export function ImportExportSection({
{selectedFile ? ( -

- {selectedFileName} -

+
+

+ 📄 {selectedFileName} +

+
) : ( -

- {t("settings.noFileSelected")} -

+
+

+ {t("settings.noFileSelected")} +

+
)}
@@ -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 ( -
- +
+
-

{t("settings.importing")}

-

{t("common.loading")}

+

{t("settings.importing")}

+

{t("common.loading")}

); @@ -151,17 +179,17 @@ function ImportStatusMessage({ if (status === "success") { return (
- -
-

{t("settings.importSuccess")}

+ +
+

{t("settings.importSuccess")}

{backupId ? ( -

+

{t("settings.backupId")}: {backupId}

) : null} -

{t("settings.autoReload")}

+

{t("settings.autoReload")}

); @@ -170,12 +198,12 @@ function ImportStatusMessage({ if (status === "partial-success") { return (
- -
-

{t("settings.importPartialSuccess")}

-

{t("settings.importPartialHint")}

+ +
+

{t("settings.importPartialSuccess")}

+

{t("settings.importPartialHint")}

); @@ -184,11 +212,11 @@ function ImportStatusMessage({ const message = errorMessage || t("settings.importFailed"); return ( -
- -
-

{t("settings.importFailed")}

-

{message}

+
+ +
+

{t("settings.importFailed")}

+

{message}

); diff --git a/src/components/settings/SettingsDialog.tsx b/src/components/settings/SettingsDialog.tsx deleted file mode 100644 index 8eb7dbf..0000000 --- a/src/components/settings/SettingsDialog.tsx +++ /dev/null @@ -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; -} - -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("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 ( - - - - {t("settings.title")} - - - {isBusy ? ( -
- -
- ) : ( -
- - - - {t("settings.tabGeneral")} - - - {t("settings.tabAdvanced")} - - {t("common.about")} - - - - {settings ? ( - <> - updateSettings({ language: lang })} - /> - - - - ) : null} - - - - {settings ? ( - <> - - - - ) : null} - - - - - - -
- )} - - - - - -
- - !open && handleRestartLater()} - > - - - {t("settings.restartRequired")} - -
-

- {t("settings.restartRequiredMessage")} -

-
- - - - -
-
-
- ); -} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx new file mode 100644 index 0000000..3dec31c --- /dev/null +++ b/src/components/settings/SettingsPage.tsx @@ -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; +} + +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("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) => { + 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 ( +
+ {isBusy ? ( +
+ +
+ ) : ( + + + + {t("settings.tabGeneral")} + + + {t("settings.tabAdvanced")} + + {t("common.about")} + + +
+ + {settings ? ( + <> + handleAutoSave({ language: lang })} + /> + + + + ) : null} + + + + {settings ? ( + <> + + + + ) : null} + + + + + +
+ + {activeTab === "advanced" ? ( +
+ +
+ ) : null} +
+ )} + + !open && handleRestartLater()} + > + + + {t("settings.restartRequired")} + +
+

+ {t("settings.restartRequiredMessage")} +

+
+ + + + +
+
+
+ ); +} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 881198d..2b1c9f3 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -33,7 +33,10 @@ export interface UseSettingsResult { browseAppConfigDir: () => Promise; resetDirectory: (app: AppId) => Promise; resetAppConfigDir: () => Promise; - saveSettings: () => Promise; + saveSettings: ( + overrides?: Partial, + options?: { silent?: boolean }, + ) => Promise; resetSettings: () => void; acknowledgeRestart: () => void; } @@ -115,24 +118,29 @@ export function useSettings(): UseSettingsResult { ]); // 保存设置 - const saveSettings = useCallback(async (): Promise => { - if (!settings) return null; + const saveSettings = useCallback( + async ( + overrides?: Partial, + options?: { silent?: boolean }, + ): Promise => { + 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; } }, [