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:
375
src/App.tsx
375
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<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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
302
src/components/settings/SettingsPage.tsx
Normal file
302
src/components/settings/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}, [
|
||||
|
||||
Reference in New Issue
Block a user