feat(diagnostic): add export, stats service, and confidence utils

- Add export module for diagnostic report data export

- Add stats-service for diagnostic analytics aggregation

- Add confidence-utils for diagnostic confidence score calculations
This commit is contained in:
SpecialX
2026-06-23 17:37:58 +08:00
parent 1abf58c0b6
commit 9ceb2b7b67
12 changed files with 1717 additions and 436 deletions

View File

@@ -4,7 +4,8 @@ import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Users, AlertTriangle, TrendingUp, FileText } from "lucide-react"
import { useTranslations } from "next-intl"
import { Users, AlertTriangle, TrendingUp, FileText, Filter } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
@@ -12,6 +13,13 @@ import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Table,
TableBody,
@@ -22,7 +30,7 @@ import {
} from "@/shared/components/ui/table"
import { usePermission } from "@/shared/hooks"
import { Permissions } from "@/shared/types/permissions"
import { generateClassReportAction } from "../actions"
import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions"
import type { ClassMasterySummary } from "../types"
interface ClassDiagnosticViewProps {
@@ -37,13 +45,29 @@ function masteryColor(level: number): string {
return "bg-red-500"
}
type KnowledgePointStudent = {
studentId: string
studentName: string
masteryLevel: number
totalQuestions: number
correctQuestions: number
lastAssessedAt: string | null
needsAttention: boolean
}
export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
const t = useTranslations("diagnostic")
const router = useRouter()
const { hasPermission } = usePermission()
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
const [isGenerating, setIsGenerating] = useState(false)
// v3-P2-5: 知识点筛选状态
const [selectedKpId, setSelectedKpId] = useState<string>("all")
const [filteredStudents, setFilteredStudents] = useState<KnowledgePointStudent[] | null>(null)
const [isFiltering, setIsFiltering] = useState(false)
const handleGenerate = async () => {
if (!summary) return
setIsGenerating(true)
@@ -56,15 +80,45 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || "Failed to generate class report")
toast.error(result.message || t("error.generateClassFailed"))
}
}
/**
* v3-P2-5: 按知识点筛选学生。
* 选择知识点后调用 server action 获取该知识点上所有学生的掌握度。
*/
const handleKpFilter = async (kpId: string) => {
setSelectedKpId(kpId)
if (!summary || kpId === "all") {
setFilteredStudents(null)
return
}
setIsFiltering(true)
try {
const result = await getClassStudentsByKnowledgePointAction({
classId: summary.classId,
knowledgePointId: kpId,
})
if (result.success && result.data) {
setFilteredStudents(result.data)
} else {
toast.error(result.message || t("error.loadFailed"))
setFilteredStudents(null)
}
} catch {
toast.error(t("error.loadFailed"))
setFilteredStudents(null)
} finally {
setIsFiltering(false)
}
}
if (!summary) {
return (
<EmptyState
title="No class data"
description="Unable to load class mastery summary."
title={t("classDiagnostic.noClassDataTitle")}
description={t("empty.noClassData")}
icon={Users}
className="border-none shadow-none"
/>
@@ -77,7 +131,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
<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">Class</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.class")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.className}</p>
@@ -85,7 +139,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Students</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.students")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentCount}</p>
@@ -93,7 +147,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Avg Mastery</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.avgMastery")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
@@ -101,7 +155,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Need Attention</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.needAttention")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
@@ -114,70 +168,194 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Knowledge Point Mastery Heatmap
{t("chart.heatmapTitle")}
</CardTitle>
<CardDescription>
Average mastery level per knowledge point (green 80%, yellow 60-79%, orange 40-59%, red &lt;40%).
{t("classDiagnostic.heatmapDescription")}
</CardDescription>
</CardHeader>
<CardContent>
{summary.knowledgePointStats.length === 0 ? (
<p className="text-sm text-muted-foreground">No knowledge point data available.</p>
<p className="text-sm text-muted-foreground">{t("classDiagnostic.noKnowledgePointData")}</p>
) : (
<div className="flex flex-wrap gap-2">
{summary.knowledgePointStats.map((kp) => (
<div
key={kp.knowledgePointId}
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (mastered ${kp.masteredCount}/${kp.totalStudents})`}
>
<span className="max-w-32 truncate text-xs font-medium">
{kp.knowledgePointName}
</span>
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
<>
<div
className="flex flex-wrap gap-2"
role="img"
aria-label={t("classDiagnostic.heatmapAriaLabel", { count: summary.knowledgePointStats.length })}
>
{summary.knowledgePointStats.map((kp) => {
const levelLabel = kp.averageMastery >= 80 ? t("classDiagnostic.masteryLevelExcellent") : kp.averageMastery >= 60 ? t("classDiagnostic.masteryLevelGood") : kp.averageMastery >= 40 ? t("classDiagnostic.masteryLevelNeedsImprovement") : t("classDiagnostic.masteryLevelWeak")
return (
<div
key={kp.knowledgePointId}
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
role="img"
aria-label={`${kp.knowledgePointName}${kp.averageMastery.toFixed(1)}%${levelLabel}${kp.masteredCount}/${kp.totalStudents}`}
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (${kp.masteredCount}/${kp.totalStudents})`}
>
<span className="max-w-32 truncate text-xs font-medium">
{kp.knowledgePointName}
</span>
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
</div>
)
})}
</div>
{/* v4-P1-8: 热力图颜色图例 */}
<div className="mt-4 flex flex-wrap items-center gap-3 border-t pt-3">
<span className="text-xs font-medium text-muted-foreground">
{t("classDiagnostic.legendLabel")}
</span>
<div className="flex flex-wrap items-center gap-3 text-xs">
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-green-500" aria-hidden="true" />
<span>{t("classDiagnostic.masteryLevelExcellent")} (80%)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-yellow-500" aria-hidden="true" />
<span>{t("classDiagnostic.masteryLevelGood")} (60-79%)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-orange-500" aria-hidden="true" />
<span>{t("classDiagnostic.masteryLevelNeedsImprovement")} (40-59%)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm bg-red-500" aria-hidden="true" />
<span>{t("classDiagnostic.masteryLevelWeak")} (&lt;40%)</span>
</div>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* v3-P2-5: 按知识点筛选学生 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter className="h-4 w-4" />
{t("classDiagnostic.filterByKpTitle")}
</CardTitle>
<CardDescription>
{t("classDiagnostic.filterByKpDescription")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="kp-filter" className="text-xs">
{t("classDiagnostic.kpFilterLabel")}
</Label>
<Select value={selectedKpId} onValueChange={handleKpFilter}>
<SelectTrigger id="kp-filter" className="w-full md:w-80">
<SelectValue placeholder={t("classDiagnostic.kpFilterPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("classDiagnostic.kpFilterAll")}</SelectItem>
{summary.knowledgePointStats.map((kp) => (
<SelectItem key={kp.knowledgePointId} value={kp.knowledgePointId}>
{kp.knowledgePointName} ({kp.averageMastery.toFixed(0)}%)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isFiltering ? (
<p className="text-sm text-muted-foreground">{t("classDiagnostic.filtering")}</p>
) : filteredStudents && filteredStudents.length > 0 ? (
<div className="rounded-md border">
{/* v4-P1-11: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("summary.student")}</TableHead>
<TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
<TableHead className="text-right">{t("classDiagnostic.totalQuestionsColumn")}</TableHead>
<TableHead className="text-right">{t("classDiagnostic.correctQuestionsColumn")}</TableHead>
<TableHead>{t("classDiagnostic.statusColumn")}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredStudents.map((s) => (
<TableRow key={s.studentId}>
<TableCell className="font-medium">{s.studentName}</TableCell>
<TableCell className="text-right font-mono">
<Badge variant={s.masteryLevel >= 80 ? "default" : s.masteryLevel >= 60 ? "secondary" : "destructive"}>
{s.masteryLevel.toFixed(0)}%
</Badge>
</TableCell>
<TableCell className="text-right">{s.totalQuestions}</TableCell>
<TableCell className="text-right">{s.correctQuestions}</TableCell>
<TableCell>
{s.needsAttention ? (
<Badge variant="destructive">{t("classDiagnostic.needsAttention")}</Badge>
) : (
<Badge variant="default">{t("classDiagnostic.mastered")}</Badge>
)}
</TableCell>
<TableCell>
<Button asChild variant="ghost" size="sm" aria-label={t("classDiagnostic.viewAriaLabel", { studentName: s.studentName })}>
<Link href={`/teacher/diagnostic/student/${s.studentId}`}>
<FileText className="mr-1 h-3 w-3" />
{t("classDiagnostic.viewAction")}
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : filteredStudents && filteredStudents.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("classDiagnostic.noStudentsForKp")}</p>
) : null}
</CardContent>
</Card>
{/* 知识点排名表 */}
<Card>
<CardHeader>
<CardTitle>Knowledge Point Ranking</CardTitle>
<CardTitle>{t("chart.rankingTitle")}</CardTitle>
</CardHeader>
<CardContent>
{summary.knowledgePointStats.length === 0 ? (
<p className="text-sm text-muted-foreground">No data.</p>
<p className="text-sm text-muted-foreground">{t("classDiagnostic.noRankingData")}</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Knowledge Point</TableHead>
<TableHead className="text-right">Avg Mastery</TableHead>
<TableHead className="text-right">Mastered (80%)</TableHead>
<TableHead className="text-right">Not Mastered (&lt;60%)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...summary.knowledgePointStats]
.sort((a, b) => b.averageMastery - a.averageMastery)
.map((kp) => (
<TableRow key={kp.knowledgePointId}>
<TableCell className="font-medium">{kp.knowledgePointName}</TableCell>
<TableCell className="text-right font-mono">
<Badge variant={kp.averageMastery >= 80 ? "default" : kp.averageMastery >= 60 ? "secondary" : "destructive"}>
{kp.averageMastery.toFixed(1)}%
</Badge>
</TableCell>
<TableCell className="text-right text-green-600">{kp.masteredCount}</TableCell>
<TableCell className="text-right text-red-600">{kp.notMasteredCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* v4-P1-11: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("classDiagnostic.knowledgePointColumn")}</TableHead>
<TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
<TableHead className="text-right">{t("classDiagnostic.masteredColumn")}</TableHead>
<TableHead className="text-right">{t("classDiagnostic.notMasteredColumn")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...summary.knowledgePointStats]
.sort((a, b) => b.averageMastery - a.averageMastery)
.map((kp) => (
<TableRow key={kp.knowledgePointId}>
<TableCell className="font-medium">{kp.knowledgePointName}</TableCell>
<TableCell className="text-right font-mono">
<Badge variant={kp.averageMastery >= 80 ? "default" : kp.averageMastery >= 60 ? "secondary" : "destructive"}>
{kp.averageMastery.toFixed(1)}%
</Badge>
</TableCell>
<TableCell className="text-right text-green-600">{kp.masteredCount}</TableCell>
<TableCell className="text-right text-red-600">{kp.notMasteredCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</CardContent>
@@ -188,44 +366,47 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-600" />
Students Needing Attention (avg &lt;60%)
{t("classDiagnostic.studentsNeedingAttentionTitle")}
</CardTitle>
<CardDescription>Students with low overall mastery.</CardDescription>
<CardDescription>{t("classDiagnostic.studentsNeedingAttentionDescription")}</CardDescription>
</CardHeader>
<CardContent>
{summary.studentsNeedingAttention.length === 0 ? (
<p className="text-sm text-muted-foreground">All students are above the attention threshold.</p>
<p className="text-sm text-muted-foreground">{t("classDiagnostic.allStudentsAboveThreshold")}</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead className="text-right">Avg Mastery</TableHead>
<TableHead className="text-right">Weak Points</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.studentsNeedingAttention.map((s) => (
<TableRow key={s.studentId}>
<TableCell className="font-medium">{s.studentName}</TableCell>
<TableCell className="text-right">
<Badge variant="destructive">{s.averageMastery.toFixed(1)}%</Badge>
</TableCell>
<TableCell className="text-right text-red-600">{s.weakCount}</TableCell>
<TableCell>
<Button asChild variant="ghost" size="sm">
<Link href={`/teacher/diagnostic/student/${s.studentId}`}>
<FileText className="mr-1 h-3 w-3" />
View
</Link>
</Button>
</TableCell>
{/* v4-P1-11: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("summary.student")}</TableHead>
<TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
<TableHead className="text-right">{t("classDiagnostic.weakPointsColumn")}</TableHead>
<TableHead></TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{summary.studentsNeedingAttention.map((s) => (
<TableRow key={s.studentId}>
<TableCell className="font-medium">{s.studentName}</TableCell>
<TableCell className="text-right">
<Badge variant="destructive">{s.averageMastery.toFixed(1)}%</Badge>
</TableCell>
<TableCell className="text-right text-red-600">{s.weakCount}</TableCell>
<TableCell>
<Button asChild variant="ghost" size="sm" aria-label={t("classDiagnostic.viewAriaLabel", { studentName: s.studentName })}>
<Link href={`/teacher/diagnostic/student/${s.studentId}`}>
<FileText className="mr-1 h-3 w-3" />
{t("classDiagnostic.viewAction")}
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</CardContent>
@@ -237,16 +418,16 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Generate Class Diagnostic Report
{t("report.generateClass")}
</CardTitle>
<CardDescription>
Generate a class-level diagnostic report with aggregated analysis.
{t("classDiagnostic.generateDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-3">
<div className="grid gap-2">
<Label htmlFor="class-period" className="text-xs">Period (YYYY-MM)</Label>
<Label htmlFor="class-period" className="text-xs">{t("classDiagnostic.periodLabel")}</Label>
<Input
id="class-period"
type="month"
@@ -256,7 +437,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
/>
</div>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? "Generating..." : "Generate Class Report"}
{isGenerating ? t("classDiagnostic.generating") : t("classDiagnostic.generateButton")}
</Button>
</div>
</CardContent>

View File

@@ -0,0 +1,31 @@
/**
* v4-P3-7: 诊断报告数据置信度工具。
*
* 置信度等级用于指示报告基于的数据量是否充足,帮助教师判断报告可信度。
* 提取到独立文件供 report-list 和 student-diagnostic-view 共享,避免重复定义。
*/
import type { DiagnosticReportWithDetails } from "../types"
export type ConfidenceLevel = "high" | "medium" | "low" | "insufficient"
/**
* 根据报告数据计算置信度。
* 简化方案overallScore === null 表示无数据insufficient
* 否则视为高置信度high
* 后续可扩展为基于 totalQuestions 等数据量字段的多级判断。
*/
export function getConfidenceLevel(report: DiagnosticReportWithDetails): ConfidenceLevel {
if (report.overallScore === null) return "insufficient"
return "high"
}
export const confidenceBadgeVariant: Record<
ConfidenceLevel,
"default" | "secondary" | "destructive" | "outline"
> = {
high: "default",
medium: "secondary",
low: "destructive",
insufficient: "outline",
}

View File

@@ -1,5 +1,6 @@
"use client"
import { useTranslations } from "next-intl"
import { Target } from "lucide-react"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
@@ -11,6 +12,7 @@ interface MasteryRadarChartProps {
}
export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
const t = useTranslations("diagnostic")
const isEmpty = !data || data.length === 0
const chartData = isEmpty
@@ -25,17 +27,29 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined)
const ariaLabel = isEmpty
? t("chart.radarAriaLabelEmpty")
: t("chart.radarAriaLabelNonEmpty", {
count: data.length,
withClassAverage: hasClassAverage ? t("chart.withClassAverage") : "",
})
return (
<ChartCardShell
title="Knowledge Point Mastery"
description="Radar chart of mastery level (0-100) across knowledge points."
title={t("chart.radarTitle")}
description={t("chart.radarDescriptionNonEmpty")}
icon={Target}
isEmpty={isEmpty}
emptyTitle="No mastery data"
emptyDescription="No knowledge point mastery records found for this student."
emptyTitle={t("chart.radarEmptyTitle")}
emptyDescription={t("chart.noMasteryDataForStudent")}
emptyClassName="h-60"
>
<div role="img" aria-label={`知识点掌握度雷达图:${isEmpty ? "暂无数据" : `${data.length} 个知识点的掌握度${hasClassAverage ? "(含班级平均对比)" : ""}`}`}>
<div
role="img"
aria-label={ariaLabel}
tabIndex={0}
className="rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
>
<ComparisonRadarChart
data={chartData}
angleKey="shortName"
@@ -48,7 +62,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
series={[
{
dataKey: "student",
name: "Student",
name: t("chart.studentSeries"),
color: "hsl(var(--primary))",
fillOpacity: 0.35,
strokeWidth: 2,
@@ -56,7 +70,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
},
{
dataKey: "classAverage",
name: "Class Avg",
name: t("chart.classAvgSeries"),
color: "hsl(var(--chart-2))",
fillOpacity: 0.15,
strokeWidth: 2,

View File

@@ -3,8 +3,9 @@
import { useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { FileText, Trash2, Send } from "lucide-react"
import { FileText, Trash2, Send, Download, Share2, Copy } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -33,17 +34,18 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
import { formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks"
import { Permissions } from "@/shared/types/permissions"
import { publishReportAction, deleteReportAction } from "../actions"
import { publishReportAction, deleteReportAction, exportDiagnosticReportAction } from "../actions"
import type { DiagnosticReportWithDetails } from "../types"
const typeLabels: Record<string, string> = {
individual: "Individual",
class: "Class",
grade: "Grade",
}
import {
getConfidenceLevel,
confidenceBadgeVariant,
type ConfidenceLevel,
} from "./confidence-utils"
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "secondary",
@@ -59,10 +61,12 @@ export function ReportList({ reports }: ReportListProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { hasPermission } = usePermission()
const t = useTranslations("diagnostic")
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [publishId, setPublishId] = useState<string | null>(null)
const [shareId, setShareId] = useState<string | null>(null)
const [isBusy, setIsBusy] = useState(false)
const updateParam = useCallback(
@@ -90,7 +94,7 @@ export function ReportList({ reports }: ReportListProps) {
setPublishId(null)
router.refresh()
} else {
toast.error(result.message || "Failed to publish")
toast.error(result.message || t("error.publishFailed"))
}
}
@@ -106,42 +110,133 @@ export function ReportList({ reports }: ReportListProps) {
setDeleteId(null)
router.refresh()
} else {
toast.error(result.message || "Failed to delete")
toast.error(result.message || t("error.deleteFailed"))
}
}
/**
* v3-P2-4: 导出诊断报告为 Excel。
* 调用 server action 获取 base64 buffer前端转 Blob 下载。
*/
const handleExport = async (reportId: string) => {
setIsBusy(true)
try {
const result = await exportDiagnosticReportAction(reportId)
if (!result.success || !result.data) {
toast.error(result.message || t("error.exportFailed"))
return
}
// base64 -> Blob -> 下载
const binaryString = atob(result.data.buffer)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i += 1) {
bytes[i] = binaryString.charCodeAt(i)
}
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = result.data.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success(t("reportList.exportSuccess"))
} catch {
toast.error(t("error.exportFailed"))
} finally {
setIsBusy(false)
}
}
// v3-P3-8: 复制报告分享链接到剪贴板
const handleCopyLink = async (): Promise<void> => {
if (!shareId) return
const url = `${window.location.origin}/teacher/diagnostic/reports/${shareId}`
try {
await navigator.clipboard.writeText(url)
toast.success(t("reportList.copyLinkSuccess"))
} catch {
toast.error(t("reportList.copyLinkFailed"))
}
}
// 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")
}
const reportType = searchParams.get("reportType") ?? "all"
const status = searchParams.get("status") ?? "all"
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
}
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 studentTargetDisplay = (r: DiagnosticReportWithDetails): string => {
if (r.studentName) return r.studentName
if (r.reportType === "class") return t("reportList.classReportPlaceholder")
if (r.reportType === "grade") return t("reportList.gradeReportPlaceholder")
return "-"
}
// v3-P3-8: 当前分享的报告及链接
const sharedReport = shareId ? reports.find((r) => r.id === shareId) ?? null : null
const shareUrl = typeof window !== "undefined" && sharedReport
? `${window.location.origin}/teacher/diagnostic/reports/${sharedReport.id}`
: ""
return (
<div className="space-y-4">
{/* 过滤器 */}
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-2">
<div className="grid gap-2">
<Label className="text-xs">Report Type</Label>
<Label htmlFor="filter-report-type" className="text-xs">{t("filters.reportType")}</Label>
<Select value={reportType} onValueChange={(v) => updateParam("reportType", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All types" />
<SelectTrigger id="filter-report-type" className="h-9">
<SelectValue placeholder={t("filters.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="individual">Individual</SelectItem>
<SelectItem value="class">Class</SelectItem>
<SelectItem value="grade">Grade</SelectItem>
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
<SelectItem value="individual">{t("type.individual")}</SelectItem>
<SelectItem value="class">{t("type.class")}</SelectItem>
<SelectItem value="grade">{t("type.grade")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Status</Label>
<Label htmlFor="filter-report-status" className="text-xs">{t("filters.status")}</Label>
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All statuses" />
<SelectTrigger id="filter-report-status" className="h-9">
<SelectValue placeholder={t("filters.allStatuses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
<SelectItem value="draft">{t("status.draft")}</SelectItem>
<SelectItem value="published">{t("status.published")}</SelectItem>
<SelectItem value="archived">{t("status.archived")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -149,71 +244,118 @@ export function ReportList({ reports }: ReportListProps) {
{reports.length === 0 ? (
<EmptyState
title="No diagnostic reports"
description="Generate diagnostic reports to see them here."
title={t("empty.noReports")}
description={t("reportList.noReportsDescription")}
icon={FileText}
className="border-none shadow-none"
/>
) : (
<div className="rounded-md border bg-card">
<Table>
<caption className="sr-only"></caption>
<caption className="sr-only">{t("reportList.caption")}</caption>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Student / Target</TableHead>
<TableHead>Period</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Status</TableHead>
<TableHead>Generated By</TableHead>
<TableHead>Date</TableHead>
{canManage ? <TableHead className="w-24">Actions</TableHead> : null}
<TableHead>{t("reportList.typeColumn")}</TableHead>
<TableHead>{t("reportList.studentTargetColumn")}</TableHead>
<TableHead>{t("reportList.periodColumn")}</TableHead>
<TableHead className="text-right">{t("reportList.scoreColumn")}</TableHead>
<TableHead>{t("reportList.confidenceColumn")}</TableHead>
<TableHead>{t("reportList.statusColumn")}</TableHead>
<TableHead>{t("reportList.generatedByColumn")}</TableHead>
<TableHead>{t("reportList.dateColumn")}</TableHead>
<TableHead className="w-40">{t("reportList.actionsColumn")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map((r) => (
<TableRow key={r.id}>
<TableCell>
<Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge>
<Badge variant="outline">{typeLabel(r.reportType)}</Badge>
</TableCell>
<TableCell className="font-medium">{r.studentName ?? (r.reportType === "class" ? "(班级报告)" : r.reportType === "grade" ? "(年级报告)" : "-")}</TableCell>
<TableCell className="font-medium">{studentTargetDisplay(r)}</TableCell>
<TableCell>{r.period ?? "-"}</TableCell>
<TableCell className="text-right font-mono">
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
</TableCell>
<TableCell>
<Badge variant={statusColors[r.status] ?? "secondary"}>{r.status}</Badge>
{(() => {
const level = getConfidenceLevel(r)
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>
)
})()}
</TableCell>
<TableCell>
<Badge variant={statusColors[r.status] ?? "secondary"}>{statusLabel(r.status)}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{r.generatedByName ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
{canManage ? (
<TableCell>
<div className="flex gap-1">
{r.status === "draft" ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-600"
onClick={() => setPublishId(r.id)}
title="Publish"
aria-label={`发布报告 ${r.studentName}`}
>
<Send className="h-4 w-4" />
</Button>
) : null}
<TableCell>
<div className="flex gap-1">
{/* v3-P2-4: 导出按钮(所有角色可见,受 exportDiagnosticReportAction 权限校验保护) */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleExport(r.id)}
disabled={isBusy}
title={t("report.export")}
aria-label={t("reportList.exportAriaLabel", { studentName: r.studentName ?? "" })}
>
<Download className="h-4 w-4" />
</Button>
{/* v3-P3-8: 分享按钮(仅教师可见) */}
{canManage ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShareId(r.id)}
disabled={isBusy}
title={t("reportList.share")}
aria-label={t("reportList.shareAriaLabel", { studentName: r.studentName ?? "" })}
>
<Share2 className="h-4 w-4" />
</Button>
) : null}
{canManage && r.status === "draft" ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-600"
onClick={() => setPublishId(r.id)}
disabled={isBusy}
title={t("report.publish")}
aria-label={t("reportList.publishAriaLabel", { studentName: r.studentName ?? "" })}
>
<Send className="h-4 w-4" />
</Button>
) : null}
{canManage ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
title="Delete"
aria-label={`删除报告 ${r.studentName}`}
disabled={isBusy}
title={t("report.delete")}
aria-label={t("reportList.deleteAriaLabel", { studentName: r.studentName ?? "" })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
) : null}
) : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -225,17 +367,17 @@ export function ReportList({ reports }: ReportListProps) {
<Dialog open={publishId !== null} onOpenChange={(open) => !open && setPublishId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Publish Report</DialogTitle>
<DialogTitle>{t("report.publishTitle")}</DialogTitle>
<DialogDescription>
Once published, the report will be visible to students. Continue?
{t("reportList.publishConfirmation")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setPublishId(null)} disabled={isBusy}>
Cancel
{t("report.cancel")}
</Button>
<Button onClick={handlePublish} disabled={isBusy}>
{isBusy ? "Publishing..." : "Publish"}
{isBusy ? t("report.publishing") : t("report.publish")}
</Button>
</DialogFooter>
</DialogContent>
@@ -245,17 +387,55 @@ export function ReportList({ reports }: ReportListProps) {
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Report</DialogTitle>
<DialogTitle>{t("report.deleteTitle")}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this diagnostic report? This action cannot be undone.
{t("report.deleteConfirmation")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isBusy}>
Cancel
{t("report.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isBusy}>
{isBusy ? "Deleting..." : "Delete"}
{isBusy ? t("report.deleting") : t("report.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* v3-P3-8: 分享报告 */}
<Dialog open={shareId !== null} onOpenChange={(open) => !open && setShareId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("reportList.shareTitle")}</DialogTitle>
<DialogDescription>{t("reportList.shareDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{sharedReport?.summary ? (
<div className="rounded-md border bg-muted/50 p-3">
<p className="text-sm">{sharedReport.summary}</p>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="share-link" className="text-xs">{t("reportList.shareLinkLabel")}</Label>
<div className="flex gap-2">
<Input
id="share-link"
readOnly
value={shareUrl}
aria-label={t("reportList.shareLinkAriaLabel")}
className="text-sm"
/>
<Button onClick={handleCopyLink} className="shrink-0">
<Copy className="mr-1 h-4 w-4" />
{t("reportList.copyLink")}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShareId(null)}>
{t("report.cancel")}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,28 +1,50 @@
"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 { formatDate } from "@/shared/lib/utils"
import { MasteryRadarChart } from "./mastery-radar-chart"
import {
getConfidenceLevel,
confidenceBadgeVariant,
type ConfidenceLevel,
} from "./confidence-utils"
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
interface StudentDiagnosticViewProps {
summary: StudentMasterySummary | null
reports: DiagnosticReportWithDetails[]
classAverageMastery?: MasteryRadarPoint[]
/**
* v3-P2-6: "练习"按钮的跳转基础路径。
* - 学生视角:默认 `/student/learning/assignments`
* - 教师视角:传入 `/teacher/questions`(题目库支持 kp 查询参数筛选)
* - 家长视角:传入 `null` 隐藏练习按钮(家长无练习入口)
* 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。
*/
practiceHrefBase?: string | null
}
export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) {
export function StudentDiagnosticView({
summary,
reports,
classAverageMastery,
practiceHrefBase = "/student/learning/assignments",
}: StudentDiagnosticViewProps) {
const t = useTranslations("diagnostic")
if (!summary) {
return (
<EmptyState
title="No diagnostic data"
description="Unable to load student mastery data."
title={t("empty.noData")}
description={t("studentDiagnostic.noDataDescription")}
icon={FileText}
className="border-none shadow-none"
/>
@@ -39,7 +61,38 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
})
const publishedReports = reports.filter((r) => r.status === "published")
const latestReport = publishedReports[0] ?? reports[0] ?? null
// 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">
@@ -47,7 +100,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<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>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.student")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentName}</p>
@@ -55,7 +108,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Overall Mastery</CardTitle>
<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>
@@ -63,7 +116,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Strengths</CardTitle>
<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>
@@ -71,7 +124,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Weaknesses</CardTitle>
<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>
@@ -88,15 +141,15 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Award className="h-4 w-4 text-green-600" />
Strengths (80%)
{t("strengths.title")}
</CardTitle>
<CardDescription>Knowledge points with high mastery.</CardDescription>
<CardDescription>{t("studentDiagnostic.strengthsDescription")}</CardDescription>
</CardHeader>
<CardContent>
{summary.strengths.length === 0 ? (
<p className="text-sm text-muted-foreground">No strengths identified yet.</p>
<p className="text-sm text-muted-foreground">{t("studentDiagnostic.noStrengths")}</p>
) : (
<ul className="space-y-2" role="list" aria-label="优势知识点列表">
<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>
@@ -111,27 +164,29 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-600" />
Weaknesses (&lt;60%)
{t("weaknesses.title")}
</CardTitle>
<CardDescription>Knowledge points needing attention.</CardDescription>
<CardDescription>{t("studentDiagnostic.weaknessesDescription")}</CardDescription>
</CardHeader>
<CardContent>
{summary.weaknesses.length === 0 ? (
<p className="text-sm text-muted-foreground">No weaknesses identified.</p>
<p className="text-sm text-muted-foreground">{t("studentDiagnostic.noWeaknesses")}</p>
) : (
<ul className="space-y-2" role="list" aria-label="薄弱知识点列表">
<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>
<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>
{practiceHrefBase ? (
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs" aria-label={t("studentDiagnostic.practiceAriaLabel", { name: m.knowledgePointName })}>
<Link href={`${practiceHrefBase}?kp=${m.knowledgePointId}`}>
{t("weaknesses.practice")}
<ArrowRight className="ml-1 h-3 w-3" />
</Link>
</Button>
) : null}
</li>
))}
</ul>
@@ -146,13 +201,32 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lightbulb className="h-4 w-4" />
Diagnostic Report
{t("studentDiagnostic.diagnosticReportTitle")}
<Badge variant={latestReport.status === "published" ? "default" : "secondary"}>
{latestReport.status}
{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>
Period: {latestReport.period ?? "-"} · Overall: {latestReport.overallScore?.toFixed(1) ?? "-"}%
{t("studentDiagnostic.reportMeta", {
period: latestReport.period ?? "-",
score: latestReport.overallScore?.toFixed(1) ?? "-",
})}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -161,10 +235,10 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
) : 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="学习建议列表">
<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={i} className="text-sm text-muted-foreground"> {rec}</li>
<li key={rec || `rec-${i}`} className="text-sm text-muted-foreground"> {rec}</li>
))}
</ul>
</div>
@@ -179,9 +253,9 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-4 w-4" />
Report History
{t("report.history")}
</CardTitle>
<CardDescription>Past diagnostic reports (newest first).</CardDescription>
<CardDescription>{t("studentDiagnostic.historyDescription")}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
@@ -193,15 +267,19 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<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"}
{r.period ?? t("studentDiagnostic.untitledPeriod")}
</span>
<Badge variant="outline" className="text-xs">
{r.reportType}
{typeLabel(r.reportType)}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{formatDate(r.createdAt)}
{r.overallScore !== null ? ` · Overall: ${r.overallScore.toFixed(1)}%` : ""}
{r.overallScore !== null
? t("studentDiagnostic.historyReportMeta", {
date: formatDate(r.createdAt),
score: r.overallScore.toFixed(1),
})
: formatDate(r.createdAt)}
</p>
</div>
</div>