feat(portal-shell): 管理域全模块功能补齐与差异修复
按 ARCHITECTURE.md 与 admin-NeedTodo.md 要求补齐所有管理页面缺失功能: - users/roles/permissions:权限矩阵搜索/折叠、zod 校验、value 字段 - audit-logs:行内详情对话框、分页页码、ChartCardShell - school:CRUD 对话框、GradeOverviewCards、academic-year 侧栏 - announcements/invitation-codes/ai-settings:发布按钮、分页、zod 校验 - course-plans/elective:Select 导入、undefined 处理 - error-book/scheduling/questions/lesson-plans/attendance:统计卡片 验证:typecheck 0 错误、arch:scan 已更新
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
"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-emerald-600 dark:text-emerald-400"
|
||||
: "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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user