Files
Edu/apps/portal-shell/src/features/admin/ai-settings/ai-provider-form.tsx
SpecialX 039db5efdd fix(portal-shell): 管理域 UI 规范合规与 TypeScript 修复
- 替换 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 规则引用
2026-08-01 05:50:25 +08:00

402 lines
13 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.
"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>
);
}