From f991bf04467911d7232ed8f33ad4b3a185ae5534 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:49:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(portal-shell):=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=9F=9F=E5=85=A8=E6=A8=A1=E5=9D=97=E5=8A=9F=E8=83=BD=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E4=B8=8E=E5=B7=AE=E5=BC=82=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 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 已更新 --- .../src/app/shell/admin/ai-settings/page.tsx | 7 + .../src/app/shell/admin/classes/page.tsx | 13 + .../src/app/shell/admin/plugins/page.tsx | 4 +- .../src/app/shell/admin/scheduling/page.tsx | 12 + .../admin/school/grades/insights/page.tsx | 25 + .../src/app/shell/admin/viewports/page.tsx | 5 + .../__tests__/transformations.test.ts | 27 +- .../ai-settings/ai-provider-delete-dialog.tsx | 123 + .../admin/ai-settings/ai-provider-form.tsx | 401 ++ .../admin/ai-settings/ai-settings-client.tsx | 283 +- .../admin/ai-settings/transformations.ts | 37 +- .../announcement-create-dialog.tsx | 233 + .../announcement-detail-client.tsx | 60 +- .../announcement-edit-client.tsx | 64 +- .../announcements-list-client.tsx | 190 +- .../announcements/grade-multi-select.tsx | 182 + .../__tests__/transformations.test.ts | 3 + .../attendance/_write-correlation-card.mjs | 0 .../attendance/admin-attendance-client.tsx | 188 +- .../attendance-grade-correlation-card.tsx | 466 ++ .../attendance/attendance-records-list.tsx | 293 ++ .../attendance/class-comparison-card.tsx | 264 ++ .../audit-logs/audit-log-detail-dialog.tsx | 177 + .../audit-logs/audit-log-export-button.tsx | 72 + .../audit-logs/audit-logs-list-client.tsx | 235 +- .../audit-logs/audit-logs-pagination.tsx | 125 + .../audit-logs/audit-overview-client.tsx | 231 +- .../audit-logs/audit-retention-settings.tsx | 233 + .../admin/audit-logs/data-changes-client.tsx | 285 +- .../admin/audit-logs/login-logs-client.tsx | 127 +- .../course-plan-create-client.tsx | 261 +- .../course-plan-detail-client.tsx | 513 ++- .../course-plans/course-plan-edit-client.tsx | 341 +- .../course-plans/course-plan-item-editor.tsx | 286 ++ .../course-plans/course-plans-list-client.tsx | 114 +- .../admin/course-plans/export-utils.ts | 141 + .../admin/course-plans/sortable-week-row.tsx | 116 + .../course-plans/template-picker-dialog.tsx | 197 + .../__tests__/transformations.test.ts | 84 +- .../curriculum-map/curriculum-map-client.tsx | 55 +- .../admin/curriculum-map/transformations.ts | 74 +- .../admin/elective/elective-create-client.tsx | 224 +- .../admin/elective/elective-detail-client.tsx | 189 +- .../admin/elective/elective-edit-client.tsx | 253 +- .../admin/elective/elective-list-client.tsx | 365 +- .../admin/elective/transformations.ts | 28 + .../__tests__/transformations.test.ts | 17 + .../admin/error-book/error-book-client.tsx | 362 +- .../error-book/error-book-detail-dialog.tsx | 267 ++ .../admin/error-book/transformations.ts | 54 + .../files/__tests__/transformations.test.ts | 41 + .../admin/files/file-batch-operations.tsx | 204 + .../admin/files/file-preview-dialog.tsx | 515 +++ .../admin/files/file-upload-button.tsx | 187 + .../admin/files/files-list-client.tsx | 514 ++- .../features/admin/files/transformations.ts | 68 + .../generate-invitation-codes-dialog.tsx | 367 ++ .../invitation-codes-list-client.tsx | 423 +- .../admin/invitation-codes/transformations.ts | 32 +- .../__tests__/transformations.test.ts | 145 +- .../lesson-plans/delete-confirm-dialog.tsx | 106 + .../lesson-plans/lesson-plan-view-client.tsx | 90 +- .../lesson-plans/lesson-plans-list-client.tsx | 354 +- .../admin/lesson-plans/transformations.ts | 10 + .../organization/organization-tree-client.tsx | 12 +- .../permissions/permissions-list-client.tsx | 6 + .../admin/questions/batch-operations.tsx | 168 + .../questions/create-question-dialog.tsx | 266 ++ .../admin/questions/import-export-buttons.tsx | 214 + .../questions/question-detail-dialog.tsx | 307 ++ .../admin/questions/questions-list-client.tsx | 463 +- .../admin/roles/_write-role-form-dialog.ps1 | 0 .../admin/roles/role-detail-client.tsx | 10 + .../features/admin/roles/role-form-dialog.tsx | 213 + .../admin/roles/role-permission-matrix.tsx | 348 ++ .../admin/roles/roles-list-client.tsx | 284 +- .../features/admin/roles/transformations.ts | 17 + .../admin/school/academic-year-client.tsx | 339 +- .../admin/school/admin-classes-client.tsx | 404 +- .../admin/school/class-invitation-manager.tsx | 408 ++ .../admin/school/class-schedule-dialog.tsx | 509 +++ .../admin/school/departments-client.tsx | 24 +- .../admin/school/grade-insights-client.tsx | 495 ++ .../features/admin/school/grades-client.tsx | 244 +- .../features/admin/school/schools-client.tsx | 226 +- .../admin/students/students-list-client.tsx | 12 +- .../admin/teachers/teachers-list-client.tsx | 12 +- .../admin/users/user-role-assign-dialog.tsx | 227 + .../admin/users/users-import-client.tsx | 140 +- .../admin/users/users-list-client.tsx | 289 +- .../admin/viewports/viewports-client.tsx | 14 +- apps/portal-shell/src/lib/api/admin-p5.ts | 1500 ++++++- apps/portal-shell/src/lib/api/admin.ts | 6 + .../src/lib/api/operations/admin.graphql.ts | 15 +- apps/portal-shell/src/messages/en.json | 3973 ++++++++++++++++- apps/portal-shell/src/messages/zh-CN.json | 3832 +++++++++++++++- apps/portal-shell/src/mocks/graphql-data.ts | 1748 +++++++- docs/troubleshooting/known-issues.md | 9 + 98 files changed, 25956 insertions(+), 2140 deletions(-) create mode 100644 apps/portal-shell/src/app/shell/admin/classes/page.tsx create mode 100644 apps/portal-shell/src/app/shell/admin/scheduling/page.tsx create mode 100644 apps/portal-shell/src/app/shell/admin/school/grades/insights/page.tsx create mode 100644 apps/portal-shell/src/features/admin/ai-settings/ai-provider-delete-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/ai-settings/ai-provider-form.tsx create mode 100644 apps/portal-shell/src/features/admin/announcements/announcement-create-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/announcements/grade-multi-select.tsx create mode 100644 apps/portal-shell/src/features/admin/attendance/_write-correlation-card.mjs create mode 100644 apps/portal-shell/src/features/admin/attendance/attendance-grade-correlation-card.tsx create mode 100644 apps/portal-shell/src/features/admin/attendance/attendance-records-list.tsx create mode 100644 apps/portal-shell/src/features/admin/attendance/class-comparison-card.tsx create mode 100644 apps/portal-shell/src/features/admin/audit-logs/audit-log-detail-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/audit-logs/audit-log-export-button.tsx create mode 100644 apps/portal-shell/src/features/admin/audit-logs/audit-logs-pagination.tsx create mode 100644 apps/portal-shell/src/features/admin/audit-logs/audit-retention-settings.tsx create mode 100644 apps/portal-shell/src/features/admin/course-plans/course-plan-item-editor.tsx create mode 100644 apps/portal-shell/src/features/admin/course-plans/export-utils.ts create mode 100644 apps/portal-shell/src/features/admin/course-plans/sortable-week-row.tsx create mode 100644 apps/portal-shell/src/features/admin/course-plans/template-picker-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/error-book/error-book-detail-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/files/file-batch-operations.tsx create mode 100644 apps/portal-shell/src/features/admin/files/file-preview-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/files/file-upload-button.tsx create mode 100644 apps/portal-shell/src/features/admin/invitation-codes/generate-invitation-codes-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/lesson-plans/delete-confirm-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/questions/batch-operations.tsx create mode 100644 apps/portal-shell/src/features/admin/questions/create-question-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/questions/import-export-buttons.tsx create mode 100644 apps/portal-shell/src/features/admin/questions/question-detail-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/roles/_write-role-form-dialog.ps1 create mode 100644 apps/portal-shell/src/features/admin/roles/role-form-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/roles/role-permission-matrix.tsx create mode 100644 apps/portal-shell/src/features/admin/school/class-invitation-manager.tsx create mode 100644 apps/portal-shell/src/features/admin/school/class-schedule-dialog.tsx create mode 100644 apps/portal-shell/src/features/admin/school/grade-insights-client.tsx create mode 100644 apps/portal-shell/src/features/admin/users/user-role-assign-dialog.tsx 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 + /> +
+ +
+ +