- 替换 41 处原生 select 为 Select 组件封装 - 替换 5 处 window.confirm 为 shadcn AlertDialog - 修复 lesson-plans delete-confirm-dialog 为 AlertDialog - 修复 5 处 Tailwind 任意值 text-[10px] - 修复 graphql-data.ts mutation case 缺少 id 定义 - 修复 use-position-persistence.ts eslint 规则引用
402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
"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<string>("");
|
||
const [type, setType] = useState<string>("openai");
|
||
const [scope, setScope] = useState<string>("global");
|
||
const [model, setModel] = useState<string>("");
|
||
const [apiBase, setApiBase] = useState<string>("");
|
||
const [apiKey, setApiKey] = useState<string>("");
|
||
const [isActive, setIsActive] = useState<boolean>(false);
|
||
const [visibility, setVisibility] = useState<string>("private");
|
||
const [isDefault, setIsDefault] = useState<boolean>(false);
|
||
const [testResult, setTestResult] = useState<{
|
||
ok: boolean;
|
||
latencyMs: number;
|
||
message: string;
|
||
} | null>(null);
|
||
const [nameError, setNameError] = useState<string | null>(null);
|
||
const [modelError, setModelError] = useState<string | null>(null);
|
||
const [apiBaseError, setApiBaseError] = useState<string | null>(null);
|
||
|
||
const { run: createProvider, loading: creating } = useCreateAiProvider();
|
||
const { run: updateProvider, loading: updating } = useUpdateAiProvider();
|
||
const { run: testProvider, loading: testing } = useTestAiProvider();
|
||
|
||
const handleTestConnection = async (): Promise<void> => {
|
||
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<void> => {
|
||
// 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<string, unknown> = {};
|
||
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 (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{mode === "create" ? t("titleCreate") : t("titleEdit")}
|
||
</DialogTitle>
|
||
<DialogDescription>{t("description")}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="aip-name">{t("fieldName")}</Label>
|
||
<Input
|
||
id="aip-name"
|
||
value={name}
|
||
onChange={(e) => {
|
||
setName(e.target.value);
|
||
if (nameError) setNameError(null);
|
||
}}
|
||
placeholder={t("fieldNamePlaceholder")}
|
||
aria-invalid={nameError !== null}
|
||
/>
|
||
{nameError ? (
|
||
<p className="text-xs text-destructive">{nameError}</p>
|
||
) : null}
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="aip-type">{t("fieldType")}</Label>
|
||
<Select
|
||
id="aip-type"
|
||
value={type}
|
||
onValueChange={setType}
|
||
options={typeOptions}
|
||
aria-label={t("fieldType")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="aip-scope">{t("fieldScope")}</Label>
|
||
<Input
|
||
id="aip-scope"
|
||
value={scope}
|
||
onChange={(e) => setScope(e.target.value)}
|
||
placeholder={t("fieldScopePlaceholder")}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="aip-model">{t("fieldModel")}</Label>
|
||
<Input
|
||
id="aip-model"
|
||
value={model}
|
||
onChange={(e) => {
|
||
setModel(e.target.value);
|
||
if (modelError) setModelError(null);
|
||
}}
|
||
placeholder={t("fieldModelPlaceholder")}
|
||
aria-invalid={modelError !== null}
|
||
/>
|
||
{modelError ? (
|
||
<p className="text-xs text-destructive">{modelError}</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="aip-apibase">{t("fieldApiBase")}</Label>
|
||
<Input
|
||
id="aip-apibase"
|
||
value={apiBase}
|
||
onChange={(e) => {
|
||
setApiBase(e.target.value);
|
||
if (apiBaseError) setApiBaseError(null);
|
||
}}
|
||
placeholder={t("fieldApiBasePlaceholder")}
|
||
aria-invalid={apiBaseError !== null}
|
||
/>
|
||
{apiBaseError ? (
|
||
<p className="text-xs text-destructive">{apiBaseError}</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="aip-apikey">{t("fieldApiKey")}</Label>
|
||
<Input
|
||
id="aip-apikey"
|
||
type="password"
|
||
value={apiKey}
|
||
onChange={(e) => setApiKey(e.target.value)}
|
||
placeholder={
|
||
mode === "edit"
|
||
? t("fieldApiKeyPlaceholderEdit")
|
||
: t("fieldApiKeyPlaceholder")
|
||
}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("fieldApiKeyHint")}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Switch
|
||
id="aip-isactive"
|
||
checked={isActive}
|
||
onCheckedChange={setIsActive}
|
||
aria-label={t("fieldIsActive")}
|
||
/>
|
||
<Label htmlFor="aip-isactive" className="cursor-pointer">
|
||
{t("fieldIsActive")}
|
||
</Label>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Switch
|
||
id="aip-isdefault"
|
||
checked={isDefault}
|
||
onCheckedChange={setIsDefault}
|
||
aria-label={t("fieldIsDefault")}
|
||
/>
|
||
<Label htmlFor="aip-isdefault" className="cursor-pointer">
|
||
{t("fieldIsDefault")}
|
||
</Label>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="aip-visibility">{t("fieldVisibility")}</Label>
|
||
<Select
|
||
id="aip-visibility"
|
||
value={visibility}
|
||
onValueChange={setVisibility}
|
||
options={PROVIDER_VISIBILITY_OPTIONS.map((value) => ({
|
||
value,
|
||
label: formatProviderVisibility(value),
|
||
}))}
|
||
aria-label={t("fieldVisibility")}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{mode === "edit" && provider ? (
|
||
<div className="rounded-md border bg-muted/30 p-3">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => void handleTestConnection()}
|
||
disabled={testing}
|
||
>
|
||
{testing ? t("testing") : t("testConnection")}
|
||
</Button>
|
||
{testResult ? (
|
||
<span
|
||
className={
|
||
testResult.ok
|
||
? "text-sm text-success"
|
||
: "text-sm text-destructive"
|
||
}
|
||
>
|
||
{testResult.ok
|
||
? t("testSuccess", { latency: testResult.latencyMs })
|
||
: t("testFailed", { message: testResult.message })}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<DialogFooter className="gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={isWorking}
|
||
>
|
||
{t("cancel")}
|
||
</Button>
|
||
<Button type="button" onClick={handleSubmit} disabled={isWorking}>
|
||
{isWorking ? t("saving") : t("submit")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|