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:
211
src/App.tsx
211
src/App.tsx
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
147
src/hooks/useProviderActions.ts
Normal file
147
src/hooks/useProviderActions.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user