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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user