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

@@ -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
}
/** 家长仪表盘聚合数据(家长姓名 + 所有子女数据)。 */