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:
SpecialX
2026-07-06 19:54:39 +08:00
parent 19a05091d3
commit 025d4de50d
26 changed files with 1914 additions and 1647 deletions

View File

@@ -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")

View File

@@ -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")

View File

@@ -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,

View File

@@ -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"

View File

@@ -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 }))

View File

@@ -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"

View File

@@ -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"

View 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>
)
}

View 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>
</>
)
}

View 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>
</>
)
}

View File

@@ -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>
) )

View 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
})
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
) )
} }

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View File

@@ -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>
) )
} }

View 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>
)
}

View File

@@ -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} />

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>