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:
@@ -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>
|
||||
|
||||
226
src/modules/exams/components/exam-analytics-dashboard.tsx
Normal file
226
src/modules/exams/components/exam-analytics-dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
src/modules/exams/stats-service.ts
Normal file
159
src/modules/exams/stats-service.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user