"use client"; /** * AI Provider 创建/编辑对话框(ARCHITECTURE.md §7.3 / §9.4 / §10 P5) * * 基于 CICD src/modules/settings/components/ai-provider-settings-card.tsx 适配到 portal-shell: * - Server Actions → Apollo Client mutations + MSW 兜底 * - 表单字段:name / type / scope / model / apiBase / apiKey / isActive * - apiKey 写入 config.apiKey,与列表页 extractApiKey 对称 * - 支持 create / edit 双模式 * * 数据契约: * - createAiProvider / updateAiProvider:❌ schema 无 → MSW 兜底(@contract-pending) * * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4 */ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { z } from "zod"; import type { AiProvider, AiProviderInput } from "@/lib/api"; import { useCreateAiProvider, useTestAiProvider, useUpdateAiProvider, } from "@/lib/api"; import { Button } from "@/shared/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/shared/components/ui/dialog"; import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; import { Select } from "@/shared/components/ui/select"; import { Switch } from "@/shared/components/ui/switch"; import { notify } from "@/shared/lib/notify"; import { PROVIDER_TYPES, PROVIDER_VISIBILITY_OPTIONS, formatProviderType, formatProviderVisibility, } from "@/features/admin/ai-settings/transformations"; export interface AiProviderFormProps { provider?: AiProvider | null; mode: "create" | "edit"; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; } /** Provider 名称 zod 校验:非空字符串,1-100 字符。 */ const providerNameSchema = z.string().min(1).max(100); /** Provider 模型 zod 校验:非空字符串,1-100 字符。 */ const providerModelSchema = z.string().min(1).max(100); /** API Base URL zod 校验:可选,若填写必须为合法 http(s) URL。 */ const providerApiBaseSchema = z .string() .trim() .optional() .refine( (value) => { if (!value) return true; try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } }, { message: "invalid-url" }, ); export function AiProviderForm({ provider, mode, open, onOpenChange, onSuccess, }: AiProviderFormProps): React.ReactElement { const t = useTranslations("admin.aiSettings.form"); const tCommon = useTranslations("common"); const [name, setName] = useState(""); const [type, setType] = useState("openai"); const [scope, setScope] = useState("global"); const [model, setModel] = useState(""); const [apiBase, setApiBase] = useState(""); const [apiKey, setApiKey] = useState(""); const [isActive, setIsActive] = useState(false); const [visibility, setVisibility] = useState("private"); const [isDefault, setIsDefault] = useState(false); const [testResult, setTestResult] = useState<{ ok: boolean; latencyMs: number; message: string; } | null>(null); const [nameError, setNameError] = useState(null); const [modelError, setModelError] = useState(null); const [apiBaseError, setApiBaseError] = useState(null); const { run: createProvider, loading: creating } = useCreateAiProvider(); const { run: updateProvider, loading: updating } = useUpdateAiProvider(); const { run: testProvider, loading: testing } = useTestAiProvider(); const handleTestConnection = async (): Promise => { if (!provider) return; setTestResult(null); try { const result = await testProvider(provider.id); setTestResult(result); if (result.ok) { notify.success(t("testSuccess", { latency: result.latencyMs })); } else { notify.error(t("testFailed", { message: result.message })); } } catch (err) { notify.error(tCommon("error.loadFailed", { message: String(err) })); } }; const isWorking = creating || updating; useEffect(() => { if (open) { setName(provider?.name ?? ""); setType(provider?.type ?? "openai"); setScope(provider?.scope ?? "global"); setModel(provider?.model ?? ""); setApiBase(provider?.apiBase ?? ""); // 编辑模式下不回填 apiKey(安全考虑:服务端不返回明文 key) setApiKey(""); setIsActive(provider?.isActive ?? false); setVisibility(provider?.visibility ?? "private"); setIsDefault(provider?.isDefault ?? false); setTestResult(null); setNameError(null); setModelError(null); setApiBaseError(null); } }, [open, provider]); const typeOptions = PROVIDER_TYPES.map((value) => ({ value, label: formatProviderType(value), })); const handleSubmit = async (): Promise => { // zod 校验:name 非空、model 非空、apiBase URL 格式(可选) const trimmedName = name.trim(); const trimmedModel = model.trim(); const trimmedApiBase = apiBase.trim(); const nameValidation = providerNameSchema.safeParse(trimmedName); if (!nameValidation.success) { setNameError(t("errorNameRequired")); return; } setNameError(null); const modelValidation = providerModelSchema.safeParse(trimmedModel); if (!modelValidation.success) { setModelError(t("errorModelRequired")); return; } setModelError(null); const apiBaseValidation = providerApiBaseSchema.safeParse(trimmedApiBase); if (!apiBaseValidation.success) { setApiBaseError(t("errorApiBaseInvalid")); return; } setApiBaseError(null); // 构建 config:仅当用户输入 apiKey 时写入(编辑模式留空则保留原 key) const config: Record = {}; if (apiKey.trim()) { config.apiKey = apiKey.trim(); } const input: AiProviderInput = { name: trimmedName, type, scope: scope.trim() || "global", model: trimmedModel, apiBase: trimmedApiBase || undefined, isActive, isDefault, visibility, config: Object.keys(config).length > 0 ? config : undefined, }; try { if (mode === "create") { await createProvider(input); notify.success(t("createSuccess")); } else if (provider) { await updateProvider(provider.id, input); notify.success(t("updateSuccess")); } onSuccess?.(); onOpenChange(false); } catch (err) { notify.error(tCommon("error.loadFailed", { message: String(err) })); } }; return ( {mode === "create" ? t("titleCreate") : t("titleEdit")} {t("description")}
{ setName(e.target.value); if (nameError) setNameError(null); }} placeholder={t("fieldNamePlaceholder")} aria-invalid={nameError !== null} /> {nameError ? (

{nameError}

) : null}
setScope(e.target.value)} placeholder={t("fieldScopePlaceholder")} />
{ setModel(e.target.value); if (modelError) setModelError(null); }} placeholder={t("fieldModelPlaceholder")} aria-invalid={modelError !== null} /> {modelError ? (

{modelError}

) : null}
{ setApiBase(e.target.value); if (apiBaseError) setApiBaseError(null); }} placeholder={t("fieldApiBasePlaceholder")} aria-invalid={apiBaseError !== null} /> {apiBaseError ? (

{apiBaseError}

) : null}
setApiKey(e.target.value)} placeholder={ mode === "edit" ? t("fieldApiKeyPlaceholderEdit") : t("fieldApiKeyPlaceholder") } />

{t("fieldApiKeyHint")}