- feat(mcp): unify notifications via onNotify in form and wizard

- refactor(mcp): remove HTML5 required to avoid native popups

- refactor(ui): propagate onNotify from App → McpPanel → McpFormModal → McpWizardModal

- feat(settings): use onNotify for export and file-selection feedback

- fix(ui): notify link-open failures via onNotify; remove unused appType prop from ProviderList

- chore: format codebase and ensure typecheck passes
This commit is contained in:
Jason
2025-10-10 20:52:16 +08:00
parent bfdf7d4ad5
commit e88562be98
10 changed files with 99 additions and 22 deletions

View File

@@ -354,7 +354,6 @@ function App() {
onSwitch={handleSwitchProvider} onSwitch={handleSwitchProvider}
onDelete={handleDeleteProvider} onDelete={handleDeleteProvider}
onEdit={setEditingProviderId} onEdit={setEditingProviderId}
appType={activeApp}
onNotify={showNotification} onNotify={showNotification}
/> />
</div> </div>
@@ -392,6 +391,7 @@ function App() {
<SettingsModal <SettingsModal
onClose={() => setIsSettingsOpen(false)} onClose={() => setIsSettingsOpen(false)}
onImportSuccess={handleImportSuccess} onImportSuccess={handleImportSuccess}
onNotify={showNotification}
/> />
)} )}

View File

@@ -17,10 +17,15 @@ const AddProviderModal: React.FC<AddProviderModalProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const title =
appType === "claude"
? t("provider.addClaudeProvider")
: t("provider.addCodexProvider");
return ( return (
<ProviderForm <ProviderForm
appType={appType} appType={appType}
title={t("provider.addNewProvider")} title={title}
submitText={t("common.add")} submitText={t("common.add")}
showPresets={true} showPresets={true}
onSubmit={onAdd} onSubmit={onAdd}

View File

@@ -18,7 +18,8 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
onClose, onClose,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [effectiveProvider, setEffectiveProvider] = useState<Provider>(provider); const [effectiveProvider, setEffectiveProvider] =
useState<Provider>(provider);
// 若为当前应用且正在编辑“当前供应商”,则优先读取 live 配置作为初始值Claude/Codex 均适用) // 若为当前应用且正在编辑“当前供应商”,则优先读取 live 配置作为初始值Claude/Codex 均适用)
useEffect(() => { useEffect(() => {
@@ -51,10 +52,15 @@ const EditProviderModal: React.FC<EditProviderModalProps> = ({
}); });
}; };
const title =
appType === "claude"
? t("provider.editClaudeProvider")
: t("provider.editCodexProvider");
return ( return (
<ProviderForm <ProviderForm
appType={appType} appType={appType}
title={t("common.edit")} title={title}
submitText={t("common.save")} submitText={t("common.save")}
initialData={effectiveProvider} initialData={effectiveProvider}
showPresets={false} showPresets={false}

View File

@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { Provider } from "../types"; import { Provider } from "../types";
import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react"; import { Play, Edit3, Trash2, CheckCircle2, Users, Check } from "lucide-react";
import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles"; import { buttonStyles, cardStyles, badgeStyles, cn } from "../lib/styles";
import { AppType } from "../lib/tauri-api";
// 不再在列表中显示分类徽章,避免造成困惑 // 不再在列表中显示分类徽章,避免造成困惑
interface ProviderListProps { interface ProviderListProps {
@@ -12,7 +11,6 @@ interface ProviderListProps {
onSwitch: (id: string) => void; onSwitch: (id: string) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onEdit: (id: string) => void; onEdit: (id: string) => void;
appType?: AppType;
onNotify?: ( onNotify?: (
message: string, message: string,
type: "success" | "error", type: "success" | "error",
@@ -26,7 +24,6 @@ const ProviderList: React.FC<ProviderListProps> = ({
onSwitch, onSwitch,
onDelete, onDelete,
onEdit, onEdit,
appType,
onNotify, onNotify,
}) => { }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -55,6 +52,11 @@ const ProviderList: React.FC<ProviderListProps> = ({
await window.api.openExternal(url); await window.api.openExternal(url);
} catch (error) { } catch (error) {
console.error(t("console.openLinkFailed"), error); console.error(t("console.openLinkFailed"), error);
onNotify?.(
`${t("console.openLinkFailed")}: ${String(error)}`,
"error",
4000,
);
} }
}; };

View File

@@ -24,11 +24,17 @@ import { isLinux } from "../lib/platform";
interface SettingsModalProps { interface SettingsModalProps {
onClose: () => void; onClose: () => void;
onImportSuccess?: () => void | Promise<void>; onImportSuccess?: () => void | Promise<void>;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
} }
export default function SettingsModal({ export default function SettingsModal({
onClose, onClose,
onImportSuccess, onImportSuccess,
onNotify,
}: SettingsModalProps) { }: SettingsModalProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -387,11 +393,19 @@ export default function SettingsModal({
const result = await window.api.exportConfigToFile(filePath); const result = await window.api.exportConfigToFile(filePath);
if (result.success) { if (result.success) {
alert(`${t("settings.configExported")}\n${result.filePath}`); onNotify?.(
`${t("settings.configExported")}\n${result.filePath}`,
"success",
4000,
);
} }
} catch (error) { } catch (error) {
console.error(t("settings.exportFailedError"), error); console.error(t("settings.exportFailedError"), error);
alert(`${t("settings.exportFailed")}: ${error}`); onNotify?.(
`${t("settings.exportFailed")}: ${String(error)}`,
"error",
5000,
);
} }
}; };
@@ -406,7 +420,11 @@ export default function SettingsModal({
} }
} catch (error) { } catch (error) {
console.error(t("settings.selectFileFailed") + ":", error); console.error(t("settings.selectFileFailed") + ":", error);
alert(`${t("settings.selectFileFailed")}: ${error}`); onNotify?.(
`${t("settings.selectFileFailed")}: ${String(error)}`,
"error",
5000,
);
} }
}; };

View File

@@ -5,13 +5,20 @@ import { McpServer } from "../../types";
import { buttonStyles, inputStyles } from "../../lib/styles"; import { buttonStyles, inputStyles } from "../../lib/styles";
import McpWizardModal from "./McpWizardModal"; import McpWizardModal from "./McpWizardModal";
import { extractErrorMessage } from "../../utils/errorUtils"; import { extractErrorMessage } from "../../utils/errorUtils";
import { AppType } from "../../lib/tauri-api";
interface McpFormModalProps { interface McpFormModalProps {
appType: AppType;
editingId?: string; editingId?: string;
initialData?: McpServer; initialData?: McpServer;
onSave: (id: string, server: McpServer) => Promise<void>; onSave: (id: string, server: McpServer) => Promise<void>;
onClose: () => void; onClose: () => void;
existingIds?: string[]; existingIds?: string[];
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
} }
/** /**
@@ -35,11 +42,13 @@ const validateJson = (text: string): string => {
* 仅包含标题必填、描述可选、JSON 配置(可选,带格式校验) * 仅包含标题必填、描述可选、JSON 配置(可选,带格式校验)
*/ */
const McpFormModal: React.FC<McpFormModalProps> = ({ const McpFormModal: React.FC<McpFormModalProps> = ({
appType,
editingId, editingId,
initialData, initialData,
onSave, onSave,
onClose, onClose,
existingIds = [], existingIds = [],
onNotify,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [formId, setFormId] = useState(editingId || ""); const [formId, setFormId] = useState(editingId || "");
@@ -111,7 +120,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formId.trim()) { if (!formId.trim()) {
alert(t("mcp.error.idRequired")); onNotify?.(t("mcp.error.idRequired"), "error", 3000);
return; return;
} }
@@ -125,7 +134,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
const currentJsonError = validateJson(formJson); const currentJsonError = validateJson(formJson);
setJsonError(currentJsonError); setJsonError(currentJsonError);
if (currentJsonError) { if (currentJsonError) {
alert(t("mcp.error.jsonInvalid")); onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000);
return; return;
} }
@@ -138,11 +147,11 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
// 前置必填校验,避免后端拒绝后才提示 // 前置必填校验,避免后端拒绝后才提示
if (server?.type === "stdio" && !server?.command?.trim()) { if (server?.type === "stdio" && !server?.command?.trim()) {
alert(t("mcp.error.commandRequired")); onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
return; return;
} }
if (server?.type === "http" && !server?.url?.trim()) { if (server?.type === "http" && !server?.url?.trim()) {
alert(t("mcp.wizard.urlRequired")); onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
return; return;
} }
} else { } else {
@@ -170,12 +179,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
// 提取后端错误信息(支持 string / {message} / tauri payload // 提取后端错误信息(支持 string / {message} / tauri payload
const detail = extractErrorMessage(error); const detail = extractErrorMessage(error);
const msg = detail || t("mcp.error.saveFailed"); const msg = detail || t("mcp.error.saveFailed");
alert(msg); onNotify?.(msg, "error", detail ? 6000 : 4000);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const getFormTitle = () => {
if (appType === "claude") {
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
} else {
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
}
};
return ( return (
<div className="fixed inset-0 z-[60] flex items-center justify-center"> <div className="fixed inset-0 z-[60] flex items-center justify-center">
{/* Backdrop */} {/* Backdrop */}
@@ -189,7 +206,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800"> <div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? t("mcp.editServer") : t("mcp.addServer")} {getFormTitle()}
</h3> </h3>
<button <button
onClick={onClose} onClick={onClose}
@@ -289,6 +306,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
isOpen={isWizardOpen} isOpen={isWizardOpen}
onClose={() => setIsWizardOpen(false)} onClose={() => setIsWizardOpen(false)}
onApply={handleWizardApply} onApply={handleWizardApply}
onNotify={onNotify}
/> />
</div> </div>
); );

View File

@@ -170,6 +170,9 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
const serverEntries = useMemo(() => Object.entries(servers), [servers]); const serverEntries = useMemo(() => Object.entries(servers), [servers]);
const panelTitle =
appType === "claude" ? t("mcp.claudeTitle") : t("mcp.codexTitle");
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */} {/* Backdrop */}
@@ -183,8 +186,7 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
{/* Header */} {/* Header */}
<div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800"> <div className="flex-shrink-0 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{t("mcp.title")} ·{" "} {panelTitle}
{t(appType === "claude" ? "apps.claude" : "apps.codex")}
</h3> </h3>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -298,11 +300,13 @@ const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify, appType }) => {
{/* Form Modal */} {/* Form Modal */}
{isFormOpen && ( {isFormOpen && (
<McpFormModal <McpFormModal
appType={appType}
editingId={editingId || undefined} editingId={editingId || undefined}
initialData={editingId ? servers[editingId] : undefined} initialData={editingId ? servers[editingId] : undefined}
existingIds={Object.keys(servers)} existingIds={Object.keys(servers)}
onSave={handleSave} onSave={handleSave}
onClose={handleCloseForm} onClose={handleCloseForm}
onNotify={onNotify}
/> />
)} )}

View File

@@ -8,6 +8,11 @@ interface McpWizardModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onApply: (json: string) => void; onApply: (json: string) => void;
onNotify?: (
message: string,
type: "success" | "error",
duration?: number,
) => void;
} }
/** /**
@@ -66,6 +71,7 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
isOpen, isOpen,
onClose, onClose,
onApply, onApply,
onNotify,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio"); const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio");
@@ -124,11 +130,11 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
const handleApply = () => { const handleApply = () => {
if (wizardType === "stdio" && !wizardCommand.trim()) { if (wizardType === "stdio" && !wizardCommand.trim()) {
alert(t("mcp.error.commandRequired")); onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
return; return;
} }
if (wizardType === "http" && !wizardUrl.trim()) { if (wizardType === "http" && !wizardUrl.trim()) {
alert(t("mcp.wizard.urlRequired")); onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
return; return;
} }
@@ -256,7 +262,6 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardCommand(e.target.value)} onChange={(e) => setWizardCommand(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.commandPlaceholder")} placeholder={t("mcp.wizard.commandPlaceholder")}
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100" className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/> />
</div> </div>
@@ -321,7 +326,6 @@ const McpWizardModal: React.FC<McpWizardModalProps> = ({
onChange={(e) => setWizardUrl(e.target.value)} onChange={(e) => setWizardUrl(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={t("mcp.wizard.urlPlaceholder")} placeholder={t("mcp.wizard.urlPlaceholder")}
required
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100" className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/> />
</div> </div>

View File

@@ -55,6 +55,10 @@
"editProvider": "Edit Provider", "editProvider": "Edit Provider",
"deleteProvider": "Delete Provider", "deleteProvider": "Delete Provider",
"addNewProvider": "Add New Provider", "addNewProvider": "Add New Provider",
"addClaudeProvider": "Add Claude Code Provider",
"addCodexProvider": "Add Codex Provider",
"editClaudeProvider": "Edit Claude Code Provider",
"editCodexProvider": "Edit Codex Provider",
"configError": "Configuration Error", "configError": "Configuration Error",
"notConfigured": "Not configured for official website", "notConfigured": "Not configured for official website",
"applyToClaudePlugin": "Apply to Claude plugin", "applyToClaudePlugin": "Apply to Claude plugin",
@@ -254,6 +258,8 @@
}, },
"mcp": { "mcp": {
"title": "MCP Management", "title": "MCP Management",
"claudeTitle": "Claude Code MCP Management",
"codexTitle": "Codex MCP Management",
"userLevelPath": "User-level MCP path", "userLevelPath": "User-level MCP path",
"serverList": "Servers", "serverList": "Servers",
"loading": "Loading...", "loading": "Loading...",
@@ -262,6 +268,10 @@
"add": "Add MCP", "add": "Add MCP",
"addServer": "Add MCP", "addServer": "Add MCP",
"editServer": "Edit MCP", "editServer": "Edit MCP",
"addClaudeServer": "Add Claude Code MCP",
"editClaudeServer": "Edit Claude Code MCP",
"addCodexServer": "Add Codex MCP",
"editCodexServer": "Edit Codex MCP",
"configPath": "Config Path", "configPath": "Config Path",
"serverCount": "{{count}} MCP server(s) configured", "serverCount": "{{count}} MCP server(s) configured",
"template": { "template": {

View File

@@ -55,6 +55,10 @@
"editProvider": "编辑供应商", "editProvider": "编辑供应商",
"deleteProvider": "删除供应商", "deleteProvider": "删除供应商",
"addNewProvider": "添加新供应商", "addNewProvider": "添加新供应商",
"addClaudeProvider": "添加 Claude Code 供应商",
"addCodexProvider": "添加 Codex 供应商",
"editClaudeProvider": "编辑 Claude Code 供应商",
"editCodexProvider": "编辑 Codex 供应商",
"configError": "配置错误", "configError": "配置错误",
"notConfigured": "未配置官网地址", "notConfigured": "未配置官网地址",
"applyToClaudePlugin": "应用到 Claude 插件", "applyToClaudePlugin": "应用到 Claude 插件",
@@ -254,6 +258,8 @@
}, },
"mcp": { "mcp": {
"title": "MCP 管理", "title": "MCP 管理",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"userLevelPath": "用户级 MCP 配置路径", "userLevelPath": "用户级 MCP 配置路径",
"serverList": "服务器列表", "serverList": "服务器列表",
"loading": "加载中...", "loading": "加载中...",
@@ -262,6 +268,10 @@
"add": "添加 MCP", "add": "添加 MCP",
"addServer": "新增 MCP", "addServer": "新增 MCP",
"editServer": "编辑 MCP", "editServer": "编辑 MCP",
"addClaudeServer": "新增 Claude Code MCP",
"editClaudeServer": "编辑 Claude Code MCP",
"addCodexServer": "新增 Codex MCP",
"editCodexServer": "编辑 Codex MCP",
"configPath": "配置路径", "configPath": "配置路径",
"serverCount": "已配置 {{count}} 个 MCP 服务器", "serverCount": "已配置 {{count}} 个 MCP 服务器",
"template": { "template": {