parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
172 lines
6.7 KiB
TypeScript
172 lines
6.7 KiB
TypeScript
import type { JSX } from "react"
|
||
import Link from "next/link"
|
||
import { useTranslations } from "next-intl"
|
||
import { GraduationCap, TrendingUp, Award, BookOpen, ChevronRight } 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 / P3-4: 家长端子女考试详情视图
|
||
*
|
||
* 对标智学网家长端,展示:
|
||
* - 考试成绩汇总卡片(已参加考试数、平均分、最高分)
|
||
* - 考试成绩列表(考试标题、分数、得分率、提交时间)
|
||
*
|
||
* P3-4 改进:
|
||
* - 所有用户可见文本使用 i18n 翻译键(next-intl)
|
||
* - 触控目标 ≥ 44px(min-h-[44px])
|
||
* - 语义化标签 + ARIA 属性
|
||
*/
|
||
export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element {
|
||
const t = useTranslations("examHomework")
|
||
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" aria-hidden />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold tabular-nums">{examCount}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("homework.parentExam.examsTaken")}
|
||
</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" aria-hidden />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("homework.parentExam.averageScore")}
|
||
</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" aria-hidden />
|
||
</div>
|
||
<div>
|
||
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("homework.parentExam.bestScore")}
|
||
</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 />
|
||
{t("homework.parentExam.examResults", { name: childName })}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{t("homework.parentExam.examResultsDescription")}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{!hasResults ? (
|
||
<EmptyState
|
||
icon={GraduationCap}
|
||
title={t("homework.parentExam.noResults")}
|
||
description={t("homework.parentExam.noResultsHint")}
|
||
className="border-none h-48"
|
||
/>
|
||
) : (
|
||
<ul className="space-y-3" aria-label={t("homework.parentExam.examResults", { name: childName })}>
|
||
{examResults.map((r) => {
|
||
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
|
||
const isPass = scoreRate >= 60
|
||
return (
|
||
<li key={r.submissionId}>
|
||
<Link
|
||
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 ? t("homework.parentExam.pass") : t("homework.parentExam.belowPass")}
|
||
</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="flex items-center gap-2 shrink-0 ml-2">
|
||
<span className="text-sm font-semibold tabular-nums">
|
||
{scoreRate.toFixed(0)}%
|
||
</span>
|
||
<ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||
</div>
|
||
</Link>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|