From f3c223d914d4d992bd77a1f512ae18e51be902e3 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:26:00 +0800 Subject: [PATCH] feat(settings,questions,school,textbooks): add brand config, question components, school dialogs, textbooks hooks settings: - Add actions-brand, brand-config, data-access-brand for brand management - Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card - Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card - Add security-recent-logins-section, security-two-factor-section - Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils questions: - Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor - Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils school: - Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards - Add use-grade-data hook textbooks: - Add textbook-form-fields component - Add use-kp-create, use-kp-delete, use-kp-update hooks --- src/modules/questions/actions.ts | 223 ++++- .../questions/components/batch-operations.tsx | 121 +++ .../components/create-question-button.tsx | 13 +- .../components/create-question-dialog.tsx | 321 ++---- .../components/import-export-buttons.tsx | 193 ++++ .../components/knowledge-point-selector.tsx | 107 ++ .../questions/components/options-editor.tsx | 106 ++ .../questions/components/question-actions.tsx | 148 +-- .../question-bank-results-client.tsx | 20 + .../components/question-cascade-filter.tsx | 154 +++ .../questions/components/question-columns.tsx | 239 +++-- .../components/question-content-renderer.tsx | 116 +++ .../components/question-data-table.tsx | 33 +- .../questions/components/question-filters.tsx | 88 +- src/modules/questions/data-access.ts | 292 +++++- src/modules/questions/types.ts | 44 +- src/modules/questions/utils/parse-content.ts | 128 +++ src/modules/questions/utils/track-event.ts | 90 ++ .../school/components/grade-delete-dialog.tsx | 71 ++ .../school/components/grade-form-dialog.tsx | 328 +++++++ .../school/components/grade-list-toolbar.tsx | 111 +++ .../components/grade-overview-cards.tsx | 154 +++ src/modules/school/components/grades-view.tsx | 919 +++--------------- .../components/school-error-boundary.tsx | 89 +- src/modules/school/hooks/use-grade-data.ts | 42 + src/modules/settings/actions-avatar.ts | 22 +- src/modules/settings/actions-brand.ts | 81 ++ src/modules/settings/actions-password.ts | 13 +- src/modules/settings/actions-security.ts | 37 +- .../settings/actions-system-settings.ts | 24 +- src/modules/settings/actions.ts | 62 +- src/modules/settings/brand-config.ts | 27 + .../components/admin-file-upload-card.tsx | 66 ++ .../admin-notification-config-card.tsx | 79 ++ .../components/admin-school-info-card.tsx | 113 +++ .../components/admin-security-policy-card.tsx | 110 +++ .../components/admin-settings-view.tsx | 316 +----- .../components/ai-provider-delete-dialog.tsx | 68 ++ .../components/ai-provider-selector.tsx | 108 ++ .../components/ai-provider-settings-card.tsx | 136 +-- .../settings/components/avatar-upload.tsx | 5 +- .../settings/components/brand-config-card.tsx | 160 +++ .../components/profile-student-overview.tsx | 18 +- .../components/profile-teacher-overview.tsx | 11 +- .../components/security-center-card.tsx | 619 +----------- .../security-recent-logins-section.tsx | 172 ++++ .../security-two-factor-section.tsx | 473 +++++++++ .../settings-section-error-boundary.tsx | 67 +- .../config/profile-overview-config.ts | 26 + src/modules/settings/data-access-brand.ts | 78 ++ .../settings/data-access-profile-overview.ts | 46 + .../settings/data-access-system-settings.ts | 2 + .../lib/system-settings-utils.test.ts | 141 +++ .../settings/lib/system-settings-utils.ts | 44 + src/modules/settings/types.ts | 2 +- src/modules/textbooks/actions.ts | 33 +- .../components/chapter-sidebar-list.tsx | 12 +- .../textbooks/components/graph-kp-node.tsx | 23 +- .../components/graph-node-detail-panel.tsx | 3 +- .../textbooks/components/knowledge-graph.tsx | 63 +- .../components/knowledge-point-dialogs.tsx | 3 +- .../components/section-error-boundary.tsx | 88 +- .../textbooks/components/textbook-card.tsx | 2 +- .../components/textbook-content-panel.tsx | 75 +- .../components/textbook-form-dialog.tsx | 71 +- .../components/textbook-form-fields.tsx | 107 ++ .../textbooks/components/textbook-reader.tsx | 62 +- .../components/textbook-settings-dialog.tsx | 84 +- src/modules/textbooks/constants.ts | 4 + src/modules/textbooks/data-access.ts | 49 +- src/modules/textbooks/graph-layout.ts | 15 +- src/modules/textbooks/hooks/use-kp-create.ts | 51 + src/modules/textbooks/hooks/use-kp-crud.ts | 105 +- src/modules/textbooks/hooks/use-kp-delete.ts | 58 ++ src/modules/textbooks/hooks/use-kp-update.ts | 44 + src/modules/textbooks/schema.ts | 9 - src/modules/textbooks/utils.ts | 24 + 77 files changed, 5397 insertions(+), 2864 deletions(-) create mode 100644 src/modules/questions/components/batch-operations.tsx create mode 100644 src/modules/questions/components/import-export-buttons.tsx create mode 100644 src/modules/questions/components/knowledge-point-selector.tsx create mode 100644 src/modules/questions/components/options-editor.tsx create mode 100644 src/modules/questions/components/question-bank-results-client.tsx create mode 100644 src/modules/questions/components/question-cascade-filter.tsx create mode 100644 src/modules/questions/components/question-content-renderer.tsx create mode 100644 src/modules/questions/utils/parse-content.ts create mode 100644 src/modules/questions/utils/track-event.ts create mode 100644 src/modules/school/components/grade-delete-dialog.tsx create mode 100644 src/modules/school/components/grade-form-dialog.tsx create mode 100644 src/modules/school/components/grade-list-toolbar.tsx create mode 100644 src/modules/school/components/grade-overview-cards.tsx create mode 100644 src/modules/school/hooks/use-grade-data.ts create mode 100644 src/modules/settings/actions-brand.ts create mode 100644 src/modules/settings/brand-config.ts create mode 100644 src/modules/settings/components/admin-file-upload-card.tsx create mode 100644 src/modules/settings/components/admin-notification-config-card.tsx create mode 100644 src/modules/settings/components/admin-school-info-card.tsx create mode 100644 src/modules/settings/components/admin-security-policy-card.tsx create mode 100644 src/modules/settings/components/ai-provider-delete-dialog.tsx create mode 100644 src/modules/settings/components/ai-provider-selector.tsx create mode 100644 src/modules/settings/components/brand-config-card.tsx create mode 100644 src/modules/settings/components/security-recent-logins-section.tsx create mode 100644 src/modules/settings/components/security-two-factor-section.tsx create mode 100644 src/modules/settings/config/profile-overview-config.ts create mode 100644 src/modules/settings/data-access-brand.ts create mode 100644 src/modules/settings/data-access-profile-overview.ts create mode 100644 src/modules/settings/lib/system-settings-utils.test.ts create mode 100644 src/modules/settings/lib/system-settings-utils.ts create mode 100644 src/modules/textbooks/components/textbook-form-fields.tsx create mode 100644 src/modules/textbooks/hooks/use-kp-create.ts create mode 100644 src/modules/textbooks/hooks/use-kp-delete.ts create mode 100644 src/modules/textbooks/hooks/use-kp-update.ts diff --git a/src/modules/questions/actions.ts b/src/modules/questions/actions.ts index 92ce5bb..bda4bb4 100644 --- a/src/modules/questions/actions.ts +++ b/src/modules/questions/actions.ts @@ -10,13 +10,23 @@ import { z } from "zod" import { createQuestionWithRelations, deleteQuestionByIdRecursive, + deleteQuestionsBatch, + exportQuestions, + getChapterOptions, getKnowledgePointOptions, + getKnowledgePointOptionsByChapter, getQuestions, + getTextbookOptions, + importQuestions, updateQuestionById, type GetQuestionsParams, + type QuestionExportItem, + type QuestionImportItem, } from "./data-access" -import type { KnowledgePointOption } from "./types" +import type { ChapterOption, KnowledgePointOption, TextbookOption } from "./types" +import { QuestionTypeEnum } from "./schema" import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils" +import { trackQuestionCreated, trackQuestionUpdated, trackQuestionDeleted } from "./utils/track-event" /** Result type of getQuestions (data + meta) */ type QuestionsListResult = Awaited> @@ -56,7 +66,10 @@ export async function createQuestionAction( const questionId = await createQuestionWithRelations(input, ctx.userId) + trackQuestionCreated(questionId, input.type, input.difficulty) + revalidatePath("/teacher/questions") + revalidatePath("/admin/questions") return { success: true, @@ -102,7 +115,10 @@ export async function updateQuestionAction( await updateQuestionById(id, updateData, canEditAll, ctx.userId) + trackQuestionUpdated(id, updateData.type, updateData.difficulty) + revalidatePath("/teacher/questions") + revalidatePath("/admin/questions") return { success: true, message: "Question updated successfully", data: id } } catch (e) { @@ -125,7 +141,10 @@ export async function deleteQuestionAction( await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId) + trackQuestionDeleted(questionId) + revalidatePath("/teacher/questions") + revalidatePath("/admin/questions") return { success: true, message: "Question deleted successfully", data: questionId } } catch (e) { @@ -133,6 +152,53 @@ export async function deleteQuestionAction( } } +const BatchDeleteSchema = z.object({ + ids: z.array(z.string().min(1)).min(1, "At least one question ID is required"), +}) + +/** + * 批量删除题目。 + * + * 输入 JSON:{ ids: string[] } + * 权限:QUESTION_DELETE,遵循数据范围过滤。 + */ +export async function deleteQuestionsBatchAction( + prevState: ActionState<{ deleted: number }> | undefined, + formData: FormData, +): Promise> { + try { + const ctx = await requirePermission(Permissions.QUESTION_DELETE) + const canDeleteAll = ctx.dataScope.type === "all" + + const jsonString = formData.get("json") + if (typeof jsonString !== "string") { + return { success: false, message: "Invalid submission format. Expected JSON." } + } + + const parsed = BatchDeleteSchema.safeParse(safeJsonParse(jsonString, "批量删除参数无效")) + if (!parsed.success) { + return { + success: false, + message: "Validation failed", + errors: parsed.error.flatten().fieldErrors, + } + } + + const deletedCount = await deleteQuestionsBatch(parsed.data.ids, canDeleteAll, ctx.userId) + + for (const id of parsed.data.ids) { + trackQuestionDeleted(id) + } + + revalidatePath("/teacher/questions") + revalidatePath("/admin/questions") + + return { success: true, message: "Batch delete completed", data: { deleted: deletedCount } } + } catch (e) { + return handleActionError(e) + } +} + export async function getQuestionsAction( params: GetQuestionsParams, ): Promise> { @@ -156,3 +222,158 @@ export async function getKnowledgePointOptionsAction(): Promise< return handleActionError(e) } } + +/** Result type of getTextbookOptions */ +type TextbookOptionsResult = TextbookOption[] + +/** Result type of getChapterOptions */ +type ChapterOptionsResult = ChapterOption[] + +/** Result type of getKnowledgePointOptionsByChapter */ +type KnowledgePointOptionsByChapterResult = { id: string; name: string }[] + +/** + * 获取教材选项列表(级联筛选第一级)。 + */ +export async function getTextbookOptionsAction(): Promise< + ActionState +> { + try { + await requirePermission(Permissions.QUESTION_READ) + const data = await getTextbookOptions() + return { success: true, data } + } catch (e) { + return handleActionError(e) + } +} + +/** + * 获取指定教材下的章节选项列表(级联筛选第二级)。 + */ +export async function getChapterOptionsAction( + textbookId: string, +): Promise> { + try { + await requirePermission(Permissions.QUESTION_READ) + const data = await getChapterOptions(textbookId) + return { success: true, data } + } catch (e) { + return handleActionError(e) + } +} + +/** + * 获取指定章节下的知识点选项列表(级联筛选第三级)。 + */ +export async function getKnowledgePointOptionsByChapterAction( + chapterId: string, +): Promise> { + try { + await requirePermission(Permissions.QUESTION_READ) + const data = await getKnowledgePointOptionsByChapter(chapterId) + return { success: true, data } + } catch (e) { + return handleActionError(e) + } +} + +// --------------------------------------------------------------------------- +// 导入/导出 Server Actions +// --------------------------------------------------------------------------- + +const ExportSchema = z.object({ + ids: z.array(z.string().min(1)).optional(), +}) + +/** + * 导出题目为 JSON 格式。 + * + * 输入 JSON:{ ids?: string[] }(不传 ids 则导出全部,受权限范围限制) + * 权限:QUESTION_READ,遵循数据范围过滤。 + */ +export async function exportQuestionsAction( + formData: FormData, +): Promise> { + try { + const ctx = await requirePermission(Permissions.QUESTION_READ) + const canExportAll = ctx.dataScope.type === "all" + + const jsonString = formData.get("json") + const inputJson = typeof jsonString === "string" ? jsonString : "{}" + const parsed = ExportSchema.safeParse(safeJsonParse(inputJson, "导出参数无效")) + + if (!parsed.success) { + return { + success: false, + message: "Validation failed", + errors: parsed.error.flatten().fieldErrors, + } + } + + const data = await exportQuestions(parsed.data.ids, canExportAll, ctx.userId) + return { success: true, data } + } catch (e) { + return handleActionError(e) + } +} + +const ImportItemSchema = z.object({ + type: QuestionTypeEnum, + difficulty: z.number().min(1).max(5), + content: z.unknown(), + knowledgePointIds: z.array(z.string()).optional(), +}) + +const ImportSchema = z.object({ + items: z.array(ImportItemSchema).min(1, "至少需要一条题目"), +}) + +/** + * 批量导入题目。 + * + * 输入 JSON:{ items: QuestionImportItem[] } + * 权限:QUESTION_CREATE。 + */ +export async function importQuestionsAction( + prevState: ActionState<{ imported: number }> | undefined, + formData: FormData, +): Promise> { + try { + const ctx = await requirePermission(Permissions.QUESTION_CREATE) + + const jsonString = formData.get("json") + if (typeof jsonString !== "string") { + return { success: false, message: "Invalid submission format. Expected JSON." } + } + + const parsed = ImportSchema.safeParse(safeJsonParse(jsonString, "导入参数无效")) + if (!parsed.success) { + return { + success: false, + message: "Validation failed", + errors: parsed.error.flatten().fieldErrors, + } + } + + const items: QuestionImportItem[] = parsed.data.items.map((item) => ({ + type: item.type, + difficulty: item.difficulty, + content: item.content, + knowledgePointIds: item.knowledgePointIds, + })) + + const createdIds = await importQuestions(items, ctx.userId) + + for (let i = 0; i < createdIds.length; i++) { + const item = items[i] + trackQuestionCreated(createdIds[i], item.type, item.difficulty) + } + + revalidatePath("/teacher/questions") + revalidatePath("/admin/questions") + + return { success: true, message: "Import completed", data: { imported: createdIds.length } } + } catch (e) { + return handleActionError(e) + } +} diff --git a/src/modules/questions/components/batch-operations.tsx b/src/modules/questions/components/batch-operations.tsx new file mode 100644 index 0000000..25bb022 --- /dev/null +++ b/src/modules/questions/components/batch-operations.tsx @@ -0,0 +1,121 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { Trash2, X } from "lucide-react" +import { useRouter } from "next/navigation" + +import { Button } from "@/shared/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { deleteQuestionsBatchAction } from "../actions" +import { usePermission } from "@/shared/hooks/use-permission" +import { Permissions } from "@/shared/types/permissions" +import { toast } from "sonner" + +interface BatchOperationsProps { + /** 选中的题目 ID 列表 */ + selectedIds: string[] + /** 清除选择回调 */ + onClearSelection: () => void +} + +/** + * 批量操作工具栏。 + * + * 当表格有选中行时显示,提供批量删除等功能。 + * 权限感知:无 QUESTION_DELETE 权限时不显示删除按钮。 + */ +export function BatchOperations({ selectedIds, onClearSelection }: BatchOperationsProps): React.ReactNode { + const t = useTranslations("questions") + const router = useRouter() + const { hasPermission } = usePermission() + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + + const canDelete = hasPermission(Permissions.QUESTION_DELETE) + + if (selectedIds.length === 0) return null + + const handleBatchDelete = async (): Promise => { + setIsDeleting(true) + try { + const fd = new FormData() + fd.set("json", JSON.stringify({ ids: selectedIds })) + const res = await deleteQuestionsBatchAction(undefined, fd) + if (res.success) { + const deleted = res.data?.deleted ?? 0 + toast.success(t("batch.deleteSuccess", { count: deleted })) + setShowDeleteDialog(false) + onClearSelection() + router.refresh() + } else { + toast.error(res.message || t("batch.deleteFailed")) + } + } catch (e) { + console.error("Failed to batch delete questions", e) + toast.error(t("batch.deleteFailed")) + } finally { + setIsDeleting(false) + } + } + + return ( + <> +
+ + {t("batch.selected", { count: selectedIds.length })} + +
+ {canDelete && ( + + )} + +
+
+ + + + + {t("batch.deleteConfirmTitle")} + + {t("batch.deleteConfirmDesc", { count: selectedIds.length })} + + + + {t("batch.cancel")} + { + e.preventDefault() + handleBatchDelete() + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + disabled={isDeleting} + > + {isDeleting ? t("batch.deleting") : t("batch.deleteConfirmAction")} + + + + + + ) +} diff --git a/src/modules/questions/components/create-question-button.tsx b/src/modules/questions/components/create-question-button.tsx index 2b79b3b..b69ed9e 100644 --- a/src/modules/questions/components/create-question-button.tsx +++ b/src/modules/questions/components/create-question-button.tsx @@ -1,18 +1,27 @@ "use client" import { useState } from "react" +import { useTranslations } from "next-intl" import { Plus } from "lucide-react" import { Button } from "@/shared/components/ui/button" +import { usePermission } from "@/shared/hooks/use-permission" +import { Permissions } from "@/shared/types/permissions" import { CreateQuestionDialog } from "./create-question-dialog" -export function CreateQuestionButton() { +export function CreateQuestionButton(): React.ReactNode { + const t = useTranslations("questions") + const { hasPermission } = usePermission() const [open, setOpen] = useState(false) + if (!hasPermission(Permissions.QUESTION_CREATE)) { + return null + } + return ( <> diff --git a/src/modules/questions/components/create-question-dialog.tsx b/src/modules/questions/components/create-question-dialog.tsx index 0d80a51..8e3379e 100644 --- a/src/modules/questions/components/create-question-dialog.tsx +++ b/src/modules/questions/components/create-question-dialog.tsx @@ -1,14 +1,13 @@ "use client" import { useState, useEffect } from "react" +import { useTranslations } from "next-intl" import { useForm, type SubmitHandler } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" -import { Plus, Trash2, GripVertical } from "lucide-react" import { useRouter } from "next/navigation" import { Button } from "@/shared/components/ui/button" -import { Checkbox } from "@/shared/components/ui/checkbox" import { Dialog, DialogContent, @@ -17,19 +16,20 @@ import { DialogHeader, DialogTitle, } from "@/shared/components/ui/dialog" -import { - Form, - FormLabel, -} from "@/shared/components/ui/form" -import { Input } from "@/shared/components/ui/input" -import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { Form } from "@/shared/components/ui/form" import { SelectField } from "@/shared/components/form-fields/select-field" import { TextareaField } from "@/shared/components/form-fields/textarea-field" import { BaseQuestionSchema } from "../schema" -import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions" +import { createQuestionAction, updateQuestionAction } from "../actions" import { toast } from "sonner" -import { Question } from "../types" -import { useActionQuery } from "@/shared/hooks/use-action-query" +import type { Question } from "../types" +import { + parseQuestionContent, + getQuestionOptions, + type QuestionOption, +} from "../utils/parse-content" +import { KnowledgePointSelector } from "./knowledge-point-selector" +import { OptionsEditor, type QuestionOptionInput } from "./options-editor" const QuestionFormSchema = BaseQuestionSchema.extend({ difficulty: z.number().min(1).max(5), @@ -39,7 +39,7 @@ const QuestionFormSchema = BaseQuestionSchema.extend({ z.object({ label: z.string(), value: z.string(), - isCorrect: z.boolean().default(false), + isCorrect: z.boolean(), }) ) .optional(), @@ -56,86 +56,55 @@ interface CreateQuestionDialogProps { defaultType?: "single_choice" | "multiple_choice" | "text" | "judgment" | "composite" } -function getInitialTextFromContent(content: unknown) { - if (typeof content === "string") return content - if (content && typeof content === "object") { - const text = (content as { text?: unknown }).text - if (typeof text === "string") return text +/** + * 将 QuestionOption[] 转换为 QuestionOptionInput[](表单格式)。 + */ +function optionsToFormInput(options: QuestionOption[] | undefined): QuestionOptionInput[] { + if (!options || options.length === 0) { + return [ + { label: "Option A", value: "A", isCorrect: true }, + { label: "Option B", value: "B", isCorrect: false }, + ] } - if (content == null) return "" - return JSON.stringify(content) + return options.map((opt) => ({ + label: opt.text, + value: opt.id, + isCorrect: opt.isCorrect, + })) } -function getInitialOptionsFromContent(content: unknown) { - if (!content || typeof content !== "object") return undefined - const rawOptions = (content as { options?: unknown }).options - if (!Array.isArray(rawOptions)) return undefined - - const mapped = rawOptions - .map((opt) => { - if (!opt || typeof opt !== "object") return null - const id = - (opt as { id?: unknown; value?: unknown }).id ?? (opt as { value?: unknown }).value - const text = - (opt as { text?: unknown; label?: unknown }).text ?? - (opt as { label?: unknown }).label - const isCorrect = (opt as { isCorrect?: unknown }).isCorrect - return { - value: typeof id === "string" ? id : "", - label: typeof text === "string" ? text : "", - isCorrect: typeof isCorrect === "boolean" ? isCorrect : false, - } - }) - .filter((v): v is NonNullable => Boolean(v && v.value && v.label)) - - return mapped.length > 0 ? mapped : undefined -} - -export function CreateQuestionDialog({ - open, - onOpenChange, +export function CreateQuestionDialog({ + open, + onOpenChange, initialData, defaultKnowledgePointIds = [], defaultContent = "", - defaultType = "single_choice" -}: CreateQuestionDialogProps) { + defaultType = "single_choice", +}: CreateQuestionDialogProps): React.ReactNode { + const t = useTranslations("questions") const router = useRouter() const [isPending, setIsPending] = useState(false) const isEdit = !!initialData - const [knowledgePointQuery, setKnowledgePointQuery] = useState("") const [selectedKnowledgePointIds, setSelectedKnowledgePointIds] = useState([]) - const { data: knowledgePointOptionsData, loading: isLoadingKnowledgePoints } = useActionQuery( - () => getKnowledgePointOptionsAction(), - { deps: [open], enabled: open, errorMessage: "Failed to load knowledge points" } - ) - const knowledgePointOptions = knowledgePointOptionsData ?? [] - const form = useForm({ resolver: zodResolver(QuestionFormSchema), defaultValues: { type: initialData?.type || defaultType, difficulty: initialData?.difficulty || 1, - content: getInitialTextFromContent(initialData?.content) || defaultContent, - options: - getInitialOptionsFromContent(initialData?.content) ?? [ - { label: "Option A", value: "A", isCorrect: true }, - { label: "Option B", value: "B", isCorrect: false }, - ], + content: parseQuestionContent(initialData?.content).text || defaultContent, + options: optionsToFormInput(getQuestionOptions(initialData?.content)), }, }) useEffect(() => { if (initialData) { + const parsed = parseQuestionContent(initialData.content) form.reset({ type: initialData.type, difficulty: initialData.difficulty, - content: getInitialTextFromContent(initialData.content), - options: - getInitialOptionsFromContent(initialData.content) ?? [ - { label: "Option A", value: "A", isCorrect: true }, - { label: "Option B", value: "B", isCorrect: false }, - ], + content: parsed.text, + options: optionsToFormInput(parsed.options), }) } else { form.reset({ @@ -153,44 +122,16 @@ export function CreateQuestionDialog({ useEffect(() => { if (!open) return if (initialData) { - const nextIds = initialData.knowledgePoints.map((kp) => kp.id) - setSelectedKnowledgePointIds((prev) => { - if (prev.length === nextIds.length && prev.every((id, idx) => id === nextIds[idx])) { - return prev - } - return nextIds - }) + setSelectedKnowledgePointIds(initialData.knowledgePoints.map((kp) => kp.id)) return } - setSelectedKnowledgePointIds((prev) => { - if ( - prev.length === defaultKnowledgePointIds.length && - prev.every((id, idx) => id === defaultKnowledgePointIds[idx]) - ) { - return prev - } - return defaultKnowledgePointIds - }) + setSelectedKnowledgePointIds(defaultKnowledgePointIds) }, [open, initialData, defaultKnowledgePointIds]) const questionType = form.watch("type") - const filteredKnowledgePoints = knowledgePointOptions.filter((kp) => { - const query = knowledgePointQuery.trim().toLowerCase() - if (!query) return true - const fullLabel = [ - kp.textbookTitle, - kp.chapterTitle, - kp.name, - kp.subject, - kp.grade, - ] - .filter(Boolean) - .join(" ") - .toLowerCase() - return fullLabel.includes(query) - }) + const formOptions = form.watch("options") ?? [] - const buildContent = (data: QuestionFormValues) => { + const buildContent = (data: QuestionFormValues): unknown => { const text = data.content.trim() if (data.type === "single_choice" || data.type === "multiple_choice") { const rawOptions = (data.options ?? []).filter((o) => o.label.trim().length > 0) @@ -223,7 +164,7 @@ export function CreateQuestionDialog({ setIsPending(true) try { if (isEdit && !initialData?.id) { - toast.error("Missing question id") + toast.error(t("error.missingId")) return } const payload = { @@ -239,18 +180,18 @@ export function CreateQuestionDialog({ ? await updateQuestionAction(undefined, fd) : await createQuestionAction(undefined, fd) if (res.success) { - toast.success(isEdit ? "Updated question" : "Created question") + toast.success(isEdit ? t("error.updatedSuccess") : t("error.createdSuccess")) onOpenChange(false) router.refresh() if (!isEdit) { form.reset() } } else { - toast.error(res.message || "Operation failed") + toast.error(res.message || t("error.operationFailed")) } } catch (e) { console.error("Failed to submit question", e) - toast.error("Unexpected error") + toast.error(t("error.unexpected")) } finally { setIsPending(false) } @@ -260,33 +201,33 @@ export function CreateQuestionDialog({ - {isEdit ? "Edit Question" : "Create New Question"} + {isEdit ? t("dialog.editTitle") : t("dialog.createTitle")} - {isEdit ? "Update question details." : "Add a new question to the bank. Fill in the details below."} + {isEdit ? t("dialog.editDesc") : t("dialog.createDesc")} - +
String(v)} fromSelectValue={(val) => { const n = parseInt(val, 10) @@ -294,7 +235,7 @@ export function CreateQuestionDialog({ }} options={[1, 2, 3, 4, 5].map((level) => ({ value: String(level), - label: `${level} - ${level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}`, + label: `${level} - ${t(`difficulty.${level}`)}`, }))} />
@@ -302,147 +243,33 @@ export function CreateQuestionDialog({ -
-
- Knowledge Points - - {selectedKnowledgePointIds.length > 0 ? `${selectedKnowledgePointIds.length} selected` : "Optional"} - -
- setKnowledgePointQuery(e.target.value)} - /> -
- - {isLoadingKnowledgePoints ? ( -
Loading...
- ) : filteredKnowledgePoints.length === 0 ? ( -
No knowledge points found.
- ) : ( -
- {filteredKnowledgePoints.map((kp) => { - const labelParts = [ - kp.textbookTitle, - kp.chapterTitle, - kp.name, - ].filter(Boolean) - const label = labelParts.join(" · ") - return ( - - ) - })} -
- )} -
-
-
+ {(questionType === "single_choice" || questionType === "multiple_choice") && ( -
-
- Options - -
- -
- {form.watch("options")?.map((option, index) => ( -
-
- -
- { - const next = [...(form.getValues("options") || [])] - if (!next[index]) return - - const isChecked = checked === true - if (questionType === "single_choice" && isChecked) { - for (let i = 0; i < next.length; i++) next[i].isCorrect = i === index - } else { - next[index].isCorrect = isChecked - } - form.setValue("options", next) - }} - aria-label="Mark correct" - /> - { - const next = [...(form.getValues("options") || [])] - if (!next[index]) return - next[index].label = e.target.value - form.setValue("options", next) - }} - placeholder={`Option ${index + 1}`} - /> - -
- ))} -
-
+ form.setValue("options", next)} + singleChoice={questionType === "single_choice"} + /> )} diff --git a/src/modules/questions/components/import-export-buttons.tsx b/src/modules/questions/components/import-export-buttons.tsx new file mode 100644 index 0000000..ec41713 --- /dev/null +++ b/src/modules/questions/components/import-export-buttons.tsx @@ -0,0 +1,193 @@ +"use client" + +import { useState, useRef } from "react" +import { useTranslations } from "next-intl" +import { Download, Upload, FileJson } from "lucide-react" +import { useRouter } from "next/navigation" + +import { Button } from "@/shared/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog" +import { usePermission } from "@/shared/hooks/use-permission" +import { Permissions } from "@/shared/types/permissions" +import { exportQuestionsAction, importQuestionsAction } from "../actions" +import { toast } from "sonner" + +/** + * 题目导入/导出组件。 + * + * 提供两个功能: + * - 导出:将当前题库导出为 JSON 文件下载 + * - 导入:上传 JSON 文件批量导入题目 + * + * 权限感知: + * - 导出需要 QUESTION_READ + * - 导入需要 QUESTION_CREATE + */ +export function ImportExportButtons(): React.ReactNode { + const t = useTranslations("questions") + const router = useRouter() + const { hasPermission } = usePermission() + const fileInputRef = useRef(null) + const [showImportDialog, setShowImportDialog] = useState(false) + const [pendingImportData, setPendingImportData] = useState(null) + const [isExporting, setIsExporting] = useState(false) + const [isImporting, setIsImporting] = useState(false) + + const canRead = hasPermission(Permissions.QUESTION_READ) + const canCreate = hasPermission(Permissions.QUESTION_CREATE) + + const handleExport = async (): Promise => { + setIsExporting(true) + try { + const fd = new FormData() + fd.set("json", JSON.stringify({})) + const res = await exportQuestionsAction(fd) + if (res.success && res.data) { + const json = JSON.stringify(res.data, null, 2) + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `questions-export-${new Date().toISOString().slice(0, 10)}.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast.success(t("importExport.exportSuccess", { count: res.data.length })) + } else { + toast.error(res.message || t("importExport.exportFailed")) + } + } catch (e) { + console.error("Failed to export questions", e) + toast.error(t("importExport.exportFailed")) + } finally { + setIsExporting(false) + } + } + + const handleFileSelect = (e: React.ChangeEvent): void => { + const file = e.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = (event) => { + const text = event.target?.result + if (typeof text === "string") { + setPendingImportData(text) + setShowImportDialog(true) + } else { + toast.error(t("importExport.invalidFile")) + } + } + reader.onerror = () => { + toast.error(t("importExport.readFailed")) + } + reader.readAsText(file) + // 重置 input 以便重复选择同一文件 + e.target.value = "" + } + + const handleImport = async (): Promise => { + if (!pendingImportData) return + setIsImporting(true) + try { + const fd = new FormData() + fd.set("json", pendingImportData) + const res = await importQuestionsAction(undefined, fd) + if (res.success) { + const imported = res.data?.imported ?? 0 + toast.success(t("importExport.importSuccess", { count: imported })) + setShowImportDialog(false) + setPendingImportData(null) + router.refresh() + } else { + toast.error(res.message || t("importExport.importFailed")) + } + } catch (e) { + console.error("Failed to import questions", e) + toast.error(t("importExport.importFailed")) + } finally { + setIsImporting(false) + } + } + + return ( + <> +
+ {canRead && ( + + )} + {canCreate && ( + <> + + + + )} +
+ + + + + + + {t("importExport.confirmTitle")} + + + {t("importExport.confirmDesc")} + + +
+
+              {pendingImportData ? pendingImportData.slice(0, 2000) : ""}
+              {pendingImportData && pendingImportData.length > 2000 ? "\n..." : ""}
+            
+
+ + + + +
+
+ + ) +} diff --git a/src/modules/questions/components/knowledge-point-selector.tsx b/src/modules/questions/components/knowledge-point-selector.tsx new file mode 100644 index 0000000..939ba15 --- /dev/null +++ b/src/modules/questions/components/knowledge-point-selector.tsx @@ -0,0 +1,107 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" + +import { Checkbox } from "@/shared/components/ui/checkbox" +import { Input } from "@/shared/components/ui/input" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { FormLabel } from "@/shared/components/ui/form" +import { useActionQuery } from "@/shared/hooks/use-action-query" +import { getKnowledgePointOptionsAction } from "../actions" +import type { KnowledgePointOption } from "../types" + +interface KnowledgePointSelectorProps { + selectedIds: string[] + onChange: (ids: string[]) => void +} + +/** + * 知识点选择器组件。 + * + * 从 create-question-dialog.tsx 抽取,负责知识点搜索和多选。 + * 使用 useActionQuery 加载知识点选项,支持模糊搜索。 + */ +export function KnowledgePointSelector({ selectedIds, onChange }: KnowledgePointSelectorProps): React.ReactNode { + const t = useTranslations("questions") + const [query, setQuery] = useState("") + + const { data: knowledgePointOptions, loading: isLoading } = useActionQuery( + () => getKnowledgePointOptionsAction(), + { deps: [], errorMessage: false } + ) + + const options = knowledgePointOptions ?? [] + + const filteredOptions = options.filter((kp: KnowledgePointOption) => { + const q = query.trim().toLowerCase() + if (!q) return true + const fullLabel = [ + kp.textbookTitle, + kp.chapterTitle, + kp.name, + kp.subject, + kp.grade, + ] + .filter(Boolean) + .join(" ") + .toLowerCase() + return fullLabel.includes(q) + }) + + const toggle = (kpId: string, checked: boolean): void => { + if (checked) { + if (selectedIds.includes(kpId)) return + onChange([...selectedIds, kpId]) + } else { + onChange(selectedIds.filter((id) => id !== kpId)) + } + } + + return ( +
+
+ {t("dialog.knowledgePoints")} + + {selectedIds.length > 0 + ? t("dialog.knowledgePointsSelected", { count: selectedIds.length }) + : t("dialog.knowledgePointsOptional")} + +
+ setQuery(e.target.value)} + /> +
+ + {isLoading ? ( +
{t("dialog.loading")}
+ ) : filteredOptions.length === 0 ? ( +
{t("dialog.noKnowledgePoints")}
+ ) : ( +
+ {filteredOptions.map((kp) => { + const labelParts = [ + kp.textbookTitle, + kp.chapterTitle, + kp.name, + ].filter(Boolean) + const label = labelParts.join(" · ") + return ( + + ) + })} +
+ )} +
+
+
+ ) +} diff --git a/src/modules/questions/components/options-editor.tsx b/src/modules/questions/components/options-editor.tsx new file mode 100644 index 0000000..101173f --- /dev/null +++ b/src/modules/questions/components/options-editor.tsx @@ -0,0 +1,106 @@ +"use client" + +import { useTranslations } from "next-intl" +import { Plus, Trash2, GripVertical } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Checkbox } from "@/shared/components/ui/checkbox" +import { Input } from "@/shared/components/ui/input" +import { FormLabel } from "@/shared/components/ui/form" + +export interface QuestionOptionInput { + label: string + value: string + isCorrect: boolean +} + +interface OptionsEditorProps { + options: QuestionOptionInput[] + onChange: (options: QuestionOptionInput[]) => void + /** 单选模式:选中一个选项时自动取消其他 */ + singleChoice: boolean +} + +/** + * 选项编辑器组件。 + * + * 从 create-question-dialog.tsx 抽取,负责选择题选项的增删改。 + * 支持单选(single_choice)和多选(multiple_choice)模式。 + */ +export function OptionsEditor({ options, onChange, singleChoice }: OptionsEditorProps): React.ReactNode { + const t = useTranslations("questions") + + const addOption = (): void => { + const nextIndex = options.length + const nextChar = nextIndex < 26 ? String.fromCharCode(65 + nextIndex) : String(nextIndex + 1) + onChange([ + ...options, + { label: `Option ${nextChar}`, value: nextChar, isCorrect: false }, + ]) + } + + const toggleCorrect = (index: number, checked: boolean): void => { + const next = [...options] + if (!next[index]) return + + if (singleChoice && checked) { + for (let i = 0; i < next.length; i++) next[i].isCorrect = i === index + } else { + next[index].isCorrect = checked + } + onChange(next) + } + + const updateLabel = (index: number, label: string): void => { + const next = [...options] + if (!next[index]) return + next[index].label = label + onChange(next) + } + + const removeOption = (index: number): void => { + const next = [...options] + next.splice(index, 1) + onChange(next) + } + + return ( +
+
+ {t("dialog.options")} + +
+ +
+ {options.map((option, index) => ( +
+
+ +
+ toggleCorrect(index, checked === true)} + aria-label={t("dialog.markCorrect")} + /> + updateLabel(index, e.target.value)} + placeholder={t("dialog.optionPlaceholder", { index: index + 1 })} + /> + +
+ ))} +
+
+ ) +} diff --git a/src/modules/questions/components/question-actions.tsx b/src/modules/questions/components/question-actions.tsx index 84cef32..4bfbbe0 100644 --- a/src/modules/questions/components/question-actions.tsx +++ b/src/modules/questions/components/question-actions.tsx @@ -1,6 +1,7 @@ "use client" import { useState } from "react" +import { useTranslations } from "next-intl" import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react" import { useRouter } from "next/navigation" @@ -31,146 +32,161 @@ import { DialogTitle, } from "@/shared/components/ui/dialog" -import { Question } from "../types" +import type { Question } from "../types" import { deleteQuestionAction } from "../actions" import { CreateQuestionDialog } from "./create-question-dialog" +import { QuestionContentRenderer } from "./question-content-renderer" +import { usePermission } from "@/shared/hooks/use-permission" +import { Permissions } from "@/shared/types/permissions" import { toast } from "sonner" interface QuestionActionsProps { question: Question } -export function QuestionActions({ question }: QuestionActionsProps) { +export function QuestionActions({ question }: QuestionActionsProps): React.ReactNode { + const t = useTranslations("questions") const router = useRouter() + const { hasPermission } = usePermission() const [showEditDialog, setShowEditDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showViewDialog, setShowViewDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) - const copyId = () => { + const canEdit = hasPermission(Permissions.QUESTION_UPDATE) + const canDelete = hasPermission(Permissions.QUESTION_DELETE) + + const copyId = (): void => { try { navigator.clipboard.writeText(question.id) - toast.success("Question ID copied to clipboard") + toast.success(t("actions.copyIdSuccess")) } catch (e) { console.error("Failed to copy question ID to clipboard", e) - toast.error("Failed to copy question ID") + toast.error(t("actions.copyIdFailed")) } } - const handleDelete = async () => { + const handleDelete = async (): Promise => { setIsDeleting(true) try { const fd = new FormData() fd.set("questionId", question.id) const res = await deleteQuestionAction(undefined, fd) if (res.success) { - toast.success("Question deleted successfully") + toast.success(t("actions.deleteSuccess")) setShowDeleteDialog(false) router.refresh() } else { - toast.error(res.message || "Failed to delete question") + toast.error(res.message || t("actions.deleteFailed")) } } catch (e) { console.error("Failed to delete question", e) - toast.error("Failed to delete question") + toast.error(t("actions.deleteFailed")) } finally { setIsDeleting(false) } } + const typeLabel = t(question.type) + return ( <> - - Actions + {t("actions.actions")} - Copy ID + {t("actions.copyId")} setShowViewDialog(true)}> - View Details - - setShowEditDialog(true)}> - Edit - - setShowDeleteDialog(true)} - > - Delete + {t("actions.viewDetails")} + {canEdit && ( + setShowEditDialog(true)}> + {t("actions.edit")} + + )} + {canDelete && ( + setShowDeleteDialog(true)} + > + {t("actions.delete")} + + )} - + {canEdit && ( + + )} - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete the question - and remove it from our servers. - - - - Cancel - { - e.preventDefault() - handleDelete() - }} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - disabled={isDeleting} - > - {isDeleting ? "Deleting..." : "Delete"} - - - - + {canDelete && ( + + + + {t("actions.deleteConfirmTitle")} + + {t("actions.deleteConfirmDesc")} + + + + {t("actions.deleteConfirmCancel")} + { + e.preventDefault() + handleDelete() + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + disabled={isDeleting} + > + {isDeleting ? t("actions.deleting") : t("actions.deleteConfirmAction")} + + + + + )} - + - Question Details - ID: {question.id} + {t("actions.detailsTitle")} + {t("actions.detailsId", { id: question.id })}
- Type: - {question.type.replaceAll("_", " ")} + {t("actions.detailsType")} + {typeLabel}
- Difficulty: + {t("actions.detailsDifficulty")} {question.difficulty}
- Content: -
- {typeof question.content === "string" - ? question.content - : JSON.stringify(question.content, null, 2)} + {t("actions.detailsContent")} +
+
{question.author && (
- Author: - {question.author.name || "Unknown"} + {t("actions.detailsAuthor")} + {question.author.name || t("actions.detailsUnknown")}
)} {question.knowledgePoints && question.knowledgePoints.length > 0 && (
- Tags: + {t("actions.detailsTags")}
{question.knowledgePoints.map(kp => ( diff --git a/src/modules/questions/components/question-bank-results-client.tsx b/src/modules/questions/components/question-bank-results-client.tsx new file mode 100644 index 0000000..cc81746 --- /dev/null +++ b/src/modules/questions/components/question-bank-results-client.tsx @@ -0,0 +1,20 @@ +"use client" + +import { QuestionDataTable } from "./question-data-table" +import { useQuestionColumns } from "./question-columns" +import type { Question } from "../types" + +interface QuestionBankResultsClientProps { + questions: Question[] +} + +/** + * 题库表格客户端组件。 + * + * 桥接 Server Component 数据与客户端 Hook(useQuestionColumns)。 + * useQuestionColumns 内部使用 useTranslations,必须在客户端组件中调用。 + */ +export function QuestionBankResultsClient({ questions }: QuestionBankResultsClientProps): React.ReactNode { + const columns = useQuestionColumns() + return q.id} /> +} diff --git a/src/modules/questions/components/question-cascade-filter.tsx b/src/modules/questions/components/question-cascade-filter.tsx new file mode 100644 index 0000000..75d21a0 --- /dev/null +++ b/src/modules/questions/components/question-cascade-filter.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useMemo } from "react" +import { useTranslations } from "next-intl" +import { useQueryState, parseAsString } from "nuqs" + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { useActionQuery } from "@/shared/hooks/use-action-query" +import { + getTextbookOptionsAction, + getChapterOptionsAction, + getKnowledgePointOptionsByChapterAction, +} from "../actions" +import type { ChapterOption } from "../types" + +/** + * 题库级联筛选组件:教材 → 章节 → 知识点。 + * + * 三级级联,选择教材后加载章节,选择章节后加载知识点。 + * 清除上级时自动清除下级。URL 状态通过 nuqs 管理。 + * + * @example + * + */ +export function QuestionCascadeFilter(): React.ReactNode { + const t = useTranslations("questions") + + const [textbookId, setTextbookId] = useQueryState("tb", parseAsString.withDefault("all")) + const [chapterId, setChapterId] = useQueryState("ch", parseAsString.withDefault("all")) + const [knowledgePointId, setKnowledgePointId] = useQueryState("kp", parseAsString.withDefault("all")) + + // 第一级:教材列表 + const { data: textbooks, loading: textbooksLoading } = useActionQuery( + () => getTextbookOptionsAction(), + { deps: [], errorMessage: false } + ) + + // 第二级:章节列表(仅当教材已选时加载) + const { data: chapters, loading: chaptersLoading } = useActionQuery( + () => getChapterOptionsAction(textbookId), + { deps: [textbookId], enabled: textbookId !== "all", errorMessage: false } + ) + + // 第三级:知识点列表(仅当章节已选时加载) + const { data: knowledgePoints, loading: kpsLoading } = useActionQuery( + () => getKnowledgePointOptionsByChapterAction(chapterId), + { deps: [chapterId], enabled: chapterId !== "all", errorMessage: false } + ) + + // 清除教材时,自动清除章节和知识点 + const handleTextbookChange = (value: string): void => { + if (value === "all") { + setTextbookId(null) + setChapterId(null) + setKnowledgePointId(null) + } else { + setTextbookId(value) + setChapterId(null) + setKnowledgePointId(null) + } + } + + // 清除章节时,自动清除知识点 + const handleChapterChange = (value: string): void => { + if (value === "all") { + setChapterId(null) + setKnowledgePointId(null) + } else { + setChapterId(value) + setKnowledgePointId(null) + } + } + + const handleKnowledgePointChange = (value: string): void => { + setKnowledgePointId(value === "all" ? null : value) + } + + // 章节选项带缩进 + const chapterItems = useMemo(() => { + if (!chapters) return [] + return chapters.map((ch: ChapterOption) => ({ + id: ch.id, + title: ch.title, + indent: ch.depth, + })) + }, [chapters]) + + return ( +
+ + + + + + + {(textbooksLoading || chaptersLoading || kpsLoading) && ( + + {t("dialog.loading")} + + )} +
+ ) +} diff --git a/src/modules/questions/components/question-columns.tsx b/src/modules/questions/components/question-columns.tsx index f1c6ba1..9f1b0a3 100644 --- a/src/modules/questions/components/question-columns.tsx +++ b/src/modules/questions/components/question-columns.tsx @@ -1,142 +1,133 @@ "use client" +import { useTranslations } from "next-intl" import { ColumnDef } from "@tanstack/react-table" +import { useMemo } from "react" import { Badge } from "@/shared/components/ui/badge" import { Checkbox } from "@/shared/components/ui/checkbox" import { StatusBadge } from "@/shared/components/ui/status-badge" import { formatDate } from "@/shared/lib/utils" -import { Question } from "../types" -import { QUESTION_TYPE_VARIANT, QUESTION_TYPE_LABEL } from "../types" +import type { Question } from "../types" +import { QUESTION_TYPE_VARIANT } from "../types" +import { getQuestionPreview } from "../utils/parse-content" import { QuestionActions } from "./question-actions" -export const columns: ColumnDef[] = [ - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - /> - ), - enableSorting: false, - enableHiding: false, - }, - { - accessorKey: "type", - header: "Type", - cell: ({ row }) => { - const type = row.original.type - return ( - [] { + const t = useTranslations("questions") + + return useMemo(() => [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={t("actions.menuLabel")} /> - ) + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={t("actions.menuLabel")} + /> + ), + enableSorting: false, + enableHiding: false, }, - }, - { - accessorKey: "content", - header: "Content", - cell: ({ row }) => { - const content = row.original.content - let preview = "" - if (typeof content === "string") { - preview = content - } else if (content && typeof content === "object") { - const text = (content as { text?: unknown }).text - if (typeof text === "string") { - preview = text - } else { - preview = JSON.stringify(content) - } - } - preview = preview.slice(0, 80) - - return ( -
- {preview} -
- ) + { + accessorKey: "type", + header: t("table.type"), + cell: ({ row }) => { + const type = row.original.type + return ( + + ) + }, }, - }, - { - accessorKey: "difficulty", - header: "Difficulty", - cell: ({ row }) => { - const diff = row.original.difficulty - const label = - diff === 1 - ? "Easy" - : diff === 2 - ? "Easy-Med" - : diff === 3 - ? "Medium" - : diff === 4 - ? "Med-Hard" - : "Hard" - return ( -
- - {label} - - ({diff}) -
- ) + { + accessorKey: "content", + header: t("table.content"), + cell: ({ row }) => { + const preview = getQuestionPreview(row.original.content, 80) + return ( +
+ {preview} +
+ ) + }, }, - }, - { - accessorKey: "knowledgePoints", - header: "Knowledge Points", - cell: ({ row }) => { - const kps = row.original.knowledgePoints - if (!kps || kps.length === 0) return - - - return ( -
- {kps.slice(0, 2).map((kp) => ( - - {kp.name} + { + accessorKey: "difficulty", + header: t("table.difficulty"), + cell: ({ row }) => { + const diff = row.original.difficulty + const label = t(`difficulty.${diff}`) + return ( +
+ + {label} - ))} - {kps.length > 2 && ( - - +{kps.length - 2} - - )} -
- ) + ({diff}) +
+ ) + }, }, - }, - { - accessorKey: "createdAt", - header: "Created", - cell: ({ row }) => { - const createdAt = row.original.createdAt - return ( - - {createdAt instanceof Date - ? formatDate(createdAt) - : typeof createdAt === "string" + { + accessorKey: "knowledgePoints", + header: t("table.knowledgePoints"), + cell: ({ row }) => { + const kps = row.original.knowledgePoints + if (!kps || kps.length === 0) return - + + return ( +
+ {kps.slice(0, 2).map((kp) => ( + + {kp.name} + + ))} + {kps.length > 2 && ( + + +{kps.length - 2} + + )} +
+ ) + }, + }, + { + accessorKey: "createdAt", + header: t("table.created"), + cell: ({ row }) => { + const createdAt = row.original.createdAt + return ( + + {createdAt instanceof Date ? formatDate(createdAt) - : "—"} - - ) + : typeof createdAt === "string" + ? formatDate(createdAt) + : "—"} +
+ ) + }, }, - }, - { - id: "actions", - cell: ({ row }) => , - }, -] + { + id: "actions", + cell: ({ row }) => , + }, + ], [t]) +} diff --git a/src/modules/questions/components/question-content-renderer.tsx b/src/modules/questions/components/question-content-renderer.tsx new file mode 100644 index 0000000..fdbc821 --- /dev/null +++ b/src/modules/questions/components/question-content-renderer.tsx @@ -0,0 +1,116 @@ +"use client" + +import { useTranslations } from "next-intl" +import { CheckCircle2 } from "lucide-react" + +import { Badge } from "@/shared/components/ui/badge" +import { cn } from "@/shared/lib/utils" +import type { Question } from "../types" +import { parseQuestionContent } from "../utils/parse-content" + +interface QuestionContentRendererProps { + question: Question + /** 是否显示答案(学生端练习前不显示) */ + showAnswer?: boolean + /** 自定义类名 */ + className?: string +} + +/** + * 题目内容结构化渲染组件。 + * + * 根据 Question.content(unknown)解析后渲染: + * - 题干文本 + * - 选项列表(选择题):单选显示圆点,多选显示方框 + * - 正确答案高亮(showAnswer=true 时) + * - 答案与解析(showAnswer=true 时) + * + * 使用 parseQuestionContent 类型守卫,不使用 `as` 断言。 + * + * @example + * + */ +export function QuestionContentRenderer({ + question, + showAnswer = false, + className, +}: QuestionContentRendererProps): React.ReactNode { + const t = useTranslations("questions") + const content = parseQuestionContent(question.content) + const isChoice = question.type === "single_choice" || question.type === "multiple_choice" + const isMultiple = question.type === "multiple_choice" + + return ( +
+ {/* 题干 */} + {content.text ? ( +

{content.text}

+ ) : ( +

{t("empty.withoutFiltersDesc")}

+ )} + + {/* 选项列表 */} + {isChoice && content.options && content.options.length > 0 && ( +
    + {content.options.map((option, idx) => { + const label = String.fromCharCode(65 + idx) + const isCorrect = option.isCorrect + return ( +
  • + {isMultiple ? ( + + {isCorrect && } + + ) : ( + + {isCorrect && } + + )} + {label}. + {option.text} +
  • + ) + })} +
+ )} + + {/* 答案与解析 */} + {showAnswer && ( +
+ {content.answer && ( +
+ + {t("actions.detailsContent")} + + {content.answer} +
+ )} + {content.explanation && ( +
+ + {t("dialog.contentDescription")} + + {content.explanation} +
+ )} +
+ )} +
+ ) +} diff --git a/src/modules/questions/components/question-data-table.tsx b/src/modules/questions/components/question-data-table.tsx index c3cf276..2dac412 100644 --- a/src/modules/questions/components/question-data-table.tsx +++ b/src/modules/questions/components/question-data-table.tsx @@ -1,6 +1,7 @@ "use client" import * as React from "react" +import { useTranslations } from "next-intl" import { ColumnDef, flexRender, @@ -23,16 +24,21 @@ import { } from "@/shared/components/ui/table" import { Button } from "@/shared/components/ui/button" import { ChevronLeft, ChevronRight } from "lucide-react" +import { BatchOperations } from "./batch-operations" interface DataTableProps { columns: ColumnDef[] data: TData[] + /** 获取行 ID 的函数(用于批量操作) */ + getRowId?: (row: TData) => string } export function QuestionDataTable({ columns, data, -}: DataTableProps) { + getRowId, +}: DataTableProps): React.ReactNode { + const t = useTranslations("questions") const [sorting, setSorting] = React.useState([]) const [rowSelection, setRowSelection] = React.useState({}) @@ -45,14 +51,27 @@ export function QuestionDataTable({ getSortedRowModel: getSortedRowModel(), onRowSelectionChange: setRowSelection, getFilteredRowModel: getFilteredRowModel(), + getRowId: getRowId ? (row) => getRowId(row) : undefined, state: { sorting, rowSelection, }, }) + const selectedIds = React.useMemo(() => { + if (!getRowId) return [] + return table.getSelectedRowModel().rows.map((row) => getRowId(row.original)) + }, [table, getRowId]) + + const clearSelection = React.useCallback(() => { + setRowSelection({}) + }, []) + return (
+ {selectedIds.length > 0 && ( + + )}
@@ -96,7 +115,7 @@ export function QuestionDataTable({ colSpan={columns.length} className="h-24 text-center" > - No results. + {t("table.noResults")} )} @@ -105,8 +124,10 @@ export function QuestionDataTable({
- {table.getFilteredSelectedRowModel().rows.length} of{" "} - {table.getFilteredRowModel().rows.length} row(s) selected. + {t("table.selected", { + count: table.getFilteredSelectedRowModel().rows.length, + total: table.getFilteredRowModel().rows.length, + })}
diff --git a/src/modules/questions/components/question-filters.tsx b/src/modules/questions/components/question-filters.tsx index a88cfb2..a61ae6b 100644 --- a/src/modules/questions/components/question-filters.tsx +++ b/src/modules/questions/components/question-filters.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useTranslations } from "next-intl" import { useQueryState, parseAsString } from "nuqs" import { @@ -11,28 +11,27 @@ import { SelectValue, } from "@/shared/components/ui/select" import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" -import { getKnowledgePointOptionsAction } from "../actions" -import type { KnowledgePointOption } from "../types" +import { QuestionCascadeFilter } from "./question-cascade-filter" + +/** + * 题库筛选栏。 + * + * 组合了: + * - 搜索框(nuqs URL 状态) + * - 题型/难度下拉(nuqs URL 状态) + * - 教材→章节→知识点级联筛选(QuestionCascadeFilter) + * + * 替代了原有的扁平知识点下拉,符合 K12 教学场景的级联选题工作流。 + */ +export function QuestionFilters(): React.ReactNode { + const t = useTranslations("questions") -export function QuestionFilters() { const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const [type, setType] = useQueryState("type", parseAsString.withDefault("all")) const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all")) - const [knowledgePointId, setKnowledgePointId] = useQueryState("kp", parseAsString.withDefault("all")) - const [knowledgePointOptions, setKnowledgePointOptions] = useState([]) - - useEffect(() => { - getKnowledgePointOptionsAction() - .then((result) => { - setKnowledgePointOptions(result.success && result.data ? result.data : []) - }) - .catch(() => { - setKnowledgePointOptions([]) - }) - }, []) const hasFilters = Boolean( - search || type !== "all" || difficulty !== "all" || knowledgePointId !== "all", + search || type !== "all" || difficulty !== "all", ) return ( @@ -44,60 +43,43 @@ export function QuestionFilters() { setSearch(null) setType(null) setDifficulty(null) - setKnowledgePointId(null) }} > -
+
setSearch(v || null)} - placeholder="Search questions..." + placeholder={t("search.placeholder")} className="flex-1 md:max-w-sm" inputClassName="border-muted-foreground/20 pl-8" /> - +
) diff --git a/src/modules/questions/data-access.ts b/src/modules/questions/data-access.ts index f32d13f..e439b4f 100644 --- a/src/modules/questions/data-access.ts +++ b/src/modules/questions/data-access.ts @@ -5,9 +5,15 @@ import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"; import { cache } from "react"; import { createId } from "@paralleldrive/cuid2"; -import { getKnowledgePointOptions as getKnowledgePointOptionsFromTextbooks } from "@/modules/textbooks/data-access"; +import { + getChaptersByTextbookId as getChaptersByTextbookIdFromTextbooks, + getKnowledgePointOptions as getKnowledgePointOptionsFromTextbooks, + getKnowledgePointsByChapterId as getKnowledgePointsByChapterIdFromTextbooks, + getKnowledgePointsByTextbookId as getKnowledgePointsByTextbookIdFromTextbooks, + getTextbooks as getTextbooksFromTextbooks, +} from "@/modules/textbooks/data-access"; import type { CreateQuestionInput } from "./schema"; -import type { KnowledgePointOption, Question, QuestionType } from "./types"; +import type { ChapterOption, KnowledgePointOption, Question, QuestionType, TextbookOption } from "./types"; type Tx = Parameters[0]>[0] @@ -24,6 +30,8 @@ export type GetQuestionsParams = { pageSize?: number; ids?: string[]; knowledgePointId?: string; + textbookId?: string; + chapterId?: string; type?: QuestionType; difficulty?: number; }; @@ -34,6 +42,8 @@ export const getQuestions = cache(async ({ pageSize = 50, ids, knowledgePointId, + textbookId, + chapterId, type, difficulty, }: GetQuestionsParams = {}) => { @@ -62,13 +72,53 @@ export const getQuestions = cache(async ({ conditions.push(eq(questions.difficulty, difficulty)); } + // 级联筛选:通过 textbooks data-access 获取知识点 ID 集合, + // 再按知识点过滤题目。避免直接查询 textbooks/chapters 表。 if (knowledgePointId) { - const subQuery = db - .select({ questionId: questionsToKnowledgePoints.questionId }) - .from(questionsToKnowledgePoints) - .where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId)); - - conditions.push(inArray(questions.id, subQuery)); + conditions.push( + inArray( + questions.id, + db + .select({ questionId: questionsToKnowledgePoints.questionId }) + .from(questionsToKnowledgePoints) + .where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId)) + ) + ); + } else if (chapterId) { + // 按章节筛选:获取该章节下所有知识点 ID + const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId) + const kpIds = kps.map((kp) => kp.id) + if (kpIds.length > 0) { + conditions.push( + inArray( + questions.id, + db + .select({ questionId: questionsToKnowledgePoints.questionId }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds)) + ) + ); + } else { + // 章节下无知识点,返回空结果 + conditions.push(sql`1 = 0`) + } + } else if (textbookId) { + // 按教材筛选:获取该教材下所有知识点 ID + const kps = await getKnowledgePointsByTextbookIdFromTextbooks(textbookId) + const kpIds = kps.map((kp) => kp.id) + if (kpIds.length > 0) { + conditions.push( + inArray( + questions.id, + db + .select({ questionId: questionsToKnowledgePoints.questionId }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds)) + ) + ); + } else { + conditions.push(sql`1 = 0`) + } } if (!ids || ids.length === 0) { @@ -276,12 +326,105 @@ export async function deleteQuestionByIdRecursive( }); } +/** + * 批量删除题目(级联删除子题)。 + * + * 遵循权限范围:非 all 范围只能删除自己创建的题目。 + * 单事务保证原子性,任一题目删除失败则全部回滚。 + * + * @param questionIds - 待删除的题目 ID 列表 + * @param canDeleteAll - 是否有全部数据范围权限 + * @param authorId - 当前用户 ID(用于权限过滤) + * @returns 实际删除的题目数量 + */ +export async function deleteQuestionsBatch( + questionIds: string[], + canDeleteAll: boolean, + authorId: string +): Promise { + if (questionIds.length === 0) return 0 + + const uniqueIds = Array.from(new Set(questionIds)) + return await db.transaction(async (tx) => { + // 权限过滤:非 all 范围只能删除自己创建的题目 + const whereClause = canDeleteAll + ? inArray(questions.id, uniqueIds) + : and(inArray(questions.id, uniqueIds), eq(questions.authorId, authorId)) + + const targetRows = await tx + .select({ id: questions.id }) + .from(questions) + .where(whereClause) + + const targetIds = targetRows.map((r) => r.id) + if (targetIds.length === 0) return 0 + + for (const id of targetIds) { + await deleteQuestionRecursive(tx, id) + } + + return targetIds.length + }) +} + export async function getKnowledgePointOptions(): Promise { // Delegate to textbooks module data-access to avoid direct queries on // textbooks/chapters/knowledgePoints tables (owned by textbooks module). return await getKnowledgePointOptionsFromTextbooks() } +/** + * 获取教材选项列表(级联筛选第一级)。 + * + * 委托 textbooks 模块 data-access 获取教材列表,避免直接查询 textbooks 表。 + */ +export async function getTextbookOptions(): Promise { + const textbooks = await getTextbooksFromTextbooks() + return textbooks.map((tb) => ({ + id: tb.id, + title: tb.title, + subject: tb.subject, + grade: tb.grade, + })) +} + +/** + * 获取指定教材下的章节选项列表(级联筛选第二级)。 + * + * 委托 textbooks 模块 data-access 获取章节树,展平为选项列表。 + */ +export async function getChapterOptions(textbookId: string): Promise { + const chapters = await getChaptersByTextbookIdFromTextbooks(textbookId) + const options: ChapterOption[] = [] + + function flatten(chs: typeof chapters, depth = 0): void { + for (const ch of chs) { + options.push({ + id: ch.id, + title: ch.title, + parentId: ch.parentId ?? null, + depth, + }) + if (ch.children && ch.children.length > 0) { + flatten(ch.children, depth + 1) + } + } + } + + flatten(chapters) + return options +} + +/** + * 获取指定章节下的知识点选项列表(级联筛选第三级)。 + * + * 委托 textbooks 模块 data-access 获取知识点列表。 + */ +export async function getKnowledgePointOptionsByChapter(chapterId: string): Promise<{ id: string; name: string }[]> { + const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId) + return kps.map((kp) => ({ id: kp.id, name: kp.name })) +} + // --------------------------------------------------------------------------- // Cross-module query interfaces — read-only access for other modules // --------------------------------------------------------------------------- @@ -355,3 +498,136 @@ export const getQuestionsContentForErrorCollection = cache( return result } ) + +/** + * 按 ID 批量获取题目类型映射。 + * + * exams 等模块在组卷/成绩录入时需要题目 type 字段,此接口封装对 questions 表的查询, + * 避免跨模块直接 JOIN questions 表(违反三层架构)。 + * + * 返回 Map,未找到的 ID 不会出现在 Map 中。 + */ +export const getQuestionTypeMapByIds = cache( + async (questionIds: string[]): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ id: questions.id, type: questions.type }) + .from(questions) + .where(inArray(questions.id, uniqueIds)) + + for (const r of rows) { + result.set(r.id, r.type) + } + return result + } +) + +// --------------------------------------------------------------------------- +// 导入/导出接口 +// --------------------------------------------------------------------------- + +/** 导出题目格式(JSON) */ +export interface QuestionExportItem { + id: string + type: QuestionType + difficulty: number + content: unknown + knowledgePoints: { id: string; name: string }[] + createdAt: Date + updatedAt: Date +} + +/** + * 导出题目为结构化 JSON 格式。 + * + * 支持按 IDs 导出指定题目,或导出全部题目(受 pageSize 限制)。 + * 遵循权限范围:非 all 范围只能导出自己创建的题目。 + */ +export async function exportQuestions( + questionIds?: string[], + canExportAll = true, + authorId?: string +): Promise { + const conditions: SQL[] = [] + + if (questionIds && questionIds.length > 0) { + conditions.push(inArray(questions.id, questionIds)) + } + + if (!canExportAll && authorId) { + conditions.push(eq(questions.authorId, authorId)) + } + + conditions.push(sql`${questions.parentId} IS NULL`) + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const rows = await db.query.questions.findMany({ + where: whereClause, + limit: 1000, + orderBy: [desc(questions.createdAt)], + with: { + knowledgePoints: { + with: { + knowledgePoint: true, + }, + }, + }, + }) + + return rows.map((row) => ({ + id: row.id, + type: row.type, + difficulty: row.difficulty ?? 1, + content: row.content, + knowledgePoints: (row.knowledgePoints ?? []).map((rel) => ({ + id: rel.knowledgePoint.id, + name: rel.knowledgePoint.name, + })), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })) +} + +/** 导入题目的单条输入 */ +export interface QuestionImportItem { + type: QuestionType + difficulty: number + content: unknown + knowledgePointIds?: string[] +} + +/** + * 批量导入题目。 + * + * 单事务保证原子性,任一题目导入失败则全部回滚。 + * 返回创建的题目 ID 列表。 + */ +export async function importQuestions( + items: QuestionImportItem[], + authorId: string +): Promise { + if (items.length === 0) return [] + + return await db.transaction(async (tx) => { + const createdIds: string[] = [] + for (const item of items) { + const id = await insertQuestionWithRelations( + tx, + { + content: item.content, + type: item.type, + difficulty: item.difficulty, + knowledgePointIds: item.knowledgePointIds, + }, + authorId, + null + ) + createdIds.push(id) + } + return createdIds + }) +} diff --git a/src/modules/questions/types.ts b/src/modules/questions/types.ts index cb36c9a..68bab16 100644 --- a/src/modules/questions/types.ts +++ b/src/modules/questions/types.ts @@ -1,7 +1,9 @@ import { z } from "zod" -import type { StatusVariantMap, StatusLabelMap } from "@/shared/components/ui/status-badge" +import type { StatusVariantMap } from "@/shared/components/ui/status-badge" import { QuestionTypeEnum } from "./schema" +export type { QuestionContent, QuestionOption } from "./utils/parse-content" + export type QuestionType = z.infer /** 题型 → Badge variant 映射 */ @@ -13,13 +15,22 @@ export const QUESTION_TYPE_VARIANT: StatusVariantMap = { composite: "secondary", } -/** 题型 → 展示文本映射 */ -export const QUESTION_TYPE_LABEL: StatusLabelMap = { - single_choice: "Single Choice", - multiple_choice: "Multiple Choice", - judgment: "True/False", - text: "Short Answer", - composite: "Composite", +/** 题型 → i18n 键映射(供 useTranslations 使用) */ +export const QUESTION_TYPE_I18N_KEY: Record = { + single_choice: "type.single_choice", + multiple_choice: "type.multiple_choice", + judgment: "type.judgment", + text: "type.text", + composite: "type.composite", +} + +/** 难度 → i18n 键映射 */ +export const DIFFICULTY_I18N_KEY: Record = { + 1: "difficulty.1", + 2: "difficulty.2", + 3: "difficulty.3", + 4: "difficulty.4", + 5: "difficulty.5", } export interface Question { @@ -51,3 +62,20 @@ export type KnowledgePointOption = { subject: string | null grade: string | null } + +/** 教材选项(级联筛选第一级) */ +export type TextbookOption = { + id: string + title: string + subject: string + grade: string | null +} + +/** 章节选项(级联筛选第二级) */ +export type ChapterOption = { + id: string + title: string + parentId: string | null + /** 层级深度,用于缩进显示 */ + depth: number +} diff --git a/src/modules/questions/utils/parse-content.ts b/src/modules/questions/utils/parse-content.ts new file mode 100644 index 0000000..e88b52b --- /dev/null +++ b/src/modules/questions/utils/parse-content.ts @@ -0,0 +1,128 @@ +/** + * 题目内容类型定义与类型守卫。 + * + * 题目 content 存储为 JSON(unknown),实际结构为: + * - { text: string } — 简答题/判断题 + * - { text: string, options: QuestionOption[] } — 选择题 + * - { text: string, options: QuestionOption[], answer?: string, explanation?: string } — 完整结构 + * + * 所有从 unknown 到具体类型的转换必须使用本文件的类型守卫函数, + * 禁止使用 `as` 断言。 + */ + +/** 选择题选项 */ +export interface QuestionOption { + id: string + text: string + isCorrect: boolean +} + +/** 题目内容结构化类型 */ +export interface QuestionContent { + text: string + options?: QuestionOption[] + answer?: string + explanation?: string +} + +/** 类型守卫:判断值是否为字符串 */ +function isString(value: unknown): value is string { + return typeof value === "string" +} + +/** 类型守卫:判断值是否为非 null 对象 */ +function isNonNullObject(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +/** 类型守卫:判断值是否为 QuestionOption */ +function isQuestionOption(value: unknown): value is QuestionOption { + if (!isNonNullObject(value)) return false + const id = value.id + const text = value.text + const isCorrect = value.isCorrect + return ( + (isString(id) || id === undefined) && + isString(text) && + typeof isCorrect === "boolean" + ) +} + +/** 类型守卫:判断值是否为 QuestionOption 数组 */ +function isQuestionOptionArray(value: unknown): value is QuestionOption[] { + return Array.isArray(value) && value.every(isQuestionOption) +} + +/** + * 从 unknown 安全解析为 QuestionContent。 + * + * 处理以下情况: + * - 字符串:转为 { text: string } + * - 对象:提取 text/options/answer/explanation 字段 + * - null/undefined:返回 { text: "" } + * - 其他:JSON.stringify 后转为 { text } + * + * @example + * const content = parseQuestionContent(question.content) + * console.log(content.text) // 题干文本 + * console.log(content.options) // 选项列表(选择题) + */ +export function parseQuestionContent(raw: unknown): QuestionContent { + if (isString(raw)) { + return { text: raw } + } + + if (isNonNullObject(raw)) { + const text = isString(raw.text) ? raw.text : "" + const optionsRaw = raw.options + const options = isQuestionOptionArray(optionsRaw) + ? optionsRaw.map((opt) => ({ + id: opt.id ?? opt.text, + text: opt.text, + isCorrect: opt.isCorrect, + })) + : undefined + const answer = isString(raw.answer) ? raw.answer : undefined + const explanation = isString(raw.explanation) ? raw.explanation : undefined + + return { text, options, answer, explanation } + } + + if (raw == null) { + return { text: "" } + } + + try { + return { text: JSON.stringify(raw) } + } catch { + return { text: "" } + } +} + +/** + * 从 QuestionContent 提取纯文本预览(截断到指定长度)。 + * + * @param raw - 原始 content(unknown) + * @param maxLength - 最大长度,默认 80 + */ +export function getQuestionPreview(raw: unknown, maxLength = 80): string { + const content = parseQuestionContent(raw) + return content.text.slice(0, maxLength) +} + +/** + * 从 QuestionContent 提取选项列表(用于表单回填)。 + * + * @returns 选项数组,无选项时返回 undefined + */ +export function getQuestionOptions(raw: unknown): QuestionOption[] | undefined { + const content = parseQuestionContent(raw) + return content.options +} + +/** + * 从 QuestionContent 提取纯文本(用于 AI 变体生成等场景)。 + */ +export function getQuestionText(raw: unknown): string { + return parseQuestionContent(raw).text +} diff --git a/src/modules/questions/utils/track-event.ts b/src/modules/questions/utils/track-event.ts new file mode 100644 index 0000000..58ad45e --- /dev/null +++ b/src/modules/questions/utils/track-event.ts @@ -0,0 +1,90 @@ +/** + * 题库模块监控埋点接口。 + * + * 预留关键操作埋点接口,当前为空实现(no-op)。 + * 接入监控系统时,替换 `trackQuestionEvent` 的实现即可, + * 无需修改调用方代码。 + */ + +/** 题库操作事件类型 */ +export type QuestionEventType = + | "question:create" + | "question:update" + | "question:delete" + | "question:search" + | "question:filter_cascade" + +/** 埋点事件载荷 */ +export interface QuestionEventPayload { + /** 事件类型 */ + type: QuestionEventType + /** 题目 ID(创建/更新/删除时) */ + questionId?: string + /** 题型 */ + questionType?: string + /** 难度 */ + difficulty?: number + /** 搜索关键词(搜索时) */ + searchQuery?: string + /** 筛选条件(筛选时) */ + filters?: Record + /** 时间戳 */ + timestamp: number +} + +/** + * 埋点函数(no-op 实现)。 + * + * 接入监控系统时替换此函数实现,例如: + * ```ts + * export function trackQuestionEvent(payload: QuestionEventPayload): void { + * analytics.track("question_event", payload) + * } + * ``` + */ +export function trackQuestionEvent(payload: QuestionEventPayload): void { + // no-op: 预留接口,接入监控系统时替换实现 + if (process.env.NODE_ENV === "development") { + console.debug("[trackQuestionEvent]", payload) + } +} + +/** 便捷方法:记录创建题目事件 */ +export function trackQuestionCreated(questionId: string, questionType: string, difficulty: number): void { + trackQuestionEvent({ + type: "question:create", + questionId, + questionType, + difficulty, + timestamp: Date.now(), + }) +} + +/** 便捷方法:记录更新题目事件 */ +export function trackQuestionUpdated(questionId: string, questionType: string, difficulty: number): void { + trackQuestionEvent({ + type: "question:update", + questionId, + questionType, + difficulty, + timestamp: Date.now(), + }) +} + +/** 便捷方法:记录删除题目事件 */ +export function trackQuestionDeleted(questionId: string): void { + trackQuestionEvent({ + type: "question:delete", + questionId, + timestamp: Date.now(), + }) +} + +/** 便捷方法:记录搜索事件 */ +export function trackQuestionSearched(searchQuery: string): void { + trackQuestionEvent({ + type: "question:search", + searchQuery, + timestamp: Date.now(), + }) +} diff --git a/src/modules/school/components/grade-delete-dialog.tsx b/src/modules/school/components/grade-delete-dialog.tsx new file mode 100644 index 0000000..ca44d09 --- /dev/null +++ b/src/modules/school/components/grade-delete-dialog.tsx @@ -0,0 +1,71 @@ +"use client" + +import { useTranslations } from "next-intl" + +import type { GradeListItem } from "../types" +import { deleteGradeAction } from "../actions" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { useActionMutation } from "@/shared/hooks/use-action-mutation" + +type GradeDeleteDialogProps = { + deleteItem: GradeListItem | null + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +/** + * 年级删除确认对话框。 + * + * 内部管理 deleteMutation,对话框的 open 状态由 `deleteItem` 是否为空推导。 + * 成功后调用 `onOpenChange(false)` 关闭对话框并触发 `onSuccess` 通知父组件刷新。 + */ +export function GradeDeleteDialog({ + deleteItem, + onOpenChange, + onSuccess, +}: GradeDeleteDialogProps) { + const t = useTranslations("school") + + const deleteMutation = useActionMutation({ + errorMessage: t("grades.failedDelete"), + onSuccess: () => { + onOpenChange(false) + onSuccess() + }, + }) + + const isWorking = deleteMutation.isWorking + + const handleDelete = (): void => { + if (!deleteItem) return + void deleteMutation.mutate(() => deleteGradeAction(deleteItem.id)) + } + + return ( + + + + {t("grades.delete.title")} + + {t("grades.delete.description", { name: deleteItem?.name || "" })} + + + + {t("grades.delete.cancel")} + + {t("grades.delete.confirm")} + + + + + ) +} diff --git a/src/modules/school/components/grade-form-dialog.tsx b/src/modules/school/components/grade-form-dialog.tsx new file mode 100644 index 0000000..5b33a4d --- /dev/null +++ b/src/modules/school/components/grade-form-dialog.tsx @@ -0,0 +1,328 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" +import { toast } from "sonner" +import { useTranslations } from "next-intl" + +import type { GradeListItem, SchoolListItem, StaffOption } from "../types" +import { createGradeAction, updateGradeAction } from "../actions" +import { Button } from "@/shared/components/ui/button" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import { useActionMutation } from "@/shared/hooks/use-action-mutation" + +type FormState = { + schoolId: string + name: string + order: string + gradeHeadId: string + teachingHeadId: string +} + +type FormErrors = Partial> + +type GradeFormDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + editItem: GradeListItem | null + schools: SchoolListItem[] + staff: StaffOption[] + grades: GradeListItem[] + onSuccess: () => void +} + +const NONE_SELECT_VALUE = "__none__" + +const normalizeName = (v: string): string => v.trim().replace(/\s+/g, " ") + +const parseOrder = (raw: string): number | null => { + const v = raw.trim() + if (!v) return 0 + const n = Number(v) + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return null + return n +} + +const toFormState = (item: GradeListItem | null, fallbackSchoolId: string): FormState => ({ + schoolId: item?.school.id ?? fallbackSchoolId, + name: item?.name ?? "", + order: String(item?.order ?? 0), + gradeHeadId: item?.gradeHead?.id ?? "", + teachingHeadId: item?.teachingHead?.id ?? "", +}) + +/** + * 年级创建/编辑表单对话框。 + * + * 根据 `editItem` 是否存在自动切换模式。内部管理表单状态与客户端校验 + * (必填、长度、order 格式、同校重名检测、isDirty 检测), + * mutation 通过 useActionMutation 统一处理 loading/toast。 + * 成功后调用 `onOpenChange(false)` 关闭对话框并触发 `onSuccess` 通知父组件刷新。 + */ +export function GradeFormDialog({ + open, + onOpenChange, + editItem, + schools, + staff, + grades, + onSuccess, +}: GradeFormDialogProps) { + const t = useTranslations("school") + const isEdit = Boolean(editItem) + const defaultSchoolId = schools[0]?.id ?? "" + + const [state, setState] = useState(() => toFormState(editItem, defaultSchoolId)) + + const staffOptions = useMemo(() => { + return [...staff].sort((a, b) => { + const byName = a.name.localeCompare(b.name) + if (byName !== 0) return byName + return a.email.localeCompare(b.email) + }) + }, [staff]) + + const validateForm = useCallback( + (formState: FormState, excludeGradeId?: string): { ok: boolean; errors: FormErrors } => { + const errors: FormErrors = {} + + const schoolId = formState.schoolId.trim() + if (!schoolId) errors.schoolId = t("grades.validation.selectSchool") + + const name = normalizeName(formState.name) + if (!name) errors.name = t("grades.validation.enterName") + if (name.length > 100) errors.name = t("grades.validation.nameTooLong") + + const order = parseOrder(formState.order) + if (order === null) errors.order = t("grades.validation.orderInvalid") + + if (schoolId && name) { + const dup = grades.find((g) => { + if (excludeGradeId && g.id === excludeGradeId) return false + return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase() + }) + if (dup) errors.name = t("grades.validation.duplicateName") + } + + return { ok: Object.keys(errors).length === 0, errors } + }, + [t, grades] + ) + + const validation = useMemo( + () => validateForm(state, editItem?.id), + [state, editItem?.id, validateForm] + ) + + const isDirty = useMemo(() => { + if (!editItem) return true + const next = { + schoolId: state.schoolId.trim(), + name: normalizeName(state.name), + order: parseOrder(state.order), + gradeHeadId: state.gradeHeadId || "", + teachingHeadId: state.teachingHeadId || "", + } + const prev = { + schoolId: editItem.school.id, + name: normalizeName(editItem.name), + order: editItem.order, + gradeHeadId: editItem.gradeHead?.id ?? "", + teachingHeadId: editItem.teachingHead?.id ?? "", + } + return ( + next.schoolId !== prev.schoolId || + next.name !== prev.name || + (typeof next.order === "number" ? next.order : null) !== prev.order || + next.gradeHeadId !== prev.gradeHeadId || + next.teachingHeadId !== prev.teachingHeadId + ) + }, [editItem, state]) + + const createMutation = useActionMutation({ + errorMessage: t("grades.failedCreate"), + onSuccess: () => { + onOpenChange(false) + onSuccess() + }, + }) + + const updateMutation = useActionMutation({ + errorMessage: t("grades.failedUpdate"), + onSuccess: () => { + onOpenChange(false) + onSuccess() + }, + }) + + const isWorking = createMutation.isWorking || updateMutation.isWorking + + const handleSubmit = (): void => { + const result = validateForm(state, editItem?.id) + if (!result.ok) { + toast.error(Object.values(result.errors)[0] || t("grades.validation.fixForm")) + return + } + if (isEdit && !isDirty) { + toast.message(t("grades.validation.noChanges")) + return + } + + const fd = new FormData() + fd.set("schoolId", state.schoolId) + fd.set("name", normalizeName(state.name)) + fd.set("order", state.order) + fd.set("gradeHeadId", state.gradeHeadId) + fd.set("teachingHeadId", state.teachingHeadId) + + if (isEdit && editItem) { + void updateMutation.mutate(() => updateGradeAction(editItem.id, undefined, fd)) + } else { + void createMutation.mutate(() => createGradeAction(undefined, fd)) + } + } + + return ( + + + + + {isEdit ? t("grades.form.editTitle") : t("grades.form.createTitle")} + + +
{ + e.preventDefault() + void handleSubmit() + }} + > +
+ +
+ +
+ {validation.errors.schoolId ? ( +
+ {validation.errors.schoolId} +
+ ) : null} +
+ +
+ + setState((p) => ({ ...p, name: e.target.value }))} + placeholder={t("grades.form.name")} + autoFocus={!isEdit} + /> + {validation.errors.name ? ( +
+ {validation.errors.name} +
+ ) : null} +
+ +
+ + setState((p) => ({ ...p, order: e.target.value }))} + /> + {validation.errors.order ? ( +
+ {validation.errors.order} +
+ ) : null} +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + + + + + +
+
+ ) +} diff --git a/src/modules/school/components/grade-list-toolbar.tsx b/src/modules/school/components/grade-list-toolbar.tsx new file mode 100644 index 0000000..0bcdeda --- /dev/null +++ b/src/modules/school/components/grade-list-toolbar.tsx @@ -0,0 +1,111 @@ +"use client" + +import { Plus } from "lucide-react" +import { useTranslations } from "next-intl" + +import type { SchoolListItem } from "../types" +import { Button } from "@/shared/components/ui/button" +import { Input } from "@/shared/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" + +type GradeListToolbarProps = { + q: string + setQ: (value: string | null) => void + school: string + setSchool: (value: string | null) => void + head: string + setHead: (value: string | null) => void + sort: string + setSort: (value: string | null) => void + hasFilters: boolean + onReset: () => void + schools: SchoolListItem[] + onCreate: () => void + isWorking: boolean +} + +/** + * 年级列表工具栏。 + * + * 展示搜索框、学校筛选、年级主任筛选、排序选择器以及「新建年级」按钮。 + * 筛选状态由父组件通过 nuqs useQueryState 管理,本组件仅负责渲染与回调。 + */ +export function GradeListToolbar({ + q, + setQ, + school, + setSchool, + head, + setHead, + sort, + setSort, + hasFilters, + onReset, + schools, + onCreate, + isWorking, +}: GradeListToolbarProps) { + const t = useTranslations("school") + + return ( +
+
+
+ setQ(e.target.value || null)} /> +
+ + + + + + + + {hasFilters ? ( + + ) : null} +
+ + +
+ ) +} diff --git a/src/modules/school/components/grade-overview-cards.tsx b/src/modules/school/components/grade-overview-cards.tsx new file mode 100644 index 0000000..9b59253 --- /dev/null +++ b/src/modules/school/components/grade-overview-cards.tsx @@ -0,0 +1,154 @@ +"use client" + +import { BarChart3, GraduationCap, MoreHorizontal, Pencil, Trash2, UserCog, Users } from "lucide-react" +import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" + +import type { GradeListItem } from "../types" +import type { GradeOverviewStats } from "../data-access" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent } from "@/shared/components/ui/card" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu" + +type GradeOverviewCardsProps = { + grades: GradeListItem[] + statsMap: Map + isWorking: boolean + onEdit: (item: GradeListItem) => void + onDelete: (item: GradeListItem) => void +} + +/** + * 年级概览卡片视图。 + * + * 以卡片网格展示前 8 个年级,每张卡片包含年级名称、所属学校、 + * 班级/学生/教师统计、年级主任/教学主任以及快捷操作入口。 + */ +export function GradeOverviewCards({ + grades, + statsMap, + isWorking, + onEdit, + onDelete, +}: GradeOverviewCardsProps) { + const t = useTranslations("school") + const router = useRouter() + + if (grades.length === 0) return null + + return ( +
+ {grades.slice(0, 8).map((g) => { + const stats = statsMap.get(g.id) + return ( + + +
+
+
{g.name}
+
{g.school.name}
+
+ + + + + + + router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`) + } + > + + {t("grades.gradeOverview.viewInsights")} + + + onEdit(g)}> + + {t("grades.actions.edit")} + + onDelete(g)} + > + + {t("grades.actions.delete")} + + + +
+ + {/* 统计指标 */} +
+
+
+ +
+
+ {stats?.classCount ?? 0} +
+
+ {t("grades.gradeOverview.classCount")} +
+
+
+
+ +
+
+ {stats?.studentCount ?? 0} +
+
+ {t("grades.gradeOverview.studentCount")} +
+
+
+
+ +
+
+ {stats?.teacherCount ?? 0} +
+
+ {t("grades.gradeOverview.teacherCount")} +
+
+
+ + {/* 年级主任/教学主任 */} +
+
+ {t("grades.gradeOverview.gradeHead")} + + {g.gradeHead?.name ?? t("grades.gradeOverview.notSet")} + +
+
+ {t("grades.gradeOverview.teachingHead")} + + {g.teachingHead?.name ?? t("grades.gradeOverview.notSet")} + +
+
+ + {/* 快捷操作 */} + +
+
+ ) + })} +
+ ) +} diff --git a/src/modules/school/components/grades-view.tsx b/src/modules/school/components/grades-view.tsx index 91baf48..e1f72db 100644 --- a/src/modules/school/components/grades-view.tsx +++ b/src/modules/school/components/grades-view.tsx @@ -1,23 +1,24 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" -import { BarChart3, MoreHorizontal, Pencil, Plus, Trash2, Users, GraduationCap, UserCog } from "lucide-react" -import { toast } from "sonner" +import { useMemo, useState } from "react" +import type { ReactNode } from "react" +import { MoreHorizontal, Pencil, Trash2 } from "lucide-react" import { useRouter } from "next/navigation" import { parseAsString, useQueryState } from "nuqs" import { useTranslations } from "next-intl" import type { GradeListItem, SchoolListItem, StaffOption } from "../types" import type { GradeOverviewStats } from "../data-access" -import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions" +import { useGradeData } from "../hooks/use-grade-data" +import { GradeDeleteDialog } from "./grade-delete-dialog" +import { GradeFormDialog } from "./grade-form-dialog" +import { GradeListToolbar } from "./grade-list-toolbar" +import { GradeOverviewCards } from "./grade-overview-cards" +import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" -import { Input } from "@/shared/components/ui/input" -import { Label } from "@/shared/components/ui/label" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { EmptyState } from "@/shared/components/ui/empty-state" -import { Badge } from "@/shared/components/ui/badge" import { DropdownMenu, DropdownMenuContent, @@ -25,49 +26,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/components/ui/alert-dialog" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import { formatDate } from "@/shared/lib/utils" -type FormState = { - schoolId: string - name: string - order: string - gradeHeadId: string - teachingHeadId: string -} - -const toFormState = (item: GradeListItem | null, fallbackSchoolId: string): FormState => ({ - schoolId: item?.school.id ?? fallbackSchoolId, - name: item?.name ?? "", - order: String(item?.order ?? 0), - gradeHeadId: item?.gradeHead?.id ?? "", - teachingHeadId: item?.teachingHead?.id ?? "", -}) - -type FormErrors = Partial> - -const normalizeName = (v: string) => v.trim().replace(/\s+/g, " ") - -const NONE_SELECT_VALUE = "__none__" - -const parseOrder = (raw: string) => { - const v = raw.trim() - if (!v) return 0 - const n = Number(v) - if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return null - return n -} - export function GradesClient({ grades, schools, @@ -81,19 +41,33 @@ export function GradesClient({ }) { const t = useTranslations("school") const router = useRouter() - const [isWorking, setIsWorking] = useState(false) - const [createOpen, setCreateOpen] = useState(false) - const [editItem, setEditItem] = useState(null) - const [deleteItem, setDeleteItem] = useState(null) + const { + createOpen, + editItem, + deleteItem, + setCreateOpen, + setEditItem, + setDeleteItem, + isWorking, + } = useGradeData() const [q, setQ] = useQueryState("q", parseAsString.withDefault("")) const [school, setSchool] = useQueryState("school", parseAsString.withDefault("all")) const [head, setHead] = useQueryState("head", parseAsString.withDefault("all")) const [sort, setSort] = useQueryState("sort", parseAsString.withDefault("default")) - const defaultSchoolId = useMemo(() => schools[0]?.id ?? "", [schools]) - const [createState, setCreateState] = useState(() => toFormState(null, defaultSchoolId)) - const [editState, setEditState] = useState(() => toFormState(null, defaultSchoolId)) + // 表单对话框会话 key:每次打开对话框时递增,强制 GradeFormDialog 重新挂载以重置表单状态 + const [formSession, setFormSession] = useState(0) + + const openCreate = (): void => { + setFormSession((s) => s + 1) + setCreateOpen(true) + } + + const openEdit = (item: GradeListItem): void => { + setFormSession((s) => s + 1) + setEditItem(item) + } // 年级概览统计映射,用于卡片视图 const statsMap = useMemo(() => { @@ -102,68 +76,6 @@ export function GradesClient({ return m }, [gradeStats]) - useEffect(() => { - if (!createOpen) return - if (createState.schoolId.trim().length > 0) return - if (!defaultSchoolId) return - setCreateState((p) => ({ ...p, schoolId: defaultSchoolId })) - }, [createOpen, createState.schoolId, defaultSchoolId]) - - useEffect(() => { - if (!editItem) return - if (editState.schoolId.trim().length > 0) return - if (!defaultSchoolId) return - setEditState((p) => ({ ...p, schoolId: defaultSchoolId })) - }, [editItem, editState.schoolId, defaultSchoolId]) - - const staffOptions = useMemo(() => { - return [...staff].sort((a, b) => { - const byName = a.name.localeCompare(b.name) - if (byName !== 0) return byName - return a.email.localeCompare(b.email) - }) - }, [staff]) - - const validateForm = useCallback( - (state: FormState, params: { grades: GradeListItem[]; excludeGradeId?: string }): { - ok: boolean - errors: FormErrors - } => { - const errors: FormErrors = {} - - const schoolId = state.schoolId.trim() - if (!schoolId) errors.schoolId = t("grades.validation.selectSchool") - - const name = normalizeName(state.name) - if (!name) errors.name = t("grades.validation.enterName") - if (name.length > 100) errors.name = t("grades.validation.nameTooLong") - - const order = parseOrder(state.order) - if (order === null) errors.order = t("grades.validation.orderInvalid") - - if (schoolId && name) { - const dup = params.grades.find((g) => { - if (params.excludeGradeId && g.id === params.excludeGradeId) return false - return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase() - }) - if (dup) errors.name = t("grades.validation.duplicateName") - } - - return { ok: Object.keys(errors).length === 0, errors } - }, - [t] - ) - - const formatStaffDetail = (u: StaffOption | null) => { - if (!u) return {t("grades.notSet")} - return ( -
-
{u.name}
-
{u.email}
-
- ) - } - const filteredGrades = useMemo(() => { const needle = q.trim().toLowerCase() const bySchool = school === "all" ? "" : school @@ -207,321 +119,68 @@ export function GradesClient({ const hasFilters = q.length > 0 || school !== "all" || head !== "all" || sort !== "default" - const openEdit = (item: GradeListItem) => { - setEditItem(item) - setEditState(toFormState(item, defaultSchoolId)) + const handleResetFilters = (): void => { + setQ(null) + setSchool(null) + setHead(null) + setSort(null) } - const openCreate = () => { - setCreateState(toFormState(null, defaultSchoolId)) - setCreateOpen(true) + const handleFormOpenChange = (open: boolean): void => { + if (!open) { + setCreateOpen(false) + setEditItem(null) + } } - const createValidation = useMemo( - () => validateForm(createState, { grades }), - [createState, grades, validateForm] - ) - const editValidation = useMemo( - () => validateForm(editState, { grades, excludeGradeId: editItem?.id }), - [editItem?.id, editState, grades, validateForm] - ) + const handleDeleteOpenChange = (open: boolean): void => { + if (!open) { + setDeleteItem(null) + } + } - const isEditDirty = useMemo(() => { - if (!editItem) return false - const next = { - schoolId: editState.schoolId.trim(), - name: normalizeName(editState.name), - order: parseOrder(editState.order), - gradeHeadId: editState.gradeHeadId || "", - teachingHeadId: editState.teachingHeadId || "", - } - const prev = { - schoolId: editItem.school.id, - name: normalizeName(editItem.name), - order: editItem.order, - gradeHeadId: editItem.gradeHead?.id ?? "", - teachingHeadId: editItem.teachingHead?.id ?? "", - } + const handleSuccess = (): void => { + router.refresh() + } + + const formatStaffDetail = (u: StaffOption | null): ReactNode => { + if (!u) return {t("grades.notSet")} return ( - next.schoolId !== prev.schoolId || - next.name !== prev.name || - (typeof next.order === "number" ? next.order : null) !== prev.order || - next.gradeHeadId !== prev.gradeHeadId || - next.teachingHeadId !== prev.teachingHeadId +
+
{u.name}
+
{u.email}
+
) - }, [editItem, editState]) - - const handleCreate = async () => { - const validation = validateForm(createState, { grades }) - if (!validation.ok) { - toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm")) - return - } - - setIsWorking(true) - try { - const fd = new FormData() - fd.set("schoolId", createState.schoolId) - fd.set("name", normalizeName(createState.name)) - fd.set("order", createState.order) - fd.set("gradeHeadId", createState.gradeHeadId) - fd.set("teachingHeadId", createState.teachingHeadId) - - const res = await createGradeAction(undefined, fd) - if (res.success) { - toast.success(res.message) - setCreateOpen(false) - router.refresh() - } else { - toast.error(res.message || t("grades.failedCreate")) - } - } catch { - toast.error(t("grades.failedCreate")) - } finally { - setIsWorking(false) - } - } - - const handleUpdate = async () => { - if (!editItem) return - const validation = validateForm(editState, { grades, excludeGradeId: editItem.id }) - if (!validation.ok) { - toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm")) - return - } - if (!isEditDirty) { - toast.message(t("grades.validation.noChanges")) - return - } - - setIsWorking(true) - try { - const fd = new FormData() - fd.set("schoolId", editState.schoolId) - fd.set("name", normalizeName(editState.name)) - fd.set("order", editState.order) - fd.set("gradeHeadId", editState.gradeHeadId) - fd.set("teachingHeadId", editState.teachingHeadId) - - const res = await updateGradeAction(editItem.id, undefined, fd) - if (res.success) { - toast.success(res.message) - setEditItem(null) - router.refresh() - } else { - toast.error(res.message || t("grades.failedUpdate")) - } - } catch { - toast.error(t("grades.failedUpdate")) - } finally { - setIsWorking(false) - } - } - - const handleDelete = async () => { - if (!deleteItem) return - setIsWorking(true) - try { - const res = await deleteGradeAction(deleteItem.id) - if (res.success) { - toast.success(res.message) - setDeleteItem(null) - router.refresh() - } else { - toast.error(res.message || t("grades.failedDelete")) - } - } catch { - toast.error(t("grades.failedDelete")) - } finally { - setIsWorking(false) - } } return ( <> {/* 年级概览卡片视图:让管理员一目了然看到各年级规模 */} {filteredGrades.length > 0 && ( -
- {filteredGrades.slice(0, 8).map((g) => { - const stats = statsMap.get(g.id) - return ( - - -
-
-
{g.name}
-
{g.school.name}
-
- - - - - - - router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`) - } - > - - {t("grades.gradeOverview.viewInsights")} - - - openEdit(g)}> - - {t("grades.actions.edit")} - - setDeleteItem(g)} - > - - {t("grades.actions.delete")} - - - -
- - {/* 统计指标 */} -
-
-
- -
-
- {stats?.classCount ?? 0} -
-
- {t("grades.gradeOverview.classCount")} -
-
-
-
- -
-
- {stats?.studentCount ?? 0} -
-
- {t("grades.gradeOverview.studentCount")} -
-
-
-
- -
-
- {stats?.teacherCount ?? 0} -
-
- {t("grades.gradeOverview.teacherCount")} -
-
-
- - {/* 年级主任/教学主任 */} -
-
- {t("grades.gradeOverview.gradeHead")} - - {g.gradeHead?.name ?? t("grades.gradeOverview.notSet")} - -
-
- {t("grades.gradeOverview.teachingHead")} - - {g.teachingHead?.name ?? t("grades.gradeOverview.notSet")} - -
-
- - {/* 快捷操作 */} - -
-
- ) - })} -
+ )} -
-
-
- setQ(e.target.value || null)} /> -
- - - - - - - - {hasFilters ? ( - - ) : null} -
- - -
+ @@ -554,368 +213,82 @@ export function GradesClient({ /> ) : (
-
- - - {t("grades.column.school")} - {t("grades.column.grade")} - {t("grades.column.order")} - {t("grades.column.gradeHead")} - {t("grades.column.teachingHead")} - {t("grades.column.updated")} - - - - - {filteredGrades.map((g) => ( - - {g.school.name} - {g.name} - {g.order} - {formatStaffDetail(g.gradeHead)} - {formatStaffDetail(g.teachingHead)} - {formatDate(g.updatedAt)} - - - - - - - - router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`) - } - > - {t("grades.actions.insights")} - - - openEdit(g)}> - - {t("grades.actions.edit")} - - setDeleteItem(g)} - > - - {t("grades.actions.delete")} - - - - +
+ + + {t("grades.column.school")} + {t("grades.column.grade")} + {t("grades.column.order")} + {t("grades.column.gradeHead")} + {t("grades.column.teachingHead")} + {t("grades.column.updated")} + - ))} - -
+ + + {filteredGrades.map((g) => ( + + {g.school.name} + {g.name} + {g.order} + {formatStaffDetail(g.gradeHead)} + {formatStaffDetail(g.teachingHead)} + {formatDate(g.updatedAt)} + + + + + + + + router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`) + } + > + {t("grades.actions.insights")} + + + openEdit(g)}> + + {t("grades.actions.edit")} + + setDeleteItem(g)} + > + + {t("grades.actions.delete")} + + + + + + ))} + +
)} - - - - {t("grades.form.createTitle")} - -
{ - e.preventDefault() - void handleCreate() - }} - > -
- -
- -
- {createValidation.errors.schoolId ? ( -
- {createValidation.errors.schoolId} -
- ) : null} -
+ -
- - setCreateState((p) => ({ ...p, name: e.target.value }))} - placeholder={t("grades.form.name")} - autoFocus - /> - {createValidation.errors.name ? ( -
- {createValidation.errors.name} -
- ) : null} -
- -
- - setCreateState((p) => ({ ...p, order: e.target.value }))} - /> - {createValidation.errors.order ? ( -
- {createValidation.errors.order} -
- ) : null} -
- -
- -
- -
-
- -
- -
- -
-
- - - - - - -
-
- - { - if (!open) setEditItem(null) - }} - > - - - {t("grades.form.editTitle")} - - {editItem ? ( -
{ - e.preventDefault() - void handleUpdate() - }} - > -
- -
- -
- {editValidation.errors.schoolId ? ( -
- {editValidation.errors.schoolId} -
- ) : null} -
- -
- - setEditState((p) => ({ ...p, name: e.target.value }))} - /> - {editValidation.errors.name ? ( -
- {editValidation.errors.name} -
- ) : null} -
- -
- - setEditState((p) => ({ ...p, order: e.target.value }))} - /> - {editValidation.errors.order ? ( -
- {editValidation.errors.order} -
- ) : null} -
- -
- -
- -
-
- -
- -
- -
-
- - - - - -
- ) : null} -
-
- - { - if (!open) setDeleteItem(null) - }} - > - - - {t("grades.delete.title")} - - {t("grades.delete.description", { name: deleteItem?.name || "" })} - - - - {t("grades.delete.cancel")} - - {t("grades.delete.confirm")} - - - - + ) } diff --git a/src/modules/school/components/school-error-boundary.tsx b/src/modules/school/components/school-error-boundary.tsx index 9885729..38ad0eb 100644 --- a/src/modules/school/components/school-error-boundary.tsx +++ b/src/modules/school/components/school-error-boundary.tsx @@ -1,72 +1,57 @@ "use client" -import { Component, type ErrorInfo, type JSX, type ReactNode } from "react" +/** + * 学校模块 Error Boundary。 + * + * 薄包装:委托给共享 SectionErrorBoundary,通过自定义 fallback 实现 + * 重试时调用 router.refresh() 刷新服务端数据。 + * 保留同名导出以兼容现有 import。 + */ + +import type { ReactNode } from "react" import { AlertCircle } from "lucide-react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" - import { Button } from "@/shared/components/ui/button" +import { SectionErrorBoundary } from "@/shared/components/section-error-boundary" interface SchoolErrorBoundaryProps { children: ReactNode fallback?: ReactNode } -interface SchoolErrorBoundaryState { - hasError: boolean -} - -function SchoolErrorFallback({ onReset }: { onReset: () => void }): JSX.Element { +export function SchoolErrorBoundary({ + children, + fallback, +}: SchoolErrorBoundaryProps): ReactNode { const t = useTranslations("school") const router = useRouter() - const handleRetry = (): void => { - onReset() - router.refresh() + const customFallback = (_error: Error, reset: () => void): ReactNode => { + const handleRetry = (): void => { + reset() + router.refresh() + } + return ( +
+
+
+

{t("errors.boundary.title")}

+

+ {t("errors.boundary.description")} +

+ +
+ ) } return ( -
-
- -
-

{t("errors.boundary.title")}

-

- {t("errors.boundary.description")} -

- -
+ fallback : customFallback}> + {children} + ) } - -export class SchoolErrorBoundary extends Component< - SchoolErrorBoundaryProps, - SchoolErrorBoundaryState -> { - constructor(props: SchoolErrorBoundaryProps) { - super(props) - this.state = { hasError: false } - } - - static getDerivedStateFromError(): SchoolErrorBoundaryState { - return { hasError: true } - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo): void { - console.error("SchoolErrorBoundary caught an error:", error, errorInfo) - } - - private handleReset = (): void => { - this.setState({ hasError: false }) - } - - render(): ReactNode { - if (this.state.hasError) { - return this.props.fallback ?? - } - return this.props.children - } -} diff --git a/src/modules/school/hooks/use-grade-data.ts b/src/modules/school/hooks/use-grade-data.ts new file mode 100644 index 0000000..0d80e8d --- /dev/null +++ b/src/modules/school/hooks/use-grade-data.ts @@ -0,0 +1,42 @@ +"use client" + +import { useState } from "react" + +import type { GradeListItem } from "../types" + +export type UseGradeDataReturn = { + createOpen: boolean + editItem: GradeListItem | null + deleteItem: GradeListItem | null + setCreateOpen: (open: boolean) => void + setEditItem: (item: GradeListItem | null) => void + setDeleteItem: (item: GradeListItem | null) => void + isWorking: boolean +} + +/** + * 年级管理客户端的数据/状态 Hook。 + * + * 集中管理创建/编辑/删除对话框的开关状态以及当前操作的年级项, + * 供 GradesClient 组合容器及其子组件共享。 + * + * `isWorking` 表示任意对话框处于打开状态,用于禁用工具栏按钮与行内操作菜单, + * 避免并发打开多个对话框;各对话框内部的 mutation loading 由对应组件自行管理。 + */ +export function useGradeData(): UseGradeDataReturn { + const [createOpen, setCreateOpen] = useState(false) + const [editItem, setEditItem] = useState(null) + const [deleteItem, setDeleteItem] = useState(null) + + const isWorking = createOpen || Boolean(editItem) || Boolean(deleteItem) + + return { + createOpen, + editItem, + deleteItem, + setCreateOpen, + setEditItem, + setDeleteItem, + isWorking, + } +} diff --git a/src/modules/settings/actions-avatar.ts b/src/modules/settings/actions-avatar.ts index 1e0d654..083e60c 100644 --- a/src/modules/settings/actions-avatar.ts +++ b/src/modules/settings/actions-avatar.ts @@ -1,12 +1,11 @@ "use server" -import { unlink } from "fs/promises" -import path from "path" import { revalidatePath } from "next/cache" import type { ActionState } from "@/shared/types/action-state" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" +import { storageProvider } from "@/shared/lib/storage-provider" import { getUserProfile, updateUserAvatar } from "@/modules/users/data-access" import { deleteFileAttachment, @@ -16,6 +15,9 @@ import { /** * 清理旧头像文件(磁盘 + DB 记录) * 静默失败,不影响主流程 + * + * P1-4:磁盘删除统一走 storageProvider 抽象, + * 不再直接 import fs/promises。 */ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise { if (!oldImageUrl) return @@ -23,17 +25,8 @@ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise { const fileRecord = await getFileByUrl(oldImageUrl) if (!fileRecord) return - // 删除磁盘文件 - const absolutePath = path.join( - process.cwd(), - "public", - fileRecord.storagePath, - ) - try { - await unlink(absolutePath) - } catch { - // 文件可能已不存在,忽略错误 - } + // 删除磁盘文件(通过 storageProvider 抽象) + await storageProvider.delete(fileRecord.storagePath) // 删除 DB 记录 await deleteFileAttachment(fileRecord.id) @@ -45,7 +38,8 @@ async function cleanupOldAvatarFile(oldImageUrl: string | null): Promise { /** * 更新用户头像 URL * - * 实际文件上传通过 /api/upload 路由完成,此 action 仅更新 users.image 字段。 + * 实际文件上传通过 /api/upload 路由完成(targetType="user_avatar"), + * 此 action 仅更新 users.image 字段。 * 更新成功后会清理旧头像文件(磁盘 + DB 记录)。 */ export async function updateUserAvatarAction( diff --git a/src/modules/settings/actions-brand.ts b/src/modules/settings/actions-brand.ts new file mode 100644 index 0000000..8d63936 --- /dev/null +++ b/src/modules/settings/actions-brand.ts @@ -0,0 +1,81 @@ +"use server" + +import { z } from "zod" +import { revalidatePath } from "next/cache" + +import type { ActionState } from "@/shared/types/action-state" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getSession } from "@/shared/lib/session" + +import { getBrandConfig, saveBrandConfig } from "./data-access-brand" +import type { BrandConfig } from "./brand-config" + +const BrandConfigSchema = z.object({ + schoolName: z.string().min(1).max(255), + logoUrl: z.string().url().or(z.literal("")).default(""), + testimonialQuote: z.string().min(1).max(500), + testimonialAuthor: z.string().min(1).max(100), +}) + +/** + * 获取品牌配置 Server Action(audit-P2-6 新增) + */ +export async function getBrandConfigAction(): Promise> { + try { + await requirePermission(Permissions.SCHOOL_MANAGE) + const config = await getBrandConfig() + return { success: true, data: config } + } catch (e) { + return { + success: false, + message: e instanceof Error ? e.message : "Failed to get brand config", + } + } +} + +/** + * 保存品牌配置 Server Action(audit-P2-6 新增) + */ +export async function saveBrandConfigAction( + prevState: ActionState, + formData: FormData, +): Promise> { + try { + await requirePermission(Permissions.SCHOOL_MANAGE) + const session = await getSession() + + const parsed = BrandConfigSchema.safeParse({ + schoolName: formData.get("schoolName"), + logoUrl: formData.get("logoUrl"), + testimonialQuote: formData.get("testimonialQuote"), + testimonialAuthor: formData.get("testimonialAuthor"), + }) + + if (!parsed.success) { + return { + success: false, + message: parsed.error.issues[0]?.message ?? "Invalid brand config", + } + } + + const config: BrandConfig = { + schoolName: parsed.data.schoolName, + logoUrl: parsed.data.logoUrl || null, + testimonialQuote: parsed.data.testimonialQuote, + testimonialAuthor: parsed.data.testimonialAuthor, + } + + await saveBrandConfig(config, session?.user?.id) + revalidatePath("/admin/settings") + revalidatePath("/(auth)/login") + revalidatePath("/(auth)/register") + + return { success: true, data: config, message: "Brand configuration saved" } + } catch (e) { + return { + success: false, + message: e instanceof Error ? e.message : "Failed to save brand config", + } + } +} diff --git a/src/modules/settings/actions-password.ts b/src/modules/settings/actions-password.ts index 20f0aa3..6d00116 100644 --- a/src/modules/settings/actions-password.ts +++ b/src/modules/settings/actions-password.ts @@ -10,6 +10,7 @@ import { Permissions } from "@/shared/types/permissions" import { validatePassword } from "@/shared/lib/password-policy" import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit" import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils" +import { checkBreachedPassword } from "@/shared/lib/breached-password" import { getPasswordSecurityByUserId, @@ -38,7 +39,7 @@ export async function changePasswordAction( const userId = ctx.userId const limitKey = rateLimitKey("pwd-change", userId) - const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE }) + const limit = await rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE }) if (!limit.success) { return { success: false, message: "Too many attempts. Please try again later." } } @@ -68,6 +69,16 @@ export async function changePasswordAction( return { success: false, message: validation.errors[0] ?? "Password does not meet requirements" } } + // audit-P2-4: Breached password 检测(HIBP k-anonymity API) + // fail-open:API 不可用时跳过检查,避免外部依赖阻断改密流程 + const breachCheck = await checkBreachedPassword(newPassword) + if (breachCheck.isBreached) { + return { + success: false, + message: "This password has appeared in a known data breach. Please choose a different password.", + } + } + // Parallelize user and passwordSecurity queries const [userRecord, existingSecurity] = await Promise.all([ getUserPasswordHash(userId), diff --git a/src/modules/settings/actions-security.ts b/src/modules/settings/actions-security.ts index 9aa1d9d..1ba615f 100644 --- a/src/modules/settings/actions-security.ts +++ b/src/modules/settings/actions-security.ts @@ -7,8 +7,9 @@ import type { ActionState } from "@/shared/types/action-state" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { db } from "@/shared/db" -import { loginLogs, sessions } from "@/shared/db/schema" +import { loginLogs } from "@/shared/db/schema" import { logLoginEvent } from "@/shared/lib/login-logger" +import { trackAuthEvent } from "@/shared/lib/track-event" import { getUserProfile } from "@/modules/users/data-access" import { @@ -239,6 +240,11 @@ export async function verifyTwoFactorAction( const status = await getTwoFactorStatus(ctx.userId) + // audit-P1-9:2FA 启用埋点(用于 2FA 启用率统计) + await trackAuthEvent("auth.2fa_enabled", { + userId: ctx.userId, + }) + return { success: true, data: { backupCodes, status }, @@ -298,6 +304,12 @@ export async function disableTwoFactorAction( revalidatePath("/settings") const status = await getTwoFactorStatus(ctx.userId) + + // audit-P1-9:2FA 禁用埋点(用于 2FA 禁用率告警,可能表明账户安全降级) + await trackAuthEvent("auth.2fa_disabled", { + userId: ctx.userId, + }) + return { success: true, data: status } } catch (error) { const message = @@ -369,27 +381,20 @@ export async function revokeAllOtherSessionsAction(): Promise< return { success: false, message: "User not found" } } - // 删除 sessions 表中该用户的所有记录 - const result = await db - .delete(sessions) - .where(eq(sessions.userId, ctx.userId)) + // audit-P1-9:JWT 策略下 sessions 表无数据,此 Action 为 no-op。 + // 真正的"远程登出其他设备"需要 JWT 黑名单或短期 token + refresh token, + // 当前架构不支持。保留此 Action 是为了: + // 1. 前端 UI 不会因 Action 缺失而报错 + // 2. 记录用户"主动登出其他设备"的意图(用于安全审计) + const revokedCount = 0 - // MySqlRawQueryResult 是 [rows, fields] 元组,rows 可能含 affectedRows - const rows = Array.isArray(result) ? result[0] : result - const revokedCount = - typeof rows === "object" && rows !== null && "affectedRows" in rows - ? Number((rows as { affectedRows: unknown }).affectedRows) - : 0 - - // 记录一条安全处置日志 await logLoginEvent({ userId: ctx.userId, userEmail: profile.email, action: "signout", status: "success", - errorMessage: revokedCount > 0 - ? `Remote logout: revoked ${revokedCount} session(s)` - : "Remote logout: no active DB sessions (JWT-based)", + errorMessage: + "Remote logout requested (JWT-based, no active DB sessions to revoke)", }) revalidatePath("/settings") diff --git a/src/modules/settings/actions-system-settings.ts b/src/modules/settings/actions-system-settings.ts index b48abde..c3927ee 100644 --- a/src/modules/settings/actions-system-settings.ts +++ b/src/modules/settings/actions-system-settings.ts @@ -10,9 +10,8 @@ import { Permissions } from "@/shared/types/permissions" import { getAllSystemSettings, upsertSystemSettings, - type SystemSettingCategory, - type SystemSettingValueType, } from "./data-access-system-settings" +import { toSettingItem } from "./lib/system-settings-utils" // --- Schemas --- @@ -53,27 +52,6 @@ const AdminSettingsFormSchema = z.object({ type AdminSettingsFormValues = z.infer -// --- Helpers --- - -function toSettingItem( - category: SystemSettingCategory, - key: string, - value: unknown, - valueType: SystemSettingValueType -): { category: SystemSettingCategory; key: string; value: string; valueType: SystemSettingValueType } { - let strValue: string - if (valueType === "json") { - strValue = JSON.stringify(value) - } else if (valueType === "boolean") { - strValue = value ? "true" : "false" - } else if (valueType === "number") { - strValue = String(value) - } else { - strValue = String(value ?? "") - } - return { category, key, value: strValue, valueType } -} - // --- Actions --- /** diff --git a/src/modules/settings/actions.ts b/src/modules/settings/actions.ts index 6fe1be9..fcf3e1e 100644 --- a/src/modules/settings/actions.ts +++ b/src/modules/settings/actions.ts @@ -26,7 +26,7 @@ import type { AiProviderSummary, AiProviderVisibility } from "./types" export type { AiProviderSummary } from "./types" -const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"]) +const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom", "ollama"]) const VisibilitySchema = z.enum(["public", "private"]) const AiProviderFormSchema = z.object({ @@ -42,6 +42,8 @@ const AiProviderFormSchema = z.object({ const AiProviderTestSchema = AiProviderFormSchema.extend({ apiKey: z.string().optional(), }).superRefine((data, ctx) => { + // Ollama 本地部署无需 API Key + if (data.provider === "ollama") return if (!data.apiKey?.trim() && !data.id?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -51,6 +53,15 @@ const AiProviderTestSchema = AiProviderFormSchema.extend({ } }) +/** Ollama 默认 baseUrl(OpenAI 兼容端点) */ +const OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1" + +/** Ollama 本地部署无需 API Key,使用占位符满足 NOT NULL 约束 */ +const OLLAMA_PLACEHOLDER_API_KEY = "ollama" + +/** 判断是否为不需要 API Key 的本地 Provider */ +const isLocalProvider = (provider: string): boolean => provider === "ollama" + /** * 校验当前用户身份,返回 { id, isAdmin } * @@ -71,6 +82,22 @@ const normalizeBaseUrl = (value: string | undefined): string | null => { .replace(/\/chat\/completions$/i, "") } +/** + * 解析 Provider 的 baseUrl,应用默认值 + * + * - Ollama:未提供时使用默认本地地址 http://localhost:11434/v1 + * - 其他 Provider:未提供时返回 null(由调用方校验) + */ +const resolveBaseUrl = ( + provider: string, + raw: string | undefined +): string | null => { + const normalized = normalizeBaseUrl(raw) + if (normalized) return normalized + if (isLocalProvider(provider)) return OLLAMA_DEFAULT_BASE_URL + return null +} + /** * 获取当前用户可见的 AI Provider 列表 * @@ -101,8 +128,8 @@ export async function upsertAiProviderAction( } const payload = parsed.data - const baseUrl = normalizeBaseUrl(payload.baseUrl) - if (payload.provider !== "openai" && !baseUrl) { + const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl) + if (!isLocalProvider(payload.provider) && !baseUrl) { return { success: false, message: "Base URL is required for this provider" } } @@ -124,9 +151,15 @@ export async function upsertAiProviderAction( const id = payload.id if (!existing) return { success: false, message: "AI provider not found" } + // Ollama 无需 API Key:未提供时使用占位符(仅新建时);更新时保留原值 const nextKey = payload.apiKey?.trim() - const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted - const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4 + const effectiveKey = nextKey + ? nextKey + : isLocalProvider(payload.provider) && !existing.apiKeyEncrypted + ? OLLAMA_PLACEHOLDER_API_KEY + : null + const encrypted = effectiveKey ? encryptAiApiKey(effectiveKey) : existing.apiKeyEncrypted + const last4 = effectiveKey ? effectiveKey.slice(-4) : existing.apiKeyLast4 const isNextDefault = payload.isDefault === false && existing.isDefault && defaultCount <= 1 @@ -153,13 +186,16 @@ export async function upsertAiProviderAction( return { success: true, message: "AI provider updated", data: id } } - if (!payload.apiKey) { + // 新建 Provider:Ollama 允许无 API Key(使用占位符) + const rawApiKey = payload.apiKey?.trim() + if (!rawApiKey && !isLocalProvider(payload.provider)) { return { success: false, message: "API key is required" } } + const effectiveApiKey = rawApiKey ?? OLLAMA_PLACEHOLDER_API_KEY const id = createId() - const encrypted = encryptAiApiKey(payload.apiKey.trim()) - const last4 = payload.apiKey.trim().slice(-4) + const encrypted = encryptAiApiKey(effectiveApiKey) + const last4 = effectiveApiKey.slice(-4) const shouldMakeDefault = payload.isDefault ?? !hasDefault await createAiProvider( @@ -198,14 +234,16 @@ export async function testAiProviderAction( return { success: false, message: "Invalid form data" } } const payload = parsed.data - const baseUrl = normalizeBaseUrl(payload.baseUrl) - if (payload.provider !== "openai" && !baseUrl) { + const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl) + if (!isLocalProvider(payload.provider) && !baseUrl) { return { success: false, message: "Base URL is required for this provider" } } const model = payload.model.trim() const apiKey = payload.apiKey?.trim() - if (apiKey) { - await testAiProviderConfig({ apiKey, baseUrl: baseUrl ?? undefined, model }) + // Ollama 无 API Key 时使用占位符进行测试 + const effectiveApiKey = apiKey ?? (isLocalProvider(payload.provider) ? OLLAMA_PLACEHOLDER_API_KEY : undefined) + if (effectiveApiKey) { + await testAiProviderConfig({ apiKey: effectiveApiKey, baseUrl: baseUrl ?? undefined, model }) } else if (payload.id) { await testAiProviderById(payload.id, { baseUrl: baseUrl ?? undefined, model }) } diff --git a/src/modules/settings/brand-config.ts b/src/modules/settings/brand-config.ts new file mode 100644 index 0000000..4a919c5 --- /dev/null +++ b/src/modules/settings/brand-config.ts @@ -0,0 +1,27 @@ +/** + * 品牌配置类型与默认值(audit-P2-6 新增) + * + * 纯类型 + 常量,无 `server-only`,可被 Server / Client Component 安全导入。 + * 数据访问函数在 `data-access-brand.ts`(server-only)。 + */ + +/** 品牌配置 */ +export interface BrandConfig { + /** 学校/品牌名称(显示在 AuthLayout 左上角) */ + schoolName: string + /** Logo URL(可选,未设置时使用默认 GraduationCap 图标) */ + logoUrl: string | null + /** 标语/引用语(显示在 AuthLayout 左下角 blockquote) */ + testimonialQuote: string + /** 标语作者 */ + testimonialAuthor: string +} + +/** 默认品牌配置(数据库未配置时使用) */ +export const DEFAULT_BRAND_CONFIG: BrandConfig = { + schoolName: "Next_Edu", + logoUrl: null, + testimonialQuote: + "This platform has completely transformed how we deliver education to our students. The attention to detail and performance is unmatched.", + testimonialAuthor: "Sofia Davis", +} diff --git a/src/modules/settings/components/admin-file-upload-card.tsx b/src/modules/settings/components/admin-file-upload-card.tsx new file mode 100644 index 0000000..be1f8ae --- /dev/null +++ b/src/modules/settings/components/admin-file-upload-card.tsx @@ -0,0 +1,66 @@ +"use client" + +import { useTranslations } from "next-intl" +import { Database } from "lucide-react" +import { type ReactElement } from "react" + +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" + +export interface FileUploadValues { + maxFileSize: number + allowedTypes: string +} + +interface FileUploadCardProps { + values: FileUploadValues + onChange: (key: keyof FileUploadValues, value: number | string) => void +} + +/** + * 管理员系统设置 - 文件上传卡片 + */ +export function FileUploadCard({ values, onChange }: FileUploadCardProps): ReactElement { + const t = useTranslations("settings.admin.fileUpload") + + return ( + + +
+ +
+ {t("title")} + {t("description")} +
+
+
+ +
+
+ + onChange("maxFileSize", Number(e.target.value))} + /> +
+
+ + onChange("allowedTypes", e.target.value)} + /> +
+
+
+
+ ) +} diff --git a/src/modules/settings/components/admin-notification-config-card.tsx b/src/modules/settings/components/admin-notification-config-card.tsx new file mode 100644 index 0000000..0dd42af --- /dev/null +++ b/src/modules/settings/components/admin-notification-config-card.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useTranslations } from "next-intl" +import { Bell } from "lucide-react" +import { type ReactElement } from "react" + +import { Label } from "@/shared/components/ui/label" +import { Switch } from "@/shared/components/ui/switch" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" + +export interface NotificationConfigValues { + notifyNewUser: boolean + notifyScheduleChange: boolean + notifyAnnouncement: boolean +} + +interface NotificationConfigCardProps { + values: NotificationConfigValues + onChange: (key: keyof NotificationConfigValues, value: boolean) => void +} + +/** + * 管理员系统设置 - 通知配置卡片 + */ +export function NotificationConfigCard({ values, onChange }: NotificationConfigCardProps): ReactElement { + const t = useTranslations("settings.admin.notificationConfig") + + return ( + + +
+ +
+ {t("title")} + {t("description")} +
+
+
+ +
+
+ +

{t("notifyNewUserDesc")}

+
+ onChange("notifyNewUser", v)} + /> +
+
+
+ +

{t("notifyScheduleChangeDesc")}

+
+ onChange("notifyScheduleChange", v)} + /> +
+
+
+ +

{t("notifyAnnouncementDesc")}

+
+ onChange("notifyAnnouncement", v)} + /> +
+
+
+ ) +} diff --git a/src/modules/settings/components/admin-school-info-card.tsx b/src/modules/settings/components/admin-school-info-card.tsx new file mode 100644 index 0000000..2d9f6f8 --- /dev/null +++ b/src/modules/settings/components/admin-school-info-card.tsx @@ -0,0 +1,113 @@ +"use client" + +import { useTranslations } from "next-intl" +import { School } from "lucide-react" +import { type ReactElement } from "react" + +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Textarea } from "@/shared/components/ui/textarea" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" + +export interface SchoolInfoValues { + schoolName: string + schoolCode: string + schoolPhone: string + schoolEmail: string + schoolAddress: string + schoolDescription: string +} + +interface SchoolInfoCardProps { + values: SchoolInfoValues + onChange: (key: keyof SchoolInfoValues, value: string) => void +} + +/** + * 管理员系统设置 - 学校信息卡片 + */ +export function SchoolInfoCard({ values, onChange }: SchoolInfoCardProps): ReactElement { + const t = useTranslations("settings.admin.schoolInfo") + + return ( + + +
+ +
+ {t("title")} + {t("description")} +
+
+
+ +
+
+ + onChange("schoolName", e.target.value)} + /> +
+
+ + onChange("schoolCode", e.target.value)} + /> +
+
+
+
+ + onChange("schoolPhone", e.target.value)} + /> +
+
+ + onChange("schoolEmail", e.target.value)} + /> +
+
+
+ + onChange("schoolAddress", e.target.value)} + /> +
+
+ +