Files
NextEdu/src/modules/diagnostic/components/student-diagnostic-view.tsx
SpecialX 138b6f1b00 feat(dashboard,diagnostic,elective): add widgets, layout, parent dashboard, role-config, services, elective components
dashboard:

- Add comparison-badge, dashboard-notification-widget, dashboard-responsive-layout, dashboard-time-range-filter

- Add parent-dashboard components directory

- Add config, hooks, and services directories

diagnostic:

- Add role-config and services directory

elective:

- Add elective-course-detail, elective-stats-cards, parent-selection-view components

- Add data-access-settings and data-access-stats
2026-07-03 10:25:46 +08:00

309 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import Link from "next/link"
import { useTranslations } from "next-intl"
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 { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { formatDate } from "@/shared/lib/utils"
import { MasteryRadarChart } from "./mastery-radar-chart"
import {
getConfidenceLevel,
confidenceBadgeVariant,
type ConfidenceLevel,
} from "./confidence-utils"
import { getDiagnosticRoleConfig, type DiagnosticRole } from "../role-config"
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
interface StudentDiagnosticViewProps {
summary: StudentMasterySummary | null
reports: DiagnosticReportWithDetails[]
classAverageMastery?: MasteryRadarPoint[]
/**
* v4-P2-2: 角色配置驱动。
* 组件内部根据 role 查找 DIAGNOSTIC_ROLE_CONFIG 获取 practiceHrefBase 等角色差异配置。
* 新增角色只需在 role-config.ts 中添加配置项,无需修改组件 props。
*/
role?: DiagnosticRole
/**
* @deprecated v4-P2-2: 请改用 `role` prop。保留向后兼容若同时传入则 role 优先。
*/
practiceHrefBase?: string | null
}
export function StudentDiagnosticView({
summary,
reports,
classAverageMastery,
role = "student",
practiceHrefBase,
}: StudentDiagnosticViewProps) {
const t = useTranslations("diagnostic")
// v4-P2-2: 角色配置驱动role prop 优先于 deprecated practiceHrefBase
const resolvedPracticeHrefBase = practiceHrefBase ?? getDiagnosticRoleConfig(role).practiceHrefBase
if (!summary) {
return (
<EmptyState
title={t("empty.noData")}
description={t("studentDiagnostic.noDataDescription")}
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")
// v4-P1-3: 移除草稿回退逻辑,仅展示已发布报告
// 调用方(学生/家长页面)已传 status: "published" 过滤,此处双重保障
const latestReport = publishedReports[0] ?? null
const statusLabel = (status: string): string => {
if (status === "draft") return t("status.draft")
if (status === "published") return t("status.published")
if (status === "archived") return t("status.archived")
return status
}
const typeLabel = (reportType: string): string => {
if (reportType === "individual") return t("type.individual")
if (reportType === "class") return t("type.class")
if (reportType === "grade") return t("type.grade")
return reportType
}
// v4-P3-7: 置信度标签与提示
const confidenceLabel = (level: ConfidenceLevel): string => {
if (level === "high") return t("reportList.confidenceHigh")
if (level === "medium") return t("reportList.confidenceMedium")
if (level === "low") return t("reportList.confidenceLow")
return t("reportList.confidenceInsufficient")
}
const confidenceHint = (level: ConfidenceLevel): string => {
if (level === "high") return t("reportList.confidenceHighHint")
if (level === "medium") return t("reportList.confidenceMediumHint")
if (level === "low") return t("reportList.confidenceLowHint")
return t("reportList.confidenceInsufficient")
}
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">{t("summary.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">{t("summary.overallMastery")}</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">{t("summary.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">{t("summary.weaknesses")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-red-600">{summary.weaknesses.length}</p>
</CardContent>
</Card>
</div>
{/* v2-P1-6: 雷达图区块独立 Error Boundary */}
<WidgetBoundary title={t("chart.radarTitle")} skeletonHeight={384}>
<MasteryRadarChart data={radarData} />
</WidgetBoundary>
{/* v2-P1-6: 强项 / 弱项区块独立 Error Boundary */}
<WidgetBoundary title={t("strengths.title")} skeletonHeight={300}>
<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" />
{t("strengths.title")}
</CardTitle>
<CardDescription>{t("studentDiagnostic.strengthsDescription")}</CardDescription>
</CardHeader>
<CardContent>
{summary.strengths.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("studentDiagnostic.noStrengths")}</p>
) : (
<ul className="space-y-2" role="list" aria-label={t("studentDiagnostic.strengthsListAriaLabel")}>
{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" />
{t("weaknesses.title")}
</CardTitle>
<CardDescription>{t("studentDiagnostic.weaknessesDescription")}</CardDescription>
</CardHeader>
<CardContent>
{summary.weaknesses.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("studentDiagnostic.noWeaknesses")}</p>
) : (
<ul className="space-y-2" role="list" aria-label={t("studentDiagnostic.weaknessesListAriaLabel")}>
{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>
{resolvedPracticeHrefBase ? (
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs" aria-label={t("studentDiagnostic.practiceAriaLabel", { name: m.knowledgePointName })}>
<Link href={`${resolvedPracticeHrefBase}?kp=${m.knowledgePointId}`}>
{t("weaknesses.practice")}
<ArrowRight className="ml-1 h-3 w-3" />
</Link>
</Button>
) : null}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</WidgetBoundary>
{/* v2-P1-6: 最新报告区块独立 Error Boundary */}
{latestReport ? (
<WidgetBoundary title={t("studentDiagnostic.diagnosticReportTitle")} skeletonHeight={200}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lightbulb className="h-4 w-4" />
{t("studentDiagnostic.diagnosticReportTitle")}
<Badge variant={latestReport.status === "published" ? "default" : "secondary"}>
{statusLabel(latestReport.status)}
</Badge>
{(() => {
const level = getConfidenceLevel(latestReport)
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={confidenceBadgeVariant[level]}
aria-label={t("reportList.confidenceAriaLabel", { level: confidenceLabel(level) })}
>
{confidenceLabel(level)}
</Badge>
</TooltipTrigger>
<TooltipContent>{confidenceHint(level)}</TooltipContent>
</Tooltip>
)
})()}
</CardTitle>
<CardDescription>
{t("studentDiagnostic.reportMeta", {
period: latestReport.period ?? "-",
score: 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">{t("report.recommendations")}</h4>
<ul className="space-y-1.5" role="list" aria-label={t("studentDiagnostic.recommendationsListAriaLabel")}>
{latestReport.recommendations.map((rec, i) => (
<li key={rec || `rec-${i}`} className="text-sm text-muted-foreground"> {rec}</li>
))}
</ul>
</div>
) : null}
</CardContent>
</Card>
</WidgetBoundary>
) : null}
{/* v2-P1-6: 历史报告区块独立 Error Boundary */}
{publishedReports.length > 1 ? (
<WidgetBoundary title={t("report.history")} skeletonHeight={200}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-4 w-4" />
{t("report.history")}
</CardTitle>
<CardDescription>{t("studentDiagnostic.historyDescription")}</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 ?? t("studentDiagnostic.untitledPeriod")}
</span>
<Badge variant="outline" className="text-xs">
{typeLabel(r.reportType)}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{r.overallScore !== null
? t("studentDiagnostic.historyReportMeta", {
date: formatDate(r.createdAt),
score: r.overallScore.toFixed(1),
})
: formatDate(r.createdAt)}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</WidgetBoundary>
) : null}
</div>
)
}