Files
NextEdu/src/modules/diagnostic/components/student-diagnostic-view.tsx
SpecialX 5f3a1a4662 refactor(grades,diagnostic): 完成成绩和学情诊断模块审计 P1+P2 改进项
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)
2026-06-22 17:07:32 +08:00

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 (&lt;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>
)
}