Files
cc-switch/src/components/settings/SettingsPage.tsx
YoVinchen 764ba81ea6 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.
2025-11-21 09:28:11 +08:00

303 lines
9.2 KiB
TypeScript

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>
);
}