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.
303 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|