- Fix negative margin overflow in all dialog content areas - Standardize dialog structure with flex-col layout - Add consistent py-4 spacing to all content areas - Ensure proper spacing between header, content, and footer Affected components: - AddProviderDialog, EditProviderDialog - McpFormModal, McpPanel - UsageScriptModal - SettingsDialog All dialogs now follow unified layout pattern: - DialogContent: flex flex-col max-h-[90vh] - Content area: flex-1 overflow-y-auto px-6 py-4 - No negative margins that cause content overflow
570 lines
18 KiB
TypeScript
570 lines
18 KiB
TypeScript
import { useEffect, useMemo, useState, useCallback } from "react";
|
||
import { useForm } from "react-hook-form";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { useTranslation } from "react-i18next";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Form } from "@/components/ui/form";
|
||
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
|
||
import type { AppType } from "@/lib/api";
|
||
import type { ProviderCategory, CustomEndpoint } from "@/types";
|
||
import { providerPresets, type ProviderPreset } from "@/config/providerPresets";
|
||
import {
|
||
codexProviderPresets,
|
||
type CodexProviderPreset,
|
||
} from "@/config/codexProviderPresets";
|
||
import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
||
import CodexConfigEditor from "./CodexConfigEditor";
|
||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||
import { BasicFormFields } from "./BasicFormFields";
|
||
import { ClaudeFormFields } from "./ClaudeFormFields";
|
||
import { CodexFormFields } from "./CodexFormFields";
|
||
import {
|
||
useProviderCategory,
|
||
useApiKeyState,
|
||
useBaseUrlState,
|
||
useModelState,
|
||
useCodexConfigState,
|
||
useApiKeyLink,
|
||
useCustomEndpoints,
|
||
useKimiModelSelector,
|
||
useTemplateValues,
|
||
useCommonConfigSnippet,
|
||
useCodexCommonConfig,
|
||
useSpeedTestEndpoints,
|
||
useCodexTomlValidation,
|
||
} from "./hooks";
|
||
|
||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {}, config: {} }, null, 2);
|
||
const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2);
|
||
|
||
type PresetEntry = {
|
||
id: string;
|
||
preset: ProviderPreset | CodexProviderPreset;
|
||
};
|
||
|
||
interface ProviderFormProps {
|
||
appType: AppType;
|
||
submitLabel: string;
|
||
onSubmit: (values: ProviderFormValues) => void;
|
||
onCancel: () => void;
|
||
initialData?: {
|
||
name?: string;
|
||
websiteUrl?: string;
|
||
settingsConfig?: Record<string, unknown>;
|
||
};
|
||
}
|
||
|
||
export function ProviderForm({
|
||
appType,
|
||
submitLabel,
|
||
onSubmit,
|
||
onCancel,
|
||
initialData,
|
||
}: ProviderFormProps) {
|
||
const { t } = useTranslation();
|
||
const isEditMode = Boolean(initialData);
|
||
|
||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
|
||
initialData ? null : "custom",
|
||
);
|
||
const [activePreset, setActivePreset] = useState<{
|
||
id: string;
|
||
category?: ProviderCategory;
|
||
} | null>(null);
|
||
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
|
||
|
||
// 新建供应商:收集端点测速弹窗中的"自定义端点",提交时一次性落盘到 meta.custom_endpoints
|
||
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
|
||
[],
|
||
);
|
||
|
||
// 使用 category hook
|
||
const { category } = useProviderCategory({
|
||
appType,
|
||
selectedPresetId,
|
||
isEditMode,
|
||
});
|
||
|
||
useEffect(() => {
|
||
setSelectedPresetId(initialData ? null : "custom");
|
||
setActivePreset(null);
|
||
}, [appType, initialData]);
|
||
|
||
const defaultValues: ProviderFormData = useMemo(
|
||
() => ({
|
||
name: initialData?.name ?? "",
|
||
websiteUrl: initialData?.websiteUrl ?? "",
|
||
settingsConfig: initialData?.settingsConfig
|
||
? JSON.stringify(initialData.settingsConfig, null, 2)
|
||
: appType === "codex"
|
||
? CODEX_DEFAULT_CONFIG
|
||
: CLAUDE_DEFAULT_CONFIG,
|
||
}),
|
||
[initialData, appType],
|
||
);
|
||
|
||
const form = useForm<ProviderFormData>({
|
||
resolver: zodResolver(providerSchema),
|
||
defaultValues,
|
||
mode: "onSubmit",
|
||
});
|
||
|
||
// 使用 API Key hook
|
||
const {
|
||
apiKey,
|
||
handleApiKeyChange,
|
||
showApiKey: shouldShowApiKey,
|
||
} = useApiKeyState({
|
||
initialConfig: form.watch("settingsConfig"),
|
||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||
selectedPresetId,
|
||
});
|
||
|
||
// 使用 Base URL hook (仅 Claude 模式)
|
||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||
appType,
|
||
category,
|
||
settingsConfig: form.watch("settingsConfig"),
|
||
codexConfig: "",
|
||
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
|
||
onCodexConfigChange: () => {
|
||
// Codex 使用 useCodexConfigState 管理 Base URL
|
||
},
|
||
});
|
||
|
||
// 使用 Model hook
|
||
const { claudeModel, claudeSmallFastModel, handleModelChange } =
|
||
useModelState({
|
||
settingsConfig: form.watch("settingsConfig"),
|
||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||
});
|
||
|
||
// 使用 Codex 配置 hook (仅 Codex 模式)
|
||
const {
|
||
codexAuth,
|
||
codexConfig,
|
||
codexApiKey,
|
||
codexBaseUrl,
|
||
codexAuthError,
|
||
setCodexAuth,
|
||
handleCodexApiKeyChange,
|
||
handleCodexBaseUrlChange,
|
||
handleCodexConfigChange: originalHandleCodexConfigChange,
|
||
resetCodexConfig,
|
||
} = useCodexConfigState({ initialData });
|
||
|
||
// 使用 Codex TOML 校验 hook (仅 Codex 模式)
|
||
const { configError: codexConfigError, debouncedValidate } =
|
||
useCodexTomlValidation();
|
||
|
||
// 包装 handleCodexConfigChange,添加实时校验
|
||
const handleCodexConfigChange = useCallback(
|
||
(value: string) => {
|
||
originalHandleCodexConfigChange(value);
|
||
debouncedValidate(value);
|
||
},
|
||
[originalHandleCodexConfigChange, debouncedValidate],
|
||
);
|
||
|
||
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
|
||
useState(false);
|
||
const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] =
|
||
useState(false);
|
||
|
||
useEffect(() => {
|
||
form.reset(defaultValues);
|
||
}, [defaultValues, form]);
|
||
|
||
const presetCategoryLabels: Record<string, string> = useMemo(
|
||
() => ({
|
||
official: t("providerPreset.categoryOfficial", {
|
||
defaultValue: "官方",
|
||
}),
|
||
cn_official: t("providerPreset.categoryCnOfficial", {
|
||
defaultValue: "国内官方",
|
||
}),
|
||
aggregator: t("providerPreset.categoryAggregator", {
|
||
defaultValue: "聚合服务",
|
||
}),
|
||
third_party: t("providerPreset.categoryThirdParty", {
|
||
defaultValue: "第三方",
|
||
}),
|
||
}),
|
||
[t],
|
||
);
|
||
|
||
const presetEntries = useMemo(() => {
|
||
if (appType === "codex") {
|
||
return codexProviderPresets.map<PresetEntry>((preset, index) => ({
|
||
id: `codex-${index}`,
|
||
preset,
|
||
}));
|
||
}
|
||
return providerPresets.map<PresetEntry>((preset, index) => ({
|
||
id: `claude-${index}`,
|
||
preset,
|
||
}));
|
||
}, [appType]);
|
||
|
||
// 使用 Kimi 模型选择器 hook
|
||
const {
|
||
shouldShow: shouldShowKimiSelector,
|
||
kimiAnthropicModel,
|
||
kimiAnthropicSmallFastModel,
|
||
handleKimiModelChange,
|
||
} = useKimiModelSelector({
|
||
initialData,
|
||
settingsConfig: form.watch("settingsConfig"),
|
||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||
selectedPresetId,
|
||
presetName:
|
||
selectedPresetId && selectedPresetId !== "custom"
|
||
? presetEntries.find((item) => item.id === selectedPresetId)?.preset
|
||
.name || ""
|
||
: "",
|
||
});
|
||
|
||
// 使用模板变量 hook (仅 Claude 模式)
|
||
const {
|
||
templateValues,
|
||
templateValueEntries,
|
||
selectedPreset: templatePreset,
|
||
handleTemplateValueChange,
|
||
validateTemplateValues,
|
||
} = useTemplateValues({
|
||
selectedPresetId: appType === "claude" ? selectedPresetId : null,
|
||
presetEntries: appType === "claude" ? presetEntries : [],
|
||
settingsConfig: form.watch("settingsConfig"),
|
||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||
});
|
||
|
||
// 使用通用配置片段 hook (仅 Claude 模式)
|
||
const {
|
||
useCommonConfig,
|
||
commonConfigSnippet,
|
||
commonConfigError,
|
||
handleCommonConfigToggle,
|
||
handleCommonConfigSnippetChange,
|
||
} = useCommonConfigSnippet({
|
||
settingsConfig: form.watch("settingsConfig"),
|
||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||
initialData: appType === "claude" ? initialData : undefined,
|
||
});
|
||
|
||
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
|
||
const {
|
||
useCommonConfig: useCodexCommonConfigFlag,
|
||
commonConfigSnippet: codexCommonConfigSnippet,
|
||
commonConfigError: codexCommonConfigError,
|
||
handleCommonConfigToggle: handleCodexCommonConfigToggle,
|
||
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
|
||
} = useCodexCommonConfig({
|
||
codexConfig,
|
||
onConfigChange: handleCodexConfigChange,
|
||
initialData: appType === "codex" ? initialData : undefined,
|
||
});
|
||
|
||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||
|
||
const handleSubmit = (values: ProviderFormData) => {
|
||
// 验证模板变量(仅 Claude 模式)
|
||
if (appType === "claude" && templateValueEntries.length > 0) {
|
||
const validation = validateTemplateValues();
|
||
if (!validation.isValid && validation.missingField) {
|
||
form.setError("settingsConfig", {
|
||
type: "manual",
|
||
message: t("providerForm.fillParameter", {
|
||
label: validation.missingField.label,
|
||
defaultValue: `请填写 ${validation.missingField.label}`,
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
let settingsConfig: string;
|
||
|
||
// Codex: 组合 auth 和 config
|
||
if (appType === "codex") {
|
||
try {
|
||
const authJson = JSON.parse(codexAuth);
|
||
const configObj = {
|
||
auth: authJson,
|
||
config: codexConfig ?? "",
|
||
};
|
||
settingsConfig = JSON.stringify(configObj);
|
||
} catch (err) {
|
||
// 如果解析失败,使用表单中的配置
|
||
settingsConfig = values.settingsConfig.trim();
|
||
}
|
||
} else {
|
||
// Claude: 使用表单配置
|
||
settingsConfig = values.settingsConfig.trim();
|
||
}
|
||
|
||
const payload: ProviderFormValues = {
|
||
...values,
|
||
name: values.name.trim(),
|
||
websiteUrl: values.websiteUrl?.trim() ?? "",
|
||
settingsConfig,
|
||
};
|
||
|
||
if (activePreset) {
|
||
payload.presetId = activePreset.id;
|
||
if (activePreset.category) {
|
||
payload.presetCategory = activePreset.category;
|
||
}
|
||
}
|
||
|
||
// 新建供应商时:添加自定义端点
|
||
if (!initialData && customEndpointsMap) {
|
||
payload.meta = { custom_endpoints: customEndpointsMap };
|
||
}
|
||
|
||
onSubmit(payload);
|
||
};
|
||
|
||
const groupedPresets = useMemo(() => {
|
||
return presetEntries.reduce<Record<string, PresetEntry[]>>((acc, entry) => {
|
||
const category = entry.preset.category ?? "others";
|
||
if (!acc[category]) {
|
||
acc[category] = [];
|
||
}
|
||
acc[category].push(entry);
|
||
return acc;
|
||
}, {});
|
||
}, [presetEntries]);
|
||
|
||
const categoryKeys = useMemo(() => {
|
||
return Object.keys(groupedPresets).filter(
|
||
(key) => key !== "custom" && groupedPresets[key]?.length,
|
||
);
|
||
}, [groupedPresets]);
|
||
|
||
// 判断是否显示端点测速(仅第三方和自定义类别)
|
||
const shouldShowSpeedTest =
|
||
category === "third_party" || category === "custom";
|
||
|
||
// 使用 API Key 链接 hook (Claude)
|
||
const {
|
||
shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,
|
||
websiteUrl: claudeWebsiteUrl,
|
||
} = useApiKeyLink({
|
||
appType: "claude",
|
||
category,
|
||
selectedPresetId,
|
||
presetEntries,
|
||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||
});
|
||
|
||
// 使用 API Key 链接 hook (Codex)
|
||
const {
|
||
shouldShowApiKeyLink: shouldShowCodexApiKeyLink,
|
||
websiteUrl: codexWebsiteUrl,
|
||
} = useApiKeyLink({
|
||
appType: "codex",
|
||
category,
|
||
selectedPresetId,
|
||
presetEntries,
|
||
formWebsiteUrl: form.watch("websiteUrl") || "",
|
||
});
|
||
|
||
// 使用自定义端点 hook
|
||
const customEndpointsMap = useCustomEndpoints({
|
||
appType,
|
||
selectedPresetId,
|
||
presetEntries,
|
||
draftCustomEndpoints,
|
||
baseUrl,
|
||
codexBaseUrl,
|
||
});
|
||
|
||
// 使用端点测速候选 hook
|
||
const speedTestEndpoints = useSpeedTestEndpoints({
|
||
appType,
|
||
selectedPresetId,
|
||
presetEntries,
|
||
baseUrl,
|
||
codexBaseUrl,
|
||
initialData,
|
||
});
|
||
|
||
const handlePresetChange = (value: string) => {
|
||
setSelectedPresetId(value);
|
||
if (value === "custom") {
|
||
setActivePreset(null);
|
||
form.reset(defaultValues);
|
||
|
||
// Codex 自定义模式:重置为空配置
|
||
if (appType === "codex") {
|
||
resetCodexConfig({}, "");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const entry = presetEntries.find((item) => item.id === value);
|
||
if (!entry) {
|
||
return;
|
||
}
|
||
|
||
setActivePreset({
|
||
id: value,
|
||
category: entry.preset.category,
|
||
});
|
||
|
||
if (appType === "codex") {
|
||
const preset = entry.preset as CodexProviderPreset;
|
||
const auth = preset.auth ?? {};
|
||
const config = preset.config ?? "";
|
||
|
||
// 重置 Codex 配置
|
||
resetCodexConfig(auth, config);
|
||
|
||
// 更新表单其他字段
|
||
form.reset({
|
||
name: preset.name,
|
||
websiteUrl: preset.websiteUrl ?? "",
|
||
settingsConfig: JSON.stringify({ auth, config }, null, 2),
|
||
});
|
||
return;
|
||
}
|
||
|
||
const preset = entry.preset as ProviderPreset;
|
||
const config = applyTemplateValues(
|
||
preset.settingsConfig,
|
||
preset.templateValues,
|
||
);
|
||
|
||
form.reset({
|
||
name: preset.name,
|
||
websiteUrl: preset.websiteUrl ?? "",
|
||
settingsConfig: JSON.stringify(config, null, 2),
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Form {...form}>
|
||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||
{/* 预设供应商选择(仅新增模式显示) */}
|
||
{!initialData && (
|
||
<ProviderPresetSelector
|
||
selectedPresetId={selectedPresetId}
|
||
groupedPresets={groupedPresets}
|
||
categoryKeys={categoryKeys}
|
||
presetCategoryLabels={presetCategoryLabels}
|
||
onPresetChange={handlePresetChange}
|
||
/>
|
||
)}
|
||
|
||
{/* 基础字段 */}
|
||
<BasicFormFields form={form} />
|
||
|
||
{/* Claude 专属字段 */}
|
||
{appType === "claude" && (
|
||
<ClaudeFormFields
|
||
shouldShowApiKey={shouldShowApiKey(
|
||
form.watch("settingsConfig"),
|
||
isEditMode,
|
||
)}
|
||
apiKey={apiKey}
|
||
onApiKeyChange={handleApiKeyChange}
|
||
category={category}
|
||
shouldShowApiKeyLink={shouldShowClaudeApiKeyLink}
|
||
websiteUrl={claudeWebsiteUrl}
|
||
templateValueEntries={templateValueEntries}
|
||
templateValues={templateValues}
|
||
templatePresetName={templatePreset?.name || ""}
|
||
onTemplateValueChange={handleTemplateValueChange}
|
||
shouldShowSpeedTest={shouldShowSpeedTest}
|
||
baseUrl={baseUrl}
|
||
onBaseUrlChange={handleClaudeBaseUrlChange}
|
||
isEndpointModalOpen={isEndpointModalOpen}
|
||
onEndpointModalToggle={setIsEndpointModalOpen}
|
||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||
shouldShowKimiSelector={shouldShowKimiSelector}
|
||
shouldShowModelSelector={
|
||
category !== "official" && !shouldShowKimiSelector
|
||
}
|
||
claudeModel={claudeModel}
|
||
claudeSmallFastModel={claudeSmallFastModel}
|
||
onModelChange={handleModelChange}
|
||
kimiAnthropicModel={kimiAnthropicModel}
|
||
kimiAnthropicSmallFastModel={kimiAnthropicSmallFastModel}
|
||
onKimiModelChange={handleKimiModelChange}
|
||
speedTestEndpoints={speedTestEndpoints}
|
||
/>
|
||
)}
|
||
|
||
{/* Codex 专属字段 */}
|
||
{appType === "codex" && (
|
||
<CodexFormFields
|
||
codexApiKey={codexApiKey}
|
||
onApiKeyChange={handleCodexApiKeyChange}
|
||
category={category}
|
||
shouldShowApiKeyLink={shouldShowCodexApiKeyLink}
|
||
websiteUrl={codexWebsiteUrl}
|
||
shouldShowSpeedTest={shouldShowSpeedTest}
|
||
codexBaseUrl={codexBaseUrl}
|
||
onBaseUrlChange={handleCodexBaseUrlChange}
|
||
isEndpointModalOpen={isCodexEndpointModalOpen}
|
||
onEndpointModalToggle={setIsCodexEndpointModalOpen}
|
||
onCustomEndpointsChange={setDraftCustomEndpoints}
|
||
speedTestEndpoints={speedTestEndpoints}
|
||
/>
|
||
)}
|
||
|
||
{/* 配置编辑器:Claude 使用通用配置编辑器,Codex 使用专用编辑器 */}
|
||
{appType === "codex" ? (
|
||
<CodexConfigEditor
|
||
authValue={codexAuth}
|
||
configValue={codexConfig}
|
||
onAuthChange={setCodexAuth}
|
||
onConfigChange={handleCodexConfigChange}
|
||
useCommonConfig={useCodexCommonConfigFlag}
|
||
onCommonConfigToggle={handleCodexCommonConfigToggle}
|
||
commonConfigSnippet={codexCommonConfigSnippet}
|
||
onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange}
|
||
commonConfigError={codexCommonConfigError}
|
||
authError={codexAuthError}
|
||
configError={codexConfigError}
|
||
isCustomMode={selectedPresetId === "custom"}
|
||
onWebsiteUrlChange={(url) => form.setValue("websiteUrl", url)}
|
||
onNameChange={(name) => form.setValue("name", name)}
|
||
isTemplateModalOpen={isCodexTemplateModalOpen}
|
||
setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
|
||
/>
|
||
) : (
|
||
<CommonConfigEditor
|
||
value={form.watch("settingsConfig")}
|
||
onChange={(value) => form.setValue("settingsConfig", value)}
|
||
useCommonConfig={useCommonConfig}
|
||
onCommonConfigToggle={handleCommonConfigToggle}
|
||
commonConfigSnippet={commonConfigSnippet}
|
||
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
|
||
commonConfigError={commonConfigError}
|
||
onEditClick={() => setIsCommonConfigModalOpen(true)}
|
||
isModalOpen={isCommonConfigModalOpen}
|
||
onModalClose={() => setIsCommonConfigModalOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<Button variant="outline" type="button" onClick={onCancel}>
|
||
{t("common.cancel", { defaultValue: "取消" })}
|
||
</Button>
|
||
<Button type="submit">{submitLabel}</Button>
|
||
</div>
|
||
</form>
|
||
</Form>
|
||
);
|
||
}
|
||
|
||
export type ProviderFormValues = ProviderFormData & {
|
||
presetId?: string;
|
||
presetCategory?: ProviderCategory;
|
||
meta?: {
|
||
custom_endpoints?: Record<string, CustomEndpoint>;
|
||
};
|
||
};
|