feat(exams,homework,parent): V3 审计深度修复 — 批量批改/考试分析/提交反馈/家长视图/移动端优化

V3-5: exam-actions.tsx 集成 useExamHomeworkFeatures hook,按角色控制菜单项可见性
V3-7: 批量批改 — 新增 batchAutoGradeSubmissions data-access + Server Action + HomeworkBatchGradingView 组件
V3-8: 考试分析仪表盘 — 新增 getExamAnalytics stats-service + ExamAnalyticsDashboard 组件 + /teacher/exams/[id]/analytics 路由
V3-9: 提交后即时反馈页 — 新增 HomeworkSubmissionResult 组件 + /student/learning/assignments/[id]/result 路由
V3-11: 家长考试详情 — 新增 ChildExamDetail 组件 + getStudentExamResults data-access + child-detail-panel exams Tab
V3-12: 移动端触控优化 — 题目导航与考试操作按钮 44px 最小触控目标

修复: instrumentation.ts 适配器补全 questionCount/averageScore/overdueCount 字段
修复: exam-homework-port.ts 类型导入对齐 ExamWithQuestionsForHomework
修复: trend-line-chart.tsx 数据类型允许 undefined(classAverage 可选场景)

同步更新 004/005 架构文档
This commit is contained in:
SpecialX
2026-06-23 01:06:27 +08:00
parent 21c5eba96c
commit a60105455e
23 changed files with 2407 additions and 263 deletions

View File

@@ -3,7 +3,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy } from "lucide-react"
import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy, BarChart3 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
@@ -36,6 +36,8 @@ import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreview
import { Exam } from "../types"
import { ExamPaperPreview } from "./assembly/exam-paper-preview"
import type { ExamNode } from "./assembly/selected-question-list"
import { createId } from "@paralleldrive/cuid2"
import { useExamHomeworkFeatures } from "@/shared/hooks/use-exam-homework-features"
// Raw structure node shape returned from the DB before hydration
type RawStructureNode = {
@@ -65,6 +67,7 @@ interface ExamActionsProps {
export function ExamActions({ exam }: ExamActionsProps) {
const router = useRouter()
const t = useTranslations("examHomework")
const features = useExamHomeworkFeatures()
const [showViewDialog, setShowViewDialog] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isWorking, setIsWorking] = useState(false)
@@ -83,7 +86,8 @@ export function ExamActions({ exam }: ExamActionsProps) {
return nodes.map((node) => {
if (node.type === "question") {
return {
id: node.id ?? node.questionId ?? "",
// Avoid empty-string fallback that could cause React key collisions
id: node.id ?? node.questionId ?? createId(),
type: "question" as const,
questionId: node.questionId,
score: node.score,
@@ -92,7 +96,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
}
if (node.type === "group") {
return {
id: node.id ?? "",
id: node.id ?? createId(),
type: "group" as const,
title: node.title,
score: node.score,
@@ -101,7 +105,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
}
// Unknown node type: treat as group with no children to avoid runtime crash
return {
id: node.id ?? "",
id: node.id ?? createId(),
type: "group" as const,
title: node.title,
children: [],
@@ -124,8 +128,13 @@ export function ExamActions({ exam }: ExamActionsProps) {
}
const copyId = () => {
navigator.clipboard.writeText(exam.id)
toast.success(t("exam.actions.idCopied"))
try {
void navigator.clipboard.writeText(exam.id)
toast.success(t("exam.actions.idCopied"))
} catch (error) {
console.error("[ExamActions]", error instanceof Error ? error.message : String(error))
toast.error(t("exam.actions.idCopied"))
}
}
const setStatus = async (status: Exam["status"]) => {
@@ -194,7 +203,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
className="h-11 w-11 sm:h-8 sm:w-8 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
handleView()
@@ -206,7 +215,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0" aria-label={t("exam.actions.openMenu")}>
<Button variant="ghost" className="h-11 w-11 sm:h-8 sm:w-8 p-0" aria-label={t("exam.actions.openMenu")}>
<span className="sr-only">{t("exam.actions.openMenu")}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
@@ -217,42 +226,64 @@ export function ExamActions({ exam }: ExamActionsProps) {
<Copy className="mr-2 h-4 w-4" /> {t("exam.actions.copyId")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<Pencil className="mr-2 h-4 w-4" /> {t("exam.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<MoreHorizontal className="mr-2 h-4 w-4" /> {t("exam.actions.build")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={duplicateExam} disabled={isWorking}>
<Copy className="mr-2 h-4 w-4" /> {t("exam.actions.duplicate")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setStatus("published")}
disabled={isWorking || exam.status === "published"}
>
<UploadCloud className="mr-2 h-4 w-4" /> {t("exam.actions.publish")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatus("draft")}
disabled={isWorking || exam.status === "draft"}
>
<Undo2 className="mr-2 h-4 w-4" /> {t("exam.actions.moveToDraft")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatus("archived")}
disabled={isWorking || exam.status === "archived"}
>
<Archive className="mr-2 h-4 w-4" /> {t("exam.actions.archive")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isWorking}
>
<Trash className="mr-2 h-4 w-4" /> {t("exam.actions.delete")}
</DropdownMenuItem>
{features.canBuild && (
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<Pencil className="mr-2 h-4 w-4" /> {t("exam.actions.edit")}
</DropdownMenuItem>
)}
{features.canBuild && (
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<MoreHorizontal className="mr-2 h-4 w-4" /> {t("exam.actions.build")}
</DropdownMenuItem>
)}
{features.canViewStats && (
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/analytics`)}>
<BarChart3 className="mr-2 h-4 w-4" /> {t("exam.analytics.viewAnalytics")}
</DropdownMenuItem>
)}
{features.canCreate && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={duplicateExam} disabled={isWorking}>
<Copy className="mr-2 h-4 w-4" /> {t("exam.actions.duplicate")}
</DropdownMenuItem>
</>
)}
{features.canPublish && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setStatus("published")}
disabled={isWorking || exam.status === "published"}
>
<UploadCloud className="mr-2 h-4 w-4" /> {t("exam.actions.publish")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatus("draft")}
disabled={isWorking || exam.status === "draft"}
>
<Undo2 className="mr-2 h-4 w-4" /> {t("exam.actions.moveToDraft")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatus("archived")}
disabled={isWorking || exam.status === "archived"}
>
<Archive className="mr-2 h-4 w-4" /> {t("exam.actions.archive")}
</DropdownMenuItem>
</>
)}
{features.canManage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isWorking}
>
<Trash className="mr-2 h-4 w-4" /> {t("exam.actions.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -0,0 +1,226 @@
import type { JSX } from "react"
import { useTranslations } from "next-intl"
import { Users, CheckCircle2, TrendingUp, Award, AlertTriangle } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Progress } from "@/shared/components/ui/progress"
import type { ExamAnalyticsSummary } from "../stats-service"
interface ExamAnalyticsDashboardProps {
analytics: ExamAnalyticsSummary
}
/**
* V3-8: 考试分析仪表盘
*
* 对标智学网考试分析功能,展示:
* - 汇总卡片(应考人数、已批改份数、平均分、及格率)
* - 分数段分布
* - 逐题分析(错误率、难度等级)
*/
export function ExamAnalyticsDashboard({ analytics }: ExamAnalyticsDashboardProps): JSX.Element {
const t = useTranslations("examHomework")
const difficultyVariant = (difficulty: "easy" | "medium" | "hard") => {
if (difficulty === "easy") return "default" as const
if (difficulty === "medium") return "secondary" as const
return "destructive" as const
}
const difficultyLabel = (difficulty: "easy" | "medium" | "hard") => {
if (difficulty === "easy") return t("exam.analytics.difficultyEasy")
if (difficulty === "medium") return t("exam.analytics.difficultyMedium")
return t("exam.analytics.difficultyHard")
}
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("exam.analytics.totalStudents")}
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.totalStudents}</div>
<p className="text-xs text-muted-foreground">
{t("exam.analytics.submitted")}: {analytics.submittedCount} / {analytics.totalStudents}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("exam.analytics.gradedCount")}
</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.gradedCount}</div>
<p className="text-xs text-muted-foreground">
{t("exam.analytics.assignmentCount")}: {analytics.assignmentCount}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("exam.analytics.averageScore")}
</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analytics.averageScore}
<span className="text-sm font-normal text-muted-foreground">
{" / "}{analytics.maxScore}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("exam.analytics.passRate")}
</CardTitle>
<Award className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(analytics.passRate * 100).toFixed(1)}%
</div>
</CardContent>
</Card>
</div>
{/* Score Distribution */}
<Card>
<CardHeader>
<CardTitle>{t("exam.analytics.scoreDistribution")}</CardTitle>
<CardDescription>{t("exam.analytics.scoreDistributionDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{analytics.scoreDistribution.map((item) => {
const maxCount = Math.max(...analytics.scoreDistribution.map((d) => d.count), 1)
const percentage = (item.count / maxCount) * 100
return (
<div key={item.range} className="flex items-center gap-3">
<span className="w-20 text-sm text-muted-foreground tabular-nums">{item.range}</span>
<Progress value={percentage} className="h-3 flex-1" />
<span className="w-10 text-sm font-medium tabular-nums text-right">{item.count}</span>
</div>
)
})}
</div>
</CardContent>
</Card>
{/* Per-Question Analysis */}
<Card>
<CardHeader>
<CardTitle>{t("exam.analytics.questionAnalysis")}</CardTitle>
<CardDescription>{t("exam.analytics.questionAnalysisDesc")}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">#</TableHead>
<TableHead>{t("exam.analytics.questionType")}</TableHead>
<TableHead className="min-w-[200px]">{t("exam.analytics.questionText")}</TableHead>
<TableHead className="text-right">{t("exam.analytics.maxScore")}</TableHead>
<TableHead className="text-right">{t("exam.analytics.errorCount")}</TableHead>
<TableHead className="w-[120px]">{t("exam.analytics.errorRate")}</TableHead>
<TableHead>{t("exam.analytics.difficulty")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analytics.questions.map((q, index) => (
<TableRow key={q.questionId}>
<TableCell className="font-medium tabular-nums">{index + 1}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{q.questionType}
</Badge>
</TableCell>
<TableCell className="max-w-[300px] truncate text-sm text-muted-foreground">
{q.questionText}
</TableCell>
<TableCell className="text-right tabular-nums">{q.maxScore}</TableCell>
<TableCell className="text-right tabular-nums">{q.errorCount}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress
value={q.errorRate * 100}
className="h-2"
/>
<span className="w-10 text-xs tabular-nums text-right">
{(q.errorRate * 100).toFixed(0)}%
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={difficultyVariant(q.difficulty)}>
{difficultyLabel(q.difficulty)}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* High Error Rate Warning */}
{analytics.questions.filter((q) => q.errorRate >= 0.7).length > 0 && (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
{t("exam.analytics.highErrorWarning")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
{t("exam.analytics.highErrorWarningDesc")}
</p>
<div className="space-y-2">
{analytics.questions
.filter((q) => q.errorRate >= 0.7)
.map((q, index) => (
<div key={q.questionId} className="flex items-center gap-2 text-sm">
<span className="font-medium tabular-nums">#{index + 1}</span>
<span className="truncate text-muted-foreground">{q.questionText}</span>
<Badge variant="destructive" className="ml-auto">
{(q.errorRate * 100).toFixed(0)}%
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,159 @@
import "server-only"
import { cache } from "react"
import { db } from "@/shared/db"
import { exams, examQuestions } from "@/shared/db/schema"
import { eq } from "drizzle-orm"
import {
getHomeworkAssignmentsByExamId,
getGradedSubmissionsByExamId,
} from "@/modules/homework/data-access"
import { getQuestionText } from "@/modules/homework/lib/question-content-utils"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const parseExamMeta = (description: string | null): Record<string, unknown> => {
if (!description) return {}
try {
const parsed: unknown = JSON.parse(description)
return isRecord(parsed) ? parsed : {}
} catch {
return {}
}
}
const getNumber = (obj: Record<string, unknown>, key: string): number | undefined => {
const v = obj[key]
return typeof v === "number" ? v : undefined
}
/**
* V3-8: 考试分析数据类型
*/
export interface ExamAnalyticsSummary {
examId: string
examTitle: string
totalScore: number
assignmentCount: number
totalStudents: number
submittedCount: number
gradedCount: number
averageScore: number
maxScore: number
passRate: number
scoreDistribution: Array<{ range: string; count: number }>
questions: Array<{
questionId: string
questionType: string
questionText: string
maxScore: number
errorCount: number
errorRate: number
difficulty: "easy" | "medium" | "hard"
}>
}
/**
* V3-8: 获取考试分析数据
*
* 对标智学网考试分析功能,聚合该考试所有作业的已批改提交数据,
* 计算:平均分、及格率、分数段分布、逐题错误率与难度。
*/
export const getExamAnalytics = cache(async (examId: string): Promise<ExamAnalyticsSummary | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
columns: { id: true, title: true, description: true },
})
if (!exam) return null
const meta = parseExamMeta(exam.description)
const examTotalScore = getNumber(meta, "totalScore") ?? 100
const [assignments, gradedSubmissions, examQuestionsList] = await Promise.all([
getHomeworkAssignmentsByExamId(examId),
getGradedSubmissionsByExamId(examId),
db.query.examQuestions.findMany({
where: eq(examQuestions.examId, examId),
with: { question: true },
orderBy: (eqRel, { asc }) => [asc(eqRel.order)],
}),
])
const totalStudents = assignments.reduce((sum, a) => sum + a.targetCount, 0)
const submittedCount = assignments.reduce((sum, a) => sum + a.submittedCount, 0)
const gradedCount = gradedSubmissions.length
// Calculate max score from exam questions
const maxScore = examQuestionsList.reduce((sum, eq) => sum + (eq.score ?? 0), 0)
// Average score and pass rate
const scores = gradedSubmissions.map((s) => s.score)
const averageScore = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
const passThreshold = maxScore * 0.6
const passCount = scores.filter((s) => s >= passThreshold).length
const passRate = scores.length > 0 ? passCount / scores.length : 0
// Score distribution (0-59, 60-69, 70-79, 80-89, 90-100)
const ranges = [
{ range: "0-59%", min: 0, max: 0.59 },
{ range: "60-69%", min: 0.6, max: 0.69 },
{ range: "70-79%", min: 0.7, max: 0.79 },
{ range: "80-89%", min: 0.8, max: 0.89 },
{ range: "90-100%", min: 0.9, max: 1.0 },
]
const scoreDistribution = ranges.map((r) => {
const count = scores.filter((s) => {
const pct = maxScore > 0 ? s / maxScore : 0
return pct >= r.min && pct <= r.max
}).length
return { range: r.range, count }
})
// Per-question error rate and difficulty
const questions: ExamAnalyticsSummary["questions"] = examQuestionsList.map((eq) => {
const questionId = eq.questionId
const maxScore = eq.score ?? 0
let errorCount = 0
let totalAttempted = 0
for (const sub of gradedSubmissions) {
const ans = sub.answers.find((a) => a.questionId === questionId)
if (!ans) continue
totalAttempted += 1
if (ans.score < maxScore) {
errorCount += 1
}
}
const errorRate = totalAttempted > 0 ? errorCount / totalAttempted : 0
const difficulty: "easy" | "medium" | "hard" =
errorRate < 0.3 ? "easy" : errorRate < 0.7 ? "medium" : "hard"
return {
questionId,
questionType: eq.question.type,
questionText: getQuestionText(eq.question.content) || "(无题目文本)",
maxScore,
errorCount,
errorRate,
difficulty,
}
})
return {
examId: exam.id,
examTitle: exam.title,
totalScore: examTotalScore,
assignmentCount: assignments.length,
totalStudents,
submittedCount,
gradedCount,
averageScore: Math.round(averageScore * 100) / 100,
maxScore,
passRate: Math.round(passRate * 100) / 100,
scoreDistribution,
questions,
}
})