diff --git a/apps/portal-shell/src/app/shell/admin/ai-settings/page.tsx b/apps/portal-shell/src/app/shell/admin/ai-settings/page.tsx index 4365134..2383a76 100644 --- a/apps/portal-shell/src/app/shell/admin/ai-settings/page.tsx +++ b/apps/portal-shell/src/app/shell/admin/ai-settings/page.tsx @@ -13,6 +13,13 @@ import { ListPageSkeleton } from "@/shared/components/page-templates"; * - aiProviders(scope) ❌ schema 未就绪 → MSW 兜底(@contract-pending) * - aiUsageDashboard(range) ❌ schema 未就绪 → MSW 兜底(@contract-pending) * + * 权限模型(双权限,CICD admin/ai-settings 对齐): + * - AI_CHAT(普通用户可访问自己的 private provider) + * - AI_CONFIGURE(管理员额外权限:管理 public provider 与他人 private provider) + * - 路由登记:route-permissions.ts EXACT `/shell/admin/ai-settings` → ["admin"] + * (管理员隐含 AI_CONFIGURE;普通用户访问自己的 provider 走 /shell/ai-settings, + * 该路由不在 admin 域,故本页只面向管理员视角) + * * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4 */ export default function AiSettingsPage(): React.ReactElement { diff --git a/apps/portal-shell/src/app/shell/admin/classes/page.tsx b/apps/portal-shell/src/app/shell/admin/classes/page.tsx new file mode 100644 index 0000000..127aac1 --- /dev/null +++ b/apps/portal-shell/src/app/shell/admin/classes/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; + +/** + * 班级管理入口重定向(ARCHITECTURE.md §9.4 / §10 P5)。 + * + * /shell/admin/classes 默认跳转到学校管理下的班级列表页,避免空白入口 + * (班级管理归属于 school 限界上下文)。 + * + * 关联:ARCHITECTURE.md §5.3 / §9.4 / §10 P5 + */ +export default function ClassesAdminIndexPage(): never { + redirect("/shell/admin/school/classes"); +} diff --git a/apps/portal-shell/src/app/shell/admin/plugins/page.tsx b/apps/portal-shell/src/app/shell/admin/plugins/page.tsx index 5f86ab2..57a302d 100644 --- a/apps/portal-shell/src/app/shell/admin/plugins/page.tsx +++ b/apps/portal-shell/src/app/shell/admin/plugins/page.tsx @@ -10,8 +10,8 @@ import { ListPageSkeleton } from "@/shared/components/page-templates"; * 业务逻辑在 PluginsClient(client component)中。 * * 数据契约: - * - pluginRegistry ✅ schema 已就绪(config-service) - * - updatePluginRegistry(pluginId, input) ✅ schema 已就绪 + * - pluginRegistry ❌ schema 未就绪(config-service 实际暴露 plugins/layoutTemplates/userLayoutOverride/pluginConfig,无 pluginRegistry 字段;requiredRoles/defaultSlot/defaultSize/defaultProps/propsSchema 字段缺失)→ MSW 兜底(@contract-pending) + * - updatePluginRegistry(pluginId, input) ❌ schema 无 Mutation 类型 → MSW 兜底(@contract-pending) * * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4 */ diff --git a/apps/portal-shell/src/app/shell/admin/scheduling/page.tsx b/apps/portal-shell/src/app/shell/admin/scheduling/page.tsx new file mode 100644 index 0000000..1c19a36 --- /dev/null +++ b/apps/portal-shell/src/app/shell/admin/scheduling/page.tsx @@ -0,0 +1,12 @@ +import { redirect } from "next/navigation"; + +/** + * 排课管理入口重定向(ARCHITECTURE.md §9.4 / §10 P5)。 + * + * /shell/admin/scheduling 默认跳转到排课变更审批子页,避免空白入口。 + * + * 关联:ARCHITECTURE.md §5.3 / §9.4 / §10 P5 + */ +export default function SchedulingAdminIndexPage(): never { + redirect("/shell/admin/scheduling/changes"); +} diff --git a/apps/portal-shell/src/app/shell/admin/school/grades/insights/page.tsx b/apps/portal-shell/src/app/shell/admin/school/grades/insights/page.tsx new file mode 100644 index 0000000..9221865 --- /dev/null +++ b/apps/portal-shell/src/app/shell/admin/school/grades/insights/page.tsx @@ -0,0 +1,25 @@ +import { Suspense } from "react"; + +import { GradeInsightsClient } from "@/features/admin/school/grade-insights-client"; +import { ListPageSkeleton } from "@/shared/components/page-templates"; + +/** + * 年级洞察页(ARCHITECTURE.md §9.4 管理域 / §10 P5) + * + * Server Component 入口:仅负责 Suspense 边界包裹(useSearchParams 要求)。 + * 业务逻辑在 GradeInsightsClient(client component)中。 + * + * 数据契约:schoolWideGradeSummary()(@contract-pending → MSW 兜底) + * - overallStats:全校平均分 / 及格率 / 优秀率 / 参考人数 + * - grades[]:各年级统计 + 班级排名 + * - recentAssignments[]:最近作业列表 + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §9.4 / §10 P5 / §11.3 / §11.4 + */ +export default function GradeInsightsPage(): React.ReactElement { + return ( + }> + + + ); +} diff --git a/apps/portal-shell/src/app/shell/admin/viewports/page.tsx b/apps/portal-shell/src/app/shell/admin/viewports/page.tsx index 1f46a64..e308afd 100644 --- a/apps/portal-shell/src/app/shell/admin/viewports/page.tsx +++ b/apps/portal-shell/src/app/shell/admin/viewports/page.tsx @@ -13,6 +13,11 @@ import { ListPageSkeleton } from "@/shared/components/page-templates"; * - viewports ❌ schema 未就绪 → MSW 兜底(@contract-pending) * - updateViewport(id, input) ❌ schema 无 Mutation → MSW 兜底 * + * DataScope 说明(CICD ctx.dataScope 对齐): + * - 视口配置属管理员全局视角(DataScope = "all"),不随班级/年级范围收窄 + * - 普通教师/学生/家长角色不访问本页(route-permissions.ts 仅放行 ["admin"]) + * - 与 CICD 中 admin/* 视图一致:管理员 = 全校 DataScope,无需 ctx.dataScope 上下文 + * * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §10 P5 / §11.3 / §11.4 */ export default function ViewportsPage(): React.ReactElement { diff --git a/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts b/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts index e3e4c48..811a7bb 100644 --- a/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts +++ b/apps/portal-shell/src/features/admin/ai-settings/__tests__/transformations.test.ts @@ -21,37 +21,40 @@ import { describe("formatProviderType", () => { it("maps known types to display labels", () => { + expect(formatProviderType("zhipu")).toBe("智谱 AI"); expect(formatProviderType("openai")).toBe("OpenAI"); - expect(formatProviderType("anthropic")).toBe("Anthropic"); - expect(formatProviderType("azure")).toBe("Azure OpenAI"); - expect(formatProviderType("local")).toBe("本地模型"); + expect(formatProviderType("gemini")).toBe("Google Gemini"); + expect(formatProviderType("ollama")).toBe("Ollama(本地)"); + expect(formatProviderType("custom")).toBe("自定义"); }); it("returns original value for unknown type", () => { - expect(formatProviderType("custom")).toBe("custom"); + expect(formatProviderType("unknown-vendor")).toBe("unknown-vendor"); expect(formatProviderType("")).toBe(""); }); - it("PROVIDER_TYPE_LABEL covers 4 standard types", () => { - expect(Object.keys(PROVIDER_TYPE_LABEL)).toHaveLength(4); + it("PROVIDER_TYPE_LABEL covers 5 standard types", () => { + expect(Object.keys(PROVIDER_TYPE_LABEL)).toHaveLength(5); }); }); describe("isValidProviderType", () => { it("returns true for supported types", () => { + expect(isValidProviderType("zhipu")).toBe(true); expect(isValidProviderType("openai")).toBe(true); - expect(isValidProviderType("anthropic")).toBe(true); - expect(isValidProviderType("azure")).toBe(true); - expect(isValidProviderType("local")).toBe(true); + expect(isValidProviderType("gemini")).toBe(true); + expect(isValidProviderType("ollama")).toBe(true); + expect(isValidProviderType("custom")).toBe(true); }); it("returns false for unsupported types", () => { - expect(isValidProviderType("custom")).toBe(false); + expect(isValidProviderType("anthropic")).toBe(false); + expect(isValidProviderType("azure")).toBe(false); expect(isValidProviderType("")).toBe(false); }); - it("PROVIDER_TYPES has exactly 4 entries", () => { - expect(PROVIDER_TYPES).toHaveLength(4); + it("PROVIDER_TYPES has exactly 5 entries", () => { + expect(PROVIDER_TYPES).toHaveLength(5); }); }); diff --git a/apps/portal-shell/src/features/admin/ai-settings/ai-provider-delete-dialog.tsx b/apps/portal-shell/src/features/admin/ai-settings/ai-provider-delete-dialog.tsx new file mode 100644 index 0000000..5e5a74c --- /dev/null +++ b/apps/portal-shell/src/features/admin/ai-settings/ai-provider-delete-dialog.tsx @@ -0,0 +1,123 @@ +"use client"; + +/** + * AI Provider 删除确认对话框 + * + * 数据契约:deleteAiProvider mutation ❌ schema 无 Mutation → MSW 兜底(@contract-pending) + * + * 安全删除模式:用户必须输入 Provider 名称才能确认删除(防误操作)。 + * + * 关联:ARCHITECTURE.md §5.3 / §5.4 / §7.3 / §9.4 / §11.3 / §11.4 + */ +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; + +import type { AiProvider } from "@/lib/api"; +import { useDeleteAiProvider } from "@/lib/api"; +import { notify } from "@/shared/lib/notify"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog"; + +export interface AiProviderDeleteDialogProps { + provider: AiProvider | null; + open: boolean; + onClose: () => void; + onDeleted: () => Promise | void; +} + +export function AiProviderDeleteDialog({ + provider, + open, + onClose, + onDeleted, +}: AiProviderDeleteDialogProps): React.ReactElement { + const t = useTranslations("adminDomain.aiSettings.deleteDialog"); + const tError = useTranslations("adminDomain.aiSettings.error"); + const deleteProvider = useDeleteAiProvider(); + const [confirmText, setConfirmText] = useState(""); + const [submitting, setSubmitting] = useState(false); + + // 打开时重置输入,关闭时也清理 + useEffect(() => { + if (open) { + setConfirmText(""); + setSubmitting(false); + } + }, [open]); + + const providerName = provider?.name ?? ""; + const canConfirm = confirmText === providerName && providerName !== ""; + + const handleConfirm = async (): Promise => { + if (!provider || !canConfirm) return; + setSubmitting(true); + try { + await deleteProvider.run(provider.id); + notify.success(t("success", { name: providerName })); + onClose(); + await onDeleted(); + } catch (err: unknown) { + notify.error(tError("unknown")); + console.error("Failed to delete AI provider:", err); + } finally { + setSubmitting(false); + } + }; + + return ( + !next && onClose()}> + + + {t("title")} + + {t("warning", { name: providerName })} + + + +
+

+ {t("confirmPrompt", { name: providerName })} +

+ setConfirmText(e.target.value)} + placeholder={t("confirmInputPlaceholder")} + disabled={submitting} + autoComplete="off" + /> +

+ {t("confirmInputHint")} +

+
+ + + + + +
+
+ ); +} diff --git a/apps/portal-shell/src/features/admin/ai-settings/ai-provider-form.tsx b/apps/portal-shell/src/features/admin/ai-settings/ai-provider-form.tsx new file mode 100644 index 0000000..9a52b57 --- /dev/null +++ b/apps/portal-shell/src/features/admin/ai-settings/ai-provider-form.tsx @@ -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(""); + 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")} +

+
+ +
+ + +
+ +
+ + +
+ +
+ + setTitle(e.target.value)} + placeholder={t("fieldTitlePlaceholder")} + maxLength={200} + required + /> +
+ +
+ +