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 架构文档
154 lines
5.8 KiB
TypeScript
154 lines
5.8 KiB
TypeScript
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}'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>
|
|
)
|
|
}
|