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