Files
CC-Switch/src/components/universal/UniversalProviderFormModal.tsx
T
Tan Kimzeg 55abd1822c feat(ui): 为多处 JsonEditor 组件接入暗色模式 (#4556)
在为 UsageScriptModal、ProviderForm、UniversalProviderFormModal 中
的 JsonEditor CodeMirror 编辑器添加 darkMode 支持,使用 useDarkMode()
hook 监听 App 主题,自动切换 oneDark 编辑器主题。
2026-06-23 23:30:01 +08:00

725 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useMemo } from "react";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { ProviderIcon } from "@/components/ProviderIcon";
import JsonEditor from "@/components/JsonEditor";
import type { UniversalProvider, UniversalProviderModels } from "@/types";
import {
universalProviderPresets,
createUniversalProviderFromPreset,
type UniversalProviderPreset,
} from "@/config/universalProviderPresets";
import { deepClone } from "@/utils/deepClone";
interface UniversalProviderFormModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (provider: UniversalProvider) => void;
onSaveAndSync?: (provider: UniversalProvider) => void;
editingProvider?: UniversalProvider | null;
initialPreset?: UniversalProviderPreset | null;
}
export function UniversalProviderFormModal({
isOpen,
onClose,
onSave,
onSaveAndSync,
editingProvider,
initialPreset,
}: UniversalProviderFormModalProps) {
const isDarkMode = useDarkMode();
const { t } = useTranslation();
const isEditMode = !!editingProvider;
// 表单状态
const [selectedPreset, setSelectedPreset] =
useState<UniversalProviderPreset | null>(null);
const [name, setName] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [apiKey, setApiKey] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [websiteUrl, setWebsiteUrl] = useState("");
const [notes, setNotes] = useState("");
// 应用启用状态
const [claudeEnabled, setClaudeEnabled] = useState(true);
const [codexEnabled, setCodexEnabled] = useState(true);
const [geminiEnabled, setGeminiEnabled] = useState(true);
// 模型配置
const [models, setModels] = useState<UniversalProviderModels>({});
// 保存并同步确认弹窗
const [syncConfirmOpen, setSyncConfirmOpen] = useState(false);
const [pendingProvider, setPendingProvider] =
useState<UniversalProvider | null>(null);
// 初始化表单
useEffect(() => {
if (editingProvider) {
// 编辑模式:加载现有数据
setName(editingProvider.name);
setBaseUrl(editingProvider.baseUrl);
setApiKey(editingProvider.apiKey);
setWebsiteUrl(editingProvider.websiteUrl || "");
setNotes(editingProvider.notes || "");
setClaudeEnabled(editingProvider.apps.claude);
setCodexEnabled(editingProvider.apps.codex);
setGeminiEnabled(editingProvider.apps.gemini);
setModels(editingProvider.models || {});
// 尝试匹配预设
const preset = universalProviderPresets.find(
(p) => p.providerType === editingProvider.providerType,
);
setSelectedPreset(preset || null);
} else {
// 新建模式:使用传入的预设或默认选择第一个预设
const defaultPreset = initialPreset || universalProviderPresets[0];
setSelectedPreset(defaultPreset);
setName(defaultPreset.name);
setBaseUrl("");
setApiKey("");
setWebsiteUrl(defaultPreset.websiteUrl || "");
setNotes("");
setClaudeEnabled(defaultPreset.defaultApps.claude);
setCodexEnabled(defaultPreset.defaultApps.codex);
setGeminiEnabled(defaultPreset.defaultApps.gemini);
setModels(deepClone(defaultPreset.defaultModels));
}
}, [editingProvider, initialPreset, isOpen]);
// 选择预设
const handlePresetSelect = useCallback(
(preset: UniversalProviderPreset) => {
setSelectedPreset(preset);
if (!isEditMode) {
setName(preset.name);
setClaudeEnabled(preset.defaultApps.claude);
setCodexEnabled(preset.defaultApps.codex);
setGeminiEnabled(preset.defaultApps.gemini);
setModels(deepClone(preset.defaultModels));
}
},
[isEditMode],
);
// 更新模型配置
const updateModel = useCallback(
(app: "claude" | "codex" | "gemini", field: string, value: string) => {
setModels((prev) => ({
...prev,
[app]: {
...(prev[app] || {}),
[field]: value,
},
}));
},
[],
);
// 计算 Claude 配置 JSON 预览
const claudeConfigJson = useMemo(() => {
if (!claudeEnabled) return null;
const model = models.claude?.model || "claude-sonnet-4-20250514";
const haiku = models.claude?.haikuModel || "claude-haiku-4-20250514";
const sonnet = models.claude?.sonnetModel || "claude-sonnet-4-20250514";
const opus = models.claude?.opusModel || "claude-sonnet-4-20250514";
return {
env: {
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: apiKey,
ANTHROPIC_MODEL: model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnet,
ANTHROPIC_DEFAULT_OPUS_MODEL: opus,
},
};
}, [claudeEnabled, baseUrl, apiKey, models.claude]);
// 计算 Codex 配置 JSON 预览
const codexConfigJson = useMemo(() => {
if (!codexEnabled) return null;
const model = models.codex?.model || "gpt-5.5";
const reasoningEffort = models.codex?.reasoningEffort || "high";
// 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API
const codexBaseUrl = baseUrl.endsWith("/v1")
? baseUrl
: `${baseUrl.replace(/\/+$/, "")}/v1`;
const configToml = `model_provider = "custom"
model = "${model}"
model_reasoning_effort = "${reasoningEffort}"
disable_response_storage = true
[model_providers.custom]
name = "NewAPI"
base_url = "${codexBaseUrl}"
wire_api = "responses"
requires_openai_auth = true`;
return {
auth: {
OPENAI_API_KEY: apiKey,
},
config: configToml,
};
}, [codexEnabled, baseUrl, apiKey, models.codex]);
// 计算 Gemini 配置 JSON 预览
const geminiConfigJson = useMemo(() => {
if (!geminiEnabled) return null;
const model = models.gemini?.model || "gemini-2.5-pro";
return {
env: {
GOOGLE_GEMINI_BASE_URL: baseUrl,
GEMINI_API_KEY: apiKey,
GEMINI_MODEL: model,
},
};
}, [geminiEnabled, baseUrl, apiKey, models.gemini]);
// 提交表单
const handleSubmit = useCallback(() => {
if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) {
return;
}
const provider: UniversalProvider = editingProvider
? {
...editingProvider,
name: name.trim(),
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim(),
websiteUrl: websiteUrl.trim() || undefined,
notes: notes.trim() || undefined,
apps: {
claude: claudeEnabled,
codex: codexEnabled,
gemini: geminiEnabled,
},
models,
}
: createUniversalProviderFromPreset(
selectedPreset || universalProviderPresets[0],
crypto.randomUUID(),
baseUrl.trim(),
apiKey.trim(),
name.trim(),
);
// 如果是新建,更新应用启用状态和模型
if (!editingProvider) {
provider.apps = {
claude: claudeEnabled,
codex: codexEnabled,
gemini: geminiEnabled,
};
provider.models = models;
provider.websiteUrl = websiteUrl.trim() || undefined;
provider.notes = notes.trim() || undefined;
}
onSave(provider);
onClose();
}, [
editingProvider,
name,
baseUrl,
apiKey,
websiteUrl,
notes,
claudeEnabled,
codexEnabled,
geminiEnabled,
models,
selectedPreset,
onSave,
onClose,
]);
// 构建 provider 对象的辅助函数
const buildProvider = useCallback((): UniversalProvider | null => {
if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) {
return null;
}
const provider: UniversalProvider = editingProvider
? {
...editingProvider,
name: name.trim(),
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim(),
websiteUrl: websiteUrl.trim() || undefined,
notes: notes.trim() || undefined,
apps: {
claude: claudeEnabled,
codex: codexEnabled,
gemini: geminiEnabled,
},
models,
}
: createUniversalProviderFromPreset(
selectedPreset || universalProviderPresets[0],
crypto.randomUUID(),
baseUrl.trim(),
apiKey.trim(),
name.trim(),
);
// 如果是新建,更新应用启用状态和模型
if (!editingProvider) {
provider.apps = {
claude: claudeEnabled,
codex: codexEnabled,
gemini: geminiEnabled,
};
provider.models = models;
provider.websiteUrl = websiteUrl.trim() || undefined;
provider.notes = notes.trim() || undefined;
}
return provider;
}, [
editingProvider,
name,
baseUrl,
apiKey,
websiteUrl,
notes,
claudeEnabled,
codexEnabled,
geminiEnabled,
models,
selectedPreset,
]);
// 打开保存并同步确认弹窗
const handleSaveAndSyncClick = useCallback(() => {
const provider = buildProvider();
if (!provider || !onSaveAndSync) return;
setPendingProvider(provider);
setSyncConfirmOpen(true);
}, [buildProvider, onSaveAndSync]);
// 确认保存并同步
const confirmSaveAndSync = useCallback(() => {
if (!pendingProvider || !onSaveAndSync) return;
onSaveAndSync(pendingProvider);
setSyncConfirmOpen(false);
setPendingProvider(null);
onClose();
}, [pendingProvider, onSaveAndSync, onClose]);
const footer = (
<>
<Button variant="outline" onClick={onClose}>
{t("common.cancel", { defaultValue: "取消" })}
</Button>
{isEditMode && onSaveAndSync ? (
<Button
onClick={handleSaveAndSyncClick}
disabled={!name.trim() || !baseUrl.trim() || !apiKey.trim()}
>
<RefreshCw className="mr-1.5 h-4 w-4" />
{t("universalProvider.saveAndSync", { defaultValue: "保存并同步" })}
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!name.trim() || !baseUrl.trim() || !apiKey.trim()}
>
{t("common.add", { defaultValue: "添加" })}
</Button>
)}
</>
);
return (
<FullScreenPanel
isOpen={isOpen}
title={
isEditMode
? t("universalProvider.edit", { defaultValue: "编辑统一供应商" })
: t("universalProvider.add", { defaultValue: "添加统一供应商" })
}
onClose={onClose}
footer={footer}
>
<div className="space-y-6">
{/* 预设选择(仅新建模式) */}
{!isEditMode && (
<div className="space-y-3">
<Label>
{t("universalProvider.selectPreset", {
defaultValue: "选择预设类型",
})}
</Label>
<div className="flex flex-wrap gap-2">
{universalProviderPresets.map((preset) => (
<button
key={preset.providerType}
type="button"
onClick={() => handlePresetSelect(preset)}
className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
selectedPreset?.providerType === preset.providerType
? "bg-primary text-primary-foreground"
: "bg-accent text-muted-foreground hover:bg-accent/80"
}`}
>
<ProviderIcon
icon={preset.icon}
name={preset.name}
size={16}
/>
{preset.name}
</button>
))}
</div>
{selectedPreset?.description && (
<p className="text-xs text-muted-foreground">
{selectedPreset.description}
</p>
)}
</div>
)}
{/* 基本信息 */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">
{t("universalProvider.name", { defaultValue: "名称" })}
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("universalProvider.namePlaceholder", {
defaultValue: "例如:我的 NewAPI",
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="baseUrl">
{t("universalProvider.baseUrl", { defaultValue: "API 地址" })}
</Label>
<Input
id="baseUrl"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">
{t("universalProvider.apiKey", { defaultValue: "API Key" })}
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? "text" : "password"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="websiteUrl">
{t("universalProvider.websiteUrl", { defaultValue: "官网地址" })}
</Label>
<Input
id="websiteUrl"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
placeholder={t("universalProvider.websiteUrlPlaceholder", {
defaultValue: "https://example.com(可选,用于在列表中显示)",
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">
{t("universalProvider.notes", { defaultValue: "备注" })}
</Label>
<Input
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("universalProvider.notesPlaceholder", {
defaultValue: "可选:添加备注信息",
})}
/>
</div>
</div>
{/* 应用启用 */}
<div className="space-y-3">
<Label>
{t("universalProvider.enabledApps", { defaultValue: "启用的应用" })}
</Label>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<ProviderIcon icon="claude" name="Claude" size={20} />
<span className="font-medium">Claude Code</span>
</div>
<Switch
checked={claudeEnabled}
onCheckedChange={setClaudeEnabled}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<ProviderIcon icon="openai" name="Codex" size={20} />
<span className="font-medium">OpenAI Codex</span>
</div>
<Switch
checked={codexEnabled}
onCheckedChange={setCodexEnabled}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<ProviderIcon icon="gemini" name="Gemini" size={20} />
<span className="font-medium">Gemini CLI</span>
</div>
<Switch
checked={geminiEnabled}
onCheckedChange={setGeminiEnabled}
/>
</div>
</div>
</div>
{/* 模型配置 */}
<div className="space-y-4">
<Label>
{t("universalProvider.modelConfig", { defaultValue: "模型配置" })}
</Label>
{/* Claude 模型 */}
{claudeEnabled && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center gap-2 font-medium">
<ProviderIcon icon="claude" name="Claude" size={16} />
Claude
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">
{t("universalProvider.model", { defaultValue: "主模型" })}
</Label>
<Input
value={models.claude?.model || ""}
onChange={(e) =>
updateModel("claude", "model", e.target.value)
}
placeholder="claude-sonnet-4-20250514"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Haiku</Label>
<Input
value={models.claude?.haikuModel || ""}
onChange={(e) =>
updateModel("claude", "haikuModel", e.target.value)
}
placeholder="claude-haiku-4-20250514"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Sonnet</Label>
<Input
value={models.claude?.sonnetModel || ""}
onChange={(e) =>
updateModel("claude", "sonnetModel", e.target.value)
}
placeholder="claude-sonnet-4-20250514"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Opus</Label>
<Input
value={models.claude?.opusModel || ""}
onChange={(e) =>
updateModel("claude", "opusModel", e.target.value)
}
placeholder="claude-sonnet-4-20250514"
/>
</div>
</div>
</div>
)}
{/* Codex 模型 */}
{codexEnabled && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center gap-2 font-medium">
<ProviderIcon icon="openai" name="Codex" size={16} />
Codex
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">
{t("universalProvider.model", { defaultValue: "模型" })}
</Label>
<Input
value={models.codex?.model || ""}
onChange={(e) =>
updateModel("codex", "model", e.target.value)
}
placeholder="gpt-5.5"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Reasoning Effort</Label>
<Input
value={models.codex?.reasoningEffort || ""}
onChange={(e) =>
updateModel("codex", "reasoningEffort", e.target.value)
}
placeholder="high"
/>
</div>
</div>
</div>
)}
{/* Gemini 模型 */}
{geminiEnabled && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center gap-2 font-medium">
<ProviderIcon icon="gemini" name="Gemini" size={16} />
Gemini
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("universalProvider.model", { defaultValue: "模型" })}
</Label>
<Input
value={models.gemini?.model || ""}
onChange={(e) =>
updateModel("gemini", "model", e.target.value)
}
placeholder="gemini-2.5-pro"
/>
</div>
</div>
)}
</div>
{/* 配置 JSON 预览 */}
{isEditMode && (claudeEnabled || codexEnabled || geminiEnabled) && (
<div className="space-y-4">
<Label>
{t("universalProvider.configJsonPreview", {
defaultValue: "配置 JSON 预览",
})}
</Label>
<p className="text-xs text-muted-foreground">
{t("universalProvider.configJsonPreviewHint", {
defaultValue:
"以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)",
})}
</p>
{/* Claude JSON */}
{claudeConfigJson && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<ProviderIcon icon="claude" name="Claude" size={16} />
Claude
</div>
<JsonEditor
value={JSON.stringify(claudeConfigJson, null, 2)}
onChange={() => {}}
height={180}
darkMode={isDarkMode}
/>
</div>
)}
{/* Codex JSON */}
{codexConfigJson && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<ProviderIcon icon="openai" name="Codex" size={16} />
Codex
</div>
<JsonEditor
value={JSON.stringify(codexConfigJson, null, 2)}
onChange={() => {}}
height={280}
darkMode={isDarkMode}
/>
</div>
)}
{/* Gemini JSON */}
{geminiConfigJson && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<ProviderIcon icon="gemini" name="Gemini" size={16} />
Gemini
</div>
<JsonEditor
value={JSON.stringify(geminiConfigJson, null, 2)}
onChange={() => {}}
height={140}
darkMode={isDarkMode}
/>
</div>
)}
</div>
)}
</div>
{/* 保存并同步确认弹窗 */}
<ConfirmDialog
isOpen={syncConfirmOpen}
title={t("universalProvider.syncConfirmTitle", {
defaultValue: "同步统一供应商",
})}
message={t("universalProvider.syncConfirmDescription", {
defaultValue: `同步 "${name}" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?`,
name: name,
})}
confirmText={t("universalProvider.saveAndSync", {
defaultValue: "保存并同步",
})}
onConfirm={confirmSaveAndSync}
onCancel={() => {
setSyncConfirmOpen(false);
setPendingProvider(null);
}}
/>
</FullScreenPanel>
);
}