diff --git a/src/modules/textbooks/components/textbook-filters.tsx b/src/app/(dashboard)/_components/textbook-filters.tsx similarity index 97% rename from src/modules/textbooks/components/textbook-filters.tsx rename to src/app/(dashboard)/_components/textbook-filters.tsx index 2b32ab8..3099bbc 100644 --- a/src/modules/textbooks/components/textbook-filters.tsx +++ b/src/app/(dashboard)/_components/textbook-filters.tsx @@ -11,7 +11,7 @@ import { SelectValue, } from "@/shared/components/ui/select" import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" -import { SUBJECTS, GRADES } from "../constants" +import { SUBJECTS, GRADES } from "@/modules/textbooks/constants" export function TextbookFilters() { const t = useTranslations("textbooks") diff --git a/src/modules/homework/components/assignment-filters.tsx b/src/app/(dashboard)/student/learning/assignments/assignment-filters.tsx similarity index 86% rename from src/modules/homework/components/assignment-filters.tsx rename to src/app/(dashboard)/student/learning/assignments/assignment-filters.tsx index 7302e3f..a5e5329 100644 --- a/src/modules/homework/components/assignment-filters.tsx +++ b/src/app/(dashboard)/student/learning/assignments/assignment-filters.tsx @@ -1,5 +1,6 @@ "use client" +import type { JSX } from "react" import { useQueryState, parseAsString } from "nuqs" import { useTranslations } from "next-intl" @@ -12,7 +13,13 @@ import { } from "@/shared/components/ui/select" import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" -export function AssignmentFilters() { +/** + * 学生作业列表筛选栏。 + * + * 组合 shared 底座 FilterBar + FilterSearchInput + Select, + * 通过 nuqs 管理搜索词与状态筛选的 URL 状态。 + */ +export function AssignmentFilters(): JSX.Element { const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all")) const t = useTranslations("examHomework") diff --git a/src/app/(dashboard)/student/learning/assignments/page.tsx b/src/app/(dashboard)/student/learning/assignments/page.tsx index b02922b..3703858 100644 --- a/src/app/(dashboard)/student/learning/assignments/page.tsx +++ b/src/app/(dashboard)/student/learning/assignments/page.tsx @@ -10,7 +10,7 @@ import { formatDate, cn } from "@/shared/lib/utils" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student" import { getCurrentStudentUser } from "@/modules/users/data-access" -import { AssignmentFilters } from "@/modules/homework/components/assignment-filters" +import { AssignmentFilters } from "./assignment-filters" import { Inbox, UserX, TriangleAlert } from "lucide-react" import type { StudentHomeworkAssignmentListItem, diff --git a/src/app/(dashboard)/student/learning/textbooks/page.tsx b/src/app/(dashboard)/student/learning/textbooks/page.tsx index 16c40fc..f18c59b 100644 --- a/src/app/(dashboard)/student/learning/textbooks/page.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/page.tsx @@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server" import { getTextbooksWithScope } from "@/modules/textbooks/data-access" import { TextbookCard } from "@/modules/textbooks/components/textbook-card" -import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters" +import { TextbookFilters } from "../../../_components/textbook-filters" import { getCurrentStudentUser } from "@/modules/users/data-access" import { getGradeNameById } from "@/modules/school/data-access" import { EmptyState } from "@/shared/components/ui/empty-state" diff --git a/src/modules/exams/components/exam-filters.tsx b/src/app/(dashboard)/teacher/exams/all/exam-filters.tsx similarity index 97% rename from src/modules/exams/components/exam-filters.tsx rename to src/app/(dashboard)/teacher/exams/all/exam-filters.tsx index 9079f01..9aded29 100644 --- a/src/modules/exams/components/exam-filters.tsx +++ b/src/app/(dashboard)/teacher/exams/all/exam-filters.tsx @@ -1,5 +1,6 @@ "use client" +import type { JSX } from "react" import { useQueryState, parseAsString } from "nuqs" import { useTranslations } from "next-intl" @@ -12,7 +13,7 @@ import { } from "@/shared/components/ui/select" import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" -export function ExamFilters() { +export function ExamFilters(): JSX.Element { const t = useTranslations("examHomework") const [search, setSearch] = useQueryState("q", parseAsString.withOptions({ shallow: false })) const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false })) diff --git a/src/app/(dashboard)/teacher/exams/all/page.tsx b/src/app/(dashboard)/teacher/exams/all/page.tsx index caedb90..58d1830 100644 --- a/src/app/(dashboard)/teacher/exams/all/page.tsx +++ b/src/app/(dashboard)/teacher/exams/all/page.tsx @@ -7,7 +7,7 @@ import { Badge } from "@/shared/components/ui/badge" import { EmptyState } from "@/shared/components/ui/empty-state" import { Skeleton } from "@/shared/components/ui/skeleton" import { ExamDataTable } from "@/modules/exams/components/exam-data-table" -import { ExamFilters } from "@/modules/exams/components/exam-filters" +import { ExamFilters } from "./exam-filters" import { getExams } from "@/modules/exams/data-access" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" diff --git a/src/app/(dashboard)/teacher/textbooks/page.tsx b/src/app/(dashboard)/teacher/textbooks/page.tsx index 5d7f421..9dddc2b 100644 --- a/src/app/(dashboard)/teacher/textbooks/page.tsx +++ b/src/app/(dashboard)/teacher/textbooks/page.tsx @@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server" import { TextbookCard } from "@/modules/textbooks/components/textbook-card" import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog" import { getTextbooks } from "@/modules/textbooks/data-access" -import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters" +import { TextbookFilters } from "../../_components/textbook-filters" import { EmptyState } from "@/shared/components/ui/empty-state" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { requirePermission } from "@/shared/lib/auth-guard" diff --git a/src/modules/exams/components/exam-assembly-config.tsx b/src/modules/exams/components/exam-assembly-config.tsx new file mode 100644 index 0000000..cf49c9d --- /dev/null +++ b/src/modules/exams/components/exam-assembly-config.tsx @@ -0,0 +1,50 @@ +"use client" + +import type { JSX } from "react" +import { useTranslations } from "next-intl" +import { FileText } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" + +interface ExamAssemblyConfigProps { + structureLength: number + onSave: (formData: FormData) => Promise + onPublish: (formData: FormData) => Promise + onOpenRichEditor: () => void +} + +/** + * 组卷页左栏底部操作栏:结构项计数 + 富文本编辑器入口 + 保存草稿/发布表单。 + * + * `onSave` / `onPublish` 为 React 19 form action,接收 FormData 并由容器负责 + * 注入 `examId` / `questionsJson` / `structureJson` 等字段后调用 Server Action。 + */ +export function ExamAssemblyConfig(props: ExamAssemblyConfigProps): JSX.Element { + const t = useTranslations("examHomework.exam.build") + + return ( +
+
+ + {props.structureLength === 0 ? t("startHint") : t("itemsInStructure", { count: props.structureLength })} + + +
+
+ +
+
+ +
+
+ ) +} diff --git a/src/modules/exams/components/exam-assembly-question-pool.tsx b/src/modules/exams/components/exam-assembly-question-pool.tsx new file mode 100644 index 0000000..3d8f9cc --- /dev/null +++ b/src/modules/exams/components/exam-assembly-question-pool.tsx @@ -0,0 +1,71 @@ +"use client" + +import type { JSX } from "react" +import { useTranslations } from "next-intl" + +import { CardHeader, CardTitle } from "@/shared/components/ui/card" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters" +import type { Question } from "@/modules/questions/types" +import { QuestionBankList } from "./assembly/question-bank-list" + +interface ExamAssemblyQuestionPoolProps { + questions: Question[] + hasMore: boolean + isLoading: boolean + search: string + typeFilter: string + difficultyFilter: string + subject: string + onSearchChange: (value: string) => void + onTypeChange: (value: string) => void + onDifficultyChange: (value: string) => void + onAdd: (question: Question) => void + isAdded: (id: string) => boolean + onLoadMore: () => void +} + +/** + * 组卷页右栏:题库筛选 + 题目列表。 + * + * 状态由容器 `ExamAssembly` 持有并通过 props 下发,本组件只负责渲染与事件透传。 + */ +export function ExamAssemblyQuestionPool(props: ExamAssemblyQuestionPoolProps): JSX.Element { + const t = useTranslations("examHomework.exam.build") + + return ( + <> + +
+ {t("questionBank")} + + {props.questions.length}{props.hasMore ? "+" : ""} {t("loaded")} + +
+ +
+ + +
+ +
+
+ + ) +} diff --git a/src/modules/exams/components/exam-assembly-selected.tsx b/src/modules/exams/components/exam-assembly-selected.tsx new file mode 100644 index 0000000..37a934a --- /dev/null +++ b/src/modules/exams/components/exam-assembly-selected.tsx @@ -0,0 +1,124 @@ +"use client" + +import type { JSX } from "react" +import { useTranslations } from "next-intl" +import { Eye } from "lucide-react" + +import { CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Button } from "@/shared/components/ui/button" +import { ScrollArea } from "@/shared/components/ui/scroll-area" +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog" +import { StructureEditor } from "./assembly/structure-editor" +import { ExamPaperPreview } from "./assembly/exam-paper-preview" +import type { ExamNode } from "./assembly/selected-question-list" + +interface ExamAssemblySelectedProps { + title: string + subject: string + grade: string + durationMin: number + totalScore: number + assignedTotal: number + progress: number + structure: ExamNode[] + previewOpen: boolean + onPreviewOpenChange: (open: boolean) => void + onChange: (nodes: ExamNode[]) => void + onScoreChange: (id: string, score: number) => void + onGroupTitleChange: (id: string, title: string) => void + onRemove: (id: string) => void + onAddGroup: () => void + onAddSection: () => void +} + +/** + * 组卷页左栏上半部分:试卷结构头(标题/学科/时长 + 预览 + 得分进度)+ 结构编辑器。 + * + * 预览弹窗内嵌于头部,展示完整试卷渲染效果。 + */ +export function ExamAssemblySelected(props: ExamAssemblySelectedProps): JSX.Element { + const t = useTranslations("examHomework.exam.build") + + return ( + <> + +
+
+ {t("examStructure")} +
+
+ {props.subject} + + {props.grade} + + {props.durationMin} {t("minutes")} +
+
+
+ + + + + +
+ {props.title} +
+
+ +
+ +
+
+
+
+
+ +
+
+
+ props.totalScore ? "text-destructive" : "text-primary"}`}> + {props.assignedTotal} + + / {props.totalScore} +
+ {t("totalScore")} +
+
+
props.totalScore ? "bg-destructive" : "bg-primary" + }`} + style={{ height: `${Math.min(props.progress, 100)}%` }} + /> +
+
+
+
+ + + +
+ +
+
+ + ) +} diff --git a/src/modules/exams/components/exam-assembly.tsx b/src/modules/exams/components/exam-assembly.tsx index 9105a0c..5dd13cf 100644 --- a/src/modules/exams/components/exam-assembly.tsx +++ b/src/modules/exams/components/exam-assembly.tsx @@ -1,25 +1,27 @@ "use client" -import { useCallback, useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react" +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, useTransition } from "react" +import type { JSX } from "react" import { useRouter } from "next/navigation" import { useTranslations } from "next-intl" import { toast } from "sonner" -import { Eye, FileText } from "lucide-react" +import { createId } from "@paralleldrive/cuid2" -import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card" -import { Button } from "@/shared/components/ui/button" -import { ScrollArea } from "@/shared/components/ui/scroll-area" -import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog" -import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters" -import type { Question } from "@/modules/questions/types" -import type { QuestionType } from "@/modules/questions/types" +import { Card } from "@/shared/components/ui/card" +import type { Question, QuestionType } from "@/modules/questions/types" import { updateExamAction } from "@/modules/exams/actions" import { getQuestionsAction } from "@/modules/questions/actions" -import { StructureEditor } from "./assembly/structure-editor" -import { QuestionBankList } from "./assembly/question-bank-list" +import { + calcTotalScore, + cleanStructure, + collectQuestionIds, + flattenStructure, + hydrateStructure, +} from "@/modules/exams/utils/exam-structure-tree" import type { ExamNode } from "./assembly/selected-question-list" -import { ExamPaperPreview } from "./assembly/exam-paper-preview" -import { createId } from "@paralleldrive/cuid2" +import { ExamAssemblyConfig } from "./exam-assembly-config" +import { ExamAssemblyQuestionPool } from "./exam-assembly-question-pool" +import { ExamAssemblySelected } from "./exam-assembly-selected" const QUESTION_TYPES: readonly QuestionType[] = [ "single_choice", @@ -42,48 +44,29 @@ type ExamAssemblyProps = { totalScore: number durationMin: number initialSelected?: Array<{ id: string; score: number }> - initialStructure?: ExamNode[] // New prop + initialStructure?: ExamNode[] questionOptions: Question[] } -export function ExamAssembly(props: ExamAssemblyProps) { +export function ExamAssembly(props: ExamAssemblyProps): JSX.Element { const t = useTranslations("examHomework.exam.build") const router = useRouter() const [search, setSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") const [difficultyFilter, setDifficultyFilter] = useState("all") const deferredSearch = useDeferredValue(search) - - // Bank state + const [bankQuestions, setBankQuestions] = useState(props.questionOptions) const [page, setPage] = useState(1) const [hasMore, setHasMore] = useState(props.questionOptions.length >= 20) const [isBankLoading, startBankTransition] = useTransition() - // Initialize structure state const [structure, setStructure] = useState(() => { - const questionById = new Map() - for (const q of props.questionOptions) questionById.set(q.id, q) - - const hydrate = (nodes: ExamNode[]): ExamNode[] => { - return nodes.map((node) => { - if (node.type === "question") { - const q = node.questionId ? questionById.get(node.questionId) : undefined - return { ...node, question: q } - } - if (node.type === "group" || node.type === "section") { - return { ...node, children: hydrate(node.children || []) } - } - return node - }) - } - - // Use initialStructure if provided (Server generated or DB stored) if (props.initialStructure && props.initialStructure.length > 0) { - return hydrate(props.initialStructure) + const questionById = new Map() + for (const q of props.questionOptions) questionById.set(q.id, q) + return hydrateStructure(props.initialStructure, questionById) } - - // Fallback logic removed as Server Component handles initial migration return [] }) @@ -91,22 +74,21 @@ export function ExamAssembly(props: ExamAssemblyProps) { startBankTransition(async () => { const nextPage = reset ? 1 : page + 1 try { - const difficultyNum = difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter, 10) + const difficultyNum = difficultyFilter === "all" ? undefined : parseInt(difficultyFilter, 10) const result = await getQuestionsAction({ q: deferredSearch, - type: typeFilter === 'all' ? undefined : (isQuestionType(typeFilter) ? typeFilter : undefined), + type: typeFilter === "all" ? undefined : (isQuestionType(typeFilter) ? typeFilter : undefined), difficulty: difficultyNum === undefined || Number.isNaN(difficultyNum) ? undefined : difficultyNum, page: nextPage, - pageSize: 20 + pageSize: 20, }) if (result.success && result.data) { const questionsList = result.data.data - setBankQuestions(prev => { + setBankQuestions((prev) => { if (reset) return questionsList - // Deduplicate just in case - const existingIds = new Set(prev.map(q => q.id)) - const newQuestions = questionsList.filter(q => !existingIds.has(q.id)) + const existingIds = new Set(prev.map((q) => q.id)) + const newQuestions = questionsList.filter((q) => !existingIds.has(q.id)) return [...prev, ...newQuestions] }) setHasMore(questionsList.length === 20) @@ -131,162 +113,78 @@ export function ExamAssembly(props: ExamAssemblyProps) { fetchQuestions(true) }, [deferredSearch, typeFilter, difficultyFilter, fetchQuestions]) - // Recursively calculate total score - const assignedTotal = useMemo(() => { - const calc = (nodes: ExamNode[]): number => { - return nodes.reduce((sum, node) => { - if (node.type === 'question') return sum + (node.score || 0) - if (node.type === 'group' || node.type === 'section') return sum + calc(node.children || []) - return sum - }, 0) - } - return calc(structure) - }, [structure]) - + const assignedTotal = useMemo(() => calcTotalScore(structure), [structure]) const progress = props.totalScore > 0 ? Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100)) : 0 - - const addedQuestionIds = useMemo(() => { - const ids = new Set() - const walk = (nodes: ExamNode[]) => { - for (const n of nodes) { - if (n.type === "question" && n.questionId) ids.add(n.questionId) - if ((n.type === "group" || n.type === "section") && n.children) walk(n.children) - } - } - walk(structure) - return ids - }, [structure]) + const addedQuestionIds = useMemo(() => collectQuestionIds(structure), [structure]) const handleAdd = (question: Question) => { setStructure((prev) => { - const has = (nodes: ExamNode[]): boolean => { - return nodes.some((n) => { + const has = (nodes: ExamNode[]): boolean => + nodes.some((n) => { if (n.type === "question") return n.questionId === question.id if ((n.type === "group" || n.type === "section") && n.children) return has(n.children) return false }) - } if (has(prev)) return prev - return [ ...prev, - { - id: createId(), - type: "question", - questionId: question.id, - score: 10, - question, - }, + { id: createId(), type: "question", questionId: question.id, score: 10, question }, ] }) } const handleAddGroup = () => { - setStructure(prev => [ + setStructure((prev) => [ ...prev, - { - id: createId(), - type: 'group', - title: t("newGroup"), - children: [] - } + { id: createId(), type: "group", title: t("newGroup"), children: [] }, ]) } const handleAddSection = () => { - setStructure(prev => [ + setStructure((prev) => [ ...prev, - { - id: createId(), - type: 'section', - title: t("newSection"), - level: 1, - children: [] - } + { id: createId(), type: "section", title: t("newSection"), level: 1, children: [] }, ]) } const handleRemove = (id: string) => { - const removeRecursive = (nodes: ExamNode[]): ExamNode[] => { - return nodes.filter(n => n.id !== id).map(n => { - if (n.type === 'group' || n.type === 'section') { - return { ...n, children: removeRecursive(n.children || []) } - } - return n - }) - } - setStructure(prev => removeRecursive(prev)) + const removeRecursive = (nodes: ExamNode[]): ExamNode[] => + nodes.filter((n) => n.id !== id).map((n) => + n.type === "group" || n.type === "section" + ? { ...n, children: removeRecursive(n.children || []) } + : n + ) + setStructure((prev) => removeRecursive(prev)) } const handleScoreChange = (id: string, score: number) => { - const updateRecursive = (nodes: ExamNode[]): ExamNode[] => { - return nodes.map(n => { + const updateRecursive = (nodes: ExamNode[]): ExamNode[] => + nodes.map((n) => { if (n.id === id) return { ...n, score } - if (n.type === 'group' || n.type === 'section') return { ...n, children: updateRecursive(n.children || []) } + if (n.type === "group" || n.type === "section") return { ...n, children: updateRecursive(n.children || []) } return n }) - } - setStructure(prev => updateRecursive(prev)) + setStructure((prev) => updateRecursive(prev)) } const handleGroupTitleChange = (id: string, title: string) => { - const updateRecursive = (nodes: ExamNode[]): ExamNode[] => { - return nodes.map(n => { + const updateRecursive = (nodes: ExamNode[]): ExamNode[] => + nodes.map((n) => { if (n.id === id) return { ...n, title } - if (n.type === 'group' || n.type === 'section') return { ...n, children: updateRecursive(n.children || []) } + if (n.type === "group" || n.type === "section") return { ...n, children: updateRecursive(n.children || []) } return n }) - } - setStructure(prev => updateRecursive(prev)) - } - - // Helper to extract flat list for DB examQuestions table - const getFlatQuestions = () => { - const list: Array<{ id: string; score: number }> = [] - const seen = new Set() - const traverse = (nodes: ExamNode[]) => { - nodes.forEach(n => { - if (n.type === 'question' && n.questionId) { - if (!seen.has(n.questionId)) { - seen.add(n.questionId) - list.push({ id: n.questionId, score: n.score || 0 }) - } - } - if (n.type === 'group' || n.type === 'section') { - traverse(n.children || []) - } - }) - } - traverse(structure) - return list - } - - // Helper to strip runtime question objects for DB structure storage - const getCleanStructure = () => { - type CleanExamNode = Omit & { children?: CleanExamNode[] } - - const clean = (nodes: ExamNode[]): CleanExamNode[] => { - return nodes.map(n => { - const { question, ...rest } = n - void question - if (n.type === 'group' || n.type === 'section') { - return { ...rest, children: clean(n.children || []) } - } - return rest - }) - } - return clean(structure) + setStructure((prev) => updateRecursive(prev)) } const [previewOpen, setPreviewOpen] = useState(false) - const handleSave = async (formData: FormData) => { + const handleSave = async (formData: FormData): Promise => { formData.set("examId", props.examId) - formData.set("questionsJson", JSON.stringify(getFlatQuestions())) - formData.set("structureJson", JSON.stringify(getCleanStructure())) - + formData.set("questionsJson", JSON.stringify(flattenStructure(structure))) + formData.set("structureJson", JSON.stringify(cleanStructure(structure))) try { const result = await updateExamAction(null, formData) if (result.success) { @@ -300,12 +198,11 @@ export function ExamAssembly(props: ExamAssemblyProps) { } } - const handlePublish = async (formData: FormData) => { + const handlePublish = async (formData: FormData): Promise => { formData.set("examId", props.examId) - formData.set("questionsJson", JSON.stringify(getFlatQuestions())) - formData.set("structureJson", JSON.stringify(getCleanStructure())) + formData.set("questionsJson", JSON.stringify(flattenStructure(structure))) + formData.set("structureJson", JSON.stringify(cleanStructure(structure))) formData.set("status", "published") - try { const result = await updateExamAction(null, formData) if (result.success) { @@ -322,145 +219,49 @@ export function ExamAssembly(props: ExamAssemblyProps) { return (
- {/* Left: Preview (8 cols) */} - -
-
- {t("examStructure")} -
-
- {props.subject} - - {props.grade} - - {props.durationMin} {t("minutes")} -
-
-
- - - - - -
- {props.title} -
-
- -
- -
-
-
-
-
- -
-
-
- props.totalScore ? "text-destructive" : "text-primary"}`}> - {assignedTotal} - - / {props.totalScore} -
- {t("totalScore")} -
-
-
props.totalScore ? "bg-destructive" : "bg-primary" - }`} - style={{ height: `${Math.min(progress, 100)}%` }} - /> -
-
-
-
- - - -
- -
-
- -
-
- - {structure.length === 0 ? t("startHint") : t("itemsInStructure", { count: structure.length })} - - -
-
- -
-
- -
-
+ + router.push(`/teacher/exams/${props.examId}/edit-rich`)} + /> - {/* Right: Question Bank (4 cols) */} - -
- {t("questionBank")} - - {bankQuestions.length}{hasMore ? "+" : ""} {t("loaded")} - -
- -
- - -
- addedQuestionIds.has(id)} - onLoadMore={() => fetchQuestions(false)} - hasMore={hasMore} - isLoading={isBankLoading} - subject={props.subject} - /> -
-
+ addedQuestionIds.has(id)} + onLoadMore={() => fetchQuestions(false)} + />
) diff --git a/src/modules/exams/utils/exam-structure-tree.ts b/src/modules/exams/utils/exam-structure-tree.ts new file mode 100644 index 0000000..8d4e8f9 --- /dev/null +++ b/src/modules/exams/utils/exam-structure-tree.ts @@ -0,0 +1,96 @@ +import type { Question } from "@/modules/questions/types" +import type { ExamNode } from "../components/assembly/selected-question-list" + +/** + * 试卷结构树的纯函数集合(无副作用、无闭包状态)。 + * + * 从 `exam-assembly.tsx` 容器中抽离,便于独立测试与复用, + * 同时控制容器组件行数在规范范围内。 + */ + +/** 已选题目扁平化结果(用于 DB examQuestions 表写入)。 */ +export type FlatQuestion = { id: string; score: number } + +/** 去除运行时 `question` 对象后的结构节点(用于 DB structure 存储)。 */ +export type CleanExamNode = Omit & { children?: CleanExamNode[] } + +/** + * 递归计算结构树中所有题目的总分。 + */ +export function calcTotalScore(nodes: ExamNode[]): number { + return nodes.reduce((sum, node) => { + if (node.type === "question") return sum + (node.score || 0) + if (node.type === "group" || node.type === "section") return sum + calcTotalScore(node.children || []) + return sum + }, 0) +} + +/** + * 收集结构树中所有已选题目的 questionId(用于去重判断)。 + */ +export function collectQuestionIds(nodes: ExamNode[]): Set { + const ids = new Set() + const walk = (list: ExamNode[]): void => { + for (const n of list) { + if (n.type === "question" && n.questionId) ids.add(n.questionId) + if ((n.type === "group" || n.type === "section") && n.children) walk(n.children) + } + } + walk(nodes) + return ids +} + +/** + * 将结构树扁平化为 `{ id, score }` 列表(去重),用于写入 examQuestions 表。 + */ +export function flattenStructure(nodes: ExamNode[]): FlatQuestion[] { + const list: FlatQuestion[] = [] + const seen = new Set() + const traverse = (items: ExamNode[]): void => { + items.forEach((n) => { + if (n.type === "question" && n.questionId) { + if (!seen.has(n.questionId)) { + seen.add(n.questionId) + list.push({ id: n.questionId, score: n.score || 0 }) + } + } + if (n.type === "group" || n.type === "section") { + traverse(n.children || []) + } + }) + } + traverse(nodes) + return list +} + +/** + * 剥离结构树中的运行时 `question` 对象,仅保留可序列化的结构信息用于 DB 存储。 + */ +export function cleanStructure(nodes: ExamNode[]): CleanExamNode[] { + return nodes.map((n) => { + const { question, ...rest } = n + void question + if (n.type === "group" || n.type === "section") { + return { ...rest, children: cleanStructure(n.children || []) } + } + return rest + }) +} + +/** + * 根据题目池映射表,回填结构树中各 question 节点的 `question` 运行时对象。 + * + * 用于将 DB / Server 传入的"瘦"结构(无 question 对象)hydrate 为可渲染的"胖"结构。 + */ +export function hydrateStructure(nodes: ExamNode[], questionById: Map): ExamNode[] { + return nodes.map((node): ExamNode => { + if (node.type === "question") { + const q = node.questionId ? questionById.get(node.questionId) : undefined + return { ...node, question: q } + } + if (node.type === "group" || node.type === "section") { + return { ...node, children: hydrateStructure(node.children || [], questionById) } + } + return node + }) +} diff --git a/src/modules/homework/components/homework-grading-panel.tsx b/src/modules/homework/components/homework-grading-panel.tsx new file mode 100644 index 0000000..8623d10 --- /dev/null +++ b/src/modules/homework/components/homework-grading-panel.tsx @@ -0,0 +1,257 @@ +"use client" + +import type { JSX } from "react" +import { useTranslations } from "next-intl" +import { + Check, + MessageSquarePlus, + User, + X, +} from "lucide-react" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Separator } from "@/shared/components/ui/separator" +import { Textarea } from "@/shared/components/ui/textarea" + +import { QuestionRenderer } from "./question-renderer" +import { AiGradingAssist } from "@/modules/ai/components/ai-grading-assist" +import { + extractAnswerValue, + extractQuestionText, + formatStudentAnswer, + getCorrectnessState, + getOptions, + getTextCorrectAnswers, + isAutoGradable, +} from "../lib/question-content-utils" +import type { HomeworkSubmissionAnswerDetails } from "../types" + +type HomeworkGradingPanelProps = { + answer: HomeworkSubmissionAnswerDetails + index: number + studentName: string + showFeedback: boolean + onScoreChange: (id: string, val: string) => void + onMarkCorrect: (id: string) => void + onMarkIncorrect: (id: string) => void + onFeedbackChange: (id: string, val: string) => void + onSetFeedbackVisible: (id: string, visible: boolean) => void +} + +/** + * 教师批改页 —— 单题评分面板。 + * + * 渲染题目内容、学生答案(含选项正误高亮)、参考答案、 + * 评分控件(正确/错误快捷按钮 + 分数输入)、反馈输入框、AI 批改辅助。 + */ +export function HomeworkGradingPanel({ + answer: ans, + index, + studentName, + showFeedback, + onScoreChange, + onMarkCorrect, + onMarkIncorrect, + onFeedbackChange, + onSetFeedbackVisible, +}: HomeworkGradingPanelProps): JSX.Element { + const t = useTranslations("examHomework") + const correctness = getCorrectnessState({ score: ans.score, maxScore: ans.maxScore }) + const borderClass = + correctness === "correct" + ? "border-l-4 border-l-emerald-500" + : correctness === "incorrect" + ? "border-l-4 border-l-red-500" + : "border-l-4 border-l-muted" + + return ( + + + + + {t("homework.grade.scoreLabel")}: + {ans.score ?? 0} / {ans.maxScore} pts + + {isAutoGradable({ questionType: ans.questionType, questionContent: ans.questionContent }) && ( + {t("homework.grade.autoGraded")} + )} +
+ } + /> +
+ + + + + {/* Student Answer Display */} +
+ + +
+ {(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") && + Array.isArray(ans.questionContent?.options) ? ( +
+ {getOptions(ans.questionContent).map((opt) => { + const answerValue = extractAnswerValue(ans.studentAnswer) + const isSelected = Array.isArray(answerValue) + ? answerValue.filter((x): x is string => typeof x === "string").includes(opt.id) + : typeof answerValue === "string" && answerValue === opt.id + + const isCorrect = opt.isCorrect === true + + let containerClass = "border-transparent hover:bg-muted/50" + let indicatorClass = "border-muted-foreground/30 text-muted-foreground" + + if (isSelected) { + if (isCorrect) { + containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20" + indicatorClass = "border-emerald-500 bg-emerald-500 text-white" + } else { + containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20" + indicatorClass = "border-red-500 bg-red-500 text-white" + } + } else if (isCorrect) { + containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10" + indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400" + } + + return ( +
+
+ {opt.id} +
+ {opt.text} + + {isCorrect ? t("homework.grade.correct") : ""} {isSelected && !isCorrect ? t("homework.grade.incorrect") : ""} + + {isCorrect &&
+ ) + })} +
+ ) : ( +

+ {formatStudentAnswer(ans.studentAnswer)} +

+ )} +
+
+ + {/* Reference Answer (for text/non-choice questions) */} + {ans.questionType === "text" && ( +
+ +
+ {getTextCorrectAnswers(ans.questionContent).join(" / ") || t("homework.grade.noReferenceAnswer")} +
+
+ )} +
+ + +
+ {/* Grading Controls */} +
+
+ + +
+ + + +
+ + onScoreChange(ans.id, e.target.value)} + /> + / {ans.maxScore} +
+
+ + {/* Feedback Toggle */} + +
+ + {/* Feedback Textarea */} + {showFeedback && ( +
+