Files
NextEdu/src/modules/homework/components/homework-grading-view.tsx
SpecialX 025d4de50d 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 零错误
2026-07-06 19:54:39 +08:00

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