refactor: extract business logic to useProviderActions hook

Major improvements:
- Create `src/hooks/useProviderActions.ts` (147 lines)
  - Consolidate provider operations (add, update, delete, switch)
  - Extract Claude plugin sync logic
  - Extract usage script save logic

- Simplify `App.tsx` (347 → 226 lines, -35%)
  - Remove 8 callback functions
  - Remove Claude plugin sync logic
  - Remove usage script save logic
  - Cleaner and more maintainable

- Replace `onNotify` prop with `toast` in:
  - `UsageScriptModal.tsx`
  - `McpPanel.tsx`
  - `McpFormModal.tsx`
  - `McpWizardModal.tsx`
  - Unified notification system using sonner

Benefits:
- Reduced coupling and improved maintainability
- Business logic isolated in hooks, easier to test
- Consistent notification system across the app
This commit is contained in:
Jason
2025-10-17 17:49:16 +08:00
parent 8d6ab63648
commit f963d58e6a
6 changed files with 234 additions and 226 deletions

View File

@@ -1,22 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { Plus, Settings } from "lucide-react"; import { Plus, Settings } from "lucide-react";
import type { Provider, UsageScript } from "@/types"; import type { Provider } from "@/types";
import { import { useProvidersQuery } from "@/lib/query";
useProvidersQuery, import { providersApi, settingsApi, type AppType, type ProviderSwitchEvent } from "@/lib/api";
useAddProviderMutation, import { useProviderActions } from "@/hooks/useProviderActions";
useUpdateProviderMutation,
useDeleteProviderMutation,
useSwitchProviderMutation,
} from "@/lib/query";
import {
providersApi,
settingsApi,
type AppType,
type ProviderSwitchEvent,
} from "@/lib/api";
import { extractErrorMessage } from "@/utils/errorUtils"; import { extractErrorMessage } from "@/utils/errorUtils";
import { AppSwitcher } from "@/components/AppSwitcher"; import { AppSwitcher } from "@/components/AppSwitcher";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
@@ -32,7 +21,6 @@ import { Button } from "@/components/ui/button";
function App() { function App() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeApp, setActiveApp] = useState<AppType>("claude"); const [activeApp, setActiveApp] = useState<AppType>("claude");
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@@ -46,11 +34,16 @@ function App() {
const providers = useMemo(() => data?.providers ?? {}, [data]); const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? ""; const currentProviderId = data?.currentProviderId ?? "";
const addProviderMutation = useAddProviderMutation(activeApp); // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const updateProviderMutation = useUpdateProviderMutation(activeApp); const {
const deleteProviderMutation = useDeleteProviderMutation(activeApp); addProvider,
const switchProviderMutation = useSwitchProviderMutation(activeApp); updateProvider,
switchProvider,
deleteProvider,
saveUsageScript,
} = useProviderActions(activeApp);
// 监听来自托盘菜单的切换事件
useEffect(() => { useEffect(() => {
let unsubscribe: (() => void) | undefined; let unsubscribe: (() => void) | undefined;
@@ -74,154 +67,42 @@ function App() {
}; };
}, [activeApp, refetch]); }, [activeApp, refetch]);
const handleNotify = useCallback( // 打开网站链接
(message: string, type: "success" | "error", duration?: number) => { const handleOpenWebsite = async (url: string) => {
const options = duration ? { duration } : undefined;
if (type === "error") {
toast.error(message, options);
} else {
toast.success(message, options);
}
},
[],
);
const handleOpenWebsite = useCallback(
async (url: string) => {
try {
await settingsApi.openExternal(url);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("notifications.openLinkFailed", {
defaultValue: "链接打开失败",
});
toast.error(detail);
}
},
[t],
);
const handleAddProvider = useCallback(
async (provider: Omit<Provider, "id">) => {
await addProviderMutation.mutateAsync(provider);
},
[addProviderMutation],
);
const handleEditProvider = useCallback(
async (provider: Provider) => {
try {
await updateProviderMutation.mutateAsync(provider);
await providersApi.updateTrayMenu();
setEditingProvider(null);
} catch {
// 错误提示由 mutation 统一处理
}
},
[updateProviderMutation],
);
const handleSyncClaudePlugin = useCallback(
async (provider: Provider) => {
if (activeApp !== "claude") return;
try {
const settings = await settingsApi.get();
if (!settings?.enableClaudePluginIntegration) {
return;
}
const isOfficial = provider.category === "official";
await settingsApi.applyClaudePluginConfig({ official: isOfficial });
toast.success(
isOfficial
? t("notifications.appliedToClaudePlugin", {
defaultValue: "已同步为官方配置",
})
: t("notifications.removedFromClaudePlugin", {
defaultValue: "已移除 Claude 插件配置",
}),
{ duration: 2200 },
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("notifications.syncClaudePluginFailed", {
defaultValue: "同步 Claude 插件失败",
});
toast.error(detail, { duration: 4200 });
}
},
[activeApp, t],
);
const handleSwitchProvider = useCallback(
async (provider: Provider) => {
try {
await switchProviderMutation.mutateAsync(provider.id);
await handleSyncClaudePlugin(provider);
} catch {
// 错误提示由 mutation 与同步函数处理
}
},
[switchProviderMutation, handleSyncClaudePlugin],
);
const handleRequestDelete = useCallback((provider: Provider) => {
setConfirmDelete(provider);
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!confirmDelete) return;
try { try {
await deleteProviderMutation.mutateAsync(confirmDelete.id); await settingsApi.openExternal(url);
} finally { } catch (error) {
setConfirmDelete(null); const detail =
extractErrorMessage(error) ||
t("notifications.openLinkFailed", {
defaultValue: "链接打开失败",
});
toast.error(detail);
} }
}, [confirmDelete, deleteProviderMutation]); };
const handleImportSuccess = useCallback(async () => { // 编辑供应商
const handleEditProvider = async (provider: Provider) => {
await updateProvider(provider);
setEditingProvider(null);
};
// 确认删除供应商
const handleConfirmDelete = async () => {
if (!confirmDelete) return;
await deleteProvider(confirmDelete.id);
setConfirmDelete(null);
};
// 导入配置成功后刷新
const handleImportSuccess = async () => {
await refetch(); await refetch();
try { try {
await providersApi.updateTrayMenu(); await providersApi.updateTrayMenu();
} catch (error) { } catch (error) {
console.error("[App] Failed to refresh tray menu", error); console.error("[App] Failed to refresh tray menu", error);
} }
}, [refetch]); };
const handleSaveUsageScript = useCallback(
async (provider: Provider, script: UsageScript) => {
try {
const updatedProvider: Provider = {
...provider,
meta: {
...provider.meta,
usage_script: script,
},
};
await providersApi.update(updatedProvider, activeApp);
await queryClient.invalidateQueries({
queryKey: ["providers", activeApp],
});
toast.success(
t("provider.usageSaved", {
defaultValue: "用量查询配置已保存",
}),
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("provider.usageSaveFailed", {
defaultValue: "用量查询配置保存失败",
});
toast.error(detail);
}
},
[activeApp, queryClient, t],
);
return ( return (
<div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950"> <div className="flex h-screen flex-col bg-gray-50 dark:bg-gray-950">
@@ -271,9 +152,9 @@ function App() {
currentProviderId={currentProviderId} currentProviderId={currentProviderId}
appType={activeApp} appType={activeApp}
isLoading={isLoading} isLoading={isLoading}
onSwitch={handleSwitchProvider} onSwitch={switchProvider}
onEdit={setEditingProvider} onEdit={setEditingProvider}
onDelete={handleRequestDelete} onDelete={setConfirmDelete}
onConfigureUsage={setUsageProvider} onConfigureUsage={setUsageProvider}
onOpenWebsite={handleOpenWebsite} onOpenWebsite={handleOpenWebsite}
onCreate={() => setIsAddOpen(true)} onCreate={() => setIsAddOpen(true)}
@@ -285,7 +166,7 @@ function App() {
open={isAddOpen} open={isAddOpen}
onOpenChange={setIsAddOpen} onOpenChange={setIsAddOpen}
appType={activeApp} appType={activeApp}
onSubmit={handleAddProvider} onSubmit={addProvider}
/> />
<EditProviderDialog <EditProviderDialog
@@ -307,9 +188,8 @@ function App() {
isOpen={Boolean(usageProvider)} isOpen={Boolean(usageProvider)}
onClose={() => setUsageProvider(null)} onClose={() => setUsageProvider(null)}
onSave={(script) => { onSave={(script) => {
void handleSaveUsageScript(usageProvider, script); void saveUsageScript(usageProvider, script);
}} }}
onNotify={handleNotify}
/> />
)} )}
@@ -338,7 +218,6 @@ function App() {
open={isMcpOpen} open={isMcpOpen}
onOpenChange={setIsMcpOpen} onOpenChange={setIsMcpOpen}
appType={activeApp} appType={activeApp}
onNotify={handleNotify}
/> />
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Play, Wand2 } from "lucide-react"; import { Play, Wand2 } from "lucide-react";
import { toast } from "sonner";
import { Provider, UsageScript } from "../types"; import { Provider, UsageScript } from "../types";
import { usageApi, type AppType } from "@/lib/api"; import { usageApi, type AppType } from "@/lib/api";
import JsonEditor from "./JsonEditor"; import JsonEditor from "./JsonEditor";
@@ -21,11 +22,6 @@ interface UsageScriptModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSave: (script: UsageScript) => void; onSave: (script: UsageScript) => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
} }
// 预设模板JS 对象字面量格式) // 预设模板JS 对象字面量格式)
@@ -91,7 +87,6 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
isOpen, isOpen,
onClose, onClose,
onSave, onSave,
onNotify,
}) => { }) => {
const [script, setScript] = useState<UsageScript>(() => { const [script, setScript] = useState<UsageScript>(() => {
return ( return (
@@ -109,19 +104,18 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const handleSave = () => { const handleSave = () => {
// 验证脚本格式 // 验证脚本格式
if (script.enabled && !script.code.trim()) { if (script.enabled && !script.code.trim()) {
onNotify?.("脚本配置不能为空", "error"); toast.error("脚本配置不能为空");
return; return;
} }
// 基本的 JS 语法检查(检查是否包含 return 语句) // 基本的 JS 语法检查(检查是否包含 return 语句)
if (script.enabled && !script.code.includes("return")) { if (script.enabled && !script.code.includes("return")) {
onNotify?.("脚本必须包含 return 语句", "error", 5000); toast.error("脚本必须包含 return 语句", { duration: 5000 });
return; return;
} }
onSave(script); onSave(script);
onClose(); onClose();
onNotify?.("用量查询配置已保存", "success", 2000);
}; };
const handleTest = async () => { const handleTest = async () => {
@@ -136,12 +130,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
return `${planInfo} 剩余: ${plan.remaining} ${plan.unit}`; return `${planInfo} 剩余: ${plan.remaining} ${plan.unit}`;
}) })
.join(", "); .join(", ");
onNotify?.(`测试成功!${summary}`, "success", 3000); toast.success(`测试成功!${summary}`, { duration: 3000 });
} else { } else {
onNotify?.(`测试失败: ${result.error || "无数据返回"}`, "error", 5000); toast.error(`测试失败: ${result.error || "无数据返回"}`, {
duration: 5000,
});
} }
} catch (error: any) { } catch (error: any) {
onNotify?.(`测试失败: ${error?.message || "未知错误"}`, "error", 5000); toast.error(`测试失败: ${error?.message || "未知错误"}`, {
duration: 5000,
});
} finally { } finally {
setTesting(false); setTesting(false);
} }
@@ -158,9 +156,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
printWidth: 80, printWidth: 80,
}); });
setScript({ ...script, code: formatted.trim() }); setScript({ ...script, code: formatted.trim() });
onNotify?.("格式化成功", "success", 1000); toast.success("格式化成功", { duration: 1000 });
} catch (error: any) { } catch (error: any) {
onNotify?.(`格式化失败: ${error?.message || "语法错误"}`, "error", 3000); toast.error(`格式化失败: ${error?.message || "语法错误"}`, {
duration: 3000,
});
} }
}; };

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useState, useEffect } from "react"; import React, { useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { import {
Save, Save,
AlertCircle, AlertCircle,
@@ -36,11 +37,6 @@ interface McpFormModalProps {
) => Promise<void>; ) => Promise<void>;
onClose: () => void; onClose: () => void;
existingIds?: string[]; existingIds?: string[];
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
} }
/** /**
@@ -55,7 +51,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
onSave, onSave,
onClose, onClose,
existingIds = [], existingIds = [],
onNotify,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatTomlError, validateTomlConfig, validateJsonConfig } = const { formatTomlError, validateTomlConfig, validateJsonConfig } =
@@ -278,7 +273,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const handleSubmit = async () => { const handleSubmit = async () => {
const trimmedId = formId.trim(); const trimmedId = formId.trim();
if (!trimmedId) { if (!trimmedId) {
onNotify?.(t("mcp.error.idRequired"), "error", 3000); toast.error(t("mcp.error.idRequired"), { duration: 3000 });
return; return;
} }
@@ -296,7 +291,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const tomlError = validateTomlConfig(formConfig); const tomlError = validateTomlConfig(formConfig);
setConfigError(tomlError); setConfigError(tomlError);
if (tomlError) { if (tomlError) {
onNotify?.(t("mcp.error.tomlInvalid"), "error", 3000); toast.error(t("mcp.error.tomlInvalid"), { duration: 3000 });
return; return;
} }
@@ -313,7 +308,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
} catch (e: any) { } catch (e: any) {
const msg = e?.message || String(e); const msg = e?.message || String(e);
setConfigError(formatTomlError(msg)); setConfigError(formatTomlError(msg));
onNotify?.(t("mcp.error.tomlInvalid"), "error", 4000); toast.error(t("mcp.error.tomlInvalid"), { duration: 4000 });
return; return;
} }
} }
@@ -322,7 +317,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const jsonError = validateJsonConfig(formConfig); const jsonError = validateJsonConfig(formConfig);
setConfigError(jsonError); setConfigError(jsonError);
if (jsonError) { if (jsonError) {
onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000); toast.error(t("mcp.error.jsonInvalid"), { duration: 3000 });
return; return;
} }
@@ -338,7 +333,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
serverSpec = JSON.parse(formConfig) as McpServerSpec; serverSpec = JSON.parse(formConfig) as McpServerSpec;
} catch (e: any) { } catch (e: any) {
setConfigError(t("mcp.error.jsonInvalid")); setConfigError(t("mcp.error.jsonInvalid"));
onNotify?.(t("mcp.error.jsonInvalid"), "error", 4000); toast.error(t("mcp.error.jsonInvalid"), { duration: 4000 });
return; return;
} }
} }
@@ -346,11 +341,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
// 前置必填校验 // 前置必填校验
if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) { if (serverSpec?.type === "stdio" && !serverSpec?.command?.trim()) {
onNotify?.(t("mcp.error.commandRequired"), "error", 3000); toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return; return;
} }
if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) { if (serverSpec?.type === "http" && !serverSpec?.url?.trim()) {
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000); toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
return; return;
} }
@@ -408,7 +403,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const detail = extractErrorMessage(error); const detail = extractErrorMessage(error);
const mapped = translateMcpBackendError(detail, t); const mapped = translateMcpBackendError(detail, t);
const msg = mapped || detail || t("mcp.error.saveFailed"); const msg = mapped || detail || t("mcp.error.saveFailed");
onNotify?.(msg, "error", mapped || detail ? 6000 : 4000); toast.error(msg, { duration: mapped || detail ? 6000 : 4000 });
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -678,7 +673,6 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
isOpen={isWizardOpen} isOpen={isWizardOpen}
onClose={() => setIsWizardOpen(false)} onClose={() => setIsWizardOpen(false)}
onApply={handleWizardApply} onApply={handleWizardApply}
onNotify={onNotify}
initialTitle={formId} initialTitle={formId}
initialServer={wizardInitialSpec} initialServer={wizardInitialSpec}
/> />

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Plus, Server, Check } from "lucide-react"; import { Plus, Server, Check } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -13,16 +14,14 @@ import { McpServer } from "@/types";
import McpListItem from "./McpListItem"; import McpListItem from "./McpListItem";
import McpFormModal from "./McpFormModal"; import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog"; import { ConfirmDialog } from "../ConfirmDialog";
import { extractErrorMessage, translateMcpBackendError } from "@/utils/errorUtils"; import {
extractErrorMessage,
translateMcpBackendError,
} from "@/utils/errorUtils";
interface McpPanelProps { interface McpPanelProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
appType: AppType; appType: AppType;
} }
@@ -33,7 +32,6 @@ interface McpPanelProps {
const McpPanel: React.FC<McpPanelProps> = ({ const McpPanel: React.FC<McpPanelProps> = ({
open, open,
onOpenChange, onOpenChange,
onNotify,
appType, appType,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -91,20 +89,18 @@ const McpPanel: React.FC<McpPanelProps> = ({
try { try {
// 后台调用 API // 后台调用 API
await mcpApi.setEnabled(appType, id, enabled); await mcpApi.setEnabled(appType, id, enabled);
onNotify?.( toast.success(
enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"), enabled ? t("mcp.msg.enabled") : t("mcp.msg.disabled"),
"success", { duration: 1500 },
1500,
); );
} catch (e: any) { } catch (e: any) {
// 失败时回滚 // 失败时回滚
setServers(previousServers); setServers(previousServers);
const detail = extractErrorMessage(e); const detail = extractErrorMessage(e);
const mapped = translateMcpBackendError(detail, t); const mapped = translateMcpBackendError(detail, t);
onNotify?.( toast.error(
mapped || detail || t("mcp.error.saveFailed"), mapped || detail || t("mcp.error.saveFailed"),
"error", { duration: mapped || detail ? 6000 : 5000 },
mapped || detail ? 6000 : 5000,
); );
} }
}; };
@@ -129,14 +125,13 @@ const McpPanel: React.FC<McpPanelProps> = ({
await mcpApi.deleteServerInConfig(appType, id); await mcpApi.deleteServerInConfig(appType, id);
await reload(); await reload();
setConfirmDialog(null); setConfirmDialog(null);
onNotify?.(t("mcp.msg.deleted"), "success", 1500); toast.success(t("mcp.msg.deleted"), { duration: 1500 });
} catch (e: any) { } catch (e: any) {
const detail = extractErrorMessage(e); const detail = extractErrorMessage(e);
const mapped = translateMcpBackendError(detail, t); const mapped = translateMcpBackendError(detail, t);
onNotify?.( toast.error(
mapped || detail || t("mcp.error.deleteFailed"), mapped || detail || t("mcp.error.deleteFailed"),
"error", { duration: mapped || detail ? 6000 : 5000 },
mapped || detail ? 6000 : 5000,
); );
} }
}, },
@@ -156,14 +151,13 @@ const McpPanel: React.FC<McpPanelProps> = ({
await reload(); await reload();
setIsFormOpen(false); setIsFormOpen(false);
setEditingId(null); setEditingId(null);
onNotify?.(t("mcp.msg.saved"), "success", 1500); toast.success(t("mcp.msg.saved"), { duration: 1500 });
} catch (e: any) { } catch (e: any) {
const detail = extractErrorMessage(e); const detail = extractErrorMessage(e);
const mapped = translateMcpBackendError(detail, t); const mapped = translateMcpBackendError(detail, t);
onNotify?.( toast.error(
mapped || detail || t("mcp.error.saveFailed"), mapped || detail || t("mcp.error.saveFailed"),
"error", { duration: mapped || detail ? 6000 : 5000 },
mapped || detail ? 6000 : 5000,
); );
// 继续抛出错误,让表单层可以给到直观反馈(避免被更高层遮挡) // 继续抛出错误,让表单层可以给到直观反馈(避免被更高层遮挡)
throw e; throw e;
@@ -283,7 +277,6 @@ const McpPanel: React.FC<McpPanelProps> = ({
existingIds={Object.keys(servers)} existingIds={Object.keys(servers)}
onSave={handleSave} onSave={handleSave}
onClose={handleCloseForm} onClose={handleCloseForm}
onNotify={onNotify}
/> />
)} )}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save } from "lucide-react"; import { Save } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -15,11 +16,6 @@ interface McpWizardModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onApply: (title: string, json: string) => void; onApply: (title: string, json: string) => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
initialTitle?: string; initialTitle?: string;
initialServer?: McpServerSpec; initialServer?: McpServerSpec;
} }
@@ -80,7 +76,6 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
isOpen, isOpen,
onClose, onClose,
onApply, onApply,
onNotify,
initialTitle, initialTitle,
initialServer, initialServer,
}) => { }) => {
@@ -137,15 +132,15 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
const handleApply = () => { const handleApply = () => {
if (!wizardTitle.trim()) { if (!wizardTitle.trim()) {
onNotify?.(t("mcp.error.idRequired"), "error", 3000); toast.error(t("mcp.error.idRequired"), { duration: 3000 });
return; return;
} }
if (wizardType === "stdio" && !wizardCommand.trim()) { if (wizardType === "stdio" && !wizardCommand.trim()) {
onNotify?.(t("mcp.error.commandRequired"), "error", 3000); toast.error(t("mcp.error.commandRequired"), { duration: 3000 });
return; return;
} }
if (wizardType === "http" && !wizardUrl.trim()) { if (wizardType === "http" && !wizardUrl.trim()) {
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000); toast.error(t("mcp.wizard.urlRequired"), { duration: 3000 });
return; return;
} }

View File

@@ -0,0 +1,147 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { providersApi, settingsApi, type AppType } from "@/lib/api";
import type { Provider, UsageScript } from "@/types";
import {
useAddProviderMutation,
useUpdateProviderMutation,
useDeleteProviderMutation,
useSwitchProviderMutation,
} from "@/lib/query";
import { extractErrorMessage } from "@/utils/errorUtils";
/**
* Hook for managing provider actions (add, update, delete, switch)
* Extracts business logic from App.tsx
*/
export function useProviderActions(activeApp: AppType) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const addProviderMutation = useAddProviderMutation(activeApp);
const updateProviderMutation = useUpdateProviderMutation(activeApp);
const deleteProviderMutation = useDeleteProviderMutation(activeApp);
const switchProviderMutation = useSwitchProviderMutation(activeApp);
// Claude 插件同步逻辑
const syncClaudePlugin = useCallback(
async (provider: Provider) => {
if (activeApp !== "claude") return;
try {
const settings = await settingsApi.get();
if (!settings?.enableClaudePluginIntegration) {
return;
}
const isOfficial = provider.category === "official";
await settingsApi.applyClaudePluginConfig({ official: isOfficial });
toast.success(
isOfficial
? t("notifications.appliedToClaudePlugin", {
defaultValue: "已同步为官方配置",
})
: t("notifications.removedFromClaudePlugin", {
defaultValue: "已移除 Claude 插件配置",
}),
{ duration: 2200 },
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("notifications.syncClaudePluginFailed", {
defaultValue: "同步 Claude 插件失败",
});
toast.error(detail, { duration: 4200 });
}
},
[activeApp, t],
);
// 添加供应商
const addProvider = useCallback(
async (provider: Omit<Provider, "id">) => {
await addProviderMutation.mutateAsync(provider);
},
[addProviderMutation],
);
// 更新供应商
const updateProvider = useCallback(
async (provider: Provider) => {
await updateProviderMutation.mutateAsync(provider);
await providersApi.updateTrayMenu();
},
[updateProviderMutation],
);
// 切换供应商
const switchProvider = useCallback(
async (provider: Provider) => {
try {
await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider);
} catch {
// 错误提示由 mutation 与同步函数处理
}
},
[switchProviderMutation, syncClaudePlugin],
);
// 删除供应商
const deleteProvider = useCallback(
async (id: string) => {
await deleteProviderMutation.mutateAsync(id);
},
[deleteProviderMutation],
);
// 保存用量脚本
const saveUsageScript = useCallback(
async (provider: Provider, script: UsageScript) => {
try {
const updatedProvider: Provider = {
...provider,
meta: {
...provider.meta,
usage_script: script,
},
};
await providersApi.update(updatedProvider, activeApp);
await queryClient.invalidateQueries({
queryKey: ["providers", activeApp],
});
toast.success(
t("provider.usageSaved", {
defaultValue: "用量查询配置已保存",
}),
);
} catch (error) {
const detail =
extractErrorMessage(error) ||
t("provider.usageSaveFailed", {
defaultValue: "用量查询配置保存失败",
});
toast.error(detail);
}
},
[activeApp, queryClient, t],
);
return {
addProvider,
updateProvider,
switchProvider,
deleteProvider,
saveUsageScript,
isLoading:
addProviderMutation.isPending ||
updateProviderMutation.isPending ||
deleteProviderMutation.isPending ||
switchProviderMutation.isPending,
};
}