- 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 零错误
171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
"use client"
|
|
|
|
import type { JSX } from "react"
|
|
import { useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { useTranslations } from "next-intl"
|
|
import { toast } from "sonner"
|
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
import { gradeHomeworkSubmissionAction } from "../actions"
|
|
import {
|
|
applyAutoGrades,
|
|
} from "../lib/question-content-utils"
|
|
import type { HomeworkSubmissionAnswerDetails } from "../types"
|
|
import { HomeworkGradingPanel } from "./homework-grading-panel"
|
|
import { HomeworkGradingToolbar } from "./homework-grading-toolbar"
|
|
|
|
type HomeworkGradingViewProps = {
|
|
submissionId: string
|
|
studentName: string
|
|
assignmentTitle: string
|
|
submittedAt: string | null
|
|
status: string
|
|
totalScore: number | null
|
|
answers: HomeworkSubmissionAnswerDetails[]
|
|
prevSubmissionId?: string | null
|
|
nextSubmissionId?: string | null
|
|
}
|
|
|
|
export function HomeworkGradingView({
|
|
submissionId,
|
|
answers: initialAnswers,
|
|
prevSubmissionId,
|
|
nextSubmissionId,
|
|
studentName,
|
|
assignmentTitle,
|
|
submittedAt,
|
|
}: HomeworkGradingViewProps): JSX.Element {
|
|
const router = useRouter()
|
|
const t = useTranslations("examHomework")
|
|
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
// Initialize feedback visibility for answers that already have feedback
|
|
const [showFeedbackByAnswerId, setShowFeedbackByAnswerId] = useState<Record<string, boolean>>(() => {
|
|
const initialVisibility: Record<string, boolean> = {}
|
|
if (initialAnswers) {
|
|
initialAnswers.forEach(a => {
|
|
if (a.feedback && a.feedback.trim().length > 0) {
|
|
initialVisibility[a.id] = true
|
|
}
|
|
})
|
|
}
|
|
return initialVisibility
|
|
})
|
|
|
|
const handleManualScoreChange = (id: string, val: string): void => {
|
|
const parsed = val === "" ? 0 : Number(val)
|
|
const targetAnswer = answers.find(a => a.id === id)
|
|
const max = targetAnswer?.maxScore ?? 100
|
|
|
|
let nextScore = Number.isFinite(parsed) ? parsed : 0
|
|
if (nextScore > max) nextScore = max
|
|
if (nextScore < 0) nextScore = 0
|
|
|
|
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: nextScore } : a)))
|
|
}
|
|
|
|
const handleMarkCorrect = (id: string): void => {
|
|
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: a.maxScore } : a)))
|
|
}
|
|
|
|
const handleMarkIncorrect = (id: string): void => {
|
|
setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, score: 0 } : a)))
|
|
}
|
|
|
|
const handleFeedbackChange = (id: string, val: string): void => {
|
|
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 maxTotal = answers.reduce((sum, a) => sum + a.maxScore, 0)
|
|
const progressPercent = maxTotal > 0 ? (currentTotal / maxTotal) * 100 : 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 partialCount = answers.reduce((sum, a) => sum + (a.score !== null && a.score > 0 && a.score < a.maxScore ? 1 : 0), 0)
|
|
|
|
const handleSubmit = async (): Promise<void> => {
|
|
setIsSubmitting(true)
|
|
const payload = answers.map((a) => {
|
|
const feedback =
|
|
typeof a.feedback === "string" && a.feedback.trim().length > 0 ? a.feedback.trim() : undefined
|
|
return { id: a.id, score: a.score || 0, feedback }
|
|
})
|
|
|
|
const formData = new FormData()
|
|
formData.set("submissionId", submissionId)
|
|
formData.set("answersJson", JSON.stringify(payload))
|
|
|
|
try {
|
|
const result = await gradeHomeworkSubmissionAction(null, formData)
|
|
|
|
if (result.success) {
|
|
toast.success(t("homework.grade.gradesSaved"))
|
|
router.refresh()
|
|
} else {
|
|
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
|
|
}
|
|
} catch {
|
|
toast.error(t("homework.grade.gradesSaveFailed"))
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const handleScrollToQuestion = (id: string): void => {
|
|
const el = document.getElementById(`question-card-${id}`)
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="grid h-[calc(100vh-8rem)] grid-cols-1 gap-6 lg:grid-cols-12">
|
|
{/* Main Content: Questions List */}
|
|
<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">
|
|
<div className="mx-auto max-w-4xl space-y-8 pb-20">
|
|
{answers.map((ans, index) => (
|
|
<HomeworkGradingPanel
|
|
key={ans.id}
|
|
answer={ans}
|
|
index={index}
|
|
studentName={studentName}
|
|
showFeedback={Boolean(showFeedbackByAnswerId[ans.id])}
|
|
onScoreChange={handleManualScoreChange}
|
|
onMarkCorrect={handleMarkCorrect}
|
|
onMarkIncorrect={handleMarkIncorrect}
|
|
onFeedbackChange={handleFeedbackChange}
|
|
onSetFeedbackVisible={handleSetFeedbackVisible}
|
|
/>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<HomeworkGradingToolbar
|
|
assignmentTitle={assignmentTitle}
|
|
studentName={studentName}
|
|
submittedAt={submittedAt}
|
|
currentTotal={currentTotal}
|
|
maxTotal={maxTotal}
|
|
progressPercent={progressPercent}
|
|
correctCount={correctCount}
|
|
incorrectCount={incorrectCount}
|
|
partialCount={partialCount}
|
|
answers={answers}
|
|
isSubmitting={isSubmitting}
|
|
prevSubmissionId={prevSubmissionId}
|
|
nextSubmissionId={nextSubmissionId}
|
|
onSubmit={handleSubmit}
|
|
onScrollToQuestion={handleScrollToQuestion}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|