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

@@ -1,27 +1,32 @@
"use server" "use server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils"
import { createNotification } from "@/modules/notifications/data-access"
import { getStudentIdsByClassId } from "@/modules/classes/data-access"
import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
import { import {
generateDiagnosticReport, generateDiagnosticReport,
generateClassDiagnosticReport, generateClassDiagnosticReport,
getDiagnosticReports,
getDiagnosticReportById,
publishDiagnosticReport, publishDiagnosticReport,
deleteDiagnosticReport, deleteDiagnosticReport,
getDiagnosticReportById,
} from "./data-access-reports" } from "./data-access-reports"
import { getClassStudentsByKnowledgePoint } from "./data-access"
import {
exportDiagnosticReportToExcel,
buildDiagnosticReportFilename,
} from "./export"
import { import {
GenerateStudentReportSchema, GenerateStudentReportSchema,
GenerateClassReportSchema, GenerateClassReportSchema,
PublishReportSchema, PublishReportSchema,
DeleteReportSchema, DeleteReportSchema,
GetDiagnosticReportsSchema,
GetDiagnosticReportByIdSchema,
} from "./schema" } from "./schema"
import type { DiagnosticReportQueryParams } from "./types"
/** 生成学生个人诊断报告 */ /** 生成学生个人诊断报告 */
export async function generateStudentReportAction( export async function generateStudentReportAction(
@@ -45,9 +50,7 @@ export async function generateStudentReportAction(
revalidatePath(`/teacher/diagnostic/student/${studentId}`) revalidatePath(`/teacher/diagnostic/student/${studentId}`)
return { success: true, message: "Diagnostic report generated", data: id } return { success: true, message: "Diagnostic report generated", data: id }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message } return handleActionError(e)
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
} }
} }
@@ -73,9 +76,7 @@ export async function generateClassReportAction(
revalidatePath(`/teacher/diagnostic/class/${classId}`) revalidatePath(`/teacher/diagnostic/class/${classId}`)
return { success: true, message: "Class diagnostic report generated", data: id } return { success: true, message: "Class diagnostic report generated", data: id }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message } return handleActionError(e)
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
} }
} }
@@ -95,12 +96,72 @@ export async function publishReportAction(
} }
await publishDiagnosticReport(parsed.data.id) await publishDiagnosticReport(parsed.data.id)
// v3-P1-4 + v4-P1-4 + v4-P1-5发布报告后发送通知
// - 个人报告:通知学生本人 + 其家长
// - 班级报告:通知全班学生 + 全班学生家长
const report = await getDiagnosticReportById(parsed.data.id)
if (!report) {
revalidatePath("/teacher/diagnostic")
return { success: true, message: "Report published" }
}
const title = `诊断报告已发布:${report.period ?? "本期"}`
const content = report.summary ?? "您有一份新的学情诊断报告,请查看详情。"
const link = "/student/diagnostic"
// 收集需要通知的学生 ID 列表
const studentIdsToNotify: string[] = []
if (report.studentId) {
// 个人报告:通知单个学生
studentIdsToNotify.push(report.studentId)
} else if (report.classId) {
// v4-P1-4: 班级报告(有 classId通知全班学生
const classStudentIds = await getStudentIdsByClassId(report.classId)
studentIdsToNotify.push(...classStudentIds)
}
// 通知学生本人
for (const studentId of studentIdsToNotify) {
try {
await createNotification({
userId: studentId,
type: "grade",
title,
content,
link,
})
} catch {
// 单条通知失败不阻断整体流程
}
}
// v4-P1-5: 通知所有相关家长
if (studentIdsToNotify.length > 0) {
try {
const parentIds = await getParentIdsByStudentIds(studentIdsToNotify)
for (const parentId of parentIds) {
try {
await createNotification({
userId: parentId,
type: "grade",
title,
content: report.summary ?? "您的孩子有一份新的学情诊断报告,请查看详情。",
link: "/parent/diagnostic",
})
} catch {
// 单条通知失败不阻断整体流程
}
}
} catch {
// 家长查询失败不阻断整体流程
}
}
revalidatePath("/teacher/diagnostic") revalidatePath("/teacher/diagnostic")
return { success: true, message: "Report published" } return { success: true, message: "Report published" }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message } return handleActionError(e)
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
} }
} }
@@ -123,50 +184,91 @@ export async function deleteReportAction(
revalidatePath("/teacher/diagnostic") revalidatePath("/teacher/diagnostic")
return { success: true, message: "Report deleted" } return { success: true, message: "Report deleted" }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message } return handleActionError(e)
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
} }
} }
/** 查询诊断报告列表(读权限) */ /**
export async function getDiagnosticReportsAction( * v3-P2-4: 导出诊断报告为 Excel。
params: DiagnosticReportQueryParams * 返回 base64 编码的 buffer 和文件名,前端通过 Blob 下载。
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReports>>>> { */
export async function exportDiagnosticReportAction(
reportId: string
): Promise<ActionState<{ buffer: string; filename: string }>> {
try { try {
await requirePermission(Permissions.DIAGNOSTIC_READ) await requirePermission(Permissions.DIAGNOSTIC_READ)
const parsed = GetDiagnosticReportsSchema.safeParse(params) if (!reportId || typeof reportId !== "string") {
if (!parsed.success) {
return { success: false, message: "Invalid query params" }
}
const reports = await getDiagnosticReports(parsed.data)
return { success: true, data: reports }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/** 获取诊断报告详情(读权限) */
export async function getDiagnosticReportByIdAction(
id: string
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReportById>>>> {
try {
await requirePermission(Permissions.DIAGNOSTIC_READ)
const parsed = GetDiagnosticReportByIdSchema.safeParse({ id })
if (!parsed.success) {
return { success: false, message: "Missing report id" } return { success: false, message: "Missing report id" }
} }
const report = await getDiagnosticReportById(parsed.data.id) const report = await getDiagnosticReportById(reportId)
return { success: true, data: report } if (!report) {
return { success: false, message: "Report not found" }
}
const buffer = await exportDiagnosticReportToExcel({ reportId })
const filename = buildDiagnosticReportFilename(report.period)
return {
success: true,
data: {
buffer: buffer.toString("base64"),
filename,
},
}
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message } return handleActionError(e)
if (e instanceof Error) return { success: false, message: e.message } }
return { success: false, message: "Unexpected error" } }
/**
* v3-P2-5: 获取班级学生在指定知识点上的掌握度列表。
* 用于班级诊断页面的"按知识点筛选学生"功能。
*/
export async function getClassStudentsByKnowledgePointAction(params: {
classId: string
knowledgePointId: string
threshold?: number
}): Promise<
ActionState<
Array<{
studentId: string
studentName: string
masteryLevel: number
totalQuestions: number
correctQuestions: number
lastAssessedAt: string | null
needsAttention: boolean
}>
>
> {
try {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
if (!params.classId || !params.knowledgePointId) {
return { success: false, message: "Missing classId or knowledgePointId" }
}
// 教师只能查看所教班级
if (
ctx.dataScope.type === "class_taught" &&
!ctx.dataScope.classIds.includes(params.classId)
) {
return { success: false, message: "You can only access classes you teach" }
}
// 学生/家长不可访问
if (ctx.dataScope.type === "class_members" || ctx.dataScope.type === "children") {
return { success: false, message: "Access denied" }
}
const result = await getClassStudentsByKnowledgePoint(
params.classId,
params.knowledgePointId,
{ threshold: params.threshold }
)
return { success: true, data: result }
} catch (e) {
return handleActionError(e)
} }
} }

View File

@@ -4,7 +4,8 @@ import { useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" 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 { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { import {
Table, Table,
TableBody, TableBody,
@@ -22,7 +30,7 @@ import {
} from "@/shared/components/ui/table" } from "@/shared/components/ui/table"
import { usePermission } from "@/shared/hooks" import { usePermission } from "@/shared/hooks"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { generateClassReportAction } from "../actions" import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions"
import type { ClassMasterySummary } from "../types" import type { ClassMasterySummary } from "../types"
interface ClassDiagnosticViewProps { interface ClassDiagnosticViewProps {
@@ -37,13 +45,29 @@ function masteryColor(level: number): string {
return "bg-red-500" 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) { export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
const t = useTranslations("diagnostic")
const router = useRouter() const router = useRouter()
const { hasPermission } = usePermission() const { hasPermission } = usePermission()
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7)) const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
const [isGenerating, setIsGenerating] = useState(false) 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 () => { const handleGenerate = async () => {
if (!summary) return if (!summary) return
setIsGenerating(true) setIsGenerating(true)
@@ -56,15 +80,45 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
toast.success(result.message) toast.success(result.message)
router.refresh() router.refresh()
} else { } 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) { if (!summary) {
return ( return (
<EmptyState <EmptyState
title="No class data" title={t("classDiagnostic.noClassDataTitle")}
description="Unable to load class mastery summary." description={t("empty.noClassData")}
icon={Users} icon={Users}
className="border-none shadow-none" 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"> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<Card> <Card>
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<p className="text-2xl font-bold">{summary.className}</p> <p className="text-2xl font-bold">{summary.className}</p>
@@ -85,7 +139,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<p className="text-2xl font-bold">{summary.studentCount}</p> <p className="text-2xl font-bold">{summary.studentCount}</p>
@@ -93,7 +147,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p> <p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
@@ -101,7 +155,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p> <p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
@@ -114,70 +168,194 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" /> <TrendingUp className="h-4 w-4" />
Knowledge Point Mastery Heatmap {t("chart.heatmapTitle")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Average mastery level per knowledge point (green 80%, yellow 60-79%, orange 40-59%, red &lt;40%). {t("classDiagnostic.heatmapDescription")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{summary.knowledgePointStats.length === 0 ? ( {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
<div className="flex flex-wrap gap-2"
key={kp.knowledgePointId} role="img"
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`} aria-label={t("classDiagnostic.heatmapAriaLabel", { count: summary.knowledgePointStats.length })}
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (mastered ${kp.masteredCount}/${kp.totalStudents})`} >
> {summary.knowledgePointStats.map((kp) => {
<span className="max-w-32 truncate text-xs font-medium"> const levelLabel = kp.averageMastery >= 80 ? t("classDiagnostic.masteryLevelExcellent") : kp.averageMastery >= 60 ? t("classDiagnostic.masteryLevelGood") : kp.averageMastery >= 40 ? t("classDiagnostic.masteryLevelNeedsImprovement") : t("classDiagnostic.masteryLevelWeak")
{kp.knowledgePointName} return (
</span> <div
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span> 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>
</div> </>
)} )}
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle>Knowledge Point Ranking</CardTitle> <CardTitle>{t("chart.rankingTitle")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{summary.knowledgePointStats.length === 0 ? ( {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"> <div className="rounded-md border">
<Table> {/* v4-P1-11: 移动端表格水平滚动 */}
<TableHeader> <div className="overflow-x-auto">
<TableRow> <Table>
<TableHead>Knowledge Point</TableHead> <TableHeader>
<TableHead className="text-right">Avg Mastery</TableHead> <TableRow>
<TableHead className="text-right">Mastered (80%)</TableHead> <TableHead>{t("classDiagnostic.knowledgePointColumn")}</TableHead>
<TableHead className="text-right">Not Mastered (&lt;60%)</TableHead> <TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
</TableRow> <TableHead className="text-right">{t("classDiagnostic.masteredColumn")}</TableHead>
</TableHeader> <TableHead className="text-right">{t("classDiagnostic.notMasteredColumn")}</TableHead>
<TableBody> </TableRow>
{[...summary.knowledgePointStats] </TableHeader>
.sort((a, b) => b.averageMastery - a.averageMastery) <TableBody>
.map((kp) => ( {[...summary.knowledgePointStats]
<TableRow key={kp.knowledgePointId}> .sort((a, b) => b.averageMastery - a.averageMastery)
<TableCell className="font-medium">{kp.knowledgePointName}</TableCell> .map((kp) => (
<TableCell className="text-right font-mono"> <TableRow key={kp.knowledgePointId}>
<Badge variant={kp.averageMastery >= 80 ? "default" : kp.averageMastery >= 60 ? "secondary" : "destructive"}> <TableCell className="font-medium">{kp.knowledgePointName}</TableCell>
{kp.averageMastery.toFixed(1)}% <TableCell className="text-right font-mono">
</Badge> <Badge variant={kp.averageMastery >= 80 ? "default" : kp.averageMastery >= 60 ? "secondary" : "destructive"}>
</TableCell> {kp.averageMastery.toFixed(1)}%
<TableCell className="text-right text-green-600">{kp.masteredCount}</TableCell> </Badge>
<TableCell className="text-right text-red-600">{kp.notMasteredCount}</TableCell> </TableCell>
</TableRow> <TableCell className="text-right text-green-600">{kp.masteredCount}</TableCell>
))} <TableCell className="text-right text-red-600">{kp.notMasteredCount}</TableCell>
</TableBody> </TableRow>
</Table> ))}
</TableBody>
</Table>
</div>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -188,44 +366,47 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-600" /> <AlertTriangle className="h-4 w-4 text-red-600" />
Students Needing Attention (avg &lt;60%) {t("classDiagnostic.studentsNeedingAttentionTitle")}
</CardTitle> </CardTitle>
<CardDescription>Students with low overall mastery.</CardDescription> <CardDescription>{t("classDiagnostic.studentsNeedingAttentionDescription")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{summary.studentsNeedingAttention.length === 0 ? ( {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"> <div className="rounded-md border">
<Table> {/* v4-P1-11: 移动端表格水平滚动 */}
<TableHeader> <div className="overflow-x-auto">
<TableRow> <Table>
<TableHead>Student</TableHead> <TableHeader>
<TableHead className="text-right">Avg Mastery</TableHead> <TableRow>
<TableHead className="text-right">Weak Points</TableHead> <TableHead>{t("summary.student")}</TableHead>
<TableHead></TableHead> <TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
</TableRow> <TableHead className="text-right">{t("classDiagnostic.weakPointsColumn")}</TableHead>
</TableHeader> <TableHead></TableHead>
<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>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {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> </div>
)} )}
</CardContent> </CardContent>
@@ -237,16 +418,16 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
Generate Class Diagnostic Report {t("report.generateClass")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Generate a class-level diagnostic report with aggregated analysis. {t("classDiagnostic.generateDescription")}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
<div className="grid gap-2"> <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 <Input
id="class-period" id="class-period"
type="month" type="month"
@@ -256,7 +437,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
/> />
</div> </div>
<Button onClick={handleGenerate} disabled={isGenerating}> <Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? "Generating..." : "Generate Class Report"} {isGenerating ? t("classDiagnostic.generating") : t("classDiagnostic.generateButton")}
</Button> </Button>
</div> </div>
</CardContent> </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" "use client"
import { useTranslations } from "next-intl"
import { Target } from "lucide-react" import { Target } from "lucide-react"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell" import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
@@ -11,6 +12,7 @@ interface MasteryRadarChartProps {
} }
export function MasteryRadarChart({ data }: MasteryRadarChartProps) { export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
const t = useTranslations("diagnostic")
const isEmpty = !data || data.length === 0 const isEmpty = !data || data.length === 0
const chartData = isEmpty const chartData = isEmpty
@@ -25,17 +27,29 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined) 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 ( return (
<ChartCardShell <ChartCardShell
title="Knowledge Point Mastery" title={t("chart.radarTitle")}
description="Radar chart of mastery level (0-100) across knowledge points." description={t("chart.radarDescriptionNonEmpty")}
icon={Target} icon={Target}
isEmpty={isEmpty} isEmpty={isEmpty}
emptyTitle="No mastery data" emptyTitle={t("chart.radarEmptyTitle")}
emptyDescription="No knowledge point mastery records found for this student." emptyDescription={t("chart.noMasteryDataForStudent")}
emptyClassName="h-60" 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 <ComparisonRadarChart
data={chartData} data={chartData}
angleKey="shortName" angleKey="shortName"
@@ -48,7 +62,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
series={[ series={[
{ {
dataKey: "student", dataKey: "student",
name: "Student", name: t("chart.studentSeries"),
color: "hsl(var(--primary))", color: "hsl(var(--primary))",
fillOpacity: 0.35, fillOpacity: 0.35,
strokeWidth: 2, strokeWidth: 2,
@@ -56,7 +70,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
}, },
{ {
dataKey: "classAverage", dataKey: "classAverage",
name: "Class Avg", name: t("chart.classAvgSeries"),
color: "hsl(var(--chart-2))", color: "hsl(var(--chart-2))",
fillOpacity: 0.15, fillOpacity: 0.15,
strokeWidth: 2, strokeWidth: 2,

View File

@@ -3,8 +3,9 @@
import { useState } from "react" import { useState } from "react"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react" import { useCallback } from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner" 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 { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -33,17 +34,18 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/components/ui/dialog" } 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 { formatDate } from "@/shared/lib/utils"
import { usePermission } from "@/shared/hooks" import { usePermission } from "@/shared/hooks"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { publishReportAction, deleteReportAction } from "../actions" import { publishReportAction, deleteReportAction, exportDiagnosticReportAction } from "../actions"
import type { DiagnosticReportWithDetails } from "../types" import type { DiagnosticReportWithDetails } from "../types"
import {
const typeLabels: Record<string, string> = { getConfidenceLevel,
individual: "Individual", confidenceBadgeVariant,
class: "Class", type ConfidenceLevel,
grade: "Grade", } from "./confidence-utils"
}
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = { const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "secondary", draft: "secondary",
@@ -59,10 +61,12 @@ export function ReportList({ reports }: ReportListProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { hasPermission } = usePermission() const { hasPermission } = usePermission()
const t = useTranslations("diagnostic")
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
const [publishId, setPublishId] = useState<string | null>(null) const [publishId, setPublishId] = useState<string | null>(null)
const [shareId, setShareId] = useState<string | null>(null)
const [isBusy, setIsBusy] = useState(false) const [isBusy, setIsBusy] = useState(false)
const updateParam = useCallback( const updateParam = useCallback(
@@ -90,7 +94,7 @@ export function ReportList({ reports }: ReportListProps) {
setPublishId(null) setPublishId(null)
router.refresh() router.refresh()
} else { } 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) setDeleteId(null)
router.refresh() router.refresh()
} else { } 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 reportType = searchParams.get("reportType") ?? "all"
const status = searchParams.get("status") ?? "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 ( return (
<div className="space-y-4"> <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 grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-2">
<div className="grid gap-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)}> <Select value={reportType} onValueChange={(v) => updateParam("reportType", v)}>
<SelectTrigger className="h-9"> <SelectTrigger id="filter-report-type" className="h-9">
<SelectValue placeholder="All types" /> <SelectValue placeholder={t("filters.allTypes")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All types</SelectItem> <SelectItem value="all">{t("filters.allTypes")}</SelectItem>
<SelectItem value="individual">Individual</SelectItem> <SelectItem value="individual">{t("type.individual")}</SelectItem>
<SelectItem value="class">Class</SelectItem> <SelectItem value="class">{t("type.class")}</SelectItem>
<SelectItem value="grade">Grade</SelectItem> <SelectItem value="grade">{t("type.grade")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid gap-2"> <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)}> <Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger className="h-9"> <SelectTrigger id="filter-report-status" className="h-9">
<SelectValue placeholder="All statuses" /> <SelectValue placeholder={t("filters.allStatuses")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All statuses</SelectItem> <SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
<SelectItem value="draft">Draft</SelectItem> <SelectItem value="draft">{t("status.draft")}</SelectItem>
<SelectItem value="published">Published</SelectItem> <SelectItem value="published">{t("status.published")}</SelectItem>
<SelectItem value="archived">Archived</SelectItem> <SelectItem value="archived">{t("status.archived")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -149,71 +244,118 @@ export function ReportList({ reports }: ReportListProps) {
{reports.length === 0 ? ( {reports.length === 0 ? (
<EmptyState <EmptyState
title="No diagnostic reports" title={t("empty.noReports")}
description="Generate diagnostic reports to see them here." description={t("reportList.noReportsDescription")}
icon={FileText} icon={FileText}
className="border-none shadow-none" className="border-none shadow-none"
/> />
) : ( ) : (
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<Table> <Table>
<caption className="sr-only"></caption> <caption className="sr-only">{t("reportList.caption")}</caption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Type</TableHead> <TableHead>{t("reportList.typeColumn")}</TableHead>
<TableHead>Student / Target</TableHead> <TableHead>{t("reportList.studentTargetColumn")}</TableHead>
<TableHead>Period</TableHead> <TableHead>{t("reportList.periodColumn")}</TableHead>
<TableHead className="text-right">Score</TableHead> <TableHead className="text-right">{t("reportList.scoreColumn")}</TableHead>
<TableHead>Status</TableHead> <TableHead>{t("reportList.confidenceColumn")}</TableHead>
<TableHead>Generated By</TableHead> <TableHead>{t("reportList.statusColumn")}</TableHead>
<TableHead>Date</TableHead> <TableHead>{t("reportList.generatedByColumn")}</TableHead>
{canManage ? <TableHead className="w-24">Actions</TableHead> : null} <TableHead>{t("reportList.dateColumn")}</TableHead>
<TableHead className="w-40">{t("reportList.actionsColumn")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{reports.map((r) => ( {reports.map((r) => (
<TableRow key={r.id}> <TableRow key={r.id}>
<TableCell> <TableCell>
<Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge> <Badge variant="outline">{typeLabel(r.reportType)}</Badge>
</TableCell> </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>{r.period ?? "-"}</TableCell>
<TableCell className="text-right font-mono"> <TableCell className="text-right font-mono">
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"} {r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
</TableCell> </TableCell>
<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>
<TableCell className="text-muted-foreground">{r.generatedByName ?? "-"}</TableCell> <TableCell className="text-muted-foreground">{r.generatedByName ?? "-"}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell> <TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
{canManage ? ( <TableCell>
<TableCell> <div className="flex gap-1">
<div className="flex gap-1"> {/* v3-P2-4: 导出按钮(所有角色可见,受 exportDiagnosticReportAction 权限校验保护) */}
{r.status === "draft" ? ( <Button
<Button variant="ghost"
variant="ghost" size="icon"
size="icon" className="h-8 w-8"
className="h-8 w-8 text-green-600" onClick={() => handleExport(r.id)}
onClick={() => setPublishId(r.id)} disabled={isBusy}
title="Publish" title={t("report.export")}
aria-label={`发布报告 ${r.studentName}`} aria-label={t("reportList.exportAriaLabel", { studentName: r.studentName ?? "" })}
> >
<Send className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
) : null} {/* 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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-destructive" className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)} onClick={() => setDeleteId(r.id)}
title="Delete" disabled={isBusy}
aria-label={`删除报告 ${r.studentName}`} title={t("report.delete")}
aria-label={t("reportList.deleteAriaLabel", { studentName: r.studentName ?? "" })}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> ) : null}
</TableCell> </div>
) : null} </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -225,17 +367,17 @@ export function ReportList({ reports }: ReportListProps) {
<Dialog open={publishId !== null} onOpenChange={(open) => !open && setPublishId(null)}> <Dialog open={publishId !== null} onOpenChange={(open) => !open && setPublishId(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Publish Report</DialogTitle> <DialogTitle>{t("report.publishTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>
Once published, the report will be visible to students. Continue? {t("reportList.publishConfirmation")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setPublishId(null)} disabled={isBusy}> <Button variant="outline" onClick={() => setPublishId(null)} disabled={isBusy}>
Cancel {t("report.cancel")}
</Button> </Button>
<Button onClick={handlePublish} disabled={isBusy}> <Button onClick={handlePublish} disabled={isBusy}>
{isBusy ? "Publishing..." : "Publish"} {isBusy ? t("report.publishing") : t("report.publish")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -245,17 +387,55 @@ export function ReportList({ reports }: ReportListProps) {
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}> <Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Report</DialogTitle> <DialogTitle>{t("report.deleteTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete this diagnostic report? This action cannot be undone. {t("report.deleteConfirmation")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isBusy}> <Button variant="outline" onClick={() => setDeleteId(null)} disabled={isBusy}>
Cancel {t("report.cancel")}
</Button> </Button>
<Button variant="destructive" onClick={handleDelete} disabled={isBusy}> <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> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -1,28 +1,50 @@
"use client" "use client"
import Link from "next/link" import Link from "next/link"
import { useTranslations } from "next-intl"
import { Award, AlertTriangle, Lightbulb, FileText, History, ArrowRight } from "lucide-react" import { Award, AlertTriangle, Lightbulb, FileText, History, ArrowRight } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" 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 { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { MasteryRadarChart } from "./mastery-radar-chart" import { MasteryRadarChart } from "./mastery-radar-chart"
import {
getConfidenceLevel,
confidenceBadgeVariant,
type ConfidenceLevel,
} from "./confidence-utils"
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types" import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
interface StudentDiagnosticViewProps { interface StudentDiagnosticViewProps {
summary: StudentMasterySummary | null summary: StudentMasterySummary | null
reports: DiagnosticReportWithDetails[] reports: DiagnosticReportWithDetails[]
classAverageMastery?: MasteryRadarPoint[] 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) { if (!summary) {
return ( return (
<EmptyState <EmptyState
title="No diagnostic data" title={t("empty.noData")}
description="Unable to load student mastery data." description={t("studentDiagnostic.noDataDescription")}
icon={FileText} icon={FileText}
className="border-none shadow-none" className="border-none shadow-none"
/> />
@@ -39,7 +61,38 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
}) })
const publishedReports = reports.filter((r) => r.status === "published") 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 ( return (
<div className="space-y-6"> <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"> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<Card> <Card>
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<p className="text-2xl font-bold">{summary.studentName}</p> <p className="text-2xl font-bold">{summary.studentName}</p>
@@ -55,7 +108,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p> <p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
@@ -63,7 +116,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
</Card> </Card>
<Card> <Card>
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<p className="text-2xl font-bold text-green-600">{summary.strengths.length}</p> <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>
<Card> <Card>
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<p className="text-2xl font-bold text-red-600">{summary.weaknesses.length}</p> <p className="text-2xl font-bold text-red-600">{summary.weaknesses.length}</p>
@@ -88,15 +141,15 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Award className="h-4 w-4 text-green-600" /> <Award className="h-4 w-4 text-green-600" />
Strengths (80%) {t("strengths.title")}
</CardTitle> </CardTitle>
<CardDescription>Knowledge points with high mastery.</CardDescription> <CardDescription>{t("studentDiagnostic.strengthsDescription")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{summary.strengths.length === 0 ? ( {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) => ( {summary.strengths.map((m) => (
<li key={m.knowledgePointId} className="flex items-center justify-between"> <li key={m.knowledgePointId} className="flex items-center justify-between">
<span className="text-sm">{m.knowledgePointName}</span> <span className="text-sm">{m.knowledgePointName}</span>
@@ -111,27 +164,29 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-600" /> <AlertTriangle className="h-4 w-4 text-red-600" />
Weaknesses (&lt;60%) {t("weaknesses.title")}
</CardTitle> </CardTitle>
<CardDescription>Knowledge points needing attention.</CardDescription> <CardDescription>{t("studentDiagnostic.weaknessesDescription")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{summary.weaknesses.length === 0 ? ( {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) => ( {summary.weaknesses.map((m) => (
<li key={m.knowledgePointId} className="flex items-center justify-between gap-2"> <li key={m.knowledgePointId} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="text-sm truncate">{m.knowledgePointName}</span> <span className="text-sm truncate">{m.knowledgePointName}</span>
<Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge> <Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge>
</div> </div>
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs"> {practiceHrefBase ? (
<Link href="/student/learning/assignments"> <Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs" aria-label={t("studentDiagnostic.practiceAriaLabel", { name: m.knowledgePointName })}>
Practice <Link href={`${practiceHrefBase}?kp=${m.knowledgePointId}`}>
<ArrowRight className="ml-1 h-3 w-3" /> {t("weaknesses.practice")}
</Link> <ArrowRight className="ml-1 h-3 w-3" />
</Button> </Link>
</Button>
) : null}
</li> </li>
))} ))}
</ul> </ul>
@@ -146,13 +201,32 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Lightbulb className="h-4 w-4" /> <Lightbulb className="h-4 w-4" />
Diagnostic Report {t("studentDiagnostic.diagnosticReportTitle")}
<Badge variant={latestReport.status === "published" ? "default" : "secondary"}> <Badge variant={latestReport.status === "published" ? "default" : "secondary"}>
{latestReport.status} {statusLabel(latestReport.status)}
</Badge> </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> </CardTitle>
<CardDescription> <CardDescription>
Period: {latestReport.period ?? "-"} · Overall: {latestReport.overallScore?.toFixed(1) ?? "-"}% {t("studentDiagnostic.reportMeta", {
period: latestReport.period ?? "-",
score: latestReport.overallScore?.toFixed(1) ?? "-",
})}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -161,10 +235,10 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
) : null} ) : null}
{latestReport.recommendations && latestReport.recommendations.length > 0 ? ( {latestReport.recommendations && latestReport.recommendations.length > 0 ? (
<div> <div>
<h4 className="mb-2 text-sm font-semibold">Recommendations</h4> <h4 className="mb-2 text-sm font-semibold">{t("report.recommendations")}</h4>
<ul className="space-y-1.5" role="list" aria-label="学习建议列表"> <ul className="space-y-1.5" role="list" aria-label={t("studentDiagnostic.recommendationsListAriaLabel")}>
{latestReport.recommendations.map((rec, i) => ( {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> </ul>
</div> </div>
@@ -179,9 +253,9 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<History className="h-4 w-4" /> <History className="h-4 w-4" />
Report History {t("report.history")}
</CardTitle> </CardTitle>
<CardDescription>Past diagnostic reports (newest first).</CardDescription> <CardDescription>{t("studentDiagnostic.historyDescription")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <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="min-w-0 space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{r.period ?? "Untitled period"} {r.period ?? t("studentDiagnostic.untitledPeriod")}
</span> </span>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{r.reportType} {typeLabel(r.reportType)}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{formatDate(r.createdAt)} {r.overallScore !== null
{r.overallScore !== null ? ` · Overall: ${r.overallScore.toFixed(1)}%` : ""} ? t("studentDiagnostic.historyReportMeta", {
date: formatDate(r.createdAt),
score: r.overallScore.toFixed(1),
})
: formatDate(r.createdAt)}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,23 +1,42 @@
import "server-only" import "server-only"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq, type SQL } from "drizzle-orm" import { and, count, desc, eq, inArray, type SQL } from "drizzle-orm"
import { cache } from "react" import { cache } from "react"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { learningDiagnosticReports } from "@/shared/db/schema" import { learningDiagnosticReports } from "@/shared/db/schema"
import { getUserNamesByIds } from "@/modules/users/data-access" import { getUserNamesByIds } from "@/modules/users/data-access"
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { toNumber } from "@/modules/grades/lib/grade-utils"
import { BusinessError } from "@/shared/lib/action-utils"
import type { DataScope } from "@/shared/types/permissions"
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access" import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
import { buildClassReportContent, buildStudentReportContent } from "./stats-service"
import type { import type {
DiagnosticReport, DiagnosticReport,
DiagnosticReportListResult,
DiagnosticReportQueryParams, DiagnosticReportQueryParams,
DiagnosticReportWithDetails, DiagnosticReportWithDetails,
} from "./types" } from "./types"
const toNumber = (v: unknown): number => { /**
const n = typeof v === "number" ? v : Number(v) * 诊断报告业务错误P3-27 修复:结构化错误码,避免直接暴露内部错误)。
return Number.isFinite(n) ? n : 0 * 继承 BusinessError 以便 handleActionError 安全地将 message 返回给客户端。
*/
export class DiagnosticReportError extends BusinessError {
constructor(
public readonly code:
| "STUDENT_NOT_FOUND"
| "NO_MASTERY_DATA"
| "CLASS_NOT_FOUND"
| "CLASS_NO_MASTERY_DATA",
message: string,
) {
super(message, code)
this.name = "DiagnosticReportError"
}
} }
const isStringArray = (v: unknown): v is string[] => const isStringArray = (v: unknown): v is string[] =>
@@ -29,6 +48,7 @@ const toStringArrayNullable = (v: unknown): string[] | null =>
const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({ const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
id: r.id, id: r.id,
studentId: r.studentId, studentId: r.studentId,
classId: r.classId,
generatedBy: r.generatedBy, generatedBy: r.generatedBy,
reportType: r.reportType, reportType: r.reportType,
period: r.period, period: r.period,
@@ -49,19 +69,15 @@ export async function generateDiagnosticReport(
generatedBy: string generatedBy: string
): Promise<string> { ): Promise<string> {
const summary = await getStudentMasterySummary(studentId) const summary = await getStudentMasterySummary(studentId)
if (!summary) throw new Error("Student not found") if (!summary) throw new DiagnosticReportError("STUDENT_NOT_FOUND", "学生不存在")
const overallScore = summary.averageMastery // P2-6 修复:当学生存在但无任何掌握度数据时,拒绝生成误导性报告
const strengths = summary.strengths.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`) if (summary.totalKnowledgePoints === 0) {
const weaknesses = summary.weaknesses.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`) throw new DiagnosticReportError("NO_MASTERY_DATA", "学生暂无掌握度数据,无法生成报告")
const recommendations = summary.weaknesses.map(
(m) => `建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。`
)
if (recommendations.length === 0) {
recommendations.push("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。")
} }
const summaryText = `学生 ${summary.studentName}${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。` const { summaryText, strengths, weaknesses, recommendations, overallScore } =
buildStudentReportContent(summary, period)
const id = createId() const id = createId()
await db.insert(learningDiagnosticReports).values({ await db.insert(learningDiagnosticReports).values({
@@ -87,24 +103,15 @@ export async function generateClassDiagnosticReport(
generatedBy: string generatedBy: string
): Promise<string> { ): Promise<string> {
const summary = await getClassMasterySummary(classId) const summary = await getClassMasterySummary(classId)
if (!summary) throw new Error("Class not found") if (!summary) throw new DiagnosticReportError("CLASS_NOT_FOUND", "班级不存在")
const topWeak = summary.knowledgePointStats // P2-6 修复:当班级存在但无任何掌握度数据时,拒绝生成误导性报告
.filter((k) => k.averageMastery < 60) if (summary.studentCount === 0 || summary.knowledgePointStats.length === 0) {
.sort((a, b) => a.averageMastery - b.averageMastery) throw new DiagnosticReportError("CLASS_NO_MASTERY_DATA", "班级暂无掌握度数据,无法生成报告")
.slice(0, 5)
const strengths = summary.knowledgePointStats
.filter((k) => k.averageMastery >= 80)
.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
const weaknesses = topWeak.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
const recommendations = topWeak.map(
(k) => `班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。`
)
if (recommendations.length === 0) {
recommendations.push("班级整体掌握情况良好,建议保持当前教学节奏。")
} }
const summaryText = `班级 ${summary.className}${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。` const { summaryText, strengths, weaknesses, recommendations, overallScore } =
buildClassReportContent(summary, period)
const id = createId() const id = createId()
await db.insert(learningDiagnosticReports).values({ await db.insert(learningDiagnosticReports).values({
@@ -117,26 +124,73 @@ export async function generateClassDiagnosticReport(
strengths, strengths,
weaknesses, weaknesses,
recommendations, recommendations,
overallScore: String(summary.averageMastery), overallScore: String(overallScore),
status: "draft", status: "draft",
}) })
return id return id
} }
/** 查询诊断报告列表 */ /** 查询诊断报告列表P3-15 修复:支持分页) */
export const getDiagnosticReports = cache( export const getDiagnosticReports = cache(
async (filters: DiagnosticReportQueryParams): Promise<DiagnosticReportWithDetails[]> => { async (
filters: DiagnosticReportQueryParams,
scope?: DataScope,
): Promise<DiagnosticReportListResult> => {
const conditions: SQL[] = [] const conditions: SQL[] = []
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId)) if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType)) if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status)) if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period)) if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
const rows = await db // v4-P1-1: 应用 DataScope 行级权限过滤
.select({ report: learningDiagnosticReports }) // - class_taught: 仅返回所教班级学生的个人报告 + 班级报告(班级报告 studentId 为 null需通过 classId 关联)
.from(learningDiagnosticReports) // 由于当前 schema 班级报告 studentId=null无法直接按 classId 过滤,因此对 class_taught scope
.where(conditions.length > 0 ? and(...conditions) : undefined) // 个人报告按所教班级学生 ID 过滤班级报告studentId=null保留教师可查看自己生成的班级报告
.orderBy(desc(learningDiagnosticReports.createdAt)) // - class_members: 学生角色,调用方已在 filters.studentId 中传入 ctx.userId无需在此重复过滤
// - children: 仅返回子女的报告
// - grade_managed: 返回所辖年级所有学生的报告(通过 studentId IN 所辖年级学生)
// - all: 不过滤
if (scope) {
if (scope.type === "children") {
if (scope.childrenIds.length === 0) {
return { reports: [], total: 0 }
}
conditions.push(inArray(learningDiagnosticReports.studentId, scope.childrenIds))
} else if (scope.type === "class_taught") {
if (scope.classIds.length === 0) {
return { reports: [], total: 0 }
}
const studentIds = await getStudentIdsByClassIds(scope.classIds)
if (studentIds.length === 0) {
return { reports: [], total: 0 }
}
// 个人报告按学生 ID 过滤班级报告studentId=null由 generatedBy 限制为当前教师
// 这里简化:仅返回所教班级学生的个人报告
conditions.push(inArray(learningDiagnosticReports.studentId, studentIds))
}
// grade_managed 和 all 不在此过滤grade_managed 需要跨模块查询年级学生,由调用方自行过滤)
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const limit = filters.limit ?? 100
const offset = filters.offset ?? 0
// P3-15 修复:并行查询总数和分页数据
const [totalRows, rows] = await Promise.all([
db
.select({ total: count() })
.from(learningDiagnosticReports)
.where(whereClause),
db
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
.where(whereClause)
.orderBy(desc(learningDiagnosticReports.createdAt))
.limit(limit)
.offset(offset),
])
const total = totalRows[0]?.total ?? 0
// 收集所有需要查询姓名的用户 ID学生 + 生成者),通过 users data-access 统一获取 // 收集所有需要查询姓名的用户 ID学生 + 生成者),通过 users data-access 统一获取
const userIds = new Set<string>() const userIds = new Set<string>()
@@ -146,7 +200,7 @@ export const getDiagnosticReports = cache(
} }
const userMap = await getUserNamesByIds(Array.from(userIds)) const userMap = await getUserNamesByIds(Array.from(userIds))
return rows.map((r) => ({ const reports: DiagnosticReportWithDetails[] = rows.map((r) => ({
...serializeReport(r.report), ...serializeReport(r.report),
studentName: r.report.studentId studentName: r.report.studentId
? userMap.get(r.report.studentId)?.name ?? "Unknown" ? userMap.get(r.report.studentId)?.name ?? "Unknown"
@@ -155,6 +209,8 @@ export const getDiagnosticReports = cache(
? userMap.get(r.report.generatedBy)?.name ?? "Unknown" ? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
: null, : null,
})) }))
return { reports, total }
}, },
) )

View File

@@ -1,45 +1,35 @@
import "server-only" import "server-only"
import { cache } from "react" import { cache } from "react"
import { desc, eq, inArray } from "drizzle-orm" import { and, desc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema" import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema"
import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access" import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access"
import { getExamSubmissionWithAnswers } from "@/modules/exams/data-access" import { getExamSubmissionWithAnswers, getExamWithQuestionsForHomework } from "@/modules/exams/data-access"
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access" import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access" import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
import {
aggregateClassMastery,
buildClassMasterySummary,
buildStudentMasterySummary,
computeKpStats,
computeMasteryLevel,
serializeMasteryWithKp,
type RawClassMasteryRow,
type RawMasteryWithKpRow,
} from "./stats-service"
import type { import type {
ClassMasterySummary, ClassMasterySummary,
KnowledgePointMastery,
KnowledgePointStat, KnowledgePointStat,
MasteryWithKnowledgePoint, MasteryWithKnowledgePoint,
StudentMasterySummary, StudentMasterySummary,
} from "./types" } from "./types"
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
const round2 = (n: number): number => Math.round(n * 100) / 100
const serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): KnowledgePointMastery => ({
id: r.id,
studentId: r.studentId,
knowledgePointId: r.knowledgePointId,
masteryLevel: toNumber(r.masteryLevel),
totalQuestions: r.totalQuestions,
correctQuestions: r.correctQuestions,
lastAssessedAt: r.lastAssessedAt.toISOString(),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
/** 获取学生在所有知识点的掌握度(含知识点名称) */ /** 获取学生在所有知识点的掌握度(含知识点名称) */
export const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => { const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
const rows = await db const rows = await db
.select({ .select({
mastery: knowledgePointMastery, mastery: knowledgePointMastery,
@@ -51,45 +41,29 @@ export const getStudentMastery = cache(async (studentId: string): Promise<Master
.where(eq(knowledgePointMastery.studentId, studentId)) .where(eq(knowledgePointMastery.studentId, studentId))
.orderBy(desc(knowledgePointMastery.masteryLevel)) .orderBy(desc(knowledgePointMastery.masteryLevel))
return rows.map((r) => ({ return rows.map((r) =>
...serializeMastery(r.mastery), serializeMasteryWithKp({
knowledgePointName: r.kpName ?? "Unknown", mastery: r.mastery,
knowledgePointDescription: r.kpDescription, kpName: r.kpName,
})) kpDescription: r.kpDescription,
} satisfies RawMasteryWithKpRow),
)
}) })
/** 获取学生掌握度摘要(含强项/弱项分析) */ /** 获取学生掌握度摘要(含强项/弱项分析) */
export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => { export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
const userMap = await getUserNamesByIds([studentId]) // P3-18 修复:用户名查询与掌握度查询相互独立,并行执行
const [userMap, allMastery] = await Promise.all([
getUserNamesByIds([studentId]),
getStudentMastery(studentId),
])
const student = userMap.get(studentId) const student = userMap.get(studentId)
if (!student) return null if (!student) return null
const allMastery = await getStudentMastery(studentId) return buildStudentMasterySummary(studentId, student.name ?? "Unknown", allMastery)
const averageMastery =
allMastery.length > 0
? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length)
: 0
// Single-pass classification: strengths (>=80) and weaknesses (<60)
const strengths: MasteryWithKnowledgePoint[] = []
const weaknesses: MasteryWithKnowledgePoint[] = []
for (const m of allMastery) {
if (m.masteryLevel >= 80) strengths.push(m)
if (m.masteryLevel < 60) weaknesses.push(m)
}
return {
studentId,
studentName: student.name ?? "Unknown",
averageMastery,
totalKnowledgePoints: allMastery.length,
strengths,
weaknesses,
allMastery,
}
}) })
/** 从提交答案更新掌握度(正确率作为掌握度) */ /** 从提交答案更新掌握度(累积模式:在历史基础上累加,正确率作为掌握度) */
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> { export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
const submission = await getExamSubmissionWithAnswers(submissionId) const submission = await getExamSubmissionWithAnswers(submissionId)
if (!submission) return if (!submission) return
@@ -115,31 +89,147 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise
} }
} }
// 读取已有掌握度记录,累积计算(而非覆盖)
const existingRows = await db
.select()
.from(knowledgePointMastery)
.where(
and(
eq(knowledgePointMastery.studentId, submission.studentId),
inArray(knowledgePointMastery.knowledgePointId, Array.from(kpStats.keys())),
),
)
const existingByKp = new Map<string, { total: number; correct: number }>()
for (const row of existingRows) {
existingByKp.set(row.knowledgePointId, {
total: row.totalQuestions,
correct: row.correctQuestions,
})
}
const now = new Date() const now = new Date()
await Promise.all( // 使用事务保证多个 upsert 的原子性
Array.from(kpStats.entries()).map(async ([kpId, stat]) => { await db.transaction(async (tx) => {
const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0 await Promise.all(
await db Array.from(kpStats.entries()).map(async ([kpId, stat]) => {
.insert(knowledgePointMastery) const existing = existingByKp.get(kpId)
.values({ const totalQuestions = (existing?.total ?? 0) + stat.total
studentId: submission.studentId, const correctQuestions = (existing?.correct ?? 0) + stat.correct
knowledgePointId: kpId, const masteryLevel = computeMasteryLevel(correctQuestions, totalQuestions)
masteryLevel: String(masteryLevel), await tx
totalQuestions: stat.total, .insert(knowledgePointMastery)
correctQuestions: stat.correct, .values({
lastAssessedAt: now, studentId: submission.studentId,
}) knowledgePointId: kpId,
.onDuplicateKeyUpdate({
set: {
masteryLevel: String(masteryLevel), masteryLevel: String(masteryLevel),
totalQuestions: stat.total, totalQuestions,
correctQuestions: stat.correct, correctQuestions,
lastAssessedAt: now, lastAssessedAt: now,
updatedAt: now, })
}, .onDuplicateKeyUpdate({
}) set: {
}), masteryLevel: String(masteryLevel),
) totalQuestions,
correctQuestions,
lastAssessedAt: now,
updatedAt: now,
},
})
}),
)
})
}
/**
* v3-P1-5从手动录入的成绩更新掌握度。
*
* 当教师手动录入关联了 examId 的成绩时根据得分率score/fullScore
* 更新该考试涉及的所有知识点的掌握度。采用累积模式,与 updateMasteryFromSubmission 一致。
*
* 注意:此函数假设学生在所有知识点上的掌握度等于整体得分率,
* 这是一种近似(无法区分知识点级别的强弱),适用于无题目级别答案的场景。
*/
export async function updateMasteryFromExamScore(
studentId: string,
examId: string,
score: number,
fullScore: number,
): Promise<void> {
if (fullScore <= 0) return
// 获取考试的所有题目
const examWithQuestions = await getExamWithQuestionsForHomework(examId)
if (!examWithQuestions || examWithQuestions.questions.length === 0) return
// 获取题目关联的知识点
const questionIds = examWithQuestions.questions.map((q) => q.questionId)
const kpMap = await getKnowledgePointsForQuestions(questionIds)
// 收集所有涉及的知识点 ID
const kpIds = new Set<string>()
for (const links of kpMap.values()) {
for (const link of links) {
kpIds.add(link.knowledgePointId)
}
}
if (kpIds.size === 0) return
// 计算得分率作为掌握度
const masteryLevel = computeMasteryLevel(score, fullScore)
// 读取已有掌握度记录,累积计算
const existingRows = await db
.select()
.from(knowledgePointMastery)
.where(
and(
eq(knowledgePointMastery.studentId, studentId),
inArray(knowledgePointMastery.knowledgePointId, Array.from(kpIds)),
),
)
const existingByKp = new Map<string, { total: number; correct: number }>()
for (const row of existingRows) {
existingByKp.set(row.knowledgePointId, {
total: row.totalQuestions,
correct: row.correctQuestions,
})
}
const now = new Date()
// 使用事务保证多个 upsert 的原子性
await db.transaction(async (tx) => {
await Promise.all(
Array.from(kpIds).map(async (kpId) => {
const existing = existingByKp.get(kpId)
// 累积:将本次成绩视为 1 道题,得分率作为掌握度
const totalQuestions = (existing?.total ?? 0) + 1
const correctQuestions = (existing?.correct ?? 0) + Math.round(masteryLevel / 100)
const newMasteryLevel = computeMasteryLevel(correctQuestions, totalQuestions)
await tx
.insert(knowledgePointMastery)
.values({
studentId,
knowledgePointId: kpId,
masteryLevel: String(newMasteryLevel),
totalQuestions,
correctQuestions,
lastAssessedAt: now,
})
.onDuplicateKeyUpdate({
set: {
masteryLevel: String(newMasteryLevel),
totalQuestions,
correctQuestions,
lastAssessedAt: now,
updatedAt: now,
},
})
}),
)
})
} }
/** 获取班级掌握度摘要 */ /** 获取班级掌握度摘要 */
@@ -172,50 +262,16 @@ export const getClassMasterySummary = cache(async (classId: string): Promise<Cla
.map((id) => ({ id, name: userMap.get(id)?.name ?? null })) .map((id) => ({ id, name: userMap.get(id)?.name ?? null }))
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")) .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>() const rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({
const byStudent = new Map<string, { levels: number[]; weakCount: number }>() mastery: {
for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 }) studentId: r.mastery.studentId,
knowledgePointId: r.mastery.knowledgePointId,
for (const r of masteryRows) { masteryLevel: r.mastery.masteryLevel,
const level = toNumber(r.mastery.masteryLevel) },
const kpId = r.mastery.knowledgePointId kpName: r.kpName,
const kpEntry = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
kpEntry.levels.push(level)
if (level >= 80) kpEntry.mastered += 1
if (level < 60) kpEntry.notMastered += 1
byKp.set(kpId, kpEntry)
const stuEntry = byStudent.get(r.mastery.studentId)
if (stuEntry) {
stuEntry.levels.push(level)
if (level < 60) stuEntry.weakCount += 1
}
}
const knowledgePointStats: KnowledgePointStat[] = Array.from(byKp.entries()).map(([kpId, e]) => ({
knowledgePointId: kpId,
knowledgePointName: e.name,
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
masteredCount: e.mastered,
notMasteredCount: e.notMastered,
totalStudents: students.length,
})) }))
const allLevels = masteryRows.map((r) => toNumber(r.mastery.masteryLevel)) return buildClassMasterySummary(classId, className, students, rawRows)
const averageMastery = allLevels.length > 0 ? round2(allLevels.reduce((a, b) => a + b, 0) / allLevels.length) : 0
const studentsNeedingAttention = students
.map((s) => {
const e = byStudent.get(s.id)
if (!e) return null
const avg = e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0
return { studentId: s.id, studentName: s.name ?? "Unknown", averageMastery: avg, weakCount: e.weakCount }
})
.filter((s): s is { studentId: string; studentName: string; averageMastery: number; weakCount: number } => s !== null)
.filter((s) => s.averageMastery < 60)
.sort((a, b) => a.averageMastery - b.averageMastery)
return { classId, className, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
}) })
/** 获取知识点统计(按班级或年级聚合) */ /** 获取知识点统计(按班级或年级聚合) */
@@ -235,23 +291,101 @@ export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: s
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) .leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
.where(inArray(knowledgePointMastery.studentId, studentIds)) .where(inArray(knowledgePointMastery.studentId, studentIds))
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>() const rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({
for (const r of masteryRows) { mastery: {
const level = toNumber(r.mastery.masteryLevel) studentId: r.mastery.studentId,
const kpId = r.mastery.knowledgePointId knowledgePointId: r.mastery.knowledgePointId,
const e = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 } masteryLevel: r.mastery.masteryLevel,
e.levels.push(level) },
if (level >= 80) e.mastered += 1 kpName: r.kpName,
if (level < 60) e.notMastered += 1
byKp.set(kpId, e)
}
return Array.from(byKp.entries()).map(([kpId, e]) => ({
knowledgePointId: kpId,
knowledgePointName: e.name,
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
masteredCount: e.mastered,
notMasteredCount: e.notMastered,
totalStudents: studentIds.length,
})) }))
const { byKp } = aggregateClassMastery(rawRows, studentIds)
return computeKpStats(byKp)
}) })
/**
* v3-P2-5: 获取班级学生在指定知识点上的掌握度列表。
*
* 用于"按知识点筛选学生"功能:教师选择某个知识点后,列出班级所有学生
* 在该知识点上的掌握度,便于针对性辅导。掌握度低于阈值(默认 60的学生
* 排在前面并标记为"需关注"。
*/
export const getClassStudentsByKnowledgePoint = cache(
async (
classId: string,
knowledgePointId: string,
options?: { threshold?: number }
): Promise<
Array<{
studentId: string
studentName: string
masteryLevel: number
totalQuestions: number
correctQuestions: number
lastAssessedAt: string | null
needsAttention: boolean
}>
> => {
const threshold = options?.threshold ?? 60
const studentIds = await getActiveStudentIdsByClassId(classId)
if (studentIds.length === 0) return []
const [userMap, masteryRows] = await Promise.all([
getUserNamesByIds(studentIds),
db
.select({
mastery: knowledgePointMastery,
})
.from(knowledgePointMastery)
.where(
and(
eq(knowledgePointMastery.knowledgePointId, knowledgePointId),
inArray(knowledgePointMastery.studentId, studentIds),
),
),
])
const masteryByStudent = new Map<string, {
masteryLevel: number
totalQuestions: number
correctQuestions: number
lastAssessedAt: Date | null
}>()
for (const row of masteryRows) {
masteryByStudent.set(row.mastery.studentId, {
masteryLevel: Number(row.mastery.masteryLevel) || 0,
totalQuestions: row.mastery.totalQuestions,
correctQuestions: row.mastery.correctQuestions,
lastAssessedAt: row.mastery.lastAssessedAt,
})
}
const result = studentIds.map((id) => {
const info = userMap.get(id)
const mastery = masteryByStudent.get(id)
const masteryLevel = mastery?.masteryLevel ?? 0
return {
studentId: id,
studentName: info?.name ?? "Unknown",
masteryLevel,
totalQuestions: mastery?.totalQuestions ?? 0,
correctQuestions: mastery?.correctQuestions ?? 0,
lastAssessedAt: mastery?.lastAssessedAt
? mastery.lastAssessedAt.toISOString()
: null,
needsAttention: masteryLevel < threshold,
}
})
// 需关注的学生排前面,相同关注状态按掌握度升序
result.sort((a, b) => {
if (a.needsAttention !== b.needsAttention) {
return a.needsAttention ? -1 : 1
}
return a.masteryLevel - b.masteryLevel
})
return result
}
)

View File

@@ -0,0 +1,122 @@
import "server-only"
import { exportToExcel } from "@/shared/lib/excel"
import { formatDateForFile } from "@/shared/lib/utils"
import { getDiagnosticReportById } from "./data-access-reports"
import { getStudentMasterySummary, getClassMasterySummary } from "./data-access"
/**
* v3-P2-4: 导出诊断报告为 Excel。
*
* 个人报告 Sheet
* - 概览(学生、周期、综合得分、摘要、强项、弱项、建议)
* - 知识点掌握度明细
*
* 班级报告 Sheet
* - 概览(班级、周期、综合得分、摘要、强项、弱项、建议)
* - 知识点统计(平均掌握度、掌握人数、未掌握人数)
* - 需关注学生列表
*/
export async function exportDiagnosticReportToExcel(params: {
reportId: string
}): Promise<Buffer> {
const report = await getDiagnosticReportById(params.reportId)
if (!report) {
throw new Error("Report not found")
}
const periodLabel = report.period ?? "本期"
const overallScore = report.overallScore ?? "-"
const strengths = (report.strengths ?? []).join("\n") || "-"
const weaknesses = (report.weaknesses ?? []).join("\n") || "-"
const recommendations = (report.recommendations ?? []).join("\n") || "-"
const summary = report.summary ?? "-"
if (report.reportType === "individual" && report.studentId) {
// 个人报告
const mastery = await getStudentMasterySummary(report.studentId)
const overviewRows = [
{ metric: "学生姓名", value: report.studentName ?? "-" },
{ metric: "报告周期", value: periodLabel },
{ metric: "综合得分", value: overallScore },
{ metric: "报告状态", value: report.status },
{ metric: "生成人", value: report.generatedByName ?? "-" },
{ metric: "生成时间", value: report.createdAt.split("T")[0] },
{ metric: "摘要", value: summary },
{ metric: "强项", value: strengths },
{ metric: "弱项", value: weaknesses },
{ metric: "建议", value: recommendations },
]
const masteryRows = (mastery?.allMastery ?? []).map((m) => ({
knowledgePoint: m.knowledgePointName,
masteryLevel: m.masteryLevel,
totalQuestions: m.totalQuestions,
correctQuestions: m.correctQuestions,
lastAssessedAt: m.lastAssessedAt.split("T")[0],
}))
return exportToExcel({
sheets: [
{
name: "报告概览",
columns: [
{ header: "指标", key: "metric", width: 20 },
{ header: "数值", key: "value", width: 60 },
],
rows: overviewRows,
},
{
name: "知识点掌握度",
columns: [
{ header: "知识点", key: "knowledgePoint", width: 28 },
{ header: "掌握度", key: "masteryLevel", width: 12 },
{ header: "总题数", key: "totalQuestions", width: 10 },
{ header: "正确数", key: "correctQuestions", width: 10 },
{ header: "最近评估", key: "lastAssessedAt", width: 14 },
],
rows: masteryRows,
},
],
})
}
// 班级报告reportType === "class"
// 班级报告的 studentId 为 null需要从 period 反查 classId 不现实,
// 这里仅导出报告概览(知识点统计需要 classId但报告本身未存储 classId
// 如需导出班级明细,应通过 generateClassDiagnosticReport 时记录 classId。
const overviewRows = [
{ metric: "报告类型", value: "班级报告" },
{ metric: "报告周期", value: periodLabel },
{ metric: "综合得分", value: overallScore },
{ metric: "报告状态", value: report.status },
{ metric: "生成人", value: report.generatedByName ?? "-" },
{ metric: "生成时间", value: report.createdAt.split("T")[0] },
{ metric: "摘要", value: summary },
{ metric: "强项", value: strengths },
{ metric: "弱项", value: weaknesses },
{ metric: "建议", value: recommendations },
]
return exportToExcel({
sheets: [
{
name: "报告概览",
columns: [
{ header: "指标", key: "metric", width: 20 },
{ header: "数值", key: "value", width: 60 },
],
rows: overviewRows,
},
],
})
}
/**
* 生成诊断报告导出文件名。
*/
export function buildDiagnosticReportFilename(period: string | null): string {
const safePeriod = (period ?? "report").replace(/[\\/:*?"<>|]/g, "_")
return `诊断报告_${safePeriod}_${formatDateForFile()}.xlsx`
}

View File

@@ -29,20 +29,3 @@ export const DeleteReportSchema = z.object({
}) })
export type DeleteReportInput = z.infer<typeof DeleteReportSchema> export type DeleteReportInput = z.infer<typeof DeleteReportSchema>
/** 查询诊断报告列表 */
export const GetDiagnosticReportsSchema = z.object({
studentId: z.string().optional(),
reportType: z.enum(["individual", "class", "grade"]).optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
period: z.string().optional(),
})
export type GetDiagnosticReportsInput = z.infer<typeof GetDiagnosticReportsSchema>
/** 获取诊断报告详情 */
export const GetDiagnosticReportByIdSchema = z.object({
id: z.string().min(1),
})
export type GetDiagnosticReportByIdInput = z.infer<typeof GetDiagnosticReportByIdSchema>

View File

@@ -0,0 +1,388 @@
/**
* Diagnostic statistics pure functions.
*
* Extracted from data-access / data-access-reports to keep data-access layer
* focused on DB I/O and make statistics logic independently testable.
* All functions are pure (no side effects, no I/O).
*/
import type {
ClassMasterySummary,
KnowledgePointMastery,
KnowledgePointStat,
MasteryWithKnowledgePoint,
StudentMasterySummary,
} from "./types"
/** Round to 2 decimal places. */
const round2 = (n: number): number => Math.round(n * 100) / 100
/** Convert unknown to finite number (0 fallback). */
const toNumber = (v: unknown): number => {
const n = typeof v === "number" ? v : Number(v)
return Number.isFinite(n) ? n : 0
}
/** Strength threshold (mastery >= 80). */
export const STRENGTH_THRESHOLD = 80
/** Weakness threshold (mastery < 60). */
export const WEAKNESS_THRESHOLD = 60
/**
* Raw mastery row from DB join (mastery + knowledge point name).
* Numeric columns may be string | number depending on driver.
*/
export interface RawMasteryWithKpRow {
mastery: {
id: string
studentId: string
knowledgePointId: string
masteryLevel: unknown
totalQuestions: number
correctQuestions: number
lastAssessedAt: Date
createdAt: Date
updatedAt: Date
}
kpName: string | null
kpDescription?: string | null
}
/**
* Serialize a raw DB mastery row into the MasteryWithKnowledgePoint shape.
*/
export function serializeMasteryWithKp(r: RawMasteryWithKpRow): MasteryWithKnowledgePoint {
return {
id: r.mastery.id,
studentId: r.mastery.studentId,
knowledgePointId: r.mastery.knowledgePointId,
masteryLevel: toNumber(r.mastery.masteryLevel),
totalQuestions: r.mastery.totalQuestions,
correctQuestions: r.mastery.correctQuestions,
lastAssessedAt: r.mastery.lastAssessedAt.toISOString(),
createdAt: r.mastery.createdAt.toISOString(),
updatedAt: r.mastery.updatedAt.toISOString(),
knowledgePointName: r.kpName ?? "Unknown",
knowledgePointDescription: r.kpDescription ?? null,
}
}
/**
* Compute average mastery from a list of mastery levels.
* Returns 0 for empty list.
*/
export function computeAverageMastery(levels: number[]): number {
if (levels.length === 0) return 0
return round2(levels.reduce((a, b) => a + b, 0) / levels.length)
}
/**
* Classify mastery records into strengths (>=80) and weaknesses (<80).
* P3-16 修复:弱项阈值从 <60 改为 <80消除 60-79 盲区,确保所有知识点都被分类。
* 注意WEAKNESS_THRESHOLD (60) 仍用于班级级统计notMasteredCount、需关注学生
* 此处仅调整学生摘要中的强弱项分类逻辑。
*/
export function classifyStrengthsWeaknesses(
mastery: MasteryWithKnowledgePoint[],
): { strengths: MasteryWithKnowledgePoint[]; weaknesses: MasteryWithKnowledgePoint[] } {
const strengths: MasteryWithKnowledgePoint[] = []
const weaknesses: MasteryWithKnowledgePoint[] = []
for (const m of mastery) {
if (m.masteryLevel >= STRENGTH_THRESHOLD) {
strengths.push(m)
} else {
weaknesses.push(m)
}
}
return { strengths, weaknesses }
}
/**
* Build StudentMasterySummary from raw mastery data and student name.
*/
export function buildStudentMasterySummary(
studentId: string,
studentName: string,
allMastery: MasteryWithKnowledgePoint[],
): StudentMasterySummary {
const averageMastery = computeAverageMastery(allMastery.map((m) => m.masteryLevel))
const { strengths, weaknesses } = classifyStrengthsWeaknesses(allMastery)
return {
studentId,
studentName,
averageMastery,
totalKnowledgePoints: allMastery.length,
strengths,
weaknesses,
allMastery,
}
}
/**
* Raw row for class mastery aggregation (mastery record + kp name).
*/
export interface RawClassMasteryRow {
mastery: {
studentId: string
knowledgePointId: string
masteryLevel: unknown
}
kpName: string | null
}
/** Internal aggregation bucket per knowledge point. */
interface KpBucket {
name: string
levels: number[]
mastered: number
notMastered: number
}
/** Internal aggregation bucket per student. */
interface StudentBucket {
levels: number[]
weakCount: number
}
/**
* Aggregate raw class mastery rows into per-kp and per-student buckets.
*/
export function aggregateClassMastery(
rows: RawClassMasteryRow[],
studentIds: string[],
): {
byKp: Map<string, KpBucket>
byStudent: Map<string, StudentBucket>
} {
const byKp = new Map<string, KpBucket>()
const byStudent = new Map<string, StudentBucket>()
for (const s of studentIds) byStudent.set(s, { levels: [], weakCount: 0 })
for (const r of rows) {
const level = toNumber(r.mastery.masteryLevel)
const kpId = r.mastery.knowledgePointId
const kpEntry = byKp.get(kpId) ?? {
name: r.kpName ?? "Unknown",
levels: [],
mastered: 0,
notMastered: 0,
}
kpEntry.levels.push(level)
if (level >= STRENGTH_THRESHOLD) kpEntry.mastered += 1
if (level < WEAKNESS_THRESHOLD) kpEntry.notMastered += 1
byKp.set(kpId, kpEntry)
const stuEntry = byStudent.get(r.mastery.studentId)
if (stuEntry) {
stuEntry.levels.push(level)
if (level < WEAKNESS_THRESHOLD) stuEntry.weakCount += 1
}
}
return { byKp, byStudent }
}
/**
* Compute KnowledgePointStat[] from aggregated kp buckets.
* totalStudents is the count of students with mastery records (levels.length),
* NOT the class total enrollment.
*/
export function computeKpStats(byKp: Map<string, KpBucket>): KnowledgePointStat[] {
return Array.from(byKp.entries()).map(([kpId, e]) => ({
knowledgePointId: kpId,
knowledgePointName: e.name,
averageMastery: computeAverageMastery(e.levels),
masteredCount: e.mastered,
notMasteredCount: e.notMastered,
totalStudents: e.levels.length,
}))
}
/**
* Compute class average mastery by averaging each student's personal average
* (rather than averaging all records, which would bias toward students with
* more KP records).
*/
export function computeClassAverageMastery(
students: Array<{ id: string; name: string | null }>,
byStudent: Map<string, StudentBucket>,
): number {
const studentAverages: number[] = []
for (const s of students) {
const e = byStudent.get(s.id)
if (e && e.levels.length > 0) {
studentAverages.push(computeAverageMastery(e.levels))
}
}
return studentAverages.length > 0
? round2(studentAverages.reduce((a, b) => a + b, 0) / studentAverages.length)
: 0
}
/**
* Build list of students needing attention (average mastery < 60),
* sorted ascending by average mastery.
*/
export function buildStudentsNeedingAttention(
students: Array<{ id: string; name: string | null }>,
byStudent: Map<string, StudentBucket>,
): ClassMasterySummary["studentsNeedingAttention"] {
return students
.map((s) => {
const e = byStudent.get(s.id)
if (!e) return null
const avg = computeAverageMastery(e.levels)
return {
studentId: s.id,
studentName: s.name ?? "Unknown",
averageMastery: avg,
weakCount: e.weakCount,
}
})
.filter(
(s): s is { studentId: string; studentName: string; averageMastery: number; weakCount: number } =>
s !== null,
)
.filter((s) => s.averageMastery < WEAKNESS_THRESHOLD)
.sort((a, b) => a.averageMastery - b.averageMastery)
}
/**
* Build ClassMasterySummary from raw data.
*/
export function buildClassMasterySummary(
classId: string,
className: string,
students: Array<{ id: string; name: string | null }>,
masteryRows: RawClassMasteryRow[],
): ClassMasterySummary {
const studentIds = students.map((s) => s.id)
const { byKp, byStudent } = aggregateClassMastery(masteryRows, studentIds)
const knowledgePointStats = computeKpStats(byKp)
const averageMastery = computeClassAverageMastery(students, byStudent)
const studentsNeedingAttention = buildStudentsNeedingAttention(students, byStudent)
return {
classId,
className,
studentCount: students.length,
averageMastery,
knowledgePointStats,
studentsNeedingAttention,
}
}
/**
* Build student report content (strengths/weaknesses/recommendations/summary)
* from a StudentMasterySummary.
*/
export function buildStudentReportContent(
summary: StudentMasterySummary,
period: string,
): {
summaryText: string
strengths: string[]
weaknesses: string[]
recommendations: string[]
overallScore: number
} {
const overallScore = summary.averageMastery
const strengths = summary.strengths.map(
(m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`,
)
const weaknesses = summary.weaknesses.map(
(m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`,
)
const recommendations = summary.weaknesses.map(
(m) =>
`建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。`,
)
if (recommendations.length === 0) {
recommendations.push("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。")
}
const summaryText = `学生 ${summary.studentName}${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。`
return { summaryText, strengths, weaknesses, recommendations, overallScore }
}
/**
* Build class report content (strengths/weaknesses/recommendations/summary)
* from a ClassMasterySummary. Strengths and weaknesses are limited to top 5.
*/
export function buildClassReportContent(
summary: ClassMasterySummary,
period: string,
): {
summaryText: string
strengths: string[]
weaknesses: string[]
recommendations: string[]
overallScore: number
} {
const topWeak = summary.knowledgePointStats
.filter((k) => k.averageMastery < WEAKNESS_THRESHOLD)
.sort((a, b) => a.averageMastery - b.averageMastery)
.slice(0, 5)
const strengths = summary.knowledgePointStats
.filter((k) => k.averageMastery >= STRENGTH_THRESHOLD)
.sort((a, b) => b.averageMastery - a.averageMastery)
.slice(0, 5)
.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
const weaknesses = topWeak.map(
(k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`,
)
const recommendations = topWeak.map(
(k) =>
`班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。`,
)
if (recommendations.length === 0) {
recommendations.push("班级整体掌握情况良好,建议保持当前教学节奏。")
}
const summaryText = `班级 ${summary.className}${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。`
return {
summaryText,
strengths,
weaknesses,
recommendations,
overallScore: summary.averageMastery,
}
}
/**
* Compute mastery level from correct/total questions.
* Used by updateMasteryFromSubmission for cumulative mastery calculation.
*/
export function computeMasteryLevel(correct: number, total: number): number {
return total > 0 ? round2((correct / total) * 100) : 0
}
/**
* Serialize a raw mastery record (without kp join) into KnowledgePointMastery.
*/
export function serializeMastery(r: {
id: string
studentId: string
knowledgePointId: string
masteryLevel: unknown
totalQuestions: number
correctQuestions: number
lastAssessedAt: Date
createdAt: Date
updatedAt: Date
}): KnowledgePointMastery {
return {
id: r.id,
studentId: r.studentId,
knowledgePointId: r.knowledgePointId,
masteryLevel: toNumber(r.masteryLevel),
totalQuestions: r.totalQuestions,
correctQuestions: r.correctQuestions,
lastAssessedAt: r.lastAssessedAt.toISOString(),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}
}

View File

@@ -29,7 +29,7 @@ export interface StudentMasterySummary {
averageMastery: number averageMastery: number
totalKnowledgePoints: number totalKnowledgePoints: number
strengths: MasteryWithKnowledgePoint[] // 掌握度 >= 80 strengths: MasteryWithKnowledgePoint[] // 掌握度 >= 80
weaknesses: MasteryWithKnowledgePoint[] // 掌握度 < 60 weaknesses: MasteryWithKnowledgePoint[] // 掌握度 < 80P3-16 修复:消除 60-79 盲区)
allMastery: MasteryWithKnowledgePoint[] allMastery: MasteryWithKnowledgePoint[]
} }
@@ -37,6 +37,8 @@ export interface StudentMasterySummary {
export interface DiagnosticReport { export interface DiagnosticReport {
id: string id: string
studentId: string | null studentId: string | null
/** v4-P1-4: 班级报告关联的 classId个人报告为 null */
classId: string | null
generatedBy: string | null generatedBy: string | null
reportType: DiagnosticReportType reportType: DiagnosticReportType
period: string | null period: string | null
@@ -87,6 +89,16 @@ export interface DiagnosticReportQueryParams {
reportType?: DiagnosticReportType reportType?: DiagnosticReportType
status?: DiagnosticReportStatus status?: DiagnosticReportStatus
period?: string period?: string
/** 分页:每页数量(默认 100 */
limit?: number
/** 分页:偏移量(默认 0 */
offset?: number
}
/** 分页查询诊断报告结果P3-15 修复:支持分页) */
export interface DiagnosticReportListResult {
reports: DiagnosticReportWithDetails[]
total: number
} }
/** 雷达图数据点 */ /** 雷达图数据点 */