P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)
P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签
P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式
同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
224 lines
9.8 KiB
TypeScript
224 lines
9.8 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo } from "react"
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
|
import { Label } from "@/shared/components/ui/label"
|
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
|
import { FileText, ChevronLeft } from "lucide-react"
|
|
import Link from "next/link"
|
|
import { useTranslations } from "next-intl"
|
|
|
|
import type { StudentHomeworkTakeData } from "../types"
|
|
import { QuestionRenderer } from "./question-renderer"
|
|
import {
|
|
getCorrectnessState,
|
|
parseSavedAnswer,
|
|
} from "../lib/question-content-utils"
|
|
|
|
type HomeworkReviewViewProps = {
|
|
initialData: StudentHomeworkTakeData
|
|
}
|
|
|
|
export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
|
const t = useTranslations("examHomework")
|
|
const submissionStatus = initialData.submission?.status ?? "not_started"
|
|
const isGraded = submissionStatus === "graded"
|
|
|
|
const answersByQuestionId = useMemo(() => {
|
|
const map = new Map<string, { answer: unknown }>()
|
|
for (const q of initialData.questions) {
|
|
map.set(q.questionId, parseSavedAnswer(q.savedAnswer, q.questionType))
|
|
}
|
|
const obj: Record<string, { answer: unknown }> = {}
|
|
for (const [k, v] of map.entries()) obj[k] = v
|
|
return obj
|
|
}, [initialData.questions])
|
|
|
|
return (
|
|
<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="border-b p-4 flex items-center justify-between bg-muted/30">
|
|
<div className="flex items-center gap-3">
|
|
<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">
|
|
{isGraded ? t("homework.review.gradedReport") : t("homework.review.submissionDetails")}
|
|
</h3>
|
|
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] capitalize">
|
|
{submissionStatus}
|
|
</Badge>
|
|
<span>•</span>
|
|
<span>{initialData.questions.length} {t("homework.review.questionsUnit")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link href="/student/learning/assignments">
|
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
|
{t("homework.review.backToList")}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<ScrollArea className="flex-1 bg-muted/10">
|
|
<div className="space-y-6 p-6 max-w-4xl mx-auto">
|
|
{initialData.questions.map((q, idx) => {
|
|
const value = answersByQuestionId[q.questionId]?.answer
|
|
const correctness = isGraded
|
|
? getCorrectnessState({ score: q.score ?? null, maxScore: q.maxScore })
|
|
: "ungraded"
|
|
const borderClass =
|
|
correctness === "correct"
|
|
? "border-l-4 border-l-emerald-500"
|
|
: correctness === "incorrect"
|
|
? "border-l-4 border-l-red-500"
|
|
: correctness === "partial"
|
|
? "border-l-4 border-l-yellow-500"
|
|
: "border-l-4 border-l-primary"
|
|
|
|
return (
|
|
<Card key={q.questionId} className={`shadow-sm ${borderClass}`}>
|
|
<CardHeader className="pb-2">
|
|
<QuestionRenderer
|
|
questionId={q.questionId}
|
|
questionType={q.questionType}
|
|
questionContent={q.questionContent}
|
|
maxScore={q.maxScore}
|
|
index={idx}
|
|
mode="review"
|
|
value={value}
|
|
showCorrectAnswer={isGraded}
|
|
feedback={isGraded ? q.feedback : null}
|
|
headerExtra={
|
|
isGraded ? (
|
|
<Badge
|
|
variant="outline"
|
|
aria-label={t(`homework.grade.${correctness === "ungraded" ? "partial" : correctness}`)}
|
|
className={
|
|
correctness === "correct"
|
|
? "text-emerald-600 border-emerald-200 bg-emerald-50"
|
|
: "text-red-600 border-red-200 bg-red-50"
|
|
}
|
|
>
|
|
{q.score} / {q.maxScore}
|
|
</Badge>
|
|
) : null
|
|
}
|
|
/>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 pt-0">
|
|
{q.knowledgePoints && q.knowledgePoints.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{q.knowledgePoints.map((kp) => (
|
|
<Badge key={kp.id} variant="secondary" className="text-[10px] px-1.5 py-0 h-5">
|
|
{kp.name}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
|
|
<div className="border-b p-4 bg-muted/30">
|
|
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
|
|
</div>
|
|
<div className="flex-1 p-4 overflow-y-auto">
|
|
<div className="space-y-6">
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<Badge variant="secondary" className="capitalize">
|
|
{submissionStatus}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.review.description")}</Label>
|
|
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
|
|
{initialData.assignment.description || t("homework.review.noDescription")}
|
|
</p>
|
|
</div>
|
|
|
|
{isGraded && (
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.review.totalScore")}</Label>
|
|
<div className="mt-2 flex items-baseline gap-2">
|
|
<span className="text-3xl font-bold text-primary">
|
|
{initialData.submission?.score ?? 0}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
/ {initialData.questions.reduce((acc, q) => acc + q.maxScore, 0)}
|
|
</span>
|
|
</div>
|
|
<div className="mt-4 space-y-2">
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<div className="h-3 w-3 rounded-full bg-emerald-600" aria-hidden="true" />
|
|
<span>{t("homework.grade.correct")}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<div className="h-3 w-3 rounded-full bg-yellow-500" aria-hidden="true" />
|
|
<span>{t("homework.grade.partial")}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<div className="h-3 w-3 rounded-full bg-red-500" aria-hidden="true" />
|
|
<span>{t("homework.grade.incorrect")}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
|
|
{isGraded ? t("homework.review.questionBreakdown") : t("homework.review.responseSummary")}
|
|
</Label>
|
|
<div className="mt-2 grid grid-cols-5 gap-2">
|
|
{initialData.questions.map((q, i) => {
|
|
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
|
answersByQuestionId[q.questionId]?.answer !== "" &&
|
|
(Array.isArray(answersByQuestionId[q.questionId]?.answer)
|
|
? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0
|
|
: true)
|
|
|
|
const score = q.score ?? 0
|
|
const max = q.maxScore
|
|
let statusClass = "bg-background text-muted-foreground border-input"
|
|
|
|
if (isGraded) {
|
|
if (score === max && max > 0) statusClass = "bg-emerald-600 text-white border-emerald-600"
|
|
else if (score > 0) statusClass = "bg-yellow-500 text-white border-yellow-500"
|
|
else statusClass = "bg-red-500 text-white border-red-500"
|
|
} else if (hasAnswer) {
|
|
statusClass = "bg-primary text-primary-foreground border-primary"
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={q.questionId}
|
|
className={`h-8 w-8 rounded flex items-center justify-center text-xs font-medium border ${statusClass}`}
|
|
>
|
|
{i + 1}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|