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

@@ -0,0 +1,35 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { getHomeworkAssignmentById, getStudentSubmissionResult } from "@/modules/homework/data-access"
import { HomeworkSubmissionResult } from "@/modules/homework/components/homework-submission-result"
import { getSession } from "@/shared/lib/session"
export const dynamic = "force-dynamic"
export default async function HomeworkResultPage({ params }: { params: Promise<{ assignmentId: string }> }): Promise<JSX.Element> {
const { assignmentId } = await params
const t = await getTranslations("examHomework")
const session = await getSession()
const studentId = session?.user?.id
if (!studentId) return notFound()
const [assignment, submission] = await Promise.all([
getHomeworkAssignmentById(assignmentId),
getStudentSubmissionResult(assignmentId, studentId),
])
if (!assignment) return notFound()
if (!submission) return notFound()
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("homework.result.title")}</h1>
<p className="text-muted-foreground">{assignment.title}</p>
</div>
<HomeworkSubmissionResult submission={submission} />
</div>
)
}

View File

@@ -0,0 +1,57 @@
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import Link from "next/link"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { BarChart3, ArrowLeft } from "lucide-react"
import { getExamById } from "@/modules/exams/data-access"
import { getExamAnalytics } from "@/modules/exams/stats-service"
import { ExamAnalyticsDashboard } from "@/modules/exams/components/exam-analytics-dashboard"
export const dynamic = "force-dynamic"
export default async function ExamAnalyticsPage({ params }: { params: Promise<{ id: string }> }): Promise<JSX.Element> {
const { id } = await params
const t = await getTranslations("examHomework")
const [exam, analytics] = await Promise.all([
getExamById(id),
getExamAnalytics(id),
])
if (!exam) return notFound()
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="min-w-0">
<div className="flex items-center gap-2">
<BarChart3 className="h-6 w-6 text-muted-foreground" />
<h1 className="text-2xl font-bold tracking-tight">{t("exam.analytics.title")}</h1>
</div>
<p className="text-muted-foreground truncate">{exam.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("exam.analytics.description")}</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/exams/all">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("homework.grade.back")}
</Link>
</Button>
</div>
</div>
{analytics && analytics.gradedCount > 0 ? (
<ExamAnalyticsDashboard analytics={analytics} />
) : (
<EmptyState
title={t("exam.analytics.title")}
description={t("exam.analytics.noData")}
icon={BarChart3}
/>
)}
</div>
)
}

View File

@@ -2,18 +2,9 @@ import type { JSX } from "react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access"
import { HomeworkBatchGradingView } from "@/modules/homework/components/homework-batch-grading-view"
export const dynamic = "force-dynamic"
@@ -52,39 +43,7 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
</div>
</div>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("homework.grade.student")}</TableHead>
<TableHead>{t("homework.grade.status")}</TableHead>
<TableHead>{t("homework.grade.submitted")}</TableHead>
<TableHead>{t("homework.grade.score")}</TableHead>
<TableHead>{t("homework.grade.action")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium truncate max-w-[160px]">{s.studentName}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
{s.isLate ? <span className="ml-2 text-xs text-destructive">{t("homework.grade.late")}</span> : null}
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell>
<Link href={`/teacher/homework/submissions/${s.id}`} className="text-sm underline-offset-4 hover:underline">
{t("homework.grade.title")}
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<HomeworkBatchGradingView submissions={submissions} />
</div>
)
}

80
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* Next.js Instrumentation 钩子
*
* 在应用启动时执行一次性初始化操作。
* 文档https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*
* V3-3: 注册 ExamHomeworkServicePort 实现
* 将 modules 层的 data-access 函数注入到 shared 层的 ServicePort
* 使 app 层可以通过 EXAM_HOMEWORK_SERVICE_PROVIDER.get() 调用,
* 而不直接依赖 modules 内部实现。
*/
import { registerExamHomeworkService } from "@/shared/services/exam-homework-port"
import { getExamById, getExams, getExamCreatorId, getExamTitleById } from "@/modules/exams/data-access"
import {
getHomeworkAssignmentById,
getHomeworkAssignments,
getAssignmentMaxScoreById,
} from "@/modules/homework/data-access"
import { getExamWithQuestionsForHomework } from "@/modules/exams/data-access"
import type { DataScope } from "@/shared/types/permissions"
import type { Exam } from "@/modules/exams/types"
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
/**
* 适配器:将 getExamById 的返回值补全为 Exam 类型
* data-access 返回的对象包含 questions 数组但缺少 questionCount 字段
*/
const adaptExam = (raw: Awaited<ReturnType<typeof getExamById>>): Exam | null => {
if (!raw) return null
return {
...raw,
questionCount: raw.questions?.length ?? 0,
}
}
/**
* 适配器:将 getHomeworkAssignmentById 的返回值补全为 HomeworkAssignmentListItem 类型
* data-access 返回的对象缺少 averageScore 和 overdueCount 字段
*/
const adaptAssignment = (raw: Awaited<ReturnType<typeof getHomeworkAssignmentById>>): HomeworkAssignmentListItem | null => {
if (!raw) return null
return {
id: raw.id,
sourceExamId: raw.sourceExamId,
sourceExamTitle: raw.sourceExamTitle,
title: raw.title,
status: raw.status,
availableAt: raw.availableAt,
dueAt: raw.dueAt,
allowLate: raw.allowLate,
lateDueAt: raw.lateDueAt,
maxAttempts: raw.maxAttempts,
createdAt: raw.createdAt,
updatedAt: raw.updatedAt,
targetCount: raw.targetCount,
submittedCount: raw.submittedCount,
gradedCount: raw.gradedCount,
averageScore: null,
overdueCount: 0,
}
}
export async function register(): Promise<void> {
registerExamHomeworkService({
// 考试
getExamById: async (id: string, scope?: DataScope) => adaptExam(await getExamById(id, scope)),
getExams,
getExamCreatorId,
getExamTitleById,
// 作业
getHomeworkAssignmentById: async (id: string, scope?: DataScope) => adaptAssignment(await getHomeworkAssignmentById(id, scope)),
getHomeworkAssignments,
getAssignmentMaxScoreByIds: getAssignmentMaxScoreById,
// 跨模块
getExamWithQuestionsForHomework,
})
}

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

View File

@@ -3,9 +3,11 @@
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError, safeJsonParse, safeParseDate } from "@/shared/lib/action-utils"
import { trackExamEvent } from "@/shared/lib/track-event"
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
import {
@@ -20,6 +22,7 @@ import {
markHomeworkSubmitted,
saveHomeworkAnswer,
startHomeworkSubmission,
batchAutoGradeSubmissions,
} from "./data-access-write"
const parseStudentIds = (raw: string): string[] => {
@@ -77,7 +80,11 @@ export async function createHomeworkAssignmentAction(
let exam: Awaited<ReturnType<typeof getExamWithQuestionsForHomework>> = null
if (!isQuickAssignment) {
const examData = await getExamWithQuestionsForHomework(input.sourceExamId!)
const sourceExamId = input.sourceExamId
if (!sourceExamId) {
return { success: false, message: "sourceExamId is required for exam mode" }
}
const examData = await getExamWithQuestionsForHomework(sourceExamId)
if (!examData) return { success: false, message: "Exam not found" }
exam = examData
}
@@ -116,9 +123,9 @@ export async function createHomeworkAssignmentAction(
}
const assignmentId = createId()
const availableAt = input.availableAt ? new Date(input.availableAt) : null
const dueAt = input.dueAt ? new Date(input.dueAt) : null
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
const availableAt = input.availableAt ? safeParseDate(input.availableAt, "可用时间") : null
const dueAt = input.dueAt ? safeParseDate(input.dueAt, "截止时间") : null
const lateDueAt = input.lateDueAt ? safeParseDate(input.lateDueAt, "迟交截止时间") : null
await createHomeworkAssignment({
assignmentId,
@@ -141,13 +148,21 @@ export async function createHomeworkAssignmentAction(
revalidatePath("/teacher/homework/assignments")
revalidatePath("/teacher/homework/submissions")
// V3-4: 埋点监控
await trackExamEvent("homework.created", {
userId: ctx.userId,
targetId: assignmentId,
properties: {
sourceExamId: input.sourceExamId ?? null,
classId: input.classId,
targetStudentCount: targetStudentIds.length,
published: publish,
},
})
return { success: true, message: "Assignment created", data: assignmentId }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
return handleActionError(e)
}
}
@@ -169,11 +184,7 @@ export async function startHomeworkSubmissionAction(
return { success: true, message: "Started", data: result.submissionId }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
return handleActionError(e)
}
}
@@ -194,17 +205,16 @@ export async function saveHomeworkAnswerAction(
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null
const payload =
typeof answerJson === "string" && answerJson.length > 0
? safeJsonParse<unknown>(answerJson, "答案数据格式无效")
: null
await saveHomeworkAnswer(submissionId, questionId, payload)
return { success: true, message: "Saved", data: submissionId }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
return handleActionError(e)
}
}
@@ -232,18 +242,30 @@ export async function submitHomeworkAction(
const isLate = Boolean(dueAt && now > dueAt)
await markHomeworkSubmitted(submissionId, isLate)
// V3-2: 即时自动批改回写
const { isFullyAutoGraded, totalScore } = await markHomeworkSubmitted(submissionId, isLate)
revalidatePath("/teacher/homework/submissions")
revalidatePath("/student/learning/assignments")
return { success: true, message: "Submitted", data: submissionId }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
// V3-4: 埋点监控
await trackExamEvent("homework.submitted", {
userId: ctx.userId,
targetId: submissionId,
properties: {
isLate,
isFullyAutoGraded,
totalScore,
},
})
return {
success: true,
message: isFullyAutoGraded ? "Submitted and auto-graded" : "Submitted",
data: submissionId,
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
} catch (e) {
return handleActionError(e)
}
}
@@ -258,7 +280,7 @@ export async function gradeHomeworkSubmissionAction(
const rawAnswers = typeof rawAnswersValue === "string" ? rawAnswersValue : null
const parsed = GradeHomeworkSchema.safeParse({
submissionId: formData.get("submissionId"),
answers: rawAnswers ? JSON.parse(rawAnswers) : [],
answers: rawAnswers ? safeJsonParse<unknown[]>(rawAnswers, "批改数据格式无效") : [],
})
if (!parsed.success) {
@@ -294,12 +316,82 @@ export async function gradeHomeworkSubmissionAction(
revalidatePath("/teacher/homework/submissions")
// V3-4: 埋点监控
await trackExamEvent("homework.graded", {
userId: ctx.userId,
targetId: submissionId,
properties: {
answerCount: answers.length,
},
})
return { success: true, message: "Grading saved" }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
return handleActionError(e)
}
}
/**
* V3-7: 批量自动批改提交
*
* 教师在提交列表页勾选多份提交后,一键自动批改所有客观题。
* 仅批改选择题/判断题,主观题保持原分数。
*/
export async function batchAutoGradeSubmissionsAction(
prevState: ActionState<{ successCount: number; failedCount: number; fullyGradedCount: number }> | null,
formData: FormData
): Promise<ActionState<{ successCount: number; failedCount: number; fullyGradedCount: number }>> {
try {
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
const rawSubmissionIds = formData.get("submissionIds")
const submissionIdsJson = typeof rawSubmissionIds === "string" ? rawSubmissionIds : "[]"
const submissionIds = safeJsonParse<string[]>(submissionIdsJson, "提交 ID 列表格式无效")
if (submissionIds.length === 0) {
return {
success: false,
message: "请至少选择一份提交",
}
}
// 权限校验:非管理员仅可批改自己创建的作业提交
if (ctx.dataScope.type !== "all") {
for (const submissionId of submissionIds) {
const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId)
if (!submissionForGrading) {
return { success: false, message: `提交不存在: ${submissionId}` }
}
if (submissionForGrading.creatorId !== ctx.userId) {
return { success: false, message: "只能批改自己创建的作业提交" }
}
}
}
const results = await batchAutoGradeSubmissions(submissionIds)
const successCount = results.filter((r) => r.success).length
const failedCount = results.filter((r) => !r.success).length
const fullyGradedCount = results.filter((r) => r.success && r.isFullyAutoGraded).length
revalidatePath("/teacher/homework/submissions")
revalidatePath("/teacher/homework/assignments")
await trackExamEvent("homework.graded", {
userId: ctx.userId,
targetId: submissionIds[0] ?? "",
properties: {
batchCount: submissionIds.length,
successCount,
fullyGradedCount,
},
})
return {
success: true,
message: `批量批改完成:成功 ${successCount} 份,失败 ${failedCount} 份,其中 ${fullyGradedCount} 份已全自动批改`,
data: { successCount, failedCount, fullyGradedCount },
}
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -0,0 +1,168 @@
"use client"
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Zap } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { batchAutoGradeSubmissionsAction } from "../actions"
import type { HomeworkSubmissionListItem } from "../types"
interface HomeworkBatchGradingViewProps {
submissions: HomeworkSubmissionListItem[]
}
/**
* V3-7: 批量批改视图
*
* 教师在提交列表页可勾选多份提交,一键自动批改所有客观题。
* 对标智学网的批量批改功能。
*/
export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingViewProps) {
const t = useTranslations("examHomework")
const router = useRouter()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [isPending, startTransition] = useTransition()
const selectableSubmissions = submissions.filter(
(s) => s.status === "submitted"
)
const allSelectableSelected =
selectableSubmissions.length > 0 &&
selectableSubmissions.every((s) => selectedIds.has(s.id))
const toggleSelectAll = () => {
if (allSelectableSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(selectableSubmissions.map((s) => s.id)))
}
}
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleBatchAutoGrade = () => {
if (selectedIds.size === 0) {
toast.error(t("homework.grade.batchSelectAtLeastOne"))
return
}
startTransition(async () => {
const formData = new FormData()
formData.set("submissionIds", JSON.stringify(Array.from(selectedIds)))
const result = await batchAutoGradeSubmissionsAction(null, formData)
if (result.success) {
toast.success(result.message)
setSelectedIds(new Set())
router.refresh()
} else {
toast.error(result.message || t("homework.grade.batchFailed"))
}
})
}
return (
<div className="space-y-4">
{selectedIds.size > 0 && (
<div className="flex items-center justify-between rounded-lg border bg-muted/50 px-4 py-3">
<span className="text-sm font-medium">
{t("homework.grade.batchSelected", { count: selectedIds.size })}
</span>
<Button
onClick={handleBatchAutoGrade}
disabled={isPending}
size="sm"
>
<Zap className="mr-2 h-4 w-4" />
{t("homework.grade.batchAutoGrade")}
</Button>
</div>
)}
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={allSelectableSelected}
onCheckedChange={toggleSelectAll}
aria-label={t("homework.grade.selectAll")}
/>
</TableHead>
<TableHead>{t("homework.grade.student")}</TableHead>
<TableHead>{t("homework.grade.status")}</TableHead>
<TableHead>{t("homework.grade.submitted")}</TableHead>
<TableHead>{t("homework.grade.score")}</TableHead>
<TableHead>{t("homework.grade.action")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissions.map((s) => {
const isSelectable = s.status === "submitted"
const isSelected = selectedIds.has(s.id)
return (
<TableRow key={s.id} data-selected={isSelected}>
<TableCell>
{isSelectable ? (
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(s.id)}
aria-label={t("homework.grade.selectRow")}
/>
) : (
<span className="text-muted-foreground" aria-hidden="true">
</span>
)}
</TableCell>
<TableCell className="font-medium truncate max-w-[160px]">{s.studentName}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
{s.isLate ? <span className="ml-2 text-xs text-destructive">{t("homework.grade.late")}</span> : null}
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell>
<a
href={`/teacher/homework/submissions/${s.id}`}
className="text-sm underline-offset-4 hover:underline"
>
{t("homework.grade.title")}
</a>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,234 @@
import type { JSX } from "react"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { CheckCircle2, XCircle, AlertCircle, Award, ArrowLeft, BookOpen } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Progress } from "@/shared/components/ui/progress"
import {
formatStudentAnswer,
getCorrectnessState,
getQuestionText,
getTextCorrectAnswers,
getChoiceCorrectIds,
getJudgmentCorrectAnswer,
} from "../lib/question-content-utils"
import type { HomeworkSubmissionDetails } from "../types"
interface HomeworkSubmissionResultProps {
submission: HomeworkSubmissionDetails
}
/**
* V3-9: 提交后即时反馈页
*
* 对标智学网/猿题库,学生提交后立即看到:
* - 分数汇总(总分/满分、得分率)
* - 对错分布(正确/错误/部分正确/待批改)
* - 错题预览(题目文本、学生答案、正确答案)
*/
export function HomeworkSubmissionResult({ submission }: HomeworkSubmissionResultProps): JSX.Element {
const t = useTranslations("examHomework")
const maxScore = submission.answers.reduce((sum, a) => sum + a.maxScore, 0)
const totalScore = submission.totalScore ?? 0
const scorePercentage = maxScore > 0 ? (totalScore / maxScore) * 100 : 0
const stats = submission.answers.reduce(
(acc, a) => {
const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore })
if (state === "correct") acc.correct += 1
else if (state === "incorrect") acc.incorrect += 1
else if (state === "partial") acc.partial += 1
else acc.ungraded += 1
return acc
},
{ correct: 0, incorrect: 0, partial: 0, ungraded: 0 }
)
const wrongAnswers = submission.answers.filter((a) => {
const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore })
return state === "incorrect" || state === "partial"
})
const formatCorrectAnswer = (questionType: string, content: unknown): string => {
if (questionType === "single_choice" || questionType === "multiple_choice") {
const ids = getChoiceCorrectIds(content)
return ids.length > 0 ? ids.join(", ") : "—"
}
if (questionType === "judgment") {
const ans = getJudgmentCorrectAnswer(content)
if (ans === null) return "—"
return ans ? t("homework.review.correctAnswerTrue") : t("homework.review.correctAnswerFalse")
}
if (questionType === "text") {
const answers = getTextCorrectAnswers(content)
return answers.length > 0 ? answers.join(" / ") : "—"
}
return "—"
}
return (
<div className="space-y-6">
{/* Score Summary */}
<Card>
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Award className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-3xl">
{totalScore}
<span className="text-lg font-normal text-muted-foreground">
{" / "}{maxScore}
</span>
</CardTitle>
<CardDescription>
{t("homework.result.scoreRate")}: {scorePercentage.toFixed(1)}%
</CardDescription>
</CardHeader>
<CardContent>
<Progress value={scorePercentage} className="h-3" />
{submission.status === "graded" ? (
<p className="mt-3 text-center text-sm text-muted-foreground">
{t("homework.result.fullyGraded")}
</p>
) : (
<p className="mt-3 text-center text-sm text-muted-foreground">
{t("homework.result.partiallyGraded")}
</p>
)}
</CardContent>
</Card>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<CheckCircle2 className="h-8 w-8 text-green-500" />
<div>
<p className="text-2xl font-bold">{stats.correct}</p>
<p className="text-xs text-muted-foreground">{t("homework.result.correctCount")}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<XCircle className="h-8 w-8 text-red-500" />
<div>
<p className="text-2xl font-bold">{stats.incorrect}</p>
<p className="text-xs text-muted-foreground">{t("homework.result.incorrectCount")}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<AlertCircle className="h-8 w-8 text-yellow-500" />
<div>
<p className="text-2xl font-bold">{stats.partial}</p>
<p className="text-xs text-muted-foreground">{t("homework.result.partialCount")}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<BookOpen className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">{stats.ungraded}</p>
<p className="text-xs text-muted-foreground">{t("homework.result.pendingCount")}</p>
</div>
</CardContent>
</Card>
</div>
{/* Wrong Answers Preview */}
{wrongAnswers.length > 0 && (
<Card>
<CardHeader>
<CardTitle>{t("homework.result.wrongAnswersTitle")}</CardTitle>
<CardDescription>{t("homework.result.wrongAnswersDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{wrongAnswers.map((a, index) => {
const state = getCorrectnessState({ score: a.score, maxScore: a.maxScore })
return (
<div key={a.id} className="rounded-lg border p-4">
<div className="mb-2 flex items-start gap-2">
<span className="font-medium tabular-nums">#{index + 1}</span>
<Badge
variant={state === "incorrect" ? "destructive" : "secondary"}
className="text-xs"
>
{state === "incorrect"
? t("homework.grade.incorrect")
: t("homework.grade.partial")}
</Badge>
<Badge variant="outline" className="text-xs">
{a.questionType}
</Badge>
<span className="ml-auto text-sm tabular-nums text-muted-foreground">
{a.score ?? 0} / {a.maxScore}
</span>
</div>
<p className="mb-3 text-sm">
{getQuestionText(a.questionContent) || t("homework.grade.noQuestionText")}
</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded bg-red-50 dark:bg-red-950/20 p-2">
<p className="mb-1 text-xs font-medium text-red-600 dark:text-red-400">
{t("homework.review.yourAnswer")}
</p>
<p className="text-sm">
{formatStudentAnswer(a.studentAnswer) || "—"}
</p>
</div>
<div className="rounded bg-green-50 dark:bg-green-950/20 p-2">
<p className="mb-1 text-xs font-medium text-green-600 dark:text-green-400">
{t("homework.review.correctAnswer")}
</p>
<p className="text-sm">
{formatCorrectAnswer(a.questionType, a.questionContent)}
</p>
</div>
</div>
{a.feedback && (
<div className="mt-2 rounded bg-blue-50 dark:bg-blue-950/20 p-2">
<p className="mb-1 text-xs font-medium text-blue-600 dark:text-blue-400">
{t("homework.review.teacherFeedback")}
</p>
<p className="text-sm">{a.feedback}</p>
</div>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-center gap-3">
<Button asChild variant="outline">
<Link href="/student/learning/assignments">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("homework.result.backToList")}
</Link>
</Button>
<Button asChild>
<Link href="/student/error-book">
<BookOpen className="mr-2 h-4 w-4" />
{t("homework.result.viewErrorBook")}
</Link>
</Button>
</div>
</div>
)
}

View File

@@ -125,18 +125,23 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const handleStart = async () => {
setIsBusy(true)
const fd = new FormData()
fd.set("assignmentId", assignmentId)
const res = await startHomeworkSubmissionAction(null, fd)
if (res.success && res.data) {
setSubmissionId(res.data)
setSubmissionStatus("started")
toast.success(t("homework.take.startSuccess"))
router.refresh()
} else {
toast.error(res.message || t("homework.take.startFailed"))
try {
const fd = new FormData()
fd.set("assignmentId", assignmentId)
const res = await startHomeworkSubmissionAction(null, fd)
if (res.success && res.data) {
setSubmissionId(res.data)
setSubmissionStatus("started")
toast.success(t("homework.take.startSuccess"))
router.refresh()
} else {
toast.error(res.message || t("homework.take.startFailed"))
}
} catch {
toast.error(t("homework.take.startFailed"))
} finally {
setIsBusy(false)
}
setIsBusy(false)
}
const handleSaveQuestion = async (questionId: string) => {
@@ -156,22 +161,29 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const handleSubmit = async () => {
if (!submissionId) return
setIsBusy(true)
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
await autoSave.flush()
try {
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
// flush 失败应中止提交,避免丢失未保存的答案
await autoSave.flush()
const submitFd = new FormData()
submitFd.set("submissionId", submissionId)
const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) {
// 提交成功后清除离线缓存
clearOfflineCache(offlineStorageKey)
toast.success(t("homework.take.submitSuccess"))
setSubmissionStatus("submitted")
router.push("/student/learning/assignments")
} else {
toast.error(submitRes.message || t("homework.take.submitFailed"))
const submitFd = new FormData()
submitFd.set("submissionId", submissionId)
const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) {
// 提交成功后清除离线缓存
clearOfflineCache(offlineStorageKey)
toast.success(t("homework.take.submitSuccess"))
setSubmissionStatus("submitted")
// V3-9: 提交后跳转到结果页,展示即时反馈
router.push(`/student/learning/assignments/${assignmentId}/result`)
} else {
toast.error(submitRes.message || t("homework.take.submitFailed"))
}
} catch {
toast.error(t("homework.take.submitFailed"))
} finally {
setIsBusy(false)
}
setIsBusy(false)
}
// 统计未作答题目数
@@ -378,7 +390,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
}}
className={cn(
"h-8 w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
"h-11 w-11 sm:h-8 sm:w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
)}
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}

View File

@@ -21,6 +21,10 @@ import {
type ExamWithQuestionsForHomework,
} from "@/modules/exams/data-access"
import type { DataScope } from "@/shared/types/permissions"
import {
autoGradeSubmission,
type AutoGradableAnswer,
} from "./lib/question-content-utils"
// ---- Types ----
@@ -267,12 +271,84 @@ export const saveHomeworkAnswer = async (
export const markHomeworkSubmitted = async (
submissionId: string,
isLate: boolean
): Promise<void> => {
): Promise<{ isFullyAutoGraded: boolean; totalScore: number }> => {
const now = new Date()
await db
.update(homeworkSubmissions)
.set({ status: "submitted", submittedAt: now, isLate, updatedAt: now })
.where(eq(homeworkSubmissions.id, submissionId))
// V3-2: 即时自动批改回写
// 1. 获取提交的所有答案 + 题目元数据
// 2. 调用 autoGradeSubmission 计算分数
// 3. 回写答案分数 + 提交状态 + 总分
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
columns: { id: true, assignmentId: true },
})
if (!submission) {
throw new Error(`Submission not found: ${submissionId}`)
}
const [answers, assignmentQuestions] = await Promise.all([
db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, submissionId),
columns: { id: true, questionId: true, answerContent: true, score: true, feedback: true },
}),
db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
with: { question: { columns: { type: true, content: true } } },
columns: { questionId: true, score: true, order: true },
}),
])
// 构建 AutoGradableAnswer 数组
const questionMetaMap = new Map(assignmentQuestions.map((aq) => [aq.questionId, aq]))
const autoGradableInputs: AutoGradableAnswer[] = answers.map((ans) => {
const meta = questionMetaMap.get(ans.questionId)
return {
id: ans.id,
questionId: ans.questionId,
questionType: meta?.question.type ?? "text",
questionContent: meta?.question.content ?? null,
maxScore: meta?.score ?? 0,
studentAnswer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
order: meta?.order ?? 0,
}
})
// 执行自动批改
const { answers: gradedAnswers, isFullyAutoGraded } = autoGradeSubmission(autoGradableInputs)
const totalScore = gradedAnswers.reduce((sum, a) => sum + a.score, 0)
// 回写 DB答案分数 + 提交状态
await db.transaction(async (tx) => {
// 批量更新答案分数
for (const ans of gradedAnswers) {
await tx
.update(homeworkAnswers)
.set({ score: ans.score, updatedAt: now })
.where(
and(
eq(homeworkAnswers.id, ans.id),
eq(homeworkAnswers.submissionId, submissionId)
)
)
}
// 更新提交状态:全部可自动判分 → graded含主观题 → submitted
await tx
.update(homeworkSubmissions)
.set({
status: isFullyAutoGraded ? "graded" : "submitted",
submittedAt: now,
isLate,
score: totalScore,
updatedAt: now,
})
.where(eq(homeworkSubmissions.id, submissionId))
})
return { isFullyAutoGraded, totalScore }
}
export const gradeHomeworkAnswers = async (
@@ -282,10 +358,17 @@ export const gradeHomeworkAnswers = async (
await db.transaction(async (tx) => {
let totalScore = 0
for (const ans of answers) {
// 关键安全约束WHERE 子句同时匹配 answer.id 和 submissionId
// 防止恶意客户端篡改 answer ID 批改其他 submission 的答案
await tx
.update(homeworkAnswers)
.set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() })
.where(eq(homeworkAnswers.id, ans.id))
.where(
and(
eq(homeworkAnswers.id, ans.id),
eq(homeworkAnswers.submissionId, submissionId)
)
)
totalScore += ans.score
}
@@ -295,3 +378,122 @@ export const gradeHomeworkAnswers = async (
.where(eq(homeworkSubmissions.id, submissionId))
})
}
/**
* V3-7: 批量自动批改提交
*
* 对多个提交执行自动批改(复用 autoGradeSubmission 逻辑)。
* 仅批改客观题(选择题/判断题),主观题保持原分数。
*
* @param submissionIds 要批量批改的提交 ID 列表
* @returns 每个提交的批改结果摘要
*/
export const batchAutoGradeSubmissions = async (
submissionIds: string[]
): Promise<Array<{
submissionId: string
success: boolean
isFullyAutoGraded: boolean
totalScore: number
message?: string
}>> => {
const now = new Date()
const results: Array<{
submissionId: string
success: boolean
isFullyAutoGraded: boolean
totalScore: number
message?: string
}> = []
for (const submissionId of submissionIds) {
try {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
columns: { id: true, assignmentId: true },
})
if (!submission) {
results.push({
submissionId,
success: false,
isFullyAutoGraded: false,
totalScore: 0,
message: "Submission not found",
})
continue
}
const [answers, assignmentQuestions] = await Promise.all([
db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, submissionId),
columns: { id: true, questionId: true, answerContent: true, score: true, feedback: true },
}),
db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
with: { question: { columns: { type: true, content: true } } },
columns: { questionId: true, score: true, order: true },
}),
])
const questionMetaMap = new Map(assignmentQuestions.map((aq) => [aq.questionId, aq]))
const autoGradableInputs: AutoGradableAnswer[] = answers.map((ans) => {
const meta = questionMetaMap.get(ans.questionId)
return {
id: ans.id,
questionId: ans.questionId,
questionType: meta?.question.type ?? "text",
questionContent: meta?.question.content ?? null,
maxScore: meta?.score ?? 0,
studentAnswer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
order: meta?.order ?? 0,
}
})
const { answers: gradedAnswers, isFullyAutoGraded } = autoGradeSubmission(autoGradableInputs)
const totalScore = gradedAnswers.reduce((sum, a) => sum + a.score, 0)
await db.transaction(async (tx) => {
for (const ans of gradedAnswers) {
await tx
.update(homeworkAnswers)
.set({ score: ans.score, updatedAt: now })
.where(
and(
eq(homeworkAnswers.id, ans.id),
eq(homeworkAnswers.submissionId, submissionId)
)
)
}
await tx
.update(homeworkSubmissions)
.set({
status: isFullyAutoGraded ? "graded" : "submitted",
score: totalScore,
updatedAt: now,
})
.where(eq(homeworkSubmissions.id, submissionId))
})
results.push({
submissionId,
success: true,
isFullyAutoGraded,
totalScore,
})
} catch (error) {
results.push({
submissionId,
success: false,
isFullyAutoGraded: false,
totalScore: 0,
message: error instanceof Error ? error.message : "Unknown error",
})
}
}
return results
}

View File

@@ -282,44 +282,44 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
const assignmentIds = assignments.map((a) => a.id)
const targetCountRows = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId)
const [targetCountRows, submittedCountRows, gradedCountRows] = await Promise.all([
db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
.groupBy(homeworkSubmissions.assignmentId),
])
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submittedCountRows = await db
.select({
assignmentId: homeworkSubmissions.assignmentId,
submittedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId)
const submittedCountByAssignmentId = new Map<string, number>()
for (const r of submittedCountRows) submittedCountByAssignmentId.set(r.assignmentId, Number(r.submittedCount ?? 0))
const gradedCountRows = await db
.select({
assignmentId: homeworkSubmissions.assignmentId,
gradedCount: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})`,
})
.from(homeworkSubmissions)
.where(and(inArray(homeworkSubmissions.assignmentId, assignmentIds), eq(homeworkSubmissions.status, "graded")))
.groupBy(homeworkSubmissions.assignmentId)
const gradedCountByAssignmentId = new Map<string, number>()
for (const r of gradedCountRows) gradedCountByAssignmentId.set(r.assignmentId, Number(r.gradedCount ?? 0))
@@ -452,27 +452,26 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
}
}
const [targetsRow] = await db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, id))
const [submissionsRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, id))
const [submittedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"]))
)
const [gradedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, id), eq(homeworkSubmissions.status, "graded")))
const [targetsRows, submissionsRows, submittedRows, gradedRows] = await Promise.all([
db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, id)),
db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, id)),
db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(eq(homeworkSubmissions.assignmentId, id), inArray(homeworkSubmissions.status, ["submitted", "graded"]))
),
db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, id), eq(homeworkSubmissions.status, "graded"))),
])
return {
id: assignment.id,
@@ -487,15 +486,137 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
targetCount: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
submittedCount: submittedRow?.c ?? 0,
gradedCount: gradedRow?.c ?? 0,
targetCount: targetsRows[0]?.c ?? 0,
submissionCount: submissionsRows[0]?.c ?? 0,
submittedCount: submittedRows[0]?.c ?? 0,
gradedCount: gradedRows[0]?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
}
})
/**
* V3-8: 获取关联到指定考试的所有作业(跨模块读接口)
*
* 供 exams 模块的考试分析仪表盘调用,获取该考试派生的所有作业及其提交统计。
*/
export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Promise<Array<{
id: string
title: string
status: string | null
targetCount: number
submittedCount: number
gradedCount: number
dueAt: string | null
}>> => {
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.sourceExamId, examId),
columns: { id: true, title: true, status: true, dueAt: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const [targetsRows, submittedRows, gradedRows] = await Promise.all([
db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId, c: count() })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId),
db
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
)
)
.groupBy(homeworkSubmissions.assignmentId),
])
const targetMap = new Map(targetsRows.map((r) => [r.assignmentId, Number(r.c)]))
const submittedMap = new Map(submittedRows.map((r) => [r.assignmentId, Number(r.c)]))
const gradedMap = new Map(gradedRows.map((r) => [r.assignmentId, Number(r.c)]))
return assignments.map((a) => ({
id: a.id,
title: a.title,
status: a.status,
targetCount: targetMap.get(a.id) ?? 0,
submittedCount: submittedMap.get(a.id) ?? 0,
gradedCount: gradedMap.get(a.id) ?? 0,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
}))
})
/**
* V3-8: 获取指定考试所有作业的已批改提交(跨模块读接口)
*
* 供 exams 模块的考试分析仪表盘调用,获取学生姓名、分数、答案内容用于统计分析。
*/
export const getGradedSubmissionsByExamId = cache(async (examId: string): Promise<Array<{
submissionId: string
assignmentId: string
studentId: string
studentName: string
score: number
answers: Array<{ questionId: string; score: number; answerContent: unknown }>
}>> => {
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.sourceExamId, examId),
columns: { id: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
),
with: {
student: true,
answers: {
columns: { questionId: true, score: true, answerContent: true },
},
},
orderBy: (s, { desc }) => [desc(s.updatedAt)],
})
// Deduplicate: keep only the latest submission per student
const latestByStudent = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
if (!latestByStudent.has(s.studentId)) latestByStudent.set(s.studentId, s)
}
return Array.from(latestByStudent.values()).map((s) => ({
submissionId: s.id,
assignmentId: s.assignmentId,
studentId: s.studentId,
studentName: s.student.name || "Unknown",
score: s.score ?? 0,
answers: s.answers.map((a) => ({
questionId: a.questionId,
score: a.score ?? 0,
answerContent: a.answerContent,
})),
}))
})
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
@@ -507,17 +628,18 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
if (!submission) return null
const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, submissionId),
with: {
question: true,
},
})
const assignmentQ = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
orderBy: [desc(homeworkAssignmentQuestions.order)],
})
const [answers, assignmentQ] = await Promise.all([
db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, submissionId),
with: {
question: true,
},
}),
db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
orderBy: [desc(homeworkAssignmentQuestions.order)],
}),
])
const answersWithDetails = answers
.map((ans) => {
@@ -579,6 +701,89 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
}
})
/**
* V3-9: 获取学生在指定作业的最新提交结果(用于提交后反馈页)
*
* 查找学生最近一次已提交/已批改的 submission返回完整详情含答案。
*/
export const getStudentSubmissionResult = cache(async (
assignmentId: string,
studentId: string
): Promise<HomeworkSubmissionDetails | null> => {
const latestSubmission = await db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.studentId, studentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
),
orderBy: [desc(homeworkSubmissions.updatedAt)],
columns: { id: true },
})
if (!latestSubmission) return null
return getHomeworkSubmissionDetails(latestSubmission.id)
})
/**
* V3-11: 获取学生的考试结果列表(供家长端展示)
*
* 查找学生所有已批改的、关联到考试的作业提交,
* 返回考试标题、科目、分数、提交时间等。
*/
export const getStudentExamResults = cache(async (studentId: string): Promise<Array<{
submissionId: string
examId: string
examTitle: string
assignmentId: string
assignmentTitle: string
score: number
maxScore: number
submittedAt: string | null
status: string
}>> => {
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.studentId, studentId),
eq(homeworkSubmissions.status, "graded")
),
with: {
assignment: {
with: { sourceExam: true },
},
},
orderBy: [desc(homeworkSubmissions.updatedAt)],
limit: 50,
})
// Filter to only exam-linked submissions, deduplicate by examId
const latestByExamId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const examId = s.assignment.sourceExamId
if (!examId) continue
if (!latestByExamId.has(examId)) latestByExamId.set(examId, s)
}
const examIds = Array.from(latestByExamId.keys())
if (examIds.length === 0) return []
// Get max scores for each assignment
const assignmentIds = Array.from(latestByExamId.values()).map((s) => s.assignmentId)
const maxScoreMap = await getAssignmentMaxScoreById(assignmentIds)
return Array.from(latestByExamId.entries()).map(([examId, s]) => ({
submissionId: s.id,
examId,
examTitle: s.assignment.sourceExam?.title ?? s.assignment.title,
assignmentId: s.assignmentId,
assignmentTitle: s.assignment.title,
score: s.score ?? 0,
maxScore: maxScoreMap.get(s.assignmentId) ?? 0,
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
status: s.status ?? "graded",
}))
})
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
if (v === "started") return "in_progress"
if (v === "submitted") return "submitted"

View File

@@ -1,26 +1,207 @@
"use client"
import { useMemo, useState } from "react"
import {
BarChart3,
CalendarDays,
ClipboardList,
GraduationCap,
Mail,
Stethoscope,
} from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { cn } from "@/shared/lib/utils"
import type { ChildDashboardData } from "@/modules/parent/types"
import { ChildGradeDetail } from "./child-grade-detail"
import { ChildGradeSummary } from "./child-grade-summary"
import { ChildHomeworkDetail } from "./child-homework-detail"
import { ChildHomeworkSummary } from "./child-homework-summary"
import { ChildScheduleCard } from "./child-schedule-card"
import type { ChildDashboardData } from "@/modules/parent/types"
import { ChildExamDetail } from "./child-exam-detail"
export function ChildDetailPanel({ child }: { child: ChildDashboardData }) {
const { basicInfo, todaySchedule, homeworkSummary, gradeTrend } = child
export type ChildDetailTab = "overview" | "homework" | "grades" | "exams" | "schedule" | "attendance" | "diagnostic"
const VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"]
const isTab = (v: string | undefined | null): v is ChildDetailTab =>
typeof v === "string" && (VALID_TABS as string[]).includes(v)
const resolveTab = (v: string | undefined | null): ChildDetailTab =>
isTab(v) ? v : "overview"
export function ChildDetailPanel({
child,
initialTab,
siblingSwitcher,
}: {
child: ChildDashboardData
initialTab?: string
siblingSwitcher?: React.ReactNode
}) {
const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child
const childName = basicInfo.name ?? "Child"
const [tab, setTab] = useState<ChildDetailTab>(resolveTab(initialTab))
const tabs = useMemo(
() => [
{ id: "overview" as const, label: "Overview", icon: ClipboardList },
{ id: "homework" as const, label: "Homework", icon: ClipboardList },
{ id: "grades" as const, label: "Grades", icon: BarChart3 },
{ id: "exams" as const, label: "Exams", icon: GraduationCap },
{ id: "schedule" as const, label: "Schedule", icon: CalendarDays },
{ id: "attendance" as const, label: "Attendance", icon: CalendarDays },
{ id: "diagnostic" as const, label: "Diagnostic", icon: Stethoscope },
],
[],
)
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="md:col-span-1 lg:col-span-2 space-y-6">
<ChildHomeworkSummary
{siblingSwitcher}
<Tabs value={tab} onValueChange={(v) => setTab(v as ChildDetailTab)} className="w-full">
<div className="overflow-x-auto">
<TabsList className="w-full justify-start">
{tabs.map((t) => (
<TabsTrigger
key={t.id}
value={t.id}
className="gap-1.5"
aria-label={`${t.label} tab`}
>
<t.icon className="h-3.5 w-3.5" />
{t.label}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value="overview" className="mt-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="md:col-span-1 lg:col-span-2 space-y-6">
<ChildHomeworkSummary
summary={homeworkSummary}
childId={basicInfo.id}
childName={childName}
/>
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
</div>
<div className="space-y-6">
<ChildScheduleCard items={todaySchedule} childName={childName} />
</div>
</div>
</TabsContent>
<TabsContent value="homework" className="mt-6">
<ChildHomeworkDetail
summary={homeworkSummary}
childId={basicInfo.id}
childName={childName}
/>
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
</div>
<div className="space-y-6">
<ChildScheduleCard items={todaySchedule} childName={childName} />
</div>
</TabsContent>
<TabsContent value="grades" className="mt-6">
<div className="space-y-6">
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
<div>
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
Subject Analysis
</h3>
<ChildGradeDetail grades={gradeTrend} />
</div>
</div>
</TabsContent>
<TabsContent value="exams" className="mt-6">
<ChildExamDetail
examResults={examResults}
childId={basicInfo.id}
childName={childName}
/>
</TabsContent>
<TabsContent value="schedule" className="mt-6">
<ChildScheduleCard
items={todaySchedule}
childName={childName}
weeklyItems={weeklySchedule}
/>
</TabsContent>
<TabsContent value="attendance" className="mt-6">
<div className="rounded-md border bg-muted/30 p-6 text-center">
<p className="text-sm text-muted-foreground">
Attendance details are available on the{" "}
<a
href="/parent/attendance"
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Attendance page
</a>
.
</p>
</div>
</TabsContent>
<TabsContent value="diagnostic" className="mt-6">
<div className="rounded-md border bg-muted/30 p-6 text-center">
<p className="text-sm text-muted-foreground">
Diagnostic reports will be available here once published by the school.
</p>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm" className="gap-2">
<a href={`/messages?studentId=${basicInfo.id}`} aria-label={`Contact teacher about ${childName}`}>
<Mail className="h-4 w-4" />
Contact Teacher
</a>
</Button>
</div>
</div>
)
}
/** 紧凑的子女切换器(用于详情页头部)。 */
export function SiblingSwitcher({
current,
siblings,
}: {
current: { id: string; name: string | null }
siblings: Array<{ id: string; name: string | null }>
}) {
if (siblings.length <= 1) return null
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">Switch child</span>
<div className="flex flex-wrap gap-1">
{siblings.map((s) => {
const isActive = s.id === current.id
const label = s.name ?? "Child"
return (
<a
key={s.id}
href={`/parent/children/${s.id}`}
aria-current={isActive ? "page" : undefined}
aria-label={`View ${label}'s details`}
className={cn(
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isActive
? "bg-primary text-primary-foreground"
: "bg-background text-foreground hover:bg-muted",
)}
>
{label}
</a>
)
})}
</div>
</div>
)

View File

@@ -0,0 +1,153 @@
import type { JSX } from "react"
import Link from "next/link"
import { GraduationCap, TrendingUp, Award, BookOpen } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
export interface ChildExamResultItem {
submissionId: string
examId: string
examTitle: string
assignmentId: string
assignmentTitle: string
score: number
maxScore: number
submittedAt: string | null
status: string
}
interface ChildExamDetailProps {
examResults: ChildExamResultItem[]
childId: string
childName: string
}
/**
* V3-11: 家长端子女考试详情视图
*
* 对标智学网家长端,展示:
* - 考试成绩汇总卡片(已参加考试数、平均分、最高分)
* - 考试成绩列表(考试标题、分数、得分率、提交时间)
* - 成绩趋势可视化
*/
export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element {
const hasResults = examResults.length > 0
const examCount = examResults.length
const averageScore = hasResults
? examResults.reduce((sum, r) => {
const rate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
return sum + rate
}, 0) / examCount
: 0
const bestScore = hasResults
? Math.max(...examResults.map((r) => (r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0)))
: 0
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<GraduationCap className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{examCount}</p>
<p className="text-xs text-muted-foreground">Exams Taken</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
<TrendingUp className="h-5 w-5 text-blue-500" />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">Average Score</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
<Award className="h-5 w-5 text-green-500" />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">Best Score</p>
</div>
</CardContent>
</Card>
</div>
{/* Exam Results List */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden />
{childName}&apos;s Exam Results
</CardTitle>
<CardDescription>Recent exam scores and performance trends</CardDescription>
</CardHeader>
<CardContent>
{!hasResults ? (
<EmptyState
icon={GraduationCap}
title="No exam results"
description="Exam results will appear here once available."
className="border-none h-48"
/>
) : (
<div className="space-y-3">
{examResults.map((r) => {
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
const isPass = scoreRate >= 60
return (
<Link
key={r.submissionId}
href={`/parent/children/${childId}?tab=grades`}
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium text-sm truncate">{r.examTitle}</div>
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
{isPass ? "Pass" : "Below 60%"}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{r.submittedAt ? (
<span>{formatDate(r.submittedAt)}</span>
) : null}
<span aria-hidden="true"></span>
<span className="tabular-nums">
{r.score} / {r.maxScore}
</span>
</div>
<Progress value={scoreRate} className="h-1.5 mt-1" />
</div>
<div className="text-sm font-semibold tabular-nums shrink-0 ml-2">
{scoreRate.toFixed(0)}%
</div>
</Link>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -13,6 +13,7 @@ import {
import {
getStudentDashboardGrades,
getStudentHomeworkAssignments,
getStudentExamResults,
} from "@/modules/homework/data-access"
import { getStudentGradeSummary } from "@/modules/grades/data-access"
import { getGradeNameById } from "@/modules/school/data-access"
@@ -22,6 +23,7 @@ import type {
ChildDashboardData,
ChildHomeworkSummaryData,
ChildScheduleItem,
ChildWeeklyScheduleItem,
ParentChildRelation,
ParentDashboardData,
} from "./types"
@@ -174,26 +176,50 @@ const buildTodaySchedule = (
.sort((a, b) => a.startTime.localeCompare(b.startTime))
}
const buildWeeklySchedule = (
schedule: Awaited<ReturnType<typeof getStudentSchedule>>,
): ChildWeeklyScheduleItem[] => {
return schedule
.map((s) => ({
id: s.id,
classId: s.classId,
className: s.className,
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
weekday: s.weekday,
}))
.sort((a, b) =>
a.weekday === b.weekday
? a.startTime.localeCompare(b.startTime)
: a.weekday - b.weekday,
)
}
export const getChildDashboardData = cache(
async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
const basicInfo = await getChildBasicInfo(studentId, relation)
if (!basicInfo) return null
const [enrolledClasses, schedule, assignments, gradeTrend, gradeSummary] = await Promise.all([
const [enrolledClasses, schedule, assignments, gradeTrend, gradeSummary, examResults] = await Promise.all([
getStudentClasses(studentId),
getStudentSchedule(studentId),
getStudentHomeworkAssignments(studentId),
getStudentDashboardGrades(studentId),
getStudentGradeSummary(studentId),
getStudentExamResults(studentId),
])
return {
basicInfo,
enrolledClasses,
todaySchedule: buildTodaySchedule(schedule),
weeklySchedule: buildWeeklySchedule(schedule),
homeworkSummary: buildHomeworkSummary(assignments),
gradeTrend,
gradeSummary,
examResults,
}
},
)
@@ -224,3 +250,20 @@ export const getParentDashboardData = cache(
}
},
)
/**
* 获取家长所有子女的轻量列表id + name用于详情页头部多子女切换器。
* 一次批量查询,避免 N+1。
*/
export const getChildNameList = cache(
async (parentId: string): Promise<Array<{ id: string; name: string | null }>> => {
const relations = await getChildren(parentId)
if (relations.length === 0) return []
const nameMap = await getUserNamesByIds(relations.map((r) => r.studentId))
return relations.map((r) => ({
id: r.studentId,
name: nameMap.get(r.studentId)?.name ?? null,
}))
},
)

View File

@@ -66,6 +66,21 @@ export type ChildDashboardData = {
/** 成绩趋势数据;`trend` 按时间升序,`recent` 按时间降序。 */
gradeTrend: StudentDashboardGradeProps
gradeSummary: StudentGradeSummary | null
/** V3-11: 考试结果列表(已批改的考试关联作业提交) */
examResults: ChildExamResultItem[]
}
/** V3-11: 单条考试结果(家长端展示用) */
export type ChildExamResultItem = {
submissionId: string
examId: string
examTitle: string
assignmentId: string
assignmentTitle: string
score: number
maxScore: number
submittedAt: string | null
status: string
}
/** 家长仪表盘聚合数据(家长姓名 + 所有子女数据)。 */

View File

@@ -41,7 +41,7 @@ export interface TrendLineSeries {
interface TrendLineChartProps {
/** 图表数据 */
data: Array<Record<string, string | number>>
data: Array<Record<string, string | number | undefined>>
/** 折线系列配置(支持单条或多条) */
series: TrendLineSeries[]
/** X 轴数据字段名(默认 "title" */

View File

@@ -96,6 +96,33 @@
"error": {
"notFound": "Exam not found",
"loadFailed": "Failed to load exam"
},
"analytics": {
"title": "Exam Analytics",
"description": "View score distribution and per-question analysis",
"totalStudents": "Total Students",
"submitted": "Submitted",
"gradedCount": "Graded",
"assignmentCount": "Assignments",
"averageScore": "Average Score",
"passRate": "Pass Rate",
"scoreDistribution": "Score Distribution",
"scoreDistributionDesc": "Student count by percentage range",
"questionAnalysis": "Question Analysis",
"questionAnalysisDesc": "Error rate and difficulty (error rate >= 70% is hard)",
"questionType": "Type",
"questionText": "Question",
"maxScore": "Max Score",
"errorCount": "Errors",
"errorRate": "Error Rate",
"difficulty": "Difficulty",
"difficultyEasy": "Easy",
"difficultyMedium": "Medium",
"difficultyHard": "Hard",
"highErrorWarning": "High Error Rate Warning",
"highErrorWarningDesc": "The following questions have an error rate above 70% and are recommended for focused review",
"noData": "No analytics data yet. Data will be available after students submit and grading is complete.",
"viewAnalytics": "View Analytics"
}
},
"homework": {
@@ -249,7 +276,13 @@
"nextStudent": "Next Student",
"prev": "Prev",
"next": "Next",
"gradesAutoSaveNote": "Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit."
"gradesAutoSaveNote": "Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit.",
"batchAutoGrade": "Batch Auto-Grade",
"batchSelected": "{{count}} submissions selected",
"batchSelectAtLeastOne": "Please select at least one submission",
"batchFailed": "Batch grading failed",
"selectAll": "Select All",
"selectRow": "Select this row"
},
"review": {
"title": "Review",
@@ -269,7 +302,23 @@
"responseSummary": "Response Summary",
"description": "Description",
"noDescription": "No description provided.",
"totalScore": "Total Score"
"totalScore": "Total Score",
"correctAnswerTrue": "True",
"correctAnswerFalse": "False"
},
"result": {
"title": "Submission Result",
"scoreRate": "Score Rate",
"fullyGraded": "All questions have been graded",
"partiallyGraded": "Objective questions auto-graded. Subjective questions awaiting teacher review.",
"correctCount": "Correct",
"incorrectCount": "Incorrect",
"partialCount": "Partial",
"pendingCount": "Pending",
"wrongAnswersTitle": "Wrong Answers Preview",
"wrongAnswersDesc": "These questions need focused review",
"backToList": "Back to Assignments",
"viewErrorBook": "View Error Book"
},
"status": {
"draft": "Draft",

View File

@@ -96,6 +96,33 @@
"error": {
"notFound": "考试不存在",
"loadFailed": "加载考试失败"
},
"analytics": {
"title": "考试分析",
"description": "查看考试成绩分布与逐题分析",
"totalStudents": "应考人数",
"submitted": "已提交",
"gradedCount": "已批改",
"assignmentCount": "关联作业数",
"averageScore": "平均分",
"passRate": "及格率",
"scoreDistribution": "分数段分布",
"scoreDistributionDesc": "按百分比区间统计学生人数",
"questionAnalysis": "逐题分析",
"questionAnalysisDesc": "错误率与难度等级错误率≥70%为难题)",
"questionType": "题型",
"questionText": "题目",
"maxScore": "满分",
"errorCount": "错误数",
"errorRate": "错误率",
"difficulty": "难度",
"difficultyEasy": "简单",
"difficultyMedium": "中等",
"difficultyHard": "困难",
"highErrorWarning": "高错误率预警",
"highErrorWarningDesc": "以下题目错误率超过 70%,建议重点讲解",
"noData": "暂无分析数据,需等待学生提交并批改后生成",
"viewAnalytics": "查看分析"
}
},
"homework": {
@@ -249,7 +276,13 @@
"nextStudent": "下一名学生",
"prev": "上一页",
"next": "下一页",
"gradesAutoSaveNote": "点击提交后成绩将自动保存。学生将在您提交后立即看到成绩和反馈。"
"gradesAutoSaveNote": "点击提交后成绩将自动保存。学生将在您提交后立即看到成绩和反馈。",
"batchAutoGrade": "批量自动批改",
"batchSelected": "已选 {{count}} 份提交",
"batchSelectAtLeastOne": "请至少选择一份提交",
"batchFailed": "批量批改失败",
"selectAll": "全选",
"selectRow": "选择此行"
},
"review": {
"title": "复习",
@@ -269,7 +302,23 @@
"responseSummary": "作答概览",
"description": "描述",
"noDescription": "无描述。",
"totalScore": "总分"
"totalScore": "总分",
"correctAnswerTrue": "正确",
"correctAnswerFalse": "错误"
},
"result": {
"title": "提交结果",
"scoreRate": "得分率",
"fullyGraded": "所有题目已批改完成",
"partiallyGraded": "客观题已自动批改,主观题等待教师批改",
"correctCount": "正确",
"incorrectCount": "错误",
"partialCount": "部分正确",
"pendingCount": "待批改",
"wrongAnswersTitle": "错题预览",
"wrongAnswersDesc": "以下题目需要重点复习",
"backToList": "返回作业列表",
"viewErrorBook": "查看错题本"
},
"status": {
"draft": "草稿",

View File

@@ -18,6 +18,7 @@
import type { DataScope } from "@/shared/types/permissions"
import type { Exam } from "@/modules/exams/types"
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
import type { ExamWithQuestionsForHomework } from "@/modules/exams/data-access"
/**
* 考试/作业模块对外暴露的服务契约。
@@ -54,15 +55,7 @@ export interface ExamHomeworkServicePort {
// ===== 跨模块 =====
/** 获取考试及其题目(供作业模块引用考试内容时使用) */
getExamWithQuestionsForHomework(examId: string): Promise<{
exam: Pick<Exam, "id" | "title" | "totalScore" | "durationMin">
questions: Array<{
id: string
questionType: string
questionContent: unknown
maxScore: number
}>
} | null>
getExamWithQuestionsForHomework(examId: string): Promise<ExamWithQuestionsForHomework | null>
}
/**