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:
@@ -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 <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")} (<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 (<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 <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>
|
||||
|
||||
31
src/modules/diagnostic/components/confidence-utils.ts
Normal file
31
src/modules/diagnostic/components/confidence-utils.ts
Normal 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",
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (<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>
|
||||
|
||||
Reference in New Issue
Block a user