refactor(homework,exams,textbooks): 组件化重构第 2 批 - 中风险模块
- homework: 拆分 take-view (428→330) + grading-view (507→170),新增 4 个子组件 - exams: 拆分 exam-assembly (467→268),新增 3 个子组件 + 1 个工具模块 - textbooks: 拆分 textbook-reader (458→291) + knowledge-graph-inner (411→267),新增 4 个子组件 - textbooks: 删除同名 section-error-boundary.tsx,改用 shared 层 - 删除 3 个重复 filter 组件,迁移到底座 - tsc 零错误
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||||
import { SUBJECTS, GRADES } from "../constants"
|
import { SUBJECTS, GRADES } from "@/modules/textbooks/constants"
|
||||||
|
|
||||||
export function TextbookFilters() {
|
export function TextbookFilters() {
|
||||||
const t = useTranslations("textbooks")
|
const t = useTranslations("textbooks")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
@@ -12,7 +13,13 @@ import {
|
|||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
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 [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||||
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
|
||||||
const t = useTranslations("examHomework")
|
const t = useTranslations("examHomework")
|
||||||
@@ -10,7 +10,7 @@ import { formatDate, cn } from "@/shared/lib/utils"
|
|||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student"
|
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student"
|
||||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
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 { Inbox, UserX, TriangleAlert } from "lucide-react"
|
||||||
import type {
|
import type {
|
||||||
StudentHomeworkAssignmentListItem,
|
StudentHomeworkAssignmentListItem,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server"
|
|||||||
|
|
||||||
import { getTextbooksWithScope } from "@/modules/textbooks/data-access"
|
import { getTextbooksWithScope } from "@/modules/textbooks/data-access"
|
||||||
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
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 { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||||
import { getGradeNameById } from "@/modules/school/data-access"
|
import { getGradeNameById } from "@/modules/school/data-access"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||||
|
|
||||||
export function ExamFilters() {
|
export function ExamFilters(): JSX.Element {
|
||||||
const t = useTranslations("examHomework")
|
const t = useTranslations("examHomework")
|
||||||
const [search, setSearch] = useQueryState("q", parseAsString.withOptions({ shallow: false }))
|
const [search, setSearch] = useQueryState("q", parseAsString.withOptions({ shallow: false }))
|
||||||
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||||
@@ -7,7 +7,7 @@ import { Badge } from "@/shared/components/ui/badge"
|
|||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
import { ExamDataTable } from "@/modules/exams/components/exam-data-table"
|
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 { getExams } from "@/modules/exams/data-access"
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server"
|
|||||||
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
||||||
import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog"
|
import { TextbookFormDialog } from "@/modules/textbooks/components/textbook-form-dialog"
|
||||||
import { getTextbooks } from "@/modules/textbooks/data-access"
|
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 { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
|||||||
50
src/modules/exams/components/exam-assembly-config.tsx
Normal file
50
src/modules/exams/components/exam-assembly-config.tsx
Normal file
@@ -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<void>
|
||||||
|
onPublish: (formData: FormData) => Promise<void>
|
||||||
|
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 (
|
||||||
|
<div className="border-t p-4 bg-background flex gap-3 justify-end items-center shadow-[0_-1px_2px_rgba(0,0,0,0.03)]">
|
||||||
|
<div className="mr-auto flex items-center gap-3">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{props.structureLength === 0 ? t("startHint") : t("itemsInStructure", { count: props.structureLength })}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={props.onOpenRichEditor}
|
||||||
|
title={t("richEditorHint")}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
{t("richEditor")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form action={props.onSave}>
|
||||||
|
<Button variant="outline" size="sm" type="submit" className="w-24">{t("saveDraft")}</Button>
|
||||||
|
</form>
|
||||||
|
<form action={props.onPublish}>
|
||||||
|
<Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">{t("publish")}</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
src/modules/exams/components/exam-assembly-question-pool.tsx
Normal file
71
src/modules/exams/components/exam-assembly-question-pool.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<CardHeader className="pb-3 space-y-3 border-b bg-muted/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-semibold">{t("questionBank")}</CardTitle>
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||||
|
{props.questions.length}{props.hasMore ? "+" : ""} {t("loaded")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<QuestionBankFilters
|
||||||
|
search={props.search}
|
||||||
|
onSearchChange={props.onSearchChange}
|
||||||
|
type={props.typeFilter}
|
||||||
|
onTypeChange={props.onTypeChange}
|
||||||
|
difficulty={props.difficultyFilter}
|
||||||
|
onDifficultyChange={props.onDifficultyChange}
|
||||||
|
layout="compact"
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 p-0 bg-muted/5">
|
||||||
|
<div className="p-3">
|
||||||
|
<QuestionBankList
|
||||||
|
questions={props.questions}
|
||||||
|
onAdd={props.onAdd}
|
||||||
|
isAdded={props.isAdded}
|
||||||
|
onLoadMore={props.onLoadMore}
|
||||||
|
hasMore={props.hasMore}
|
||||||
|
isLoading={props.isLoading}
|
||||||
|
subject={props.subject}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
src/modules/exams/components/exam-assembly-selected.tsx
Normal file
124
src/modules/exams/components/exam-assembly-selected.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<CardHeader className="bg-muted/30 pb-4 border-b">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CardTitle className="text-lg">{t("examStructure")}</CardTitle>
|
||||||
|
<div className="h-4 w-[1px] bg-border mx-1" />
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">{props.subject}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{props.grade}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{props.durationMin} {t("minutes")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Dialog open={props.previewOpen} onOpenChange={props.onPreviewOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
{t("preview")}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
||||||
|
<div className="p-4 border-b shrink-0 flex items-center justify-between">
|
||||||
|
<DialogTitle className="text-lg font-semibold tracking-tight">{props.title}</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="max-w-3xl mx-auto py-8 px-6">
|
||||||
|
<ExamPaperPreview
|
||||||
|
title={props.title}
|
||||||
|
subject={props.subject}
|
||||||
|
grade={props.grade}
|
||||||
|
durationMin={props.durationMin}
|
||||||
|
totalScore={props.totalScore}
|
||||||
|
nodes={props.structure}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={`text-lg font-bold ${props.assignedTotal > props.totalScore ? "text-destructive" : "text-primary"}`}>
|
||||||
|
{props.assignedTotal}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">/ {props.totalScore}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t("totalScore")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-2 rounded-full bg-secondary overflow-hidden flex flex-col-reverse">
|
||||||
|
<div
|
||||||
|
className={`w-full transition-all ${
|
||||||
|
props.assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
|
||||||
|
}`}
|
||||||
|
style={{ height: `${Math.min(props.progress, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 bg-muted/5">
|
||||||
|
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||||
|
<StructureEditor
|
||||||
|
items={props.structure}
|
||||||
|
onChange={props.onChange}
|
||||||
|
onScoreChange={props.onScoreChange}
|
||||||
|
onGroupTitleChange={props.onGroupTitleChange}
|
||||||
|
onRemove={props.onRemove}
|
||||||
|
onAddGroup={props.onAddGroup}
|
||||||
|
onAddSection={props.onAddSection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
"use client"
|
"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 { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
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 { Card } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import type { Question, QuestionType } from "@/modules/questions/types"
|
||||||
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 { updateExamAction } from "@/modules/exams/actions"
|
import { updateExamAction } from "@/modules/exams/actions"
|
||||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||||
import { StructureEditor } from "./assembly/structure-editor"
|
import {
|
||||||
import { QuestionBankList } from "./assembly/question-bank-list"
|
calcTotalScore,
|
||||||
|
cleanStructure,
|
||||||
|
collectQuestionIds,
|
||||||
|
flattenStructure,
|
||||||
|
hydrateStructure,
|
||||||
|
} from "@/modules/exams/utils/exam-structure-tree"
|
||||||
import type { ExamNode } from "./assembly/selected-question-list"
|
import type { ExamNode } from "./assembly/selected-question-list"
|
||||||
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
|
import { ExamAssemblyConfig } from "./exam-assembly-config"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { ExamAssemblyQuestionPool } from "./exam-assembly-question-pool"
|
||||||
|
import { ExamAssemblySelected } from "./exam-assembly-selected"
|
||||||
|
|
||||||
const QUESTION_TYPES: readonly QuestionType[] = [
|
const QUESTION_TYPES: readonly QuestionType[] = [
|
||||||
"single_choice",
|
"single_choice",
|
||||||
@@ -42,48 +44,29 @@ type ExamAssemblyProps = {
|
|||||||
totalScore: number
|
totalScore: number
|
||||||
durationMin: number
|
durationMin: number
|
||||||
initialSelected?: Array<{ id: string; score: number }>
|
initialSelected?: Array<{ id: string; score: number }>
|
||||||
initialStructure?: ExamNode[] // New prop
|
initialStructure?: ExamNode[]
|
||||||
questionOptions: Question[]
|
questionOptions: Question[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExamAssembly(props: ExamAssemblyProps) {
|
export function ExamAssembly(props: ExamAssemblyProps): JSX.Element {
|
||||||
const t = useTranslations("examHomework.exam.build")
|
const t = useTranslations("examHomework.exam.build")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||||||
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
const [difficultyFilter, setDifficultyFilter] = useState<string>("all")
|
||||||
const deferredSearch = useDeferredValue(search)
|
const deferredSearch = useDeferredValue(search)
|
||||||
|
|
||||||
// Bank state
|
|
||||||
const [bankQuestions, setBankQuestions] = useState<Question[]>(props.questionOptions)
|
const [bankQuestions, setBankQuestions] = useState<Question[]>(props.questionOptions)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [hasMore, setHasMore] = useState(props.questionOptions.length >= 20)
|
const [hasMore, setHasMore] = useState(props.questionOptions.length >= 20)
|
||||||
const [isBankLoading, startBankTransition] = useTransition()
|
const [isBankLoading, startBankTransition] = useTransition()
|
||||||
|
|
||||||
// Initialize structure state
|
|
||||||
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
const [structure, setStructure] = useState<ExamNode[]>(() => {
|
||||||
const questionById = new Map<string, Question>()
|
|
||||||
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) {
|
if (props.initialStructure && props.initialStructure.length > 0) {
|
||||||
return hydrate(props.initialStructure)
|
const questionById = new Map<string, Question>()
|
||||||
|
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 []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -91,22 +74,21 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
startBankTransition(async () => {
|
startBankTransition(async () => {
|
||||||
const nextPage = reset ? 1 : page + 1
|
const nextPage = reset ? 1 : page + 1
|
||||||
try {
|
try {
|
||||||
const difficultyNum = difficultyFilter === 'all' ? undefined : parseInt(difficultyFilter, 10)
|
const difficultyNum = difficultyFilter === "all" ? undefined : parseInt(difficultyFilter, 10)
|
||||||
const result = await getQuestionsAction({
|
const result = await getQuestionsAction({
|
||||||
q: deferredSearch,
|
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,
|
difficulty: difficultyNum === undefined || Number.isNaN(difficultyNum) ? undefined : difficultyNum,
|
||||||
page: nextPage,
|
page: nextPage,
|
||||||
pageSize: 20
|
pageSize: 20,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const questionsList = result.data.data
|
const questionsList = result.data.data
|
||||||
setBankQuestions(prev => {
|
setBankQuestions((prev) => {
|
||||||
if (reset) return questionsList
|
if (reset) return questionsList
|
||||||
// Deduplicate just in case
|
const existingIds = new Set(prev.map((q) => q.id))
|
||||||
const existingIds = new Set(prev.map(q => q.id))
|
const newQuestions = questionsList.filter((q) => !existingIds.has(q.id))
|
||||||
const newQuestions = questionsList.filter(q => !existingIds.has(q.id))
|
|
||||||
return [...prev, ...newQuestions]
|
return [...prev, ...newQuestions]
|
||||||
})
|
})
|
||||||
setHasMore(questionsList.length === 20)
|
setHasMore(questionsList.length === 20)
|
||||||
@@ -131,162 +113,78 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
fetchQuestions(true)
|
fetchQuestions(true)
|
||||||
}, [deferredSearch, typeFilter, difficultyFilter, fetchQuestions])
|
}, [deferredSearch, typeFilter, difficultyFilter, fetchQuestions])
|
||||||
|
|
||||||
// Recursively calculate total score
|
const assignedTotal = useMemo(() => calcTotalScore(structure), [structure])
|
||||||
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 progress = props.totalScore > 0
|
const progress = props.totalScore > 0
|
||||||
? Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
|
? Math.min(100, Math.max(0, (assignedTotal / props.totalScore) * 100))
|
||||||
: 0
|
: 0
|
||||||
|
const addedQuestionIds = useMemo(() => collectQuestionIds(structure), [structure])
|
||||||
const addedQuestionIds = useMemo(() => {
|
|
||||||
const ids = new Set<string>()
|
|
||||||
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 handleAdd = (question: Question) => {
|
const handleAdd = (question: Question) => {
|
||||||
setStructure((prev) => {
|
setStructure((prev) => {
|
||||||
const has = (nodes: ExamNode[]): boolean => {
|
const has = (nodes: ExamNode[]): boolean =>
|
||||||
return nodes.some((n) => {
|
nodes.some((n) => {
|
||||||
if (n.type === "question") return n.questionId === question.id
|
if (n.type === "question") return n.questionId === question.id
|
||||||
if ((n.type === "group" || n.type === "section") && n.children) return has(n.children)
|
if ((n.type === "group" || n.type === "section") && n.children) return has(n.children)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
}
|
|
||||||
if (has(prev)) return prev
|
if (has(prev)) return prev
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{ id: createId(), type: "question", questionId: question.id, score: 10, question },
|
||||||
id: createId(),
|
|
||||||
type: "question",
|
|
||||||
questionId: question.id,
|
|
||||||
score: 10,
|
|
||||||
question,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddGroup = () => {
|
const handleAddGroup = () => {
|
||||||
setStructure(prev => [
|
setStructure((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{ id: createId(), type: "group", title: t("newGroup"), children: [] },
|
||||||
id: createId(),
|
|
||||||
type: 'group',
|
|
||||||
title: t("newGroup"),
|
|
||||||
children: []
|
|
||||||
}
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddSection = () => {
|
const handleAddSection = () => {
|
||||||
setStructure(prev => [
|
setStructure((prev) => [
|
||||||
...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 handleRemove = (id: string) => {
|
||||||
const removeRecursive = (nodes: ExamNode[]): ExamNode[] => {
|
const removeRecursive = (nodes: ExamNode[]): ExamNode[] =>
|
||||||
return nodes.filter(n => n.id !== id).map(n => {
|
nodes.filter((n) => n.id !== id).map((n) =>
|
||||||
if (n.type === 'group' || n.type === 'section') {
|
n.type === "group" || n.type === "section"
|
||||||
return { ...n, children: removeRecursive(n.children || []) }
|
? { ...n, children: removeRecursive(n.children || []) }
|
||||||
}
|
: n
|
||||||
return n
|
)
|
||||||
})
|
setStructure((prev) => removeRecursive(prev))
|
||||||
}
|
|
||||||
setStructure(prev => removeRecursive(prev))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScoreChange = (id: string, score: number) => {
|
const handleScoreChange = (id: string, score: number) => {
|
||||||
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
|
const updateRecursive = (nodes: ExamNode[]): ExamNode[] =>
|
||||||
return nodes.map(n => {
|
nodes.map((n) => {
|
||||||
if (n.id === id) return { ...n, score }
|
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
|
return n
|
||||||
})
|
})
|
||||||
}
|
setStructure((prev) => updateRecursive(prev))
|
||||||
setStructure(prev => updateRecursive(prev))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGroupTitleChange = (id: string, title: string) => {
|
const handleGroupTitleChange = (id: string, title: string) => {
|
||||||
const updateRecursive = (nodes: ExamNode[]): ExamNode[] => {
|
const updateRecursive = (nodes: ExamNode[]): ExamNode[] =>
|
||||||
return nodes.map(n => {
|
nodes.map((n) => {
|
||||||
if (n.id === id) return { ...n, title }
|
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
|
return n
|
||||||
})
|
})
|
||||||
}
|
setStructure((prev) => updateRecursive(prev))
|
||||||
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<string>()
|
|
||||||
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<ExamNode, "question"> & { 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [previewOpen, setPreviewOpen] = useState(false)
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
|
|
||||||
const handleSave = async (formData: FormData) => {
|
const handleSave = async (formData: FormData): Promise<void> => {
|
||||||
formData.set("examId", props.examId)
|
formData.set("examId", props.examId)
|
||||||
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
formData.set("questionsJson", JSON.stringify(flattenStructure(structure)))
|
||||||
formData.set("structureJson", JSON.stringify(getCleanStructure()))
|
formData.set("structureJson", JSON.stringify(cleanStructure(structure)))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await updateExamAction(null, formData)
|
const result = await updateExamAction(null, formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -300,12 +198,11 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePublish = async (formData: FormData) => {
|
const handlePublish = async (formData: FormData): Promise<void> => {
|
||||||
formData.set("examId", props.examId)
|
formData.set("examId", props.examId)
|
||||||
formData.set("questionsJson", JSON.stringify(getFlatQuestions()))
|
formData.set("questionsJson", JSON.stringify(flattenStructure(structure)))
|
||||||
formData.set("structureJson", JSON.stringify(getCleanStructure()))
|
formData.set("structureJson", JSON.stringify(cleanStructure(structure)))
|
||||||
formData.set("status", "published")
|
formData.set("status", "published")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await updateExamAction(null, formData)
|
const result = await updateExamAction(null, formData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -322,145 +219,49 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-[calc(100vh-8rem)] gap-4 lg:grid-cols-12">
|
<div className="grid h-[calc(100vh-8rem)] gap-4 lg:grid-cols-12">
|
||||||
{/* Left: Preview (8 cols) */}
|
|
||||||
<Card className="lg:col-span-8 flex flex-col overflow-hidden border-2 border-primary/10 shadow-sm">
|
<Card className="lg:col-span-8 flex flex-col overflow-hidden border-2 border-primary/10 shadow-sm">
|
||||||
<CardHeader className="bg-muted/30 pb-4 border-b">
|
<ExamAssemblySelected
|
||||||
<div className="flex items-center justify-between">
|
title={props.title}
|
||||||
<div className="flex items-center gap-3">
|
subject={props.subject}
|
||||||
<CardTitle className="text-lg">{t("examStructure")}</CardTitle>
|
grade={props.grade}
|
||||||
<div className="h-4 w-[1px] bg-border mx-1" />
|
durationMin={props.durationMin}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
totalScore={props.totalScore}
|
||||||
<span className="font-medium text-foreground">{props.subject}</span>
|
assignedTotal={assignedTotal}
|
||||||
<span>•</span>
|
progress={progress}
|
||||||
<span>{props.grade}</span>
|
structure={structure}
|
||||||
<span>•</span>
|
previewOpen={previewOpen}
|
||||||
<span>{props.durationMin} {t("minutes")}</span>
|
onPreviewOpenChange={setPreviewOpen}
|
||||||
</div>
|
onChange={setStructure}
|
||||||
</div>
|
onScoreChange={handleScoreChange}
|
||||||
<div className="flex items-center gap-6">
|
onGroupTitleChange={handleGroupTitleChange}
|
||||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
onRemove={handleRemove}
|
||||||
<DialogTrigger asChild>
|
onAddGroup={handleAddGroup}
|
||||||
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground hover:text-foreground">
|
onAddSection={handleAddSection}
|
||||||
<Eye className="h-4 w-4" />
|
/>
|
||||||
{t("preview")}
|
<ExamAssemblyConfig
|
||||||
</Button>
|
structureLength={structure.length}
|
||||||
</DialogTrigger>
|
onSave={handleSave}
|
||||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 gap-0">
|
onPublish={handlePublish}
|
||||||
<div className="p-4 border-b shrink-0 flex items-center justify-between">
|
onOpenRichEditor={() => router.push(`/teacher/exams/${props.examId}/edit-rich`)}
|
||||||
<DialogTitle className="text-lg font-semibold tracking-tight">{props.title}</DialogTitle>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 relative">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="max-w-3xl mx-auto py-8 px-6">
|
|
||||||
<ExamPaperPreview
|
|
||||||
title={props.title}
|
|
||||||
subject={props.subject}
|
|
||||||
grade={props.grade}
|
|
||||||
durationMin={props.durationMin}
|
|
||||||
totalScore={props.totalScore}
|
|
||||||
nodes={structure}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-sm">
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span className={`text-lg font-bold ${assignedTotal > props.totalScore ? "text-destructive" : "text-primary"}`}>
|
|
||||||
{assignedTotal}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">/ {props.totalScore}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{t("totalScore")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-10 w-2 rounded-full bg-secondary overflow-hidden flex flex-col-reverse">
|
|
||||||
<div
|
|
||||||
className={`w-full transition-all ${
|
|
||||||
assignedTotal > props.totalScore ? "bg-destructive" : "bg-primary"
|
|
||||||
}`}
|
|
||||||
style={{ height: `${Math.min(progress, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 bg-muted/5">
|
|
||||||
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
|
||||||
<StructureEditor
|
|
||||||
items={structure}
|
|
||||||
onChange={setStructure}
|
|
||||||
onScoreChange={handleScoreChange}
|
|
||||||
onGroupTitleChange={handleGroupTitleChange}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onAddGroup={handleAddGroup}
|
|
||||||
onAddSection={handleAddSection}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="border-t p-4 bg-background flex gap-3 justify-end items-center shadow-[0_-1px_2px_rgba(0,0,0,0.03)]">
|
|
||||||
<div className="mr-auto flex items-center gap-3">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{structure.length === 0 ? t("startHint") : t("itemsInStructure", { count: structure.length })}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => router.push(`/teacher/exams/${props.examId}/edit-rich`)}
|
|
||||||
title={t("richEditorHint")}
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
{t("richEditor")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<form action={handleSave}>
|
|
||||||
<Button variant="outline" size="sm" type="submit" className="w-24">{t("saveDraft")}</Button>
|
|
||||||
</form>
|
|
||||||
<form action={handlePublish}>
|
|
||||||
<Button size="sm" type="submit" className="w-24 bg-green-600 hover:bg-green-700 text-white">{t("publish")}</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Right: Question Bank (4 cols) */}
|
|
||||||
<Card className="lg:col-span-4 flex flex-col overflow-hidden shadow-sm h-full">
|
<Card className="lg:col-span-4 flex flex-col overflow-hidden shadow-sm h-full">
|
||||||
<CardHeader className="pb-3 space-y-3 border-b bg-muted/10">
|
<ExamAssemblyQuestionPool
|
||||||
<div className="flex items-center justify-between">
|
questions={bankQuestions}
|
||||||
<CardTitle className="text-base font-semibold">{t("questionBank")}</CardTitle>
|
hasMore={hasMore}
|
||||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
isLoading={isBankLoading}
|
||||||
{bankQuestions.length}{hasMore ? "+" : ""} {t("loaded")}
|
search={search}
|
||||||
</span>
|
typeFilter={typeFilter}
|
||||||
</div>
|
difficultyFilter={difficultyFilter}
|
||||||
<QuestionBankFilters
|
subject={props.subject}
|
||||||
search={search}
|
onSearchChange={setSearch}
|
||||||
onSearchChange={setSearch}
|
onTypeChange={setTypeFilter}
|
||||||
type={typeFilter}
|
onDifficultyChange={setDifficultyFilter}
|
||||||
onTypeChange={setTypeFilter}
|
onAdd={handleAdd}
|
||||||
difficulty={difficultyFilter}
|
isAdded={(id) => addedQuestionIds.has(id)}
|
||||||
onDifficultyChange={setDifficultyFilter}
|
onLoadMore={() => fetchQuestions(false)}
|
||||||
layout="compact"
|
/>
|
||||||
/>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-0 bg-muted/5">
|
|
||||||
<div className="p-3">
|
|
||||||
<QuestionBankList
|
|
||||||
questions={bankQuestions}
|
|
||||||
onAdd={handleAdd}
|
|
||||||
isAdded={(id) => addedQuestionIds.has(id)}
|
|
||||||
onLoadMore={() => fetchQuestions(false)}
|
|
||||||
hasMore={hasMore}
|
|
||||||
isLoading={isBankLoading}
|
|
||||||
subject={props.subject}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
96
src/modules/exams/utils/exam-structure-tree.ts
Normal file
96
src/modules/exams/utils/exam-structure-tree.ts
Normal file
@@ -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<ExamNode, "question"> & { 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<string> {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
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<string>()
|
||||||
|
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<string, Question>): 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
|
||||||
|
})
|
||||||
|
}
|
||||||
257
src/modules/homework/components/homework-grading-panel.tsx
Normal file
257
src/modules/homework/components/homework-grading-panel.tsx
Normal file
@@ -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 (
|
||||||
|
<Card id={`question-card-${ans.id}`} className={`overflow-hidden transition-all ${borderClass}`}>
|
||||||
|
<CardHeader className="bg-card pb-4">
|
||||||
|
<QuestionRenderer
|
||||||
|
questionId={ans.id}
|
||||||
|
questionType={ans.questionType}
|
||||||
|
questionContent={ans.questionContent}
|
||||||
|
maxScore={ans.maxScore}
|
||||||
|
index={index}
|
||||||
|
mode="grade"
|
||||||
|
value={extractAnswerValue(ans.studentAnswer)}
|
||||||
|
showCorrectAnswer={true}
|
||||||
|
headerExtra={
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge variant="outline" className="whitespace-nowrap">
|
||||||
|
<span className="sr-only">{t("homework.grade.scoreLabel")}: </span>
|
||||||
|
{ans.score ?? 0} / {ans.maxScore} pts
|
||||||
|
</Badge>
|
||||||
|
{isAutoGradable({ questionType: ans.questionType, questionContent: ans.questionContent }) && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<CardContent className="bg-card/50 p-6 space-y-6">
|
||||||
|
{/* Student Answer Display */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<User className="h-3 w-3" /> {t("homework.grade.studentAnswer")}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-background p-4 shadow-sm">
|
||||||
|
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
|
||||||
|
Array.isArray(ans.questionContent?.options) ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={opt.id}
|
||||||
|
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
|
||||||
|
>
|
||||||
|
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
|
||||||
|
{opt.id}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1">{opt.text}</span>
|
||||||
|
<span className="sr-only">
|
||||||
|
{isCorrect ? t("homework.grade.correct") : ""} {isSelected && !isCorrect ? t("homework.grade.incorrect") : ""}
|
||||||
|
</span>
|
||||||
|
{isCorrect && <Check className="h-4 w-4 text-emerald-600" aria-hidden="true" />}
|
||||||
|
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" aria-hidden="true" />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
{formatStudentAnswer(ans.studentAnswer)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference Answer (for text/non-choice questions) */}
|
||||||
|
{ans.questionType === "text" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<Check className="h-3 w-3" /> {t("homework.grade.referenceAnswer")}
|
||||||
|
</Label>
|
||||||
|
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
|
||||||
|
{getTextCorrectAnswers(ans.questionContent).join(" / ") || t("homework.grade.noReferenceAnswer")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between w-full gap-4">
|
||||||
|
{/* Grading Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={correctness === "correct" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={correctness === "correct"}
|
||||||
|
className={correctness === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
|
||||||
|
onClick={() => onMarkCorrect(ans.id)}
|
||||||
|
>
|
||||||
|
<Check className="mr-1 h-4 w-4" /> {t("homework.grade.correctButton")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={correctness === "incorrect" ? "destructive" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={correctness === "incorrect"}
|
||||||
|
className={correctness === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
|
||||||
|
onClick={() => onMarkIncorrect(ans.id)}
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-4 w-4" /> {t("homework.grade.incorrectButton")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">{t("homework.grade.scoreLabel")}:</Label>
|
||||||
|
<Input
|
||||||
|
id={`score-${ans.id}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={ans.maxScore}
|
||||||
|
className="w-20 h-8"
|
||||||
|
value={ans.score ?? ""}
|
||||||
|
onChange={(e) => onScoreChange(ans.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={showFeedback}
|
||||||
|
className={showFeedback ? "bg-primary/10 text-primary" : "text-muted-foreground"}
|
||||||
|
onClick={() => onSetFeedbackVisible(ans.id, !showFeedback)}
|
||||||
|
>
|
||||||
|
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||||
|
{showFeedback ? t("homework.grade.hideFeedback") : t("homework.grade.addFeedback")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Textarea */}
|
||||||
|
{showFeedback && (
|
||||||
|
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<Textarea
|
||||||
|
placeholder={t("homework.grade.feedbackPlaceholder", { name: studentName })}
|
||||||
|
value={ans.feedback ?? ""}
|
||||||
|
onChange={(e) => onFeedbackChange(ans.id, e.target.value)}
|
||||||
|
className="min-h-20 bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Grading Assist (subjective questions only) */}
|
||||||
|
{!isAutoGradable({ questionType: ans.questionType, questionContent: ans.questionContent }) && (
|
||||||
|
<AiGradingAssist
|
||||||
|
questionText={extractQuestionText(ans.questionContent)}
|
||||||
|
questionType={ans.questionType}
|
||||||
|
studentAnswer={formatStudentAnswer(ans.studentAnswer)}
|
||||||
|
correctAnswer={getTextCorrectAnswers(ans.questionContent).join(" / ")}
|
||||||
|
maxScore={ans.maxScore}
|
||||||
|
onApplyScore={(score) => onScoreChange(ans.id, String(score))}
|
||||||
|
onApplyFeedback={(feedback) => {
|
||||||
|
onFeedbackChange(ans.id, feedback)
|
||||||
|
onSetFeedbackVisible(ans.id, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
215
src/modules/homework/components/homework-grading-toolbar.tsx
Normal file
215
src/modules/homework/components/homework-grading-toolbar.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Save,
|
||||||
|
User,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Separator } from "@/shared/components/ui/separator"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import { getCorrectnessState } from "../lib/question-content-utils"
|
||||||
|
import type { HomeworkSubmissionAnswerDetails } from "../types"
|
||||||
|
|
||||||
|
type HomeworkGradingToolbarProps = {
|
||||||
|
assignmentTitle: string
|
||||||
|
studentName: string
|
||||||
|
submittedAt: string | null
|
||||||
|
currentTotal: number
|
||||||
|
maxTotal: number
|
||||||
|
progressPercent: number
|
||||||
|
correctCount: number
|
||||||
|
incorrectCount: number
|
||||||
|
partialCount: number
|
||||||
|
answers: HomeworkSubmissionAnswerDetails[]
|
||||||
|
isSubmitting: boolean
|
||||||
|
prevSubmissionId?: string | null
|
||||||
|
nextSubmissionId?: string | null
|
||||||
|
onSubmit: () => void
|
||||||
|
onScrollToQuestion: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师批改页 —— 右侧工具栏。
|
||||||
|
*
|
||||||
|
* 展示:作业标题、学生姓名、提交时间、总分进度条、
|
||||||
|
* 正确/错误/部分统计、题目状态网格(点击跳转)、保存按钮、上/下学生切换。
|
||||||
|
*/
|
||||||
|
export function HomeworkGradingToolbar({
|
||||||
|
assignmentTitle,
|
||||||
|
studentName,
|
||||||
|
submittedAt,
|
||||||
|
currentTotal,
|
||||||
|
maxTotal,
|
||||||
|
progressPercent,
|
||||||
|
correctCount,
|
||||||
|
incorrectCount,
|
||||||
|
partialCount,
|
||||||
|
answers,
|
||||||
|
isSubmitting,
|
||||||
|
prevSubmissionId,
|
||||||
|
nextSubmissionId,
|
||||||
|
onSubmit,
|
||||||
|
onScrollToQuestion,
|
||||||
|
}: HomeworkGradingToolbarProps): JSX.Element {
|
||||||
|
const router = useRouter()
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-3 h-full flex flex-col gap-6">
|
||||||
|
<Card className="flex flex-col shadow-md border-t-4 border-t-primary">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-lg">{t("homework.grade.gradingSummary")}</CardTitle>
|
||||||
|
<CardDescription>{assignmentTitle}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">{t("homework.grade.totalScore")}</span>
|
||||||
|
<span className="font-bold">{currentTotal} / {maxTotal}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
||||||
|
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<User className="h-4 w-4" /> {t("homework.grade.student")}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{studentName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" /> {t("homework.grade.submitted")}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{submittedAt ? formatDate(submittedAt) : "N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{answers.length > 0 && (
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-md border bg-emerald-50/50 p-2 dark:bg-emerald-950/20">
|
||||||
|
<span className="text-2xl font-bold text-emerald-600">{correctCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t("homework.grade.correct")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-md border bg-red-50/50 p-2 dark:bg-red-950/20">
|
||||||
|
<span className="text-2xl font-bold text-red-600">{incorrectCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t("homework.grade.incorrect")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-md border bg-amber-50/50 p-2 dark:bg-amber-950/20">
|
||||||
|
<span className="text-2xl font-bold text-amber-600">{partialCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t("homework.grade.partial")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
|
||||||
|
{t("homework.grade.questionStatus")}
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{answers.map((ans, i) => {
|
||||||
|
const state = getCorrectnessState({ score: ans.score, maxScore: ans.maxScore })
|
||||||
|
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
|
||||||
|
|
||||||
|
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
|
||||||
|
else if (state === "incorrect") badgeClass = "border-red-200 bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:border-red-800 dark:text-red-400"
|
||||||
|
else if (state === "partial") badgeClass = "border-amber-200 bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-400"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ans.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onScrollToQuestion(ans.id)}
|
||||||
|
className={`flex h-8 items-center justify-center rounded border text-xs font-medium transition-colors cursor-pointer hover:ring-2 hover:ring-ring hover:ring-offset-2 ${badgeClass}`}
|
||||||
|
title={`Q${i + 1}: ${state}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>{t("homework.grade.saving")}</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" /> {t("homework.grade.submitGrades")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!prevSubmissionId}
|
||||||
|
onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" /> {t("homework.grade.prev")}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("homework.grade.previousStudent")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!nextSubmissionId}
|
||||||
|
onClick={() => nextSubmissionId && router.push(`/teacher/homework/submissions/${nextSubmissionId}`)}
|
||||||
|
>
|
||||||
|
{t("homework.grade.next")} <ChevronRight className="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("homework.grade.nextStudent")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-950/30 dark:text-blue-300 border border-blue-200 dark:border-blue-900">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
{t("homework.grade.gradesAutoSaveNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,44 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
MessageSquarePlus,
|
|
||||||
X,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Save,
|
|
||||||
User,
|
|
||||||
AlertCircle,
|
|
||||||
Clock
|
|
||||||
} from "lucide-react"
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/shared/components/ui/card"
|
|
||||||
import { Input } from "@/shared/components/ui/input"
|
|
||||||
import { Label } from "@/shared/components/ui/label"
|
|
||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Separator } from "@/shared/components/ui/separator"
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
|
||||||
import { gradeHomeworkSubmissionAction } from "../actions"
|
import { gradeHomeworkSubmissionAction } from "../actions"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
|
||||||
import { QuestionRenderer } from "./question-renderer"
|
|
||||||
import { AiGradingAssist } from "@/modules/ai/components/ai-grading-assist"
|
|
||||||
import {
|
import {
|
||||||
applyAutoGrades,
|
applyAutoGrades,
|
||||||
extractAnswerValue,
|
|
||||||
extractQuestionText,
|
|
||||||
formatStudentAnswer,
|
|
||||||
getCorrectnessState,
|
|
||||||
getOptions,
|
|
||||||
getTextCorrectAnswers,
|
|
||||||
isAutoGradable,
|
|
||||||
} from "../lib/question-content-utils"
|
} from "../lib/question-content-utils"
|
||||||
import type { HomeworkSubmissionAnswerDetails } from "../types"
|
import type { HomeworkSubmissionAnswerDetails } from "../types"
|
||||||
|
import { HomeworkGradingPanel } from "./homework-grading-panel"
|
||||||
|
import { HomeworkGradingToolbar } from "./homework-grading-toolbar"
|
||||||
|
|
||||||
type HomeworkGradingViewProps = {
|
type HomeworkGradingViewProps = {
|
||||||
submissionId: string
|
submissionId: string
|
||||||
@@ -60,12 +34,12 @@ export function HomeworkGradingView({
|
|||||||
studentName,
|
studentName,
|
||||||
assignmentTitle,
|
assignmentTitle,
|
||||||
submittedAt,
|
submittedAt,
|
||||||
}: HomeworkGradingViewProps) {
|
}: HomeworkGradingViewProps): JSX.Element {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const t = useTranslations("examHomework")
|
const t = useTranslations("examHomework")
|
||||||
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
// Initialize feedback visibility for answers that already have feedback
|
// Initialize feedback visibility for answers that already have feedback
|
||||||
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>(() => {
|
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>(() => {
|
||||||
const initialVisibility: Record<string, boolean> = {}
|
const initialVisibility: Record<string, boolean> = {}
|
||||||
@@ -79,14 +53,11 @@ export function HomeworkGradingView({
|
|||||||
return initialVisibility
|
return initialVisibility
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleManualScoreChange = (id: string, val: string) => {
|
const handleManualScoreChange = (id: string, val: string): void => {
|
||||||
const parsed = val === "" ? 0 : Number(val)
|
const parsed = val === "" ? 0 : Number(val)
|
||||||
// Clamp score between 0 and maxScore? Or allow extra credit?
|
|
||||||
// Usually maxScore is the limit, but let's just ensure it's a number.
|
|
||||||
// Ideally we should clamp it to [0, maxScore] to avoid errors, but sometimes teachers want to give 0 for invalid input.
|
|
||||||
const targetAnswer = answers.find(a => a.id === id)
|
const targetAnswer = answers.find(a => a.id === id)
|
||||||
const max = targetAnswer?.maxScore ?? 100
|
const max = targetAnswer?.maxScore ?? 100
|
||||||
|
|
||||||
let nextScore = Number.isFinite(parsed) ? parsed : 0
|
let nextScore = Number.isFinite(parsed) ? parsed : 0
|
||||||
if (nextScore > max) nextScore = max
|
if (nextScore > max) nextScore = max
|
||||||
if (nextScore < 0) nextScore = 0
|
if (nextScore < 0) nextScore = 0
|
||||||
@@ -94,27 +65,31 @@ export function HomeworkGradingView({
|
|||||||
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
|
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMarkCorrect = (id: string) => {
|
const handleMarkCorrect = (id: string): void => {
|
||||||
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: a.maxScore } : a)))
|
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: a.maxScore } : a)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMarkIncorrect = (id: string) => {
|
const handleMarkIncorrect = (id: string): void => {
|
||||||
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: 0 } : a)))
|
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: 0 } : a)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFeedbackChange = (id: string, val: string) => {
|
const handleFeedbackChange = (id: string, val: string): void => {
|
||||||
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, feedback: val } : a)))
|
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, feedback: val } : a)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSetFeedbackVisible = (id: string, visible: boolean): void => {
|
||||||
|
setShowFeedbackByAnswerId((prev) => ({ ...prev, [id]: visible }))
|
||||||
|
}
|
||||||
|
|
||||||
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
const currentTotal = answers.reduce((sum, a) => sum + (a.score || 0), 0)
|
||||||
const maxTotal = answers.reduce((sum, a) => sum + a.maxScore, 0)
|
const maxTotal = answers.reduce((sum, a) => sum + a.maxScore, 0)
|
||||||
const progressPercent = maxTotal > 0 ? (currentTotal / maxTotal) * 100 : 0
|
const progressPercent = maxTotal > 0 ? (currentTotal / maxTotal) * 100 : 0
|
||||||
|
|
||||||
const correctCount = answers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
|
const correctCount = answers.reduce((sum, a) => sum + (a.score === a.maxScore ? 1 : 0), 0)
|
||||||
const incorrectCount = answers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
|
const incorrectCount = answers.reduce((sum, a) => sum + (a.score === 0 ? 1 : 0), 0)
|
||||||
const partialCount = answers.reduce((sum, a) => sum + (a.score !== null && a.score > 0 && a.score < a.maxScore ? 1 : 0), 0)
|
const partialCount = answers.reduce((sum, a) => sum + (a.score !== null && a.score > 0 && a.score < a.maxScore ? 1 : 0), 0)
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async (): Promise<void> => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
const payload = answers.map((a) => {
|
const payload = answers.map((a) => {
|
||||||
const feedback =
|
const feedback =
|
||||||
@@ -131,7 +106,6 @@ export function HomeworkGradingView({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(t("homework.grade.gradesSaved"))
|
toast.success(t("homework.grade.gradesSaved"))
|
||||||
// Optionally redirect or stay
|
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
|
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
|
||||||
@@ -143,7 +117,7 @@ export function HomeworkGradingView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScrollToQuestion = (id: string) => {
|
const handleScrollToQuestion = (id: string): void => {
|
||||||
const el = document.getElementById(`question-card-${id}`)
|
const el = document.getElementById(`question-card-${id}`)
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
@@ -156,353 +130,41 @@ export function HomeworkGradingView({
|
|||||||
<div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
|
<div className="lg:col-span-9 h-full overflow-hidden flex flex-col rounded-md border bg-muted/10">
|
||||||
<ScrollArea className="flex-1 p-4 lg:p-8">
|
<ScrollArea className="flex-1 p-4 lg:p-8">
|
||||||
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
||||||
{answers.map((ans, index) => {
|
{answers.map((ans, index) => (
|
||||||
const correctness = getCorrectnessState({ score: ans.score, maxScore: ans.maxScore })
|
<HomeworkGradingPanel
|
||||||
const borderClass =
|
key={ans.id}
|
||||||
correctness === "correct"
|
answer={ans}
|
||||||
? "border-l-4 border-l-emerald-500"
|
index={index}
|
||||||
: correctness === "incorrect"
|
studentName={studentName}
|
||||||
? "border-l-4 border-l-red-500"
|
showFeedback={Boolean(showFeedbackByAnswerId[ans.id])}
|
||||||
: "border-l-4 border-l-muted"
|
onScoreChange={handleManualScoreChange}
|
||||||
return (
|
onMarkCorrect={handleMarkCorrect}
|
||||||
<Card id={`question-card-${ans.id}`} key={ans.id} className={`overflow-hidden transition-all ${borderClass}`}>
|
onMarkIncorrect={handleMarkIncorrect}
|
||||||
<CardHeader className="bg-card pb-4">
|
onFeedbackChange={handleFeedbackChange}
|
||||||
<QuestionRenderer
|
onSetFeedbackVisible={handleSetFeedbackVisible}
|
||||||
questionId={ans.id}
|
/>
|
||||||
questionType={ans.questionType}
|
))}
|
||||||
questionContent={ans.questionContent}
|
|
||||||
maxScore={ans.maxScore}
|
|
||||||
index={index}
|
|
||||||
mode="grade"
|
|
||||||
value={extractAnswerValue(ans.studentAnswer)}
|
|
||||||
showCorrectAnswer={true}
|
|
||||||
headerExtra={
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<Badge variant="outline" className="whitespace-nowrap">
|
|
||||||
<span className="sr-only">{t("homework.grade.scoreLabel")}: </span>
|
|
||||||
{ans.score ?? 0} / {ans.maxScore} pts
|
|
||||||
</Badge>
|
|
||||||
{isAutoGradable({ questionType: ans.questionType, questionContent: ans.questionContent }) && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<CardContent className="bg-card/50 p-6 space-y-6">
|
|
||||||
{/* Student Answer Display */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
|
||||||
<User className="h-3 w-3" /> {t("homework.grade.studentAnswer")}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<div className="rounded-md border bg-background p-4 shadow-sm">
|
|
||||||
{(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") &&
|
|
||||||
Array.isArray(ans.questionContent?.options) ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{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 (
|
|
||||||
<div
|
|
||||||
key={opt.id}
|
|
||||||
className={`flex items-center gap-3 rounded-md border p-3 text-sm transition-colors ${containerClass}`}
|
|
||||||
>
|
|
||||||
<div className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium ${indicatorClass}`}>
|
|
||||||
{opt.id}
|
|
||||||
</div>
|
|
||||||
<span className="flex-1">{opt.text}</span>
|
|
||||||
<span className="sr-only">
|
|
||||||
{isCorrect ? t("homework.grade.correct") : ""} {isSelected && !isCorrect ? t("homework.grade.incorrect") : ""}
|
|
||||||
</span>
|
|
||||||
{isCorrect && <Check className="h-4 w-4 text-emerald-600" aria-hidden="true" />}
|
|
||||||
{isSelected && !isCorrect && <X className="h-4 w-4 text-red-600" aria-hidden="true" />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
|
||||||
{formatStudentAnswer(ans.studentAnswer)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reference Answer (for text/non-choice questions) */}
|
|
||||||
{ans.questionType === "text" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
|
|
||||||
<Check className="h-3 w-3" /> {t("homework.grade.referenceAnswer")}
|
|
||||||
</Label>
|
|
||||||
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
|
|
||||||
{getTextCorrectAnswers(ans.questionContent).join(" / ") || t("homework.grade.noReferenceAnswer")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="bg-muted/30 p-4 border-t flex flex-col gap-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between w-full gap-4">
|
|
||||||
{/* Grading Controls */}
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant={correctness === "correct" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
aria-pressed={correctness === "correct"}
|
|
||||||
className={correctness === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
|
|
||||||
onClick={() => handleMarkCorrect(ans.id)}
|
|
||||||
>
|
|
||||||
<Check className="mr-1 h-4 w-4" /> {t("homework.grade.correctButton")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={correctness === "incorrect" ? "destructive" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
aria-pressed={correctness === "incorrect"}
|
|
||||||
className={correctness === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
|
|
||||||
onClick={() => handleMarkIncorrect(ans.id)}
|
|
||||||
>
|
|
||||||
<X className="mr-1 h-4 w-4" /> {t("homework.grade.incorrectButton")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">{t("homework.grade.scoreLabel")}:</Label>
|
|
||||||
<Input
|
|
||||||
id={`score-${ans.id}`}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={ans.maxScore}
|
|
||||||
className="w-20 h-8"
|
|
||||||
value={ans.score ?? ""}
|
|
||||||
onChange={(e) => handleManualScoreChange(ans.id, e.target.value)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">/ {ans.maxScore}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feedback Toggle */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
aria-pressed={Boolean(showFeedbackByAnswerId[ans.id])}
|
|
||||||
className={showFeedbackByAnswerId[ans.id] ? "bg-primary/10 text-primary" : "text-muted-foreground"}
|
|
||||||
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
|
|
||||||
>
|
|
||||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
|
||||||
{showFeedbackByAnswerId[ans.id] ? t("homework.grade.hideFeedback") : t("homework.grade.addFeedback")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feedback Textarea */}
|
|
||||||
{showFeedbackByAnswerId[ans.id] && (
|
|
||||||
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
|
|
||||||
<Textarea
|
|
||||||
placeholder={t("homework.grade.feedbackPlaceholder", { name: studentName })}
|
|
||||||
value={ans.feedback ?? ""}
|
|
||||||
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
|
|
||||||
className="min-h-20 bg-background"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI Grading Assist (subjective questions only) */}
|
|
||||||
{!isAutoGradable({ questionType: ans.questionType, questionContent: ans.questionContent }) && (
|
|
||||||
<AiGradingAssist
|
|
||||||
questionText={extractQuestionText(ans.questionContent)}
|
|
||||||
questionType={ans.questionType}
|
|
||||||
studentAnswer={formatStudentAnswer(ans.studentAnswer)}
|
|
||||||
correctAnswer={getTextCorrectAnswers(ans.questionContent).join(" / ")}
|
|
||||||
maxScore={ans.maxScore}
|
|
||||||
onApplyScore={(score) => handleManualScoreChange(ans.id, String(score))}
|
|
||||||
onApplyFeedback={(feedback) => {
|
|
||||||
handleFeedbackChange(ans.id, feedback)
|
|
||||||
setShowFeedbackByAnswerId((prev) => ({ ...prev, [ans.id]: true }))
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar: Summary & Actions */}
|
<HomeworkGradingToolbar
|
||||||
<div className="lg:col-span-3 h-full flex flex-col gap-6">
|
assignmentTitle={assignmentTitle}
|
||||||
<Card className="flex flex-col shadow-md border-t-4 border-t-primary">
|
studentName={studentName}
|
||||||
<CardHeader className="pb-2">
|
submittedAt={submittedAt}
|
||||||
<CardTitle className="text-lg">{t("homework.grade.gradingSummary")}</CardTitle>
|
currentTotal={currentTotal}
|
||||||
<CardDescription>{assignmentTitle}</CardDescription>
|
maxTotal={maxTotal}
|
||||||
</CardHeader>
|
progressPercent={progressPercent}
|
||||||
<CardContent className="space-y-6">
|
correctCount={correctCount}
|
||||||
<div className="space-y-1">
|
incorrectCount={incorrectCount}
|
||||||
<div className="flex items-center justify-between text-sm">
|
partialCount={partialCount}
|
||||||
<span className="text-muted-foreground">{t("homework.grade.totalScore")}</span>
|
answers={answers}
|
||||||
<span className="font-bold">{currentTotal} / {maxTotal}</span>
|
isSubmitting={isSubmitting}
|
||||||
</div>
|
prevSubmissionId={prevSubmissionId}
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
nextSubmissionId={nextSubmissionId}
|
||||||
<div
|
onSubmit={handleSubmit}
|
||||||
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
onScrollToQuestion={handleScrollToQuestion}
|
||||||
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 pt-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
|
||||||
<User className="h-4 w-4" /> {t("homework.grade.student")}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{studentName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="flex items-center gap-2 text-muted-foreground">
|
|
||||||
<Clock className="h-4 w-4" /> {t("homework.grade.submitted")}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{submittedAt ? formatDate(submittedAt) : "N/A"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{answers.length > 0 && (
|
|
||||||
<div className="space-y-4 pt-2">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-md border bg-emerald-50/50 p-2 dark:bg-emerald-950/20">
|
|
||||||
<span className="text-2xl font-bold text-emerald-600">{correctCount}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{t("homework.grade.correct")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-md border bg-red-50/50 p-2 dark:bg-red-950/20">
|
|
||||||
<span className="text-2xl font-bold text-red-600">{incorrectCount}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{t("homework.grade.incorrect")}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-md border bg-amber-50/50 p-2 dark:bg-amber-950/20">
|
|
||||||
<span className="text-2xl font-bold text-amber-600">{partialCount}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{t("homework.grade.partial")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
|
|
||||||
{t("homework.grade.questionStatus")}
|
|
||||||
</Label>
|
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
{answers.map((ans, i) => {
|
|
||||||
const state = getCorrectnessState({ score: ans.score, maxScore: ans.maxScore })
|
|
||||||
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
|
|
||||||
|
|
||||||
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
|
|
||||||
else if (state === "incorrect") badgeClass = "border-red-200 bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:border-red-800 dark:text-red-400"
|
|
||||||
else if (state === "partial") badgeClass = "border-amber-200 bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-400"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={ans.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleScrollToQuestion(ans.id)}
|
|
||||||
className={`flex h-8 items-center justify-center rounded border text-xs font-medium transition-colors cursor-pointer hover:ring-2 hover:ring-ring hover:ring-offset-2 ${badgeClass}`}
|
|
||||||
title={`Q${i + 1}: ${state}`}
|
|
||||||
>
|
|
||||||
{i + 1}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex flex-col gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>{t("homework.grade.saving")}</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="mr-2 h-4 w-4" /> {t("homework.grade.submitGrades")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!prevSubmissionId}
|
|
||||||
onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" /> {t("homework.grade.prev")}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("homework.grade.previousStudent")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!nextSubmissionId}
|
|
||||||
onClick={() => nextSubmissionId && router.push(`/teacher/homework/submissions/${nextSubmissionId}`)}
|
|
||||||
>
|
|
||||||
{t("homework.grade.next")} <ChevronRight className="ml-1 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("homework.grade.nextStudent")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="rounded-md bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-950/30 dark:text-blue-300 border border-blue-200 dark:border-blue-900">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
|
||||||
<p>
|
|
||||||
{t("homework.grade.gradesAutoSaveNote")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
126
src/modules/homework/components/homework-take-question.tsx
Normal file
126
src/modules/homework/components/homework-take-question.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Camera, Save } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
|
||||||
|
import { QuestionRenderer } from "./question-renderer"
|
||||||
|
import { ScanUploader, type ScanImage } from "./scan-uploader"
|
||||||
|
import type { StudentHomeworkTakeQuestion } from "../types"
|
||||||
|
|
||||||
|
type HomeworkTakeQuestionProps = {
|
||||||
|
question: StudentHomeworkTakeQuestion
|
||||||
|
index: number
|
||||||
|
value: unknown
|
||||||
|
disabled: boolean
|
||||||
|
isBusy: boolean
|
||||||
|
isReviewMode: boolean
|
||||||
|
feedback: string | null | undefined
|
||||||
|
onChange: (answer: unknown) => void
|
||||||
|
onSaveQuestion: (questionId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生作答页 —— 单题作答卡片。
|
||||||
|
*
|
||||||
|
* 包含题目渲染(QuestionRenderer)+ 单题保存按钮(仅作答中可见)。
|
||||||
|
* review 模式下显示正确答案与批改反馈。
|
||||||
|
*/
|
||||||
|
export function HomeworkTakeQuestion({
|
||||||
|
question: q,
|
||||||
|
index,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
isBusy,
|
||||||
|
isReviewMode,
|
||||||
|
feedback,
|
||||||
|
onChange,
|
||||||
|
onSaveQuestion,
|
||||||
|
}: HomeworkTakeQuestionProps): JSX.Element {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card id={`question-${q.questionId}`} className="border-l-4 border-l-primary shadow-sm scroll-mt-4">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<QuestionRenderer
|
||||||
|
questionId={q.questionId}
|
||||||
|
questionType={q.questionType}
|
||||||
|
questionContent={q.questionContent}
|
||||||
|
maxScore={q.maxScore}
|
||||||
|
index={index}
|
||||||
|
mode={isReviewMode ? "review" : "take"}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={onChange}
|
||||||
|
showCorrectAnswer={isReviewMode}
|
||||||
|
feedback={feedback ?? null}
|
||||||
|
footerExtra={
|
||||||
|
disabled ? null : (
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSaveQuestion(q.questionId)}
|
||||||
|
disabled={isBusy}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-3 w-3" />
|
||||||
|
{t("homework.take.saveAnswer")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HomeworkTakeScanCardProps = {
|
||||||
|
scanImages: ScanImage[]
|
||||||
|
onChange: (images: ScanImage[]) => void
|
||||||
|
onDeleteScan: (fileId: string) => Promise<void>
|
||||||
|
submissionId: string
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生作答页 —— 答题扫描图上传卡片。
|
||||||
|
*
|
||||||
|
* 允许学生拍照上传答题纸扫描图,供教师阅卷式批改时参考。
|
||||||
|
*/
|
||||||
|
export function HomeworkTakeScanCard({
|
||||||
|
scanImages,
|
||||||
|
onChange,
|
||||||
|
onDeleteScan,
|
||||||
|
submissionId,
|
||||||
|
disabled,
|
||||||
|
}: HomeworkTakeScanCardProps): JSX.Element {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-l-4 border-l-blue-500 shadow-sm">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Camera className="h-4 w-4 text-blue-500" />
|
||||||
|
<h3 className="font-semibold text-sm">{t("homework.take.scanTitle")}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("homework.take.scanDescription")}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<ScanUploader
|
||||||
|
images={scanImages}
|
||||||
|
onChange={onChange}
|
||||||
|
onDeleteScan={onDeleteScan}
|
||||||
|
submissionId={submissionId}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/modules/homework/components/homework-take-toolbar.tsx
Normal file
120
src/modules/homework/components/homework-take-toolbar.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { JSX } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { CheckCircle2, ChevronLeft, FileText, Timer } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import type { ExamCountdownState } from "../hooks/use-exam-countdown"
|
||||||
|
|
||||||
|
type HomeworkTakeToolbarProps = {
|
||||||
|
submissionStatus: string
|
||||||
|
questionsCount: number
|
||||||
|
canEdit: boolean
|
||||||
|
isBusy: boolean
|
||||||
|
isTimedExam: boolean
|
||||||
|
timedExamMinutes: number | null
|
||||||
|
countdown: ExamCountdownState | null
|
||||||
|
onStart: () => void
|
||||||
|
onSubmitClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCountdown = (s: ExamCountdownState): string => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (s.hours > 0) parts.push(`${s.hours}h`)
|
||||||
|
parts.push(`${s.minutes.toString().padStart(2, "0")}m`)
|
||||||
|
parts.push(`${s.seconds.toString().padStart(2, "0")}s`)
|
||||||
|
return parts.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生作答页 —— 顶部工具栏。
|
||||||
|
*
|
||||||
|
* 左侧:返回链接 + 作业标题 + 状态徽章 + 题目数量。
|
||||||
|
* 右侧(未开始):限时考试提示 + 开始按钮。
|
||||||
|
* 右侧(作答中):倒计时(紧急/到时高亮)+ 提交按钮。
|
||||||
|
*/
|
||||||
|
export function HomeworkTakeToolbar({
|
||||||
|
submissionStatus,
|
||||||
|
questionsCount,
|
||||||
|
canEdit,
|
||||||
|
isBusy,
|
||||||
|
isTimedExam,
|
||||||
|
timedExamMinutes,
|
||||||
|
countdown,
|
||||||
|
onStart,
|
||||||
|
onSubmitClick,
|
||||||
|
}: HomeworkTakeToolbarProps): JSX.Element {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="mr-1">
|
||||||
|
<Link href="/student/learning/assignments">
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
|
{t("homework.take.back")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<FileText className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold leading-none">{t("homework.take.questions")}</h3>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
|
||||||
|
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
|
||||||
|
</Badge>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{questionsCount} {t("homework.take.questions")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canEdit ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isTimedExam && timedExamMinutes !== null && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-md border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs text-orange-700 dark:border-orange-900 dark:bg-orange-950 dark:text-orange-300">
|
||||||
|
<Timer className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{t("homework.take.timedExam", { minutes: timedExamMinutes })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button onClick={onStart} disabled={isBusy} size="sm">
|
||||||
|
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{countdown && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-semibold tabular-nums",
|
||||||
|
countdown.isExpired
|
||||||
|
? "border-destructive bg-destructive/10 text-destructive"
|
||||||
|
: countdown.isUrgent
|
||||||
|
? "border-destructive bg-destructive/5 text-destructive animate-pulse"
|
||||||
|
: "border-muted-foreground/20 bg-muted/50 text-foreground"
|
||||||
|
)}
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={t("homework.take.timeRemaining")}
|
||||||
|
>
|
||||||
|
<Timer className="h-4 w-4" />
|
||||||
|
<span>{formatCountdown(countdown)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button onClick={onSubmitClick} disabled={isBusy} size="sm">
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAssignment")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,24 +2,21 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardHeader } from "@/shared/components/ui/card"
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, Camera, Timer } from "lucide-react"
|
import { Clock } from "lucide-react"
|
||||||
import { cn } from "@/shared/lib/utils"
|
|
||||||
|
|
||||||
import type { StudentHomeworkTakeData } from "../types"
|
import type { StudentHomeworkTakeData } from "../types"
|
||||||
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions"
|
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions"
|
||||||
import { QuestionRenderer } from "./question-renderer"
|
import type { ScanImage } from "./scan-uploader"
|
||||||
import { ScanUploader, type ScanImage } from "./scan-uploader"
|
|
||||||
import { parseSavedAnswer } from "../lib/question-content-utils"
|
import { parseSavedAnswer } from "../lib/question-content-utils"
|
||||||
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
|
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
|
||||||
import { useExamCountdown } from "../hooks/use-exam-countdown"
|
import { useExamCountdown } from "../hooks/use-exam-countdown"
|
||||||
|
import { HomeworkTakeQuestion, HomeworkTakeScanCard } from "./homework-take-question"
|
||||||
|
import { HomeworkTakeToolbar } from "./homework-take-toolbar"
|
||||||
import { HomeworkTakeSidebar } from "./homework-take-sidebar"
|
import { HomeworkTakeSidebar } from "./homework-take-sidebar"
|
||||||
import { HomeworkTakeConfirmDialog } from "./homework-take-confirm-dialog"
|
import { HomeworkTakeConfirmDialog } from "./homework-take-confirm-dialog"
|
||||||
|
|
||||||
@@ -226,15 +223,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatCountdown = (s: { hours: number; minutes: number; seconds: number } | null): string => {
|
|
||||||
if (!s) return ""
|
|
||||||
const parts: string[] = []
|
|
||||||
if (s.hours > 0) parts.push(`${s.hours}h`)
|
|
||||||
parts.push(`${s.minutes.toString().padStart(2, "0")}m`)
|
|
||||||
parts.push(`${s.seconds.toString().padStart(2, "0")}s`)
|
|
||||||
return parts.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleQuestionJump = (questionId: string) => {
|
const handleQuestionJump = (questionId: string) => {
|
||||||
const el = document.getElementById(`question-${questionId}`)
|
const el = document.getElementById(`question-${questionId}`)
|
||||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
@@ -243,70 +231,17 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
return (
|
return (
|
||||||
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
||||||
<div className="border-b p-4 flex items-center justify-between bg-muted/30">
|
<HomeworkTakeToolbar
|
||||||
<div className="flex items-center gap-3">
|
submissionStatus={submissionStatus}
|
||||||
<Button asChild variant="ghost" size="sm" className="mr-1">
|
questionsCount={initialData.questions.length}
|
||||||
<Link href="/student/learning/assignments">
|
canEdit={canEdit}
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
isBusy={isBusy}
|
||||||
{t("homework.take.back")}
|
isTimedExam={isTimedExam}
|
||||||
</Link>
|
timedExamMinutes={examModeConfig?.durationMinutes ?? null}
|
||||||
</Button>
|
countdown={countdown}
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
|
onStart={handleStart}
|
||||||
<FileText className="h-4 w-4 text-primary" />
|
onSubmitClick={() => setShowSubmitConfirm(true)}
|
||||||
</div>
|
/>
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold leading-none">{t("homework.take.questions")}</h3>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
|
|
||||||
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
|
|
||||||
</Badge>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{initialData.questions.length} {t("homework.take.questions")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!canEdit ? (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{isTimedExam && examModeConfig && (
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs text-orange-700 dark:border-orange-900 dark:bg-orange-950 dark:text-orange-300">
|
|
||||||
<Timer className="h-3.5 w-3.5" />
|
|
||||||
<span className="font-medium">
|
|
||||||
{t("homework.take.timedExam", { minutes: examModeConfig.durationMinutes ?? 0 })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button onClick={handleStart} disabled={isBusy} size="sm">
|
|
||||||
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{countdown && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-semibold tabular-nums",
|
|
||||||
countdown.isExpired
|
|
||||||
? "border-destructive bg-destructive/10 text-destructive"
|
|
||||||
: countdown.isUrgent
|
|
||||||
? "border-destructive bg-destructive/5 text-destructive animate-pulse"
|
|
||||||
: "border-muted-foreground/20 bg-muted/50 text-foreground"
|
|
||||||
)}
|
|
||||||
role="timer"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-label={t("homework.take.timeRemaining")}
|
|
||||||
>
|
|
||||||
<Timer className="h-4 w-4" />
|
|
||||||
<span>{formatCountdown(countdown)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button onClick={() => setShowSubmitConfirm(true)} disabled={isBusy} size="sm">
|
|
||||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
||||||
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAssignment")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 bg-muted/10">
|
<ScrollArea className="flex-1 bg-muted/10">
|
||||||
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
||||||
@@ -327,70 +262,37 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
|
|
||||||
{showQuestions && initialData.questions.map((q, idx) => {
|
{showQuestions && initialData.questions.map((q, idx) => {
|
||||||
const value = answersByQuestionId[q.questionId]?.answer
|
const value = answersByQuestionId[q.questionId]?.answer
|
||||||
|
const isReviewMode = submissionStatus === "graded"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={q.questionId} id={`question-${q.questionId}`} className="border-l-4 border-l-primary shadow-sm scroll-mt-4">
|
<HomeworkTakeQuestion
|
||||||
<CardHeader className="pb-2">
|
key={q.questionId}
|
||||||
<QuestionRenderer
|
question={q}
|
||||||
questionId={q.questionId}
|
index={idx}
|
||||||
questionType={q.questionType}
|
value={value}
|
||||||
questionContent={q.questionContent}
|
disabled={!canEdit}
|
||||||
maxScore={q.maxScore}
|
isBusy={isBusy}
|
||||||
index={idx}
|
isReviewMode={isReviewMode}
|
||||||
mode={submissionStatus === "graded" ? "review" : "take"}
|
feedback={isReviewMode ? q.feedback : null}
|
||||||
value={value}
|
onChange={(answer) =>
|
||||||
disabled={!canEdit}
|
setAnswersByQuestionId((prev) => ({
|
||||||
onChange={(answer) =>
|
...prev,
|
||||||
setAnswersByQuestionId((prev) => ({
|
[q.questionId]: { answer },
|
||||||
...prev,
|
}))
|
||||||
[q.questionId]: { answer },
|
}
|
||||||
}))
|
onSaveQuestion={handleSaveQuestion}
|
||||||
}
|
/>
|
||||||
showCorrectAnswer={submissionStatus === "graded"}
|
|
||||||
feedback={submissionStatus === "graded" ? q.feedback : null}
|
|
||||||
footerExtra={
|
|
||||||
canEdit ? (
|
|
||||||
<div className="flex justify-end pt-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSaveQuestion(q.questionId)}
|
|
||||||
disabled={isBusy}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Save className="mr-2 h-3 w-3" />
|
|
||||||
{t("homework.take.saveAnswer")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{showQuestions && submissionId && (
|
{showQuestions && submissionId && (
|
||||||
<Card className="border-l-4 border-l-blue-500 shadow-sm">
|
<HomeworkTakeScanCard
|
||||||
<CardHeader className="pb-2">
|
scanImages={scanImages}
|
||||||
<div className="flex items-center gap-2">
|
onChange={setScanImages}
|
||||||
<Camera className="h-4 w-4 text-blue-500" />
|
onDeleteScan={handleDeleteScan}
|
||||||
<h3 className="font-semibold text-sm">{t("homework.take.scanTitle")}</h3>
|
submissionId={submissionId}
|
||||||
</div>
|
disabled={!canEdit}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
/>
|
||||||
{t("homework.take.scanDescription")}
|
|
||||||
</p>
|
|
||||||
<div className="mt-3">
|
|
||||||
<ScanUploader
|
|
||||||
images={scanImages}
|
|
||||||
onChange={setScanImages}
|
|
||||||
onDeleteScan={handleDeleteScan}
|
|
||||||
submissionId={submissionId}
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
123
src/modules/textbooks/components/knowledge-graph-controls.tsx
Normal file
123
src/modules/textbooks/components/knowledge-graph-controls.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识图谱控制面子组件:添加前置依赖对话框。
|
||||||
|
*
|
||||||
|
* 从 knowledge-graph-inner.tsx 拆分而来,封装对话框 UI、
|
||||||
|
* 可选前置知识点计算、以及 createPrerequisiteAction 调用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { notify } from "@/shared/lib/notify"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import type { KpWithRelations } from "../types"
|
||||||
|
import { createPrerequisiteAction } from "../actions"
|
||||||
|
|
||||||
|
interface AddPrerequisiteDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
selectedKpId: string | null
|
||||||
|
textbookId: string
|
||||||
|
knowledgePoints: KpWithRelations[]
|
||||||
|
onAdded: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddPrerequisiteDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
selectedKpId,
|
||||||
|
textbookId,
|
||||||
|
knowledgePoints,
|
||||||
|
onAdded,
|
||||||
|
}: AddPrerequisiteDialogProps): React.ReactNode {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
const [newPrereqId, setNewPrereqId] = useState<string>("")
|
||||||
|
const [isSavingPrereq, setIsSavingPrereq] = useState(false)
|
||||||
|
|
||||||
|
// 可选的前置知识点(排除自身和已是前置的)
|
||||||
|
const availablePrereqs = useMemo(() => {
|
||||||
|
if (!selectedKpId) return []
|
||||||
|
const existing = new Set(
|
||||||
|
knowledgePoints.find((kp) => kp.id === selectedKpId)?.prerequisiteIds ?? [],
|
||||||
|
)
|
||||||
|
return knowledgePoints.filter(
|
||||||
|
(kp) => kp.id !== selectedKpId && !existing.has(kp.id),
|
||||||
|
)
|
||||||
|
}, [knowledgePoints, selectedKpId])
|
||||||
|
|
||||||
|
const handleAddPrerequisite = useCallback(async () => {
|
||||||
|
if (!selectedKpId || !newPrereqId || !textbookId) return
|
||||||
|
setIsSavingPrereq(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.set("knowledgePointId", selectedKpId)
|
||||||
|
formData.set("prerequisiteKpId", newPrereqId)
|
||||||
|
formData.set("textbookId", textbookId)
|
||||||
|
const result = await createPrerequisiteAction(formData)
|
||||||
|
if (result.success) {
|
||||||
|
notify.success(t("graph.detail.prerequisiteAdded"))
|
||||||
|
onOpenChange(false)
|
||||||
|
setNewPrereqId("")
|
||||||
|
onAdded()
|
||||||
|
} else {
|
||||||
|
notify.error(result.message)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(
|
||||||
|
e instanceof Error ? e.message : t("graph.detail.prerequisiteAddFailed"),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setIsSavingPrereq(false)
|
||||||
|
}
|
||||||
|
}, [selectedKpId, newPrereqId, textbookId, t, onOpenChange, onAdded])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("graph.detail.addPrerequisiteTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("graph.detail.addPrerequisiteDesc")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Select value={newPrereqId} onValueChange={setNewPrereqId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("graph.detail.selectPrerequisite")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availablePrereqs.map((kp) => (
|
||||||
|
<SelectItem key={kp.id} value={kp.id}>
|
||||||
|
{kp.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
{t("graph.detail.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddPrerequisite}
|
||||||
|
disabled={!newPrereqId || isSavingPrereq}
|
||||||
|
>
|
||||||
|
{isSavingPrereq ? t("graph.detail.saving") : t("graph.detail.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,63 +1,35 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReactFlow 知识图谱内部实现。
|
* ReactFlow 知识图谱内部实现(容器)。
|
||||||
*
|
*
|
||||||
* 通过 next/dynamic 在外层 `knowledge-graph.tsx` 中懒加载,
|
* 通过 next/dynamic 在外层 `knowledge-graph.tsx` 中懒加载,
|
||||||
* 避免 @xyflow/react (~80-100 KB) 打入首屏 chunk。
|
* 避免 @xyflow/react (~80-100 KB) 打入首屏 chunk。
|
||||||
* 外层使用 ReactFlowProvider 包裹本组件,保证 useReactFlow() hook 可用。
|
* 外层使用 ReactFlowProvider 包裹本组件,保证 useReactFlow() hook 可用。
|
||||||
|
*
|
||||||
|
* 职责:状态管理 + 数据加载 + 布局计算 + 节点/边组装 + 事件编排。
|
||||||
|
* 渲染拆分至:
|
||||||
|
* - knowledge-graph-node.tsx — ReactFlow / 力导向图画布
|
||||||
|
* - knowledge-graph-controls.tsx — 添加前置依赖对话框
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react"
|
import { useState, useMemo, useCallback } from "react"
|
||||||
import {
|
import { useReactFlow } from "@xyflow/react"
|
||||||
ReactFlow,
|
import type { Node, Edge } from "@xyflow/react"
|
||||||
Background,
|
|
||||||
BackgroundVariant,
|
|
||||||
Controls,
|
|
||||||
MiniMap,
|
|
||||||
useReactFlow,
|
|
||||||
type Node,
|
|
||||||
type Edge,
|
|
||||||
} from "@xyflow/react"
|
|
||||||
import "@xyflow/react/dist/style.css"
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { notify } from "@/shared/lib/notify"
|
import { notify } from "@/shared/lib/notify"
|
||||||
import { Share2 } from "lucide-react"
|
import { Share2 } from "lucide-react"
|
||||||
import { usePermission } from "@/shared/hooks/use-permission"
|
import { usePermission } from "@/shared/hooks/use-permission"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/components/ui/dialog"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import type { GraphLayoutMode, GraphViewMode, GraphNodeData } from "../types"
|
import type { GraphLayoutMode, GraphViewMode, GraphNodeData } from "../types"
|
||||||
import { computeGraphLayout } from "../graph-layout"
|
import { computeGraphLayout } from "../graph-layout"
|
||||||
import type { GraphLayoutNodeData } from "../graph-layout"
|
|
||||||
import { useGraphData } from "../hooks/use-graph-data"
|
import { useGraphData } from "../hooks/use-graph-data"
|
||||||
import {
|
import { deletePrerequisiteAction } from "../actions"
|
||||||
createPrerequisiteAction,
|
|
||||||
deletePrerequisiteAction,
|
|
||||||
} from "../actions"
|
|
||||||
import { GraphKpNode } from "./graph-kp-node"
|
|
||||||
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
|
||||||
import { GraphToolbar } from "./graph-toolbar"
|
import { GraphToolbar } from "./graph-toolbar"
|
||||||
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
|
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
|
||||||
import { ForceKnowledgeGraph } from "./force-graph"
|
import { KnowledgeGraphNode } from "./knowledge-graph-node"
|
||||||
|
import { AddPrerequisiteDialog } from "./knowledge-graph-controls"
|
||||||
const nodeTypes = { kpNode: GraphKpNode }
|
|
||||||
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
|
|
||||||
|
|
||||||
/** 章节颜色调色板 */
|
/** 章节颜色调色板 */
|
||||||
const CHAPTER_COLORS = [
|
const CHAPTER_COLORS = [
|
||||||
@@ -87,10 +59,8 @@ export function KnowledgeGraphInner({
|
|||||||
const [layoutMode, setLayoutMode] = useState<GraphLayoutMode>(initialLayoutMode)
|
const [layoutMode, setLayoutMode] = useState<GraphLayoutMode>(initialLayoutMode)
|
||||||
const [searchText, setSearchText] = useState("")
|
const [searchText, setSearchText] = useState("")
|
||||||
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
||||||
// 添加前置依赖对话框状态
|
// 添加前置依赖对话框开关(对话框内部状态由子组件管理)
|
||||||
const [addPrereqOpen, setAddPrereqOpen] = useState(false)
|
const [addPrereqOpen, setAddPrereqOpen] = useState(false)
|
||||||
const [newPrereqId, setNewPrereqId] = useState<string>("")
|
|
||||||
const [isSavingPrereq, setIsSavingPrereq] = useState(false)
|
|
||||||
|
|
||||||
const { data, isLoading, isRefreshing, error, reload } = useGraphData(textbookId, viewMode)
|
const { data, isLoading, isRefreshing, error, reload } = useGraphData(textbookId, viewMode)
|
||||||
|
|
||||||
@@ -199,10 +169,6 @@ export function KnowledgeGraphInner({
|
|||||||
}))
|
}))
|
||||||
}, [layout, selectedKpId, relatedIds])
|
}, [layout, selectedKpId, relatedIds])
|
||||||
|
|
||||||
const onNodeClick = useCallback((_event: unknown, node: Node) => {
|
|
||||||
setSelectedKpId(node.id)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const resetView = useCallback(() => {
|
const resetView = useCallback(() => {
|
||||||
if (layoutMode === "hierarchical") {
|
if (layoutMode === "hierarchical") {
|
||||||
reactFlow.fitView({ duration: 300 })
|
reactFlow.fitView({ duration: 300 })
|
||||||
@@ -218,31 +184,6 @@ export function KnowledgeGraphInner({
|
|||||||
}
|
}
|
||||||
}, [reactFlow, layoutMode])
|
}, [reactFlow, layoutMode])
|
||||||
|
|
||||||
// 添加前置依赖
|
|
||||||
const handleAddPrerequisite = useCallback(async () => {
|
|
||||||
if (!selectedKpId || !newPrereqId || !textbookId) return
|
|
||||||
setIsSavingPrereq(true)
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.set("knowledgePointId", selectedKpId)
|
|
||||||
formData.set("prerequisiteKpId", newPrereqId)
|
|
||||||
formData.set("textbookId", textbookId)
|
|
||||||
const result = await createPrerequisiteAction(formData)
|
|
||||||
if (result.success) {
|
|
||||||
notify.success(t("graph.detail.prerequisiteAdded"))
|
|
||||||
setAddPrereqOpen(false)
|
|
||||||
setNewPrereqId("")
|
|
||||||
reload()
|
|
||||||
} else {
|
|
||||||
notify.error(result.message)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
notify.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteAddFailed"))
|
|
||||||
} finally {
|
|
||||||
setIsSavingPrereq(false)
|
|
||||||
}
|
|
||||||
}, [selectedKpId, newPrereqId, textbookId, t, reload])
|
|
||||||
|
|
||||||
// 删除前置依赖
|
// 删除前置依赖
|
||||||
const handleRemovePrerequisite = useCallback(async (prereqId: string) => {
|
const handleRemovePrerequisite = useCallback(async (prereqId: string) => {
|
||||||
if (!selectedKpId || !textbookId) return
|
if (!selectedKpId || !textbookId) return
|
||||||
@@ -263,15 +204,6 @@ export function KnowledgeGraphInner({
|
|||||||
}
|
}
|
||||||
}, [selectedKpId, textbookId, t, reload])
|
}, [selectedKpId, textbookId, t, reload])
|
||||||
|
|
||||||
// 可选的前置知识点(排除自身和已是前置的)
|
|
||||||
const availablePrereqs = useMemo(() => {
|
|
||||||
if (!data || !selectedKpId) return []
|
|
||||||
const existing = new Set(data.knowledgePoints.find((kp) => kp.id === selectedKpId)?.prerequisiteIds ?? [])
|
|
||||||
return data.knowledgePoints.filter((kp) =>
|
|
||||||
kp.id !== selectedKpId && !existing.has(kp.id),
|
|
||||||
)
|
|
||||||
}, [data, selectedKpId])
|
|
||||||
|
|
||||||
if (isLoading && !data) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||||
@@ -320,40 +252,16 @@ export function KnowledgeGraphInner({
|
|||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-h-0 relative">
|
<div className="flex-1 min-h-0 relative">
|
||||||
{layoutMode === "hierarchical" ? (
|
<KnowledgeGraphNode
|
||||||
<ReactFlow
|
rfNodes={rfNodes}
|
||||||
nodes={rfNodes}
|
rfEdges={rfEdges}
|
||||||
edges={rfEdges}
|
layoutMode={layoutMode}
|
||||||
nodeTypes={nodeTypes}
|
data={data}
|
||||||
edgeTypes={edgeTypes}
|
viewMode={viewMode}
|
||||||
onNodeClick={onNodeClick}
|
searchText={searchText}
|
||||||
fitView
|
selectedKpId={selectedKpId}
|
||||||
fitViewOptions={{ padding: 0.2 }}
|
onSelectKp={setSelectedKpId}
|
||||||
minZoom={0.2}
|
/>
|
||||||
maxZoom={2}
|
|
||||||
proOptions={{ hideAttribution: true }}
|
|
||||||
>
|
|
||||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
||||||
<Controls className="!bg-background !border !rounded-lg" />
|
|
||||||
<MiniMap
|
|
||||||
className="!bg-background !border !rounded-lg"
|
|
||||||
nodeColor={(node) => {
|
|
||||||
// 安全的类型收窄:node.data 是 Record<string, unknown>,
|
|
||||||
// GraphLayoutNodeData 有索引签名 [key: string]: unknown,是 Record 的子类型
|
|
||||||
const data = node.data as GraphLayoutNodeData
|
|
||||||
return data.graphData?.chapterColor ?? "hsl(var(--muted-foreground))"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ReactFlow>
|
|
||||||
) : (
|
|
||||||
<ForceKnowledgeGraph
|
|
||||||
data={data}
|
|
||||||
viewMode={viewMode}
|
|
||||||
searchText={searchText}
|
|
||||||
selectedKpId={selectedKpId}
|
|
||||||
onSelectKp={setSelectedKpId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -374,38 +282,14 @@ export function KnowledgeGraphInner({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 添加前置依赖对话框 */}
|
<AddPrerequisiteDialog
|
||||||
<Dialog open={addPrereqOpen} onOpenChange={setAddPrereqOpen}>
|
open={addPrereqOpen}
|
||||||
<DialogContent>
|
onOpenChange={setAddPrereqOpen}
|
||||||
<DialogHeader>
|
selectedKpId={selectedKpId}
|
||||||
<DialogTitle>{t("graph.detail.addPrerequisiteTitle")}</DialogTitle>
|
textbookId={textbookId}
|
||||||
<DialogDescription>{t("graph.detail.addPrerequisiteDesc")}</DialogDescription>
|
knowledgePoints={data.knowledgePoints}
|
||||||
</DialogHeader>
|
onAdded={reload}
|
||||||
<Select value={newPrereqId} onValueChange={setNewPrereqId}>
|
/>
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t("graph.detail.selectPrerequisite")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availablePrereqs.map((kp) => (
|
|
||||||
<SelectItem key={kp.id} value={kp.id}>
|
|
||||||
{kp.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setAddPrereqOpen(false)}>
|
|
||||||
{t("graph.detail.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleAddPrerequisite}
|
|
||||||
disabled={!newPrereqId || isSavingPrereq}
|
|
||||||
>
|
|
||||||
{isSavingPrereq ? t("graph.detail.saving") : t("graph.detail.confirm")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
89
src/modules/textbooks/components/knowledge-graph-node.tsx
Normal file
89
src/modules/textbooks/components/knowledge-graph-node.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识图谱画布子组件:负责 ReactFlow / 力导向图的节点与边渲染。
|
||||||
|
*
|
||||||
|
* 从 knowledge-graph-inner.tsx 拆分而来,容器负责状态与数据计算,
|
||||||
|
* 本组件仅负责图渲染与节点点击交互的转发。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
} from "@xyflow/react"
|
||||||
|
import "@xyflow/react/dist/style.css"
|
||||||
|
|
||||||
|
import type { GraphLayoutMode, GraphViewMode, KnowledgeGraphData } from "../types"
|
||||||
|
import type { GraphLayoutNodeData } from "../graph-layout"
|
||||||
|
import { GraphKpNode } from "./graph-kp-node"
|
||||||
|
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
||||||
|
import { ForceKnowledgeGraph } from "./force-graph"
|
||||||
|
|
||||||
|
const nodeTypes = { kpNode: GraphKpNode }
|
||||||
|
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
|
||||||
|
|
||||||
|
interface KnowledgeGraphNodeProps {
|
||||||
|
rfNodes: Node[]
|
||||||
|
rfEdges: Edge[]
|
||||||
|
layoutMode: GraphLayoutMode
|
||||||
|
data: KnowledgeGraphData
|
||||||
|
viewMode: GraphViewMode
|
||||||
|
searchText: string
|
||||||
|
selectedKpId: string | null
|
||||||
|
onSelectKp: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgeGraphNode({
|
||||||
|
rfNodes,
|
||||||
|
rfEdges,
|
||||||
|
layoutMode,
|
||||||
|
data,
|
||||||
|
viewMode,
|
||||||
|
searchText,
|
||||||
|
selectedKpId,
|
||||||
|
onSelectKp,
|
||||||
|
}: KnowledgeGraphNodeProps): React.ReactNode {
|
||||||
|
if (layoutMode !== "hierarchical") {
|
||||||
|
return (
|
||||||
|
<ForceKnowledgeGraph
|
||||||
|
data={data}
|
||||||
|
viewMode={viewMode}
|
||||||
|
searchText={searchText}
|
||||||
|
selectedKpId={selectedKpId}
|
||||||
|
onSelectKp={onSelectKp}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlow
|
||||||
|
nodes={rfNodes}
|
||||||
|
edges={rfEdges}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
onNodeClick={(_event: unknown, node: Node) => onSelectKp(node.id)}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
minZoom={0.2}
|
||||||
|
maxZoom={2}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||||
|
<Controls className="!bg-background !border !rounded-lg" />
|
||||||
|
<MiniMap
|
||||||
|
className="!bg-background !border !rounded-lg"
|
||||||
|
nodeColor={(node) => {
|
||||||
|
// 安全的类型收窄:node.data 是 Record<string, unknown>,
|
||||||
|
// GraphLayoutNodeData 有索引签名 [key: string]: unknown,是 Record 的子类型
|
||||||
|
const nodeData = node.data as GraphLayoutNodeData
|
||||||
|
return nodeData.graphData?.chapterColor ?? "hsl(var(--muted-foreground))"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,405 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react"
|
/**
|
||||||
import {
|
* 知识图谱外层包装:ReactFlowProvider 壳。
|
||||||
ReactFlow,
|
*
|
||||||
Background,
|
* 实际实现位于 knowledge-graph-inner.tsx,本文件仅提供 Provider 上下文,
|
||||||
BackgroundVariant,
|
* 保证 inner 组件中 useReactFlow() hook 可用。
|
||||||
Controls,
|
*/
|
||||||
MiniMap,
|
|
||||||
ReactFlowProvider,
|
|
||||||
useReactFlow,
|
|
||||||
type Node,
|
|
||||||
type Edge,
|
|
||||||
} from "@xyflow/react"
|
|
||||||
import "@xyflow/react/dist/style.css"
|
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { Share2 } from "lucide-react"
|
|
||||||
import { usePermission } from "@/shared/hooks/use-permission"
|
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/components/ui/dialog"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import type { GraphLayoutMode, GraphViewMode, GraphNodeData } from "../types"
|
|
||||||
import { computeGraphLayout } from "../graph-layout"
|
|
||||||
import type { GraphLayoutNodeData } from "../graph-layout"
|
|
||||||
import { useGraphData } from "../hooks/use-graph-data"
|
|
||||||
import {
|
|
||||||
createPrerequisiteAction,
|
|
||||||
deletePrerequisiteAction,
|
|
||||||
} from "../actions"
|
|
||||||
import { GraphKpNode } from "./graph-kp-node"
|
|
||||||
import { GraphPrerequisiteEdge } from "./graph-prerequisite-edge"
|
|
||||||
import { GraphToolbar } from "./graph-toolbar"
|
|
||||||
import { GraphNodeDetailPanel } from "./graph-node-detail-panel"
|
|
||||||
import { ForceKnowledgeGraph } from "./force-graph"
|
|
||||||
|
|
||||||
const nodeTypes = { kpNode: GraphKpNode }
|
import type { ReactNode } from "react"
|
||||||
const edgeTypes = { prerequisiteEdge: GraphPrerequisiteEdge }
|
import { ReactFlowProvider } from "@xyflow/react"
|
||||||
|
|
||||||
/** 章节颜色调色板 */
|
import { KnowledgeGraphInner, type KnowledgeGraphProps } from "./knowledge-graph-inner"
|
||||||
const CHAPTER_COLORS = [
|
|
||||||
"hsl(var(--graph-node-1))", "hsl(var(--graph-node-2))", "hsl(var(--graph-node-3))", "hsl(var(--graph-node-4))",
|
|
||||||
"hsl(var(--graph-node-5))", "hsl(var(--graph-node-6))", "hsl(var(--graph-node-1))", "hsl(var(--graph-node-2))",
|
|
||||||
]
|
|
||||||
|
|
||||||
interface KnowledgeGraphProps {
|
export function KnowledgeGraph(props: KnowledgeGraphProps): ReactNode {
|
||||||
textbookId: string
|
|
||||||
/** 初始视图模式,默认 structure */
|
|
||||||
initialViewMode?: GraphViewMode
|
|
||||||
/** 初始布局模式,默认 hierarchical(分层有向图) */
|
|
||||||
initialLayoutMode?: GraphLayoutMode
|
|
||||||
}
|
|
||||||
|
|
||||||
function KnowledgeGraphInner({ textbookId, initialViewMode = "structure", initialLayoutMode = "hierarchical" }: KnowledgeGraphProps) {
|
|
||||||
const t = useTranslations("textbooks")
|
|
||||||
const { hasPermission } = usePermission()
|
|
||||||
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
|
|
||||||
const reactFlow = useReactFlow()
|
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<GraphViewMode>(initialViewMode)
|
|
||||||
const [layoutMode, setLayoutMode] = useState<GraphLayoutMode>(initialLayoutMode)
|
|
||||||
const [searchText, setSearchText] = useState("")
|
|
||||||
const [selectedKpId, setSelectedKpId] = useState<string | null>(null)
|
|
||||||
// 添加前置依赖对话框状态
|
|
||||||
const [addPrereqOpen, setAddPrereqOpen] = useState(false)
|
|
||||||
const [newPrereqId, setNewPrereqId] = useState<string>("")
|
|
||||||
const [isSavingPrereq, setIsSavingPrereq] = useState(false)
|
|
||||||
|
|
||||||
const { data, isLoading, isRefreshing, error, reload } = useGraphData(textbookId, viewMode)
|
|
||||||
|
|
||||||
// 教师可查看班级掌握度,学生可查看个人掌握度
|
|
||||||
const availableViewModes: GraphViewMode[] = canEdit
|
|
||||||
? ["structure", "class-mastery"]
|
|
||||||
: ["structure", "student-mastery"]
|
|
||||||
|
|
||||||
// 章节颜色映射
|
|
||||||
const chapterColorMap = useMemo(() => {
|
|
||||||
const map = new Map<string, string>()
|
|
||||||
if (!data) return map
|
|
||||||
const chapterIds = [...new Set(
|
|
||||||
data.knowledgePoints
|
|
||||||
.map((kp) => kp.chapterId)
|
|
||||||
.filter((id): id is string => id !== null),
|
|
||||||
)]
|
|
||||||
chapterIds.forEach((id, index) => {
|
|
||||||
map.set(id, CHAPTER_COLORS[index % CHAPTER_COLORS.length]!)
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
const layout = useMemo(() => {
|
|
||||||
if (!data) return { nodes: [], edges: [], width: 0, height: 0 }
|
|
||||||
return computeGraphLayout(data.knowledgePoints)
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
// 搜索高亮
|
|
||||||
const matchedIds = useMemo(() => {
|
|
||||||
if (!searchText || !data) return new Set<string>()
|
|
||||||
const searchLower = searchText.toLowerCase()
|
|
||||||
return new Set(
|
|
||||||
data.knowledgePoints
|
|
||||||
.filter((kp) => kp.name.toLowerCase().includes(searchLower))
|
|
||||||
.map((kp) => kp.id),
|
|
||||||
)
|
|
||||||
}, [searchText, data])
|
|
||||||
|
|
||||||
// 关联节点高亮(选中节点的前置+后置)
|
|
||||||
const relatedIds = useMemo(() => {
|
|
||||||
if (!selectedKpId || !data) return new Set<string>()
|
|
||||||
const related = new Set<string>([selectedKpId])
|
|
||||||
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
|
|
||||||
if (selectedKp) {
|
|
||||||
for (const id of selectedKp.prerequisiteIds) related.add(id)
|
|
||||||
for (const kp of data.knowledgePoints) {
|
|
||||||
if (kp.prerequisiteIds.includes(selectedKpId)) related.add(kp.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return related
|
|
||||||
}, [selectedKpId, data])
|
|
||||||
|
|
||||||
// 从已加载数据计算前置/后置列表(避免 server-only 导入)
|
|
||||||
const prerequisites = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
|
|
||||||
if (!selectedKpId || !data) return []
|
|
||||||
const selectedKp = data.knowledgePoints.find((kp) => kp.id === selectedKpId)
|
|
||||||
if (!selectedKp) return []
|
|
||||||
return data.knowledgePoints
|
|
||||||
.filter((kp) => selectedKp.prerequisiteIds.includes(kp.id))
|
|
||||||
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
|
|
||||||
}, [selectedKpId, data])
|
|
||||||
|
|
||||||
const successors = useMemo<{ id: string; name: string; description: string | null }[]>(() => {
|
|
||||||
if (!selectedKpId || !data) return []
|
|
||||||
return data.knowledgePoints
|
|
||||||
.filter((kp) => kp.prerequisiteIds.includes(selectedKpId))
|
|
||||||
.map((kp) => ({ id: kp.id, name: kp.name, description: kp.description }))
|
|
||||||
}, [selectedKpId, data])
|
|
||||||
|
|
||||||
// 组装 React Flow nodes
|
|
||||||
const rfNodes: Node[] = useMemo(() => {
|
|
||||||
return layout.nodes.map((node) => {
|
|
||||||
const kp = node.data.kp
|
|
||||||
const mastery = data?.masteryMap[kp.id] ?? null
|
|
||||||
const isSelected = selectedKpId === node.id
|
|
||||||
const isHighlighted = !searchText
|
|
||||||
? (selectedKpId === null || relatedIds.has(node.id))
|
|
||||||
: matchedIds.has(node.id)
|
|
||||||
|
|
||||||
const graphData: GraphNodeData = {
|
|
||||||
kp,
|
|
||||||
mastery,
|
|
||||||
viewMode,
|
|
||||||
isSelected,
|
|
||||||
isHighlighted,
|
|
||||||
chapterColor: chapterColorMap.get(kp.chapterId ?? "") ?? "hsl(var(--muted-foreground))",
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
data: { ...node.data, graphData },
|
|
||||||
selected: isSelected,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [layout, data, selectedKpId, relatedIds, matchedIds, searchText, viewMode, chapterColorMap])
|
|
||||||
|
|
||||||
// 组装 React Flow edges
|
|
||||||
const rfEdges: Edge[] = useMemo(() => {
|
|
||||||
return layout.edges.map((edge) => ({
|
|
||||||
...edge,
|
|
||||||
data: {
|
|
||||||
...edge.data,
|
|
||||||
isHighlighted: selectedKpId === null || relatedIds.has(edge.source) || relatedIds.has(edge.target),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}, [layout, selectedKpId, relatedIds])
|
|
||||||
|
|
||||||
const onNodeClick = useCallback((_event: unknown, node: Node) => {
|
|
||||||
setSelectedKpId(node.id)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const resetView = useCallback(() => {
|
|
||||||
if (layoutMode === "hierarchical") {
|
|
||||||
reactFlow.fitView({ duration: 300 })
|
|
||||||
}
|
|
||||||
setSearchText("")
|
|
||||||
setSelectedKpId(null)
|
|
||||||
}, [reactFlow, layoutMode])
|
|
||||||
|
|
||||||
const onJumpToKp = useCallback((kpId: string) => {
|
|
||||||
setSelectedKpId(kpId)
|
|
||||||
if (layoutMode === "hierarchical") {
|
|
||||||
reactFlow.fitView({ nodes: [{ id: kpId }], duration: 300 })
|
|
||||||
}
|
|
||||||
}, [reactFlow, layoutMode])
|
|
||||||
|
|
||||||
// 添加前置依赖
|
|
||||||
const handleAddPrerequisite = useCallback(async () => {
|
|
||||||
if (!selectedKpId || !newPrereqId || !textbookId) return
|
|
||||||
setIsSavingPrereq(true)
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.set("knowledgePointId", selectedKpId)
|
|
||||||
formData.set("prerequisiteKpId", newPrereqId)
|
|
||||||
formData.set("textbookId", textbookId)
|
|
||||||
const result = await createPrerequisiteAction(formData)
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(t("graph.detail.prerequisiteAdded"))
|
|
||||||
setAddPrereqOpen(false)
|
|
||||||
setNewPrereqId("")
|
|
||||||
reload()
|
|
||||||
} else {
|
|
||||||
toast.error(result.message)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteAddFailed"))
|
|
||||||
} finally {
|
|
||||||
setIsSavingPrereq(false)
|
|
||||||
}
|
|
||||||
}, [selectedKpId, newPrereqId, textbookId, t, reload])
|
|
||||||
|
|
||||||
// 删除前置依赖
|
|
||||||
const handleRemovePrerequisite = useCallback(async (prereqId: string) => {
|
|
||||||
if (!selectedKpId || !textbookId) return
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.set("knowledgePointId", selectedKpId)
|
|
||||||
formData.set("prerequisiteKpId", prereqId)
|
|
||||||
formData.set("textbookId", textbookId)
|
|
||||||
const result = await deletePrerequisiteAction(formData)
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(t("graph.detail.prerequisiteRemoved"))
|
|
||||||
reload()
|
|
||||||
} else {
|
|
||||||
toast.error(result.message)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : t("graph.detail.prerequisiteRemoveFailed"))
|
|
||||||
}
|
|
||||||
}, [selectedKpId, textbookId, t, reload])
|
|
||||||
|
|
||||||
// 可选的前置知识点(排除自身和已是前置的)
|
|
||||||
const availablePrereqs = useMemo(() => {
|
|
||||||
if (!data || !selectedKpId) return []
|
|
||||||
const existing = new Set(data.knowledgePoints.find((kp) => kp.id === selectedKpId)?.prerequisiteIds ?? [])
|
|
||||||
return data.knowledgePoints.filter((kp) =>
|
|
||||||
kp.id !== selectedKpId && !existing.has(kp.id),
|
|
||||||
)
|
|
||||||
}, [data, selectedKpId])
|
|
||||||
|
|
||||||
if (isLoading && !data) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
|
||||||
{t("reader.loadingKnowledge")}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={Share2}
|
|
||||||
title={t("graph.error.loadFailed")}
|
|
||||||
description={error}
|
|
||||||
className="h-full border-none shadow-none bg-transparent"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data || data.knowledgePoints.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={Share2}
|
|
||||||
title={t("reader.emptyKnowledge")}
|
|
||||||
description={t("reader.emptyKnowledgeDesc")}
|
|
||||||
className="h-full border-none shadow-none bg-transparent"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedKp = selectedKpId ? data.knowledgePoints.find((kp) => kp.id === selectedKpId) : null
|
|
||||||
const selectedMastery = selectedKpId ? data.masteryMap[selectedKpId] ?? null : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full">
|
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
|
||||||
<GraphToolbar
|
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={setViewMode}
|
|
||||||
availableViewModes={availableViewModes}
|
|
||||||
layoutMode={layoutMode}
|
|
||||||
onLayoutModeChange={setLayoutMode}
|
|
||||||
searchText={searchText}
|
|
||||||
onSearchChange={setSearchText}
|
|
||||||
onResetView={resetView}
|
|
||||||
isRefreshing={isRefreshing}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-h-0 relative">
|
|
||||||
{layoutMode === "hierarchical" ? (
|
|
||||||
<ReactFlow
|
|
||||||
nodes={rfNodes}
|
|
||||||
edges={rfEdges}
|
|
||||||
nodeTypes={nodeTypes}
|
|
||||||
edgeTypes={edgeTypes}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
fitView
|
|
||||||
fitViewOptions={{ padding: 0.2 }}
|
|
||||||
minZoom={0.2}
|
|
||||||
maxZoom={2}
|
|
||||||
proOptions={{ hideAttribution: true }}
|
|
||||||
>
|
|
||||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
||||||
<Controls className="!bg-background !border !rounded-lg" />
|
|
||||||
<MiniMap
|
|
||||||
className="!bg-background !border !rounded-lg"
|
|
||||||
nodeColor={(node) => {
|
|
||||||
// 安全的类型收窄:node.data 是 Record<string, unknown>,
|
|
||||||
// GraphLayoutNodeData 有索引签名 [key: string]: unknown,是 Record 的子类型
|
|
||||||
const data = node.data as GraphLayoutNodeData
|
|
||||||
return data.graphData?.chapterColor ?? "hsl(var(--muted-foreground))"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ReactFlow>
|
|
||||||
) : (
|
|
||||||
<ForceKnowledgeGraph
|
|
||||||
data={data}
|
|
||||||
viewMode={viewMode}
|
|
||||||
searchText={searchText}
|
|
||||||
selectedKpId={selectedKpId}
|
|
||||||
onSelectKp={setSelectedKpId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedKp && (
|
|
||||||
// 任意值 w-[300px]:详情面板固定宽度,保证内容可读性
|
|
||||||
<div className="w-[300px] shrink-0">
|
|
||||||
<GraphNodeDetailPanel
|
|
||||||
kp={selectedKp}
|
|
||||||
mastery={selectedMastery}
|
|
||||||
prerequisites={prerequisites}
|
|
||||||
successors={successors}
|
|
||||||
canEdit={canEdit}
|
|
||||||
onClose={() => setSelectedKpId(null)}
|
|
||||||
onJumpToKp={onJumpToKp}
|
|
||||||
onAddPrerequisite={() => setAddPrereqOpen(true)}
|
|
||||||
onRemovePrerequisite={handleRemovePrerequisite}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 添加前置依赖对话框 */}
|
|
||||||
<Dialog open={addPrereqOpen} onOpenChange={setAddPrereqOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t("graph.detail.addPrerequisiteTitle")}</DialogTitle>
|
|
||||||
<DialogDescription>{t("graph.detail.addPrerequisiteDesc")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Select value={newPrereqId} onValueChange={setNewPrereqId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={t("graph.detail.selectPrerequisite")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{availablePrereqs.map((kp) => (
|
|
||||||
<SelectItem key={kp.id} value={kp.id}>
|
|
||||||
{kp.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setAddPrereqOpen(false)}>
|
|
||||||
{t("graph.detail.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleAddPrerequisite}
|
|
||||||
disabled={!newPrereqId || isSavingPrereq}
|
|
||||||
>
|
|
||||||
{isSavingPrereq ? t("graph.detail.saving") : t("graph.detail.confirm")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KnowledgeGraph(props: KnowledgeGraphProps) {
|
|
||||||
return (
|
return (
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<KnowledgeGraphInner {...props} />
|
<KnowledgeGraphInner {...props} />
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 教材模块内联 Error Boundary。
|
|
||||||
*
|
|
||||||
* 薄包装:委托给共享 SectionErrorBoundary,通过自定义 fallback 渲染
|
|
||||||
* 调用方传入的 fallbackTitle/fallbackDescription/retryLabel。
|
|
||||||
* 保留同名导出和 props 以兼容现有 import。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
import { AlertCircle } from "lucide-react"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
|
||||||
|
|
||||||
interface TextbookSectionErrorBoundaryProps {
|
|
||||||
children: ReactNode
|
|
||||||
/** 自定义降级 UI 标题 */
|
|
||||||
fallbackTitle?: string
|
|
||||||
/** 自定义降级 UI 描述 */
|
|
||||||
fallbackDescription?: string
|
|
||||||
/** 重试按钮文案 */
|
|
||||||
retryLabel?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextbookSectionErrorBoundary({
|
|
||||||
children,
|
|
||||||
fallbackTitle,
|
|
||||||
fallbackDescription,
|
|
||||||
retryLabel,
|
|
||||||
}: TextbookSectionErrorBoundaryProps): ReactNode {
|
|
||||||
const fallback = (_error: Error, reset: () => void): ReactNode => (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
aria-live="assertive"
|
|
||||||
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center"
|
|
||||||
>
|
|
||||||
<AlertCircle className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
|
||||||
{fallbackTitle && (
|
|
||||||
<p className="text-sm font-medium text-foreground">{fallbackTitle}</p>
|
|
||||||
)}
|
|
||||||
{fallbackDescription && (
|
|
||||||
<p className="text-xs text-muted-foreground">{fallbackDescription}</p>
|
|
||||||
)}
|
|
||||||
{retryLabel && (
|
|
||||||
<Button size="sm" variant="outline" onClick={reset}>
|
|
||||||
{retryLabel}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionErrorBoundary fallback={fallback}>
|
|
||||||
{children}
|
|
||||||
</SectionErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
182
src/modules/textbooks/components/textbook-reader-content.tsx
Normal file
182
src/modules/textbooks/components/textbook-reader-content.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Menu, GraduationCap } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import type { Chapter, KnowledgePoint } from "../types"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
|
|
||||||
|
import { TextbookContentPanel } from "./textbook-content-panel"
|
||||||
|
import {
|
||||||
|
KnowledgePointDialogs,
|
||||||
|
type QuestionCreatorRenderProps,
|
||||||
|
} from "./knowledge-point-dialogs"
|
||||||
|
|
||||||
|
interface TextbookReaderContentProps {
|
||||||
|
/** 当前选中章节 */
|
||||||
|
selected: Chapter | null
|
||||||
|
/** 教材 ID */
|
||||||
|
textbookId: string
|
||||||
|
/** 是否可编辑 */
|
||||||
|
canEdit: boolean
|
||||||
|
/** 是否可创建教案 */
|
||||||
|
canCreateLessonPlan: boolean
|
||||||
|
/** 移动端侧栏开关回调(打开抽屉) */
|
||||||
|
onOpenMobileSidebar: () => void
|
||||||
|
|
||||||
|
/** 删除知识点确认对话框 */
|
||||||
|
deleteConfirmOpen: boolean
|
||||||
|
onDeleteConfirmChange: (open: boolean) => void
|
||||||
|
onConfirmDelete: () => void
|
||||||
|
|
||||||
|
/** 知识点对话框组:创建/编辑/题目 */
|
||||||
|
kpDialogs: {
|
||||||
|
createDialogOpen: boolean
|
||||||
|
setCreateDialogOpen: (open: boolean) => void
|
||||||
|
selectedText: string
|
||||||
|
isCreating: boolean
|
||||||
|
onCreateKnowledgePoint: (formData: FormData) => Promise<void>
|
||||||
|
editKpDialogOpen: boolean
|
||||||
|
setEditKpDialogOpen: (open: boolean) => void
|
||||||
|
editingKp: KnowledgePoint | null
|
||||||
|
isUpdatingKp: boolean
|
||||||
|
onUpdateKnowledgePoint: (formData: FormData) => Promise<void>
|
||||||
|
questionDialogOpen: boolean
|
||||||
|
setQuestionDialogOpen: (open: boolean) => void
|
||||||
|
targetKpForQuestion: KnowledgePoint | null
|
||||||
|
renderQuestionCreator?: (props: QuestionCreatorRenderProps) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内容面板:章节内容 + 编辑 + 选区 + 高亮(透传给 TextbookContentPanel) */
|
||||||
|
chapter: { selected: Chapter | null; processedContent: string }
|
||||||
|
editing: {
|
||||||
|
isEditing: boolean
|
||||||
|
editContent: string
|
||||||
|
setEditContent: (content: string) => void
|
||||||
|
isSaving: boolean
|
||||||
|
startEditing: () => void
|
||||||
|
cancelEditing: () => void
|
||||||
|
saveContent: () => void
|
||||||
|
}
|
||||||
|
selection: {
|
||||||
|
contentRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
onPointerDown: (e: React.PointerEvent) => void
|
||||||
|
onContextMenuChange: (open: boolean) => void
|
||||||
|
selectedText: string
|
||||||
|
setCreateDialogOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
|
highlight: {
|
||||||
|
highlightedKpId: string | null
|
||||||
|
onHighlight: (id: string) => void
|
||||||
|
onSwitchToKnowledgeTab: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextbookReaderContent({
|
||||||
|
selected,
|
||||||
|
textbookId,
|
||||||
|
canEdit,
|
||||||
|
canCreateLessonPlan,
|
||||||
|
onOpenMobileSidebar,
|
||||||
|
deleteConfirmOpen,
|
||||||
|
onDeleteConfirmChange,
|
||||||
|
onConfirmDelete,
|
||||||
|
kpDialogs,
|
||||||
|
chapter,
|
||||||
|
editing,
|
||||||
|
selection,
|
||||||
|
highlight,
|
||||||
|
}: TextbookReaderContentProps): React.ReactNode {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
|
{/* 移动端侧栏触发按钮 + 为此课文备课按钮 */}
|
||||||
|
<div className="flex items-center gap-2 mb-3 px-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenMobileSidebar}
|
||||||
|
className="lg:hidden"
|
||||||
|
>
|
||||||
|
<Menu className="mr-2 h-4 w-4" />
|
||||||
|
{t("reader.openSidebar")}
|
||||||
|
</Button>
|
||||||
|
{selected && (
|
||||||
|
<span className="text-sm text-muted-foreground truncate hidden lg:inline">
|
||||||
|
{selected.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selected && canCreateLessonPlan && (
|
||||||
|
<Button asChild variant="default" size="sm" className="ml-auto">
|
||||||
|
<Link
|
||||||
|
href={`/teacher/lesson-plans/new?textbookId=${encodeURIComponent(textbookId)}&chapterId=${encodeURIComponent(selected.id)}`}
|
||||||
|
>
|
||||||
|
<GraduationCap className="mr-2 h-4 w-4" />
|
||||||
|
{t("reader.prepareLesson")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteConfirmOpen} onOpenChange={onDeleteConfirmChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("dialog.knowledge.deleteTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("dialog.knowledge.deleteDesc")}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirmDelete}>
|
||||||
|
{t("dialog.knowledge.delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<KnowledgePointDialogs
|
||||||
|
createDialogOpen={kpDialogs.createDialogOpen}
|
||||||
|
setCreateDialogOpen={kpDialogs.setCreateDialogOpen}
|
||||||
|
selectedText={kpDialogs.selectedText}
|
||||||
|
isCreating={kpDialogs.isCreating}
|
||||||
|
onCreateKnowledgePoint={kpDialogs.onCreateKnowledgePoint}
|
||||||
|
editKpDialogOpen={kpDialogs.editKpDialogOpen}
|
||||||
|
setEditKpDialogOpen={kpDialogs.setEditKpDialogOpen}
|
||||||
|
editingKp={kpDialogs.editingKp}
|
||||||
|
isUpdatingKp={kpDialogs.isUpdatingKp}
|
||||||
|
onUpdateKnowledgePoint={kpDialogs.onUpdateKnowledgePoint}
|
||||||
|
questionDialogOpen={kpDialogs.questionDialogOpen}
|
||||||
|
setQuestionDialogOpen={kpDialogs.setQuestionDialogOpen}
|
||||||
|
targetKpForQuestion={kpDialogs.targetKpForQuestion}
|
||||||
|
renderQuestionCreator={kpDialogs.renderQuestionCreator}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionErrorBoundary
|
||||||
|
title={t("error.loadFailed")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
retryLabel={t("error.retry")}
|
||||||
|
>
|
||||||
|
<TextbookContentPanel
|
||||||
|
chapter={chapter}
|
||||||
|
editing={editing}
|
||||||
|
selection={selection}
|
||||||
|
highlight={highlight}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/modules/textbooks/components/textbook-reader-toc.tsx
Normal file
142
src/modules/textbooks/components/textbook-reader-toc.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tag, List, Share2 } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import type { Chapter, KnowledgePoint } from "../types"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
import { ChapterSidebarList } from "./chapter-sidebar-list"
|
||||||
|
import { KnowledgeGraph } from "./knowledge-graph"
|
||||||
|
import { KnowledgePointList } from "./knowledge-point-list"
|
||||||
|
|
||||||
|
interface TextbookReaderTocProps {
|
||||||
|
activeTab: string
|
||||||
|
onTabChange: (tab: string) => void
|
||||||
|
chapters: Chapter[]
|
||||||
|
textbookId: string
|
||||||
|
selectedChapterId: string | null
|
||||||
|
onSelectChapter: (chapter: Chapter) => void
|
||||||
|
canEdit: boolean
|
||||||
|
canCreateQuestion: boolean
|
||||||
|
currentChapterKPs: KnowledgePoint[]
|
||||||
|
isLoadingKPs: boolean
|
||||||
|
highlightedKpId: string | null
|
||||||
|
onHighlightKp: (id: string) => void
|
||||||
|
onEditKp: (kp: KnowledgePoint) => void
|
||||||
|
onDeleteKp: (kpId: string, e: React.MouseEvent) => void
|
||||||
|
onCreateQuestion: (kp: KnowledgePoint) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextbookReaderToc({
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
chapters,
|
||||||
|
textbookId,
|
||||||
|
selectedChapterId,
|
||||||
|
onSelectChapter,
|
||||||
|
canEdit,
|
||||||
|
canCreateQuestion,
|
||||||
|
currentChapterKPs,
|
||||||
|
isLoadingKPs,
|
||||||
|
highlightedKpId,
|
||||||
|
onHighlightKp,
|
||||||
|
onEditKp,
|
||||||
|
onDeleteKp,
|
||||||
|
onCreateQuestion,
|
||||||
|
}: TextbookReaderTocProps): React.ReactNode {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs value={activeTab} onValueChange={onTabChange} className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="chapters" className="gap-2">
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
{t("reader.tabs.chapters")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedChapterId}>
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
{t("reader.tabs.knowledge")}
|
||||||
|
{/* 任意值 text-[10px]:紧凑徽章,text-xs(12px) 在标签栏中过大 */}
|
||||||
|
{currentChapterKPs.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
|
||||||
|
{currentChapterKPs.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="graph" className="gap-2">
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
{t("reader.tabs.graph")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
|
||||||
|
<SectionErrorBoundary
|
||||||
|
title={t("error.loadFailed")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
retryLabel={t("error.retry")}
|
||||||
|
>
|
||||||
|
<ScrollArea className="flex-1 h-full px-2">
|
||||||
|
<div className="space-y-1 pb-4">
|
||||||
|
<ChapterSidebarList
|
||||||
|
chapters={chapters}
|
||||||
|
selectedChapterId={selectedChapterId || undefined}
|
||||||
|
onSelectChapter={onSelectChapter}
|
||||||
|
textbookId={textbookId || ""}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
|
||||||
|
<SectionErrorBoundary
|
||||||
|
title={t("error.loadFailed")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
retryLabel={t("error.retry")}
|
||||||
|
>
|
||||||
|
{!selectedChapterId ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Tag}
|
||||||
|
title={t("reader.selectChapterKnowledge")}
|
||||||
|
description={t("reader.selectChapterKnowledgeDesc")}
|
||||||
|
className="h-full border-none shadow-none bg-transparent"
|
||||||
|
/>
|
||||||
|
) : isLoadingKPs ? (
|
||||||
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("reader.loadingKnowledge")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<KnowledgePointList
|
||||||
|
knowledgePoints={currentChapterKPs}
|
||||||
|
canEdit={canEdit}
|
||||||
|
canCreateQuestion={canCreateQuestion}
|
||||||
|
highlightedKpId={highlightedKpId}
|
||||||
|
onHighlight={onHighlightKp}
|
||||||
|
onEdit={onEditKp}
|
||||||
|
onDelete={onDeleteKp}
|
||||||
|
onCreateQuestion={onCreateQuestion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
|
||||||
|
<SectionErrorBoundary
|
||||||
|
title={t("error.loadFailed")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
retryLabel={t("error.retry")}
|
||||||
|
>
|
||||||
|
<KnowledgeGraph textbookId={textbookId} />
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,40 +2,13 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
|
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
import { Tag, List, Share2, Menu, GraduationCap } from "lucide-react"
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
import type { Chapter, KnowledgePoint } from "../types"
|
import type { Chapter, KnowledgePoint } from "../types"
|
||||||
import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions"
|
import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { usePermission } from "@/shared/hooks/use-permission"
|
import { usePermission } from "@/shared/hooks/use-permission"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
import { ChapterSidebarList } from "./chapter-sidebar-list"
|
|
||||||
import { KnowledgeGraph } from "./knowledge-graph"
|
|
||||||
import { KnowledgePointList } from "./knowledge-point-list"
|
|
||||||
import { TextbookContentPanel } from "./textbook-content-panel"
|
|
||||||
import {
|
|
||||||
KnowledgePointDialogs,
|
|
||||||
type QuestionCreatorRenderProps,
|
|
||||||
} from "./knowledge-point-dialogs"
|
|
||||||
import { TextbookSectionErrorBoundary } from "./section-error-boundary"
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -47,6 +20,10 @@ import { useTextSelection } from "../hooks/use-text-selection"
|
|||||||
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
|
import { useKnowledgePointActions } from "../hooks/use-knowledge-point-actions"
|
||||||
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
|
import { buildChapterIndex, highlightKnowledgePoints } from "../utils"
|
||||||
|
|
||||||
|
import { TextbookReaderToc } from "./textbook-reader-toc"
|
||||||
|
import { TextbookReaderContent } from "./textbook-reader-content"
|
||||||
|
import type { QuestionCreatorRenderProps } from "./knowledge-point-dialogs"
|
||||||
|
|
||||||
export interface TextbookReaderProps {
|
export interface TextbookReaderProps {
|
||||||
chapters: Chapter[]
|
chapters: Chapter[]
|
||||||
/**
|
/**
|
||||||
@@ -231,201 +208,84 @@ export function TextbookReader({
|
|||||||
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||||
}, [highlightedKpId])
|
}, [highlightedKpId])
|
||||||
|
|
||||||
// P2-4 侧边栏内容(章节/知识点/图谱 Tabs),桌面端内联、移动端抽屉复用同一份
|
|
||||||
const sidebarContent = (
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col h-full">
|
|
||||||
<div className="flex items-center justify-between mb-4 px-2 shrink-0">
|
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="chapters" className="gap-2">
|
|
||||||
<List className="h-4 w-4" />
|
|
||||||
{t("reader.tabs.chapters")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
|
|
||||||
<Tag className="h-4 w-4" />
|
|
||||||
{t("reader.tabs.knowledge")}
|
|
||||||
{/* 任意值 text-[10px]:紧凑徽章,text-xs(12px) 在标签栏中过大 */}
|
|
||||||
{currentChapterKPs.length > 0 && (
|
|
||||||
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
|
|
||||||
{currentChapterKPs.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="graph" className="gap-2">
|
|
||||||
<Share2 className="h-4 w-4" />
|
|
||||||
{t("reader.tabs.graph")}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent value="chapters" className="flex-1 min-h-0 mt-0">
|
|
||||||
<TextbookSectionErrorBoundary
|
|
||||||
fallbackTitle={t("error.loadFailed")}
|
|
||||||
fallbackDescription={t("error.loadFailedDesc")}
|
|
||||||
retryLabel={t("error.retry")}
|
|
||||||
>
|
|
||||||
<ScrollArea className="flex-1 h-full px-2">
|
|
||||||
<div className="space-y-1 pb-4">
|
|
||||||
<ChapterSidebarList
|
|
||||||
chapters={chapters}
|
|
||||||
selectedChapterId={selectedId || undefined}
|
|
||||||
onSelectChapter={handleSelect}
|
|
||||||
textbookId={textbookId || ""}
|
|
||||||
canEdit={canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</TextbookSectionErrorBoundary>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="knowledge" className="flex-1 min-h-0 mt-0">
|
|
||||||
<TextbookSectionErrorBoundary
|
|
||||||
fallbackTitle={t("error.loadFailed")}
|
|
||||||
fallbackDescription={t("error.loadFailedDesc")}
|
|
||||||
retryLabel={t("error.retry")}
|
|
||||||
>
|
|
||||||
{!selectedId ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Tag}
|
|
||||||
title={t("reader.selectChapterKnowledge")}
|
|
||||||
description={t("reader.selectChapterKnowledgeDesc")}
|
|
||||||
className="h-full border-none shadow-none bg-transparent"
|
|
||||||
/>
|
|
||||||
) : isLoadingKPs ? (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
|
||||||
{t("reader.loadingKnowledge")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<KnowledgePointList
|
|
||||||
knowledgePoints={currentChapterKPs}
|
|
||||||
canEdit={canEdit}
|
|
||||||
canCreateQuestion={canCreateQuestion}
|
|
||||||
highlightedKpId={highlightedKpId}
|
|
||||||
onHighlight={setHighlightedKpId}
|
|
||||||
onEdit={(kp) => {
|
|
||||||
setEditingKp(kp)
|
|
||||||
setEditKpDialogOpen(true)
|
|
||||||
}}
|
|
||||||
onDelete={requestDeleteKnowledgePoint}
|
|
||||||
onCreateQuestion={(kp) => {
|
|
||||||
setTargetKpForQuestion(kp)
|
|
||||||
setQuestionDialogOpen(true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TextbookSectionErrorBoundary>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="graph" className="flex-1 min-h-0 mt-0">
|
|
||||||
<TextbookSectionErrorBoundary
|
|
||||||
fallbackTitle={t("error.loadFailed")}
|
|
||||||
fallbackDescription={t("error.loadFailedDesc")}
|
|
||||||
retryLabel={t("error.retry")}
|
|
||||||
>
|
|
||||||
<KnowledgeGraph textbookId={textbookId} />
|
|
||||||
</TextbookSectionErrorBoundary>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)
|
|
||||||
|
|
||||||
// 图谱 tab 时侧栏(图谱)占更大比例,其他 tab 时侧栏(目录/知识点)占较小比例
|
// 图谱 tab 时侧栏(图谱)占更大比例,其他 tab 时侧栏(目录/知识点)占较小比例
|
||||||
const sidebarInitialPct = activeTab === "graph" ? 60 : 35
|
const sidebarInitialPct = activeTab === "graph" ? 60 : 35
|
||||||
|
|
||||||
|
const tocContent = (
|
||||||
|
<TextbookReaderToc
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
chapters={chapters}
|
||||||
|
textbookId={textbookId}
|
||||||
|
selectedChapterId={selectedId}
|
||||||
|
onSelectChapter={handleSelect}
|
||||||
|
canEdit={canEdit}
|
||||||
|
canCreateQuestion={canCreateQuestion}
|
||||||
|
currentChapterKPs={currentChapterKPs}
|
||||||
|
isLoadingKPs={isLoadingKPs}
|
||||||
|
highlightedKpId={highlightedKpId}
|
||||||
|
onHighlightKp={setHighlightedKpId}
|
||||||
|
onEditKp={(kp) => {
|
||||||
|
setEditingKp(kp)
|
||||||
|
setEditKpDialogOpen(true)
|
||||||
|
}}
|
||||||
|
onDeleteKp={requestDeleteKnowledgePoint}
|
||||||
|
onCreateQuestion={(kp) => {
|
||||||
|
setTargetKpForQuestion(kp)
|
||||||
|
setQuestionDialogOpen(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
const contentPanel = (
|
const contentPanel = (
|
||||||
<div className="flex flex-col h-full min-h-0 relative">
|
<TextbookReaderContent
|
||||||
{/* 移动端侧栏触发按钮 + 为此课文备课按钮 */}
|
selected={selected}
|
||||||
<div className="flex items-center gap-2 mb-3 px-2 shrink-0">
|
textbookId={textbookId}
|
||||||
<Button
|
canEdit={canEdit}
|
||||||
variant="outline"
|
canCreateLessonPlan={canCreateLessonPlan}
|
||||||
size="sm"
|
onOpenMobileSidebar={() => setMobileSidebarOpen(true)}
|
||||||
onClick={() => setMobileSidebarOpen(true)}
|
deleteConfirmOpen={deleteConfirmOpen}
|
||||||
className="lg:hidden"
|
onDeleteConfirmChange={setDeleteConfirmOpen}
|
||||||
aria-expanded={mobileSidebarOpen}
|
onConfirmDelete={confirmDeleteKnowledgePoint}
|
||||||
aria-controls="mobile-sidebar-sheet"
|
kpDialogs={{
|
||||||
>
|
createDialogOpen,
|
||||||
<Menu className="mr-2 h-4 w-4" />
|
setCreateDialogOpen,
|
||||||
{t("reader.openSidebar")}
|
selectedText,
|
||||||
</Button>
|
isCreating,
|
||||||
{selected && (
|
onCreateKnowledgePoint,
|
||||||
<span className="text-sm text-muted-foreground truncate hidden lg:inline">
|
editKpDialogOpen,
|
||||||
{selected.title}
|
setEditKpDialogOpen,
|
||||||
</span>
|
editingKp,
|
||||||
)}
|
isUpdatingKp,
|
||||||
{selected && canCreateLessonPlan && (
|
onUpdateKnowledgePoint: handleUpdateKnowledgePoint,
|
||||||
<Button asChild variant="default" size="sm" className="ml-auto">
|
questionDialogOpen,
|
||||||
<Link
|
setQuestionDialogOpen,
|
||||||
href={`/teacher/lesson-plans/new?textbookId=${encodeURIComponent(textbookId)}&chapterId=${encodeURIComponent(selected.id)}`}
|
targetKpForQuestion,
|
||||||
>
|
renderQuestionCreator: canCreateQuestion ? renderQuestionCreator : undefined,
|
||||||
<GraduationCap className="mr-2 h-4 w-4" />
|
}}
|
||||||
{t("reader.prepareLesson")}
|
chapter={{ selected, processedContent }}
|
||||||
</Link>
|
editing={{
|
||||||
</Button>
|
isEditing,
|
||||||
)}
|
editContent,
|
||||||
</div>
|
setEditContent,
|
||||||
|
isSaving,
|
||||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
startEditing,
|
||||||
<AlertDialogContent>
|
cancelEditing: () => setIsEditing(false),
|
||||||
<AlertDialogHeader>
|
saveContent: handleSaveContent,
|
||||||
<AlertDialogTitle>{t("dialog.knowledge.deleteTitle")}</AlertDialogTitle>
|
}}
|
||||||
<AlertDialogDescription>{t("dialog.knowledge.deleteDesc")}</AlertDialogDescription>
|
selection={{
|
||||||
</AlertDialogHeader>
|
contentRef,
|
||||||
<AlertDialogFooter>
|
onPointerDown: handleContentPointerDown,
|
||||||
<AlertDialogCancel>{t("dialog.knowledge.cancel")}</AlertDialogCancel>
|
onContextMenuChange: handleContextMenuChange,
|
||||||
<AlertDialogAction onClick={confirmDeleteKnowledgePoint}>
|
selectedText,
|
||||||
{t("dialog.knowledge.delete")}
|
setCreateDialogOpen,
|
||||||
</AlertDialogAction>
|
}}
|
||||||
</AlertDialogFooter>
|
highlight={{
|
||||||
</AlertDialogContent>
|
highlightedKpId,
|
||||||
</AlertDialog>
|
onHighlight: setHighlightedKpId,
|
||||||
|
onSwitchToKnowledgeTab: () => setActiveTab("knowledge"),
|
||||||
<KnowledgePointDialogs
|
}}
|
||||||
createDialogOpen={createDialogOpen}
|
/>
|
||||||
setCreateDialogOpen={setCreateDialogOpen}
|
|
||||||
selectedText={selectedText}
|
|
||||||
isCreating={isCreating}
|
|
||||||
onCreateKnowledgePoint={onCreateKnowledgePoint}
|
|
||||||
editKpDialogOpen={editKpDialogOpen}
|
|
||||||
setEditKpDialogOpen={setEditKpDialogOpen}
|
|
||||||
editingKp={editingKp}
|
|
||||||
isUpdatingKp={isUpdatingKp}
|
|
||||||
onUpdateKnowledgePoint={handleUpdateKnowledgePoint}
|
|
||||||
questionDialogOpen={questionDialogOpen}
|
|
||||||
setQuestionDialogOpen={setQuestionDialogOpen}
|
|
||||||
targetKpForQuestion={targetKpForQuestion}
|
|
||||||
renderQuestionCreator={canCreateQuestion ? renderQuestionCreator : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextbookSectionErrorBoundary
|
|
||||||
fallbackTitle={t("error.loadFailed")}
|
|
||||||
fallbackDescription={t("error.loadFailedDesc")}
|
|
||||||
retryLabel={t("error.retry")}
|
|
||||||
>
|
|
||||||
<TextbookContentPanel
|
|
||||||
chapter={{ selected, processedContent }}
|
|
||||||
editing={{
|
|
||||||
isEditing,
|
|
||||||
editContent,
|
|
||||||
setEditContent,
|
|
||||||
isSaving,
|
|
||||||
startEditing,
|
|
||||||
cancelEditing: () => setIsEditing(false),
|
|
||||||
saveContent: handleSaveContent,
|
|
||||||
}}
|
|
||||||
selection={{
|
|
||||||
contentRef,
|
|
||||||
onPointerDown: handleContentPointerDown,
|
|
||||||
onContextMenuChange: handleContextMenuChange,
|
|
||||||
selectedText,
|
|
||||||
setCreateDialogOpen,
|
|
||||||
}}
|
|
||||||
highlight={{
|
|
||||||
highlightedKpId,
|
|
||||||
onHighlight: setHighlightedKpId,
|
|
||||||
onSwitchToKnowledgeTab: () => setActiveTab("knowledge"),
|
|
||||||
}}
|
|
||||||
canEdit={canEdit}
|
|
||||||
/>
|
|
||||||
</TextbookSectionErrorBoundary>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -438,7 +298,7 @@ export function TextbookReader({
|
|||||||
<SheetHeader className="px-4 py-3 border-b shrink-0">
|
<SheetHeader className="px-4 py-3 border-b shrink-0">
|
||||||
<SheetTitle className="text-left">{t("reader.sidebar")}</SheetTitle>
|
<SheetTitle className="text-left">{t("reader.sidebar")}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex-1 min-h-0 p-2">{sidebarContent}</div>
|
<div className="flex-1 min-h-0 p-2">{tocContent}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
@@ -449,7 +309,7 @@ export function TextbookReader({
|
|||||||
initialLeft={sidebarInitialPct}
|
initialLeft={sidebarInitialPct}
|
||||||
minLeft={20}
|
minLeft={20}
|
||||||
minRight={25}
|
minRight={25}
|
||||||
left={<div className="h-full pr-3 border-r">{sidebarContent}</div>}
|
left={<div className="h-full pr-3 border-r">{tocContent}</div>}
|
||||||
right={<div className="h-full pl-3">{contentPanel}</div>}
|
right={<div className="h-full pl-3">{contentPanel}</div>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user