P1-1: 抽取 stats-service.ts,将 8 个统计计算纯函数从 data-access 层分离 P1-5: 创建 WidgetBoundary 组件 + 补齐 teacher 路由 loading.tsx/error.tsx (14 文件) P1-6: 同步架构图文档 004/005,新增 stats-service 与 widget-boundary 节点 P2-1: 补充 a11y ARIA 属性(5 图表 role=img + aria-label,2 表格 caption,3 列表 role=list,3 按钮 aria-label) P2-3: 修复班级报告 studentId 字段语义错误(schema 改为可空 + 迁移 + 代码适配) P2-4: 修复 grade_managed scope 返回空数据(改为子查询 classes 表按 gradeId 过滤) P2-5: 新增 /parent/diagnostic/ 页面(多子女学情诊断聚合 + loading + error) P2-6: 统一 SearchParams 工具(student/grades 和 management/grade/insights 改用 @/shared/lib/search-params)
216 lines
8.4 KiB
TypeScript
216 lines
8.4 KiB
TypeScript
"use client"
|
|
|
|
import Link from "next/link"
|
|
import { Award, AlertTriangle, Lightbulb, FileText, History, ArrowRight } from "lucide-react"
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import { Button } from "@/shared/components/ui/button"
|
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
import { formatDate } from "@/shared/lib/utils"
|
|
import { MasteryRadarChart } from "./mastery-radar-chart"
|
|
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
|
|
|
|
interface StudentDiagnosticViewProps {
|
|
summary: StudentMasterySummary | null
|
|
reports: DiagnosticReportWithDetails[]
|
|
classAverageMastery?: MasteryRadarPoint[]
|
|
}
|
|
|
|
export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) {
|
|
if (!summary) {
|
|
return (
|
|
<EmptyState
|
|
title="No diagnostic data"
|
|
description="Unable to load student mastery data."
|
|
icon={FileText}
|
|
className="border-none shadow-none"
|
|
/>
|
|
)
|
|
}
|
|
|
|
const radarData: MasteryRadarPoint[] = summary.allMastery.map((m) => {
|
|
const classAvg = classAverageMastery?.find((c) => c.knowledgePoint === m.knowledgePointName)
|
|
return {
|
|
knowledgePoint: m.knowledgePointName,
|
|
student: Math.round(m.masteryLevel * 100) / 100,
|
|
classAverage: classAvg?.classAverage,
|
|
}
|
|
})
|
|
|
|
const publishedReports = reports.filter((r) => r.status === "published")
|
|
const latestReport = publishedReports[0] ?? reports[0] ?? null
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 概览卡片 */}
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold">{summary.studentName}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Overall Mastery</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Strengths</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold text-green-600">{summary.strengths.length}</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-muted-foreground">Weaknesses</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-2xl font-bold text-red-600">{summary.weaknesses.length}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 雷达图 */}
|
|
<MasteryRadarChart data={radarData} />
|
|
|
|
{/* 强项 / 弱项 */}
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Award className="h-4 w-4 text-green-600" />
|
|
Strengths (≥80%)
|
|
</CardTitle>
|
|
<CardDescription>Knowledge points with high mastery.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{summary.strengths.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No strengths identified yet.</p>
|
|
) : (
|
|
<ul className="space-y-2" role="list" aria-label="优势知识点列表">
|
|
{summary.strengths.map((m) => (
|
|
<li key={m.knowledgePointId} className="flex items-center justify-between">
|
|
<span className="text-sm">{m.knowledgePointName}</span>
|
|
<Badge variant="default" className="bg-green-600">{m.masteryLevel.toFixed(1)}%</Badge>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-red-600" />
|
|
Weaknesses (<60%)
|
|
</CardTitle>
|
|
<CardDescription>Knowledge points needing attention.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{summary.weaknesses.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No weaknesses identified.</p>
|
|
) : (
|
|
<ul className="space-y-2" role="list" aria-label="薄弱知识点列表">
|
|
{summary.weaknesses.map((m) => (
|
|
<li key={m.knowledgePointId} className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-sm truncate">{m.knowledgePointName}</span>
|
|
<Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge>
|
|
</div>
|
|
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs">
|
|
<Link href="/student/learning/assignments">
|
|
Practice
|
|
<ArrowRight className="ml-1 h-3 w-3" />
|
|
</Link>
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 最新报告 / 建议 */}
|
|
{latestReport ? (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Lightbulb className="h-4 w-4" />
|
|
Diagnostic Report
|
|
<Badge variant={latestReport.status === "published" ? "default" : "secondary"}>
|
|
{latestReport.status}
|
|
</Badge>
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Period: {latestReport.period ?? "-"} · Overall: {latestReport.overallScore?.toFixed(1) ?? "-"}%
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{latestReport.summary ? (
|
|
<p className="text-sm">{latestReport.summary}</p>
|
|
) : null}
|
|
{latestReport.recommendations && latestReport.recommendations.length > 0 ? (
|
|
<div>
|
|
<h4 className="mb-2 text-sm font-semibold">Recommendations</h4>
|
|
<ul className="space-y-1.5" role="list" aria-label="学习建议列表">
|
|
{latestReport.recommendations.map((rec, i) => (
|
|
<li key={i} className="text-sm text-muted-foreground">• {rec}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{/* 历史报告列表 */}
|
|
{publishedReports.length > 1 ? (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<History className="h-4 w-4" />
|
|
Report History
|
|
</CardTitle>
|
|
<CardDescription>Past diagnostic reports (newest first).</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{publishedReports.map((r) => (
|
|
<div
|
|
key={r.id}
|
|
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
|
>
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">
|
|
{r.period ?? "Untitled period"}
|
|
</span>
|
|
<Badge variant="outline" className="text-xs">
|
|
{r.reportType}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{formatDate(r.createdAt)}
|
|
{r.overallScore !== null ? ` · Overall: ${r.overallScore.toFixed(1)}%` : ""}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|