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:
@@ -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>
|
||||
)
|
||||
|
||||
153
src/modules/parent/components/child-exam-detail.tsx
Normal file
153
src/modules/parent/components/child-exam-detail.tsx
Normal 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}'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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 家长仪表盘聚合数据(家长姓名 + 所有子女数据)。 */
|
||||
|
||||
Reference in New Issue
Block a user