Files
cc-switch/src/components/providers/forms/ProviderForm.tsx
Jason 2c1346a23d refactor: convert provider preset selector to flat button layout
Replace dropdown select menu with flat button layout matching MCP design.
Selecting a preset now fills the form without auto-submitting.
2025-10-16 16:51:47 +08:00

350 lines
10 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 { useEffect, useMemo, useState } 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,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useTheme } from "@/components/theme-provider";
import JsonEditor from "@/components/JsonEditor";
import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppType } from "@/lib/api";
import type { ProviderCategory } from "@/types";
import { providerPresets, type ProviderPreset } from "@/config/providerPresets";
import {
codexProviderPresets,
type CodexProviderPreset,
} from "@/config/codexProviderPresets";
import { applyTemplateValues } from "@/utils/providerConfigUtils";
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 { theme } = useTheme();
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
initialData ? null : "custom",
);
const [activePreset, setActivePreset] = useState<{
id: string;
category?: ProviderCategory;
} | null>(null);
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",
});
useEffect(() => {
form.reset(defaultValues);
}, [defaultValues, form]);
const isDarkMode = useMemo(() => {
if (theme === "dark") return true;
if (theme === "light") return false;
return typeof window !== "undefined"
? window.document.documentElement.classList.contains("dark")
: false;
}, [theme]);
const handleSubmit = (values: ProviderFormData) => {
const payload: ProviderFormValues = {
...values,
name: values.name.trim(),
websiteUrl: values.websiteUrl?.trim() ?? "",
settingsConfig: values.settingsConfig.trim(),
};
if (activePreset) {
payload.presetId = activePreset.id;
if (activePreset.category) {
payload.presetCategory = activePreset.category;
}
}
onSubmit(payload);
};
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]);
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 handlePresetChange = (value: string) => {
setSelectedPresetId(value);
if (value === "custom") {
setActivePreset(null);
form.reset(defaultValues);
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 config = {
auth: preset.auth ?? {},
config: preset.config ?? "",
};
form.reset({
name: preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(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 && (
<div className="space-y-3">
<FormLabel>
{t("providerPreset.label", { defaultValue: "预设供应商" })}
</FormLabel>
<div className="flex flex-wrap gap-2">
{/* 自定义按钮 */}
<button
type="button"
onClick={() => handlePresetChange("custom")}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPresetId === "custom"
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{t("providerPreset.custom", { defaultValue: "自定义配置" })}
</button>
{/* 预设按钮 */}
{categoryKeys.map((category) => {
const entries = groupedPresets[category];
if (!entries || entries.length === 0) return null;
return entries.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => handlePresetChange(entry.id)}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPresetId === entry.id
? "bg-emerald-500 text-white dark:bg-emerald-600"
: "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
title={
presetCategoryLabels[category] ??
t("providerPreset.categoryOther", {
defaultValue: "其他",
})
}
>
{entry.preset.name}
</button>
));
})}
</div>
<p className="text-xs text-muted-foreground">
{t("providerPreset.helper", {
defaultValue: "选择预设后可继续调整下方字段。",
})}
</p>
</div>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("provider.name", { defaultValue: "供应商名称" })}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("provider.namePlaceholder", {
defaultValue: "例如Claude 官方",
})}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("provider.websiteUrl", { defaultValue: "官网链接" })}
</FormLabel>
<FormControl>
<Input {...field} placeholder="https://" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="settingsConfig"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("provider.configJson", { defaultValue: "配置 JSON" })}
</FormLabel>
<FormControl>
<div className="rounded-md border">
<JsonEditor
value={field.value}
onChange={field.onChange}
placeholder={
appType === "codex"
? CODEX_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG
}
darkMode={isDarkMode}
rows={14}
showValidation
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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;
};