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:
SpecialX
2026-07-30 17:49:49 +08:00
parent 071542b757
commit f991bf0446
98 changed files with 25956 additions and 2140 deletions

View File

@@ -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>
);
}