From 9ceb2b7b6795cba93fe099ce0679d69d666716ff Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:37:58 +0800 Subject: [PATCH] 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 --- src/modules/diagnostic/actions.ts | 202 ++++++--- .../components/class-diagnostic-view.tsx | 357 ++++++++++++---- .../diagnostic/components/confidence-utils.ts | 31 ++ .../components/mastery-radar-chart.tsx | 28 +- .../diagnostic/components/report-list.tsx | 312 +++++++++++--- .../components/student-diagnostic-view.tsx | 146 +++++-- src/modules/diagnostic/data-access-reports.ts | 132 ++++-- src/modules/diagnostic/data-access.ts | 404 ++++++++++++------ src/modules/diagnostic/export.ts | 122 ++++++ src/modules/diagnostic/schema.ts | 17 - src/modules/diagnostic/stats-service.ts | 388 +++++++++++++++++ src/modules/diagnostic/types.ts | 14 +- 12 files changed, 1717 insertions(+), 436 deletions(-) create mode 100644 src/modules/diagnostic/components/confidence-utils.ts create mode 100644 src/modules/diagnostic/export.ts create mode 100644 src/modules/diagnostic/stats-service.ts diff --git a/src/modules/diagnostic/actions.ts b/src/modules/diagnostic/actions.ts index f9a0ad7..aab2454 100644 --- a/src/modules/diagnostic/actions.ts +++ b/src/modules/diagnostic/actions.ts @@ -1,27 +1,32 @@ "use server" 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 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 { generateDiagnosticReport, generateClassDiagnosticReport, - getDiagnosticReports, - getDiagnosticReportById, publishDiagnosticReport, deleteDiagnosticReport, + getDiagnosticReportById, } from "./data-access-reports" +import { getClassStudentsByKnowledgePoint } from "./data-access" +import { + exportDiagnosticReportToExcel, + buildDiagnosticReportFilename, +} from "./export" import { GenerateStudentReportSchema, GenerateClassReportSchema, PublishReportSchema, DeleteReportSchema, - GetDiagnosticReportsSchema, - GetDiagnosticReportByIdSchema, } from "./schema" -import type { DiagnosticReportQueryParams } from "./types" /** 生成学生个人诊断报告 */ export async function generateStudentReportAction( @@ -45,9 +50,7 @@ export async function generateStudentReportAction( revalidatePath(`/teacher/diagnostic/student/${studentId}`) return { success: true, message: "Diagnostic report generated", data: id } } 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" } + return handleActionError(e) } } @@ -73,9 +76,7 @@ export async function generateClassReportAction( revalidatePath(`/teacher/diagnostic/class/${classId}`) return { success: true, message: "Class diagnostic report generated", data: id } } 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" } + return handleActionError(e) } } @@ -95,12 +96,72 @@ export async function publishReportAction( } 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") return { success: true, message: "Report published" } } 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" } + return handleActionError(e) } } @@ -123,50 +184,91 @@ export async function deleteReportAction( revalidatePath("/teacher/diagnostic") return { success: true, message: "Report deleted" } } 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" } + return handleActionError(e) } } -/** 查询诊断报告列表(读权限) */ -export async function getDiagnosticReportsAction( - params: DiagnosticReportQueryParams -): Promise>>> { +/** + * v3-P2-4: 导出诊断报告为 Excel。 + * 返回 base64 编码的 buffer 和文件名,前端通过 Blob 下载。 + */ +export async function exportDiagnosticReportAction( + reportId: string +): Promise> { try { await requirePermission(Permissions.DIAGNOSTIC_READ) - const parsed = GetDiagnosticReportsSchema.safeParse(params) - 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>>> { - try { - await requirePermission(Permissions.DIAGNOSTIC_READ) - - const parsed = GetDiagnosticReportByIdSchema.safeParse({ id }) - if (!parsed.success) { + if (!reportId || typeof reportId !== "string") { return { success: false, message: "Missing report id" } } - const report = await getDiagnosticReportById(parsed.data.id) - return { success: true, data: report } + const report = await getDiagnosticReportById(reportId) + 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) { - 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" } + return handleActionError(e) + } +} + +/** + * 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) } } diff --git a/src/modules/diagnostic/components/class-diagnostic-view.tsx b/src/modules/diagnostic/components/class-diagnostic-view.tsx index 6945c8a..514abe8 100644 --- a/src/modules/diagnostic/components/class-diagnostic-view.tsx +++ b/src/modules/diagnostic/components/class-diagnostic-view.tsx @@ -4,7 +4,8 @@ import { useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { toast } from "sonner" -import { Users, AlertTriangle, TrendingUp, FileText } from "lucide-react" +import { useTranslations } from "next-intl" +import { Users, AlertTriangle, TrendingUp, FileText, Filter } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" @@ -12,6 +13,13 @@ import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { EmptyState } from "@/shared/components/ui/empty-state" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" import { Table, TableBody, @@ -22,7 +30,7 @@ import { } from "@/shared/components/ui/table" import { usePermission } from "@/shared/hooks" import { Permissions } from "@/shared/types/permissions" -import { generateClassReportAction } from "../actions" +import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions" import type { ClassMasterySummary } from "../types" interface ClassDiagnosticViewProps { @@ -37,13 +45,29 @@ function masteryColor(level: number): string { return "bg-red-500" } +type KnowledgePointStudent = { + studentId: string + studentName: string + masteryLevel: number + totalQuestions: number + correctQuestions: number + lastAssessedAt: string | null + needsAttention: boolean +} + export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { + const t = useTranslations("diagnostic") const router = useRouter() const { hasPermission } = usePermission() const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7)) const [isGenerating, setIsGenerating] = useState(false) + // v3-P2-5: 知识点筛选状态 + const [selectedKpId, setSelectedKpId] = useState("all") + const [filteredStudents, setFilteredStudents] = useState(null) + const [isFiltering, setIsFiltering] = useState(false) + const handleGenerate = async () => { if (!summary) return setIsGenerating(true) @@ -56,15 +80,45 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { toast.success(result.message) router.refresh() } else { - toast.error(result.message || "Failed to generate class report") + toast.error(result.message || t("error.generateClassFailed")) + } + } + + /** + * v3-P2-5: 按知识点筛选学生。 + * 选择知识点后调用 server action 获取该知识点上所有学生的掌握度。 + */ + const handleKpFilter = async (kpId: string) => { + setSelectedKpId(kpId) + if (!summary || kpId === "all") { + setFilteredStudents(null) + return + } + setIsFiltering(true) + try { + const result = await getClassStudentsByKnowledgePointAction({ + classId: summary.classId, + knowledgePointId: kpId, + }) + if (result.success && result.data) { + setFilteredStudents(result.data) + } else { + toast.error(result.message || t("error.loadFailed")) + setFilteredStudents(null) + } + } catch { + toast.error(t("error.loadFailed")) + setFilteredStudents(null) + } finally { + setIsFiltering(false) } } if (!summary) { return ( @@ -77,7 +131,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
- Class + {t("summary.class")}

{summary.className}

@@ -85,7 +139,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
- Students + {t("summary.students")}

{summary.studentCount}

@@ -93,7 +147,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
- Avg Mastery + {t("summary.avgMastery")}

{summary.averageMastery.toFixed(1)}%

@@ -101,7 +155,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
- Need Attention + {t("summary.needAttention")}

{summary.studentsNeedingAttention.length}

@@ -114,70 +168,194 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { - Knowledge Point Mastery Heatmap + {t("chart.heatmapTitle")} - Average mastery level per knowledge point (green ≥80%, yellow 60-79%, orange 40-59%, red <40%). + {t("classDiagnostic.heatmapDescription")} {summary.knowledgePointStats.length === 0 ? ( -

No knowledge point data available.

+

{t("classDiagnostic.noKnowledgePointData")}

) : ( -
- {summary.knowledgePointStats.map((kp) => ( -
- - {kp.knowledgePointName} - - {kp.averageMastery.toFixed(0)}% + <> +
+ {summary.knowledgePointStats.map((kp) => { + const levelLabel = kp.averageMastery >= 80 ? t("classDiagnostic.masteryLevelExcellent") : kp.averageMastery >= 60 ? t("classDiagnostic.masteryLevelGood") : kp.averageMastery >= 40 ? t("classDiagnostic.masteryLevelNeedsImprovement") : t("classDiagnostic.masteryLevelWeak") + return ( +
+ + {kp.knowledgePointName} + + {kp.averageMastery.toFixed(0)}% +
+ ) + })} +
+ {/* v4-P1-8: 热力图颜色图例 */} +
+ + {t("classDiagnostic.legendLabel")} + +
+
+
+
+
+
+
+
+
- ))} -
+
+ )} + {/* v3-P2-5: 按知识点筛选学生 */} + + + + + {t("classDiagnostic.filterByKpTitle")} + + + {t("classDiagnostic.filterByKpDescription")} + + + +
+ + +
+ + {isFiltering ? ( +

{t("classDiagnostic.filtering")}

+ ) : filteredStudents && filteredStudents.length > 0 ? ( +
+ {/* v4-P1-11: 移动端表格水平滚动 */} +
+ + + + {t("summary.student")} + {t("classDiagnostic.avgMasteryColumn")} + {t("classDiagnostic.totalQuestionsColumn")} + {t("classDiagnostic.correctQuestionsColumn")} + {t("classDiagnostic.statusColumn")} + + + + + {filteredStudents.map((s) => ( + + {s.studentName} + + = 80 ? "default" : s.masteryLevel >= 60 ? "secondary" : "destructive"}> + {s.masteryLevel.toFixed(0)}% + + + {s.totalQuestions} + {s.correctQuestions} + + {s.needsAttention ? ( + {t("classDiagnostic.needsAttention")} + ) : ( + {t("classDiagnostic.mastered")} + )} + + + + + + ))} + +
+
+
+ ) : filteredStudents && filteredStudents.length === 0 ? ( +

{t("classDiagnostic.noStudentsForKp")}

+ ) : null} +
+
+ {/* 知识点排名表 */} - Knowledge Point Ranking + {t("chart.rankingTitle")} {summary.knowledgePointStats.length === 0 ? ( -

No data.

+

{t("classDiagnostic.noRankingData")}

) : (
- - - - Knowledge Point - Avg Mastery - Mastered (≥80%) - Not Mastered (<60%) - - - - {[...summary.knowledgePointStats] - .sort((a, b) => b.averageMastery - a.averageMastery) - .map((kp) => ( - - {kp.knowledgePointName} - - = 80 ? "default" : kp.averageMastery >= 60 ? "secondary" : "destructive"}> - {kp.averageMastery.toFixed(1)}% - - - {kp.masteredCount} - {kp.notMasteredCount} - - ))} - -
+ {/* v4-P1-11: 移动端表格水平滚动 */} +
+ + + + {t("classDiagnostic.knowledgePointColumn")} + {t("classDiagnostic.avgMasteryColumn")} + {t("classDiagnostic.masteredColumn")} + {t("classDiagnostic.notMasteredColumn")} + + + + {[...summary.knowledgePointStats] + .sort((a, b) => b.averageMastery - a.averageMastery) + .map((kp) => ( + + {kp.knowledgePointName} + + = 80 ? "default" : kp.averageMastery >= 60 ? "secondary" : "destructive"}> + {kp.averageMastery.toFixed(1)}% + + + {kp.masteredCount} + {kp.notMasteredCount} + + ))} + +
+
)}
@@ -188,44 +366,47 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { - Students Needing Attention (avg <60%) + {t("classDiagnostic.studentsNeedingAttentionTitle")} - Students with low overall mastery. + {t("classDiagnostic.studentsNeedingAttentionDescription")} {summary.studentsNeedingAttention.length === 0 ? ( -

All students are above the attention threshold.

+

{t("classDiagnostic.allStudentsAboveThreshold")}

) : (
- - - - Student - Avg Mastery - Weak Points - - - - - {summary.studentsNeedingAttention.map((s) => ( - - {s.studentName} - - {s.averageMastery.toFixed(1)}% - - {s.weakCount} - - - + {/* v4-P1-11: 移动端表格水平滚动 */} +
+
+ + + {t("summary.student")} + {t("classDiagnostic.avgMasteryColumn")} + {t("classDiagnostic.weakPointsColumn")} + - ))} - -
+ + + {summary.studentsNeedingAttention.map((s) => ( + + {s.studentName} + + {s.averageMastery.toFixed(1)}% + + {s.weakCount} + + + + + ))} + + +
)}
@@ -237,16 +418,16 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) { - Generate Class Diagnostic Report + {t("report.generateClass")} - Generate a class-level diagnostic report with aggregated analysis. + {t("classDiagnostic.generateDescription")}
- +
diff --git a/src/modules/diagnostic/components/confidence-utils.ts b/src/modules/diagnostic/components/confidence-utils.ts new file mode 100644 index 0000000..07de924 --- /dev/null +++ b/src/modules/diagnostic/components/confidence-utils.ts @@ -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", +} diff --git a/src/modules/diagnostic/components/mastery-radar-chart.tsx b/src/modules/diagnostic/components/mastery-radar-chart.tsx index d550476..43f4829 100644 --- a/src/modules/diagnostic/components/mastery-radar-chart.tsx +++ b/src/modules/diagnostic/components/mastery-radar-chart.tsx @@ -1,5 +1,6 @@ "use client" +import { useTranslations } from "next-intl" import { Target } from "lucide-react" import { ChartCardShell } from "@/shared/components/charts/chart-card-shell" @@ -11,6 +12,7 @@ interface MasteryRadarChartProps { } export function MasteryRadarChart({ data }: MasteryRadarChartProps) { + const t = useTranslations("diagnostic") const isEmpty = !data || data.length === 0 const chartData = isEmpty @@ -25,17 +27,29 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) { const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined) + const ariaLabel = isEmpty + ? t("chart.radarAriaLabelEmpty") + : t("chart.radarAriaLabelNonEmpty", { + count: data.length, + withClassAverage: hasClassAverage ? t("chart.withClassAverage") : "", + }) + return ( -
+
= { - individual: "Individual", - class: "Class", - grade: "Grade", -} +import { + getConfidenceLevel, + confidenceBadgeVariant, + type ConfidenceLevel, +} from "./confidence-utils" const statusColors: Record = { draft: "secondary", @@ -59,10 +61,12 @@ export function ReportList({ reports }: ReportListProps) { const router = useRouter() const searchParams = useSearchParams() const { hasPermission } = usePermission() + const t = useTranslations("diagnostic") const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE) const [deleteId, setDeleteId] = useState(null) const [publishId, setPublishId] = useState(null) + const [shareId, setShareId] = useState(null) const [isBusy, setIsBusy] = useState(false) const updateParam = useCallback( @@ -90,7 +94,7 @@ export function ReportList({ reports }: ReportListProps) { setPublishId(null) router.refresh() } else { - toast.error(result.message || "Failed to publish") + toast.error(result.message || t("error.publishFailed")) } } @@ -106,42 +110,133 @@ export function ReportList({ reports }: ReportListProps) { setDeleteId(null) router.refresh() } else { - toast.error(result.message || "Failed to delete") + toast.error(result.message || t("error.deleteFailed")) } } + /** + * v3-P2-4: 导出诊断报告为 Excel。 + * 调用 server action 获取 base64 buffer,前端转 Blob 下载。 + */ + const handleExport = async (reportId: string) => { + setIsBusy(true) + try { + const result = await exportDiagnosticReportAction(reportId) + if (!result.success || !result.data) { + toast.error(result.message || t("error.exportFailed")) + return + } + // base64 -> Blob -> 下载 + const binaryString = atob(result.data.buffer) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i += 1) { + bytes[i] = binaryString.charCodeAt(i) + } + const blob = new Blob([bytes], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = result.data.filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast.success(t("reportList.exportSuccess")) + } catch { + toast.error(t("error.exportFailed")) + } finally { + setIsBusy(false) + } + } + + // v3-P3-8: 复制报告分享链接到剪贴板 + const handleCopyLink = async (): Promise => { + if (!shareId) return + const url = `${window.location.origin}/teacher/diagnostic/reports/${shareId}` + try { + await navigator.clipboard.writeText(url) + toast.success(t("reportList.copyLinkSuccess")) + } catch { + toast.error(t("reportList.copyLinkFailed")) + } + } + + // v4-P3-7: 置信度标签与提示 + const confidenceLabel = (level: ConfidenceLevel): string => { + if (level === "high") return t("reportList.confidenceHigh") + if (level === "medium") return t("reportList.confidenceMedium") + if (level === "low") return t("reportList.confidenceLow") + return t("reportList.confidenceInsufficient") + } + + const confidenceHint = (level: ConfidenceLevel): string => { + if (level === "high") return t("reportList.confidenceHighHint") + if (level === "medium") return t("reportList.confidenceMediumHint") + if (level === "low") return t("reportList.confidenceLowHint") + return t("reportList.confidenceInsufficient") + } + const reportType = searchParams.get("reportType") ?? "all" const status = searchParams.get("status") ?? "all" + const typeLabel = (reportType: string): string => { + if (reportType === "individual") return t("type.individual") + if (reportType === "class") return t("type.class") + if (reportType === "grade") return t("type.grade") + return reportType + } + + const statusLabel = (status: string): string => { + if (status === "draft") return t("status.draft") + if (status === "published") return t("status.published") + if (status === "archived") return t("status.archived") + return status + } + + const studentTargetDisplay = (r: DiagnosticReportWithDetails): string => { + if (r.studentName) return r.studentName + if (r.reportType === "class") return t("reportList.classReportPlaceholder") + if (r.reportType === "grade") return t("reportList.gradeReportPlaceholder") + return "-" + } + + // v3-P3-8: 当前分享的报告及链接 + const sharedReport = shareId ? reports.find((r) => r.id === shareId) ?? null : null + const shareUrl = typeof window !== "undefined" && sharedReport + ? `${window.location.origin}/teacher/diagnostic/reports/${sharedReport.id}` + : "" + return (
{/* 过滤器 */}
- +
- +
@@ -149,71 +244,118 @@ export function ReportList({ reports }: ReportListProps) { {reports.length === 0 ? ( ) : (
- + - Type - Student / Target - Period - Score - Status - Generated By - Date - {canManage ? Actions : null} + {t("reportList.typeColumn")} + {t("reportList.studentTargetColumn")} + {t("reportList.periodColumn")} + {t("reportList.scoreColumn")} + {t("reportList.confidenceColumn")} + {t("reportList.statusColumn")} + {t("reportList.generatedByColumn")} + {t("reportList.dateColumn")} + {t("reportList.actionsColumn")} {reports.map((r) => ( - {typeLabels[r.reportType] ?? r.reportType} + {typeLabel(r.reportType)} - {r.studentName ?? (r.reportType === "class" ? "(班级报告)" : r.reportType === "grade" ? "(年级报告)" : "-")} + {studentTargetDisplay(r)} {r.period ?? "-"} {r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"} - {r.status} + {(() => { + const level = getConfidenceLevel(r) + return ( + + + + {confidenceLabel(level)} + + + {confidenceHint(level)} + + ) + })()} + + + {statusLabel(r.status)} {r.generatedByName ?? "-"} {formatDate(r.createdAt)} - {canManage ? ( - -
- {r.status === "draft" ? ( - - ) : null} + +
+ {/* v3-P2-4: 导出按钮(所有角色可见,受 exportDiagnosticReportAction 权限校验保护) */} + + {/* v3-P3-8: 分享按钮(仅教师可见) */} + {canManage ? ( + + ) : null} + {canManage && r.status === "draft" ? ( + + ) : null} + {canManage ? ( -
-
- ) : null} + ) : null} +
+
))}
@@ -225,17 +367,17 @@ export function ReportList({ reports }: ReportListProps) { !open && setPublishId(null)}> - Publish Report + {t("report.publishTitle")} - Once published, the report will be visible to students. Continue? + {t("reportList.publishConfirmation")} @@ -245,17 +387,55 @@ export function ReportList({ reports }: ReportListProps) { !open && setDeleteId(null)}> - Delete Report + {t("report.deleteTitle")} - Are you sure you want to delete this diagnostic report? This action cannot be undone. + {t("report.deleteConfirmation")} + + + + + {/* v3-P3-8: 分享报告 */} + !open && setShareId(null)}> + + + {t("reportList.shareTitle")} + {t("reportList.shareDescription")} + +
+ {sharedReport?.summary ? ( +
+

{sharedReport.summary}

+
+ ) : null} +
+ +
+ + +
+
+
+ +
diff --git a/src/modules/diagnostic/components/student-diagnostic-view.tsx b/src/modules/diagnostic/components/student-diagnostic-view.tsx index 8f7b447..733e824 100644 --- a/src/modules/diagnostic/components/student-diagnostic-view.tsx +++ b/src/modules/diagnostic/components/student-diagnostic-view.tsx @@ -1,28 +1,50 @@ "use client" import Link from "next/link" +import { useTranslations } from "next-intl" import { Award, AlertTriangle, Lightbulb, FileText, History, ArrowRight } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip" import { EmptyState } from "@/shared/components/ui/empty-state" import { formatDate } from "@/shared/lib/utils" import { MasteryRadarChart } from "./mastery-radar-chart" +import { + getConfidenceLevel, + confidenceBadgeVariant, + type ConfidenceLevel, +} from "./confidence-utils" import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types" interface StudentDiagnosticViewProps { summary: StudentMasterySummary | null reports: DiagnosticReportWithDetails[] classAverageMastery?: MasteryRadarPoint[] + /** + * v3-P2-6: "练习"按钮的跳转基础路径。 + * - 学生视角:默认 `/student/learning/assignments` + * - 教师视角:传入 `/teacher/questions`(题目库支持 kp 查询参数筛选) + * - 家长视角:传入 `null` 隐藏练习按钮(家长无练习入口) + * 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。 + */ + practiceHrefBase?: string | null } -export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) { +export function StudentDiagnosticView({ + summary, + reports, + classAverageMastery, + practiceHrefBase = "/student/learning/assignments", +}: StudentDiagnosticViewProps) { + const t = useTranslations("diagnostic") + if (!summary) { return ( @@ -39,7 +61,38 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }: }) const publishedReports = reports.filter((r) => r.status === "published") - const latestReport = publishedReports[0] ?? reports[0] ?? null + // v4-P1-3: 移除草稿回退逻辑,仅展示已发布报告 + // 调用方(学生/家长页面)已传 status: "published" 过滤,此处双重保障 + const latestReport = publishedReports[0] ?? null + + const statusLabel = (status: string): string => { + if (status === "draft") return t("status.draft") + if (status === "published") return t("status.published") + if (status === "archived") return t("status.archived") + return status + } + + const typeLabel = (reportType: string): string => { + if (reportType === "individual") return t("type.individual") + if (reportType === "class") return t("type.class") + if (reportType === "grade") return t("type.grade") + return reportType + } + + // v4-P3-7: 置信度标签与提示 + const confidenceLabel = (level: ConfidenceLevel): string => { + if (level === "high") return t("reportList.confidenceHigh") + if (level === "medium") return t("reportList.confidenceMedium") + if (level === "low") return t("reportList.confidenceLow") + return t("reportList.confidenceInsufficient") + } + + const confidenceHint = (level: ConfidenceLevel): string => { + if (level === "high") return t("reportList.confidenceHighHint") + if (level === "medium") return t("reportList.confidenceMediumHint") + if (level === "low") return t("reportList.confidenceLowHint") + return t("reportList.confidenceInsufficient") + } return (
@@ -47,7 +100,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
- Student + {t("summary.student")}

{summary.studentName}

@@ -55,7 +108,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
- Overall Mastery + {t("summary.overallMastery")}

{summary.averageMastery.toFixed(1)}%

@@ -63,7 +116,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
- Strengths + {t("summary.strengths")}

{summary.strengths.length}

@@ -71,7 +124,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
- Weaknesses + {t("summary.weaknesses")}

{summary.weaknesses.length}

@@ -88,15 +141,15 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }: - Strengths (≥80%) + {t("strengths.title")} - Knowledge points with high mastery. + {t("studentDiagnostic.strengthsDescription")} {summary.strengths.length === 0 ? ( -

No strengths identified yet.

+

{t("studentDiagnostic.noStrengths")}

) : ( -
    +
      {summary.strengths.map((m) => (
    • {m.knowledgePointName} @@ -111,27 +164,29 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }: - Weaknesses (<60%) + {t("weaknesses.title")} - Knowledge points needing attention. + {t("studentDiagnostic.weaknessesDescription")} {summary.weaknesses.length === 0 ? ( -

      No weaknesses identified.

      +

      {t("studentDiagnostic.noWeaknesses")}

      ) : ( -
        +
          {summary.weaknesses.map((m) => (
        • {m.knowledgePointName} {m.masteryLevel.toFixed(1)}%
          - + {practiceHrefBase ? ( + + ) : null}
        • ))}
        @@ -146,13 +201,32 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }: - Diagnostic Report + {t("studentDiagnostic.diagnosticReportTitle")} - {latestReport.status} + {statusLabel(latestReport.status)} + {(() => { + const level = getConfidenceLevel(latestReport) + return ( + + + + {confidenceLabel(level)} + + + {confidenceHint(level)} + + ) + })()} - Period: {latestReport.period ?? "-"} · Overall: {latestReport.overallScore?.toFixed(1) ?? "-"}% + {t("studentDiagnostic.reportMeta", { + period: latestReport.period ?? "-", + score: latestReport.overallScore?.toFixed(1) ?? "-", + })} @@ -161,10 +235,10 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }: ) : null} {latestReport.recommendations && latestReport.recommendations.length > 0 ? (
        -

        Recommendations

        -
          +

          {t("report.recommendations")}

          +
            {latestReport.recommendations.map((rec, i) => ( -
          • • {rec}
          • +
          • • {rec}
          • ))}
        @@ -179,9 +253,9 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }: - Report History + {t("report.history")} - Past diagnostic reports (newest first). + {t("studentDiagnostic.historyDescription")}
        @@ -193,15 +267,19 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
        - {r.period ?? "Untitled period"} + {r.period ?? t("studentDiagnostic.untitledPeriod")} - {r.reportType} + {typeLabel(r.reportType)}

        - {formatDate(r.createdAt)} - {r.overallScore !== null ? ` · Overall: ${r.overallScore.toFixed(1)}%` : ""} + {r.overallScore !== null + ? t("studentDiagnostic.historyReportMeta", { + date: formatDate(r.createdAt), + score: r.overallScore.toFixed(1), + }) + : formatDate(r.createdAt)}

        diff --git a/src/modules/diagnostic/data-access-reports.ts b/src/modules/diagnostic/data-access-reports.ts index b54fa62..af18f55 100644 --- a/src/modules/diagnostic/data-access-reports.ts +++ b/src/modules/diagnostic/data-access-reports.ts @@ -1,23 +1,42 @@ import "server-only" 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 { db } from "@/shared/db" import { learningDiagnosticReports } from "@/shared/db/schema" 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 { buildClassReportContent, buildStudentReportContent } from "./stats-service" import type { DiagnosticReport, + DiagnosticReportListResult, DiagnosticReportQueryParams, DiagnosticReportWithDetails, } from "./types" -const toNumber = (v: unknown): number => { - const n = typeof v === "number" ? v : Number(v) - return Number.isFinite(n) ? n : 0 +/** + * 诊断报告业务错误(P3-27 修复:结构化错误码,避免直接暴露内部错误)。 + * 继承 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[] => @@ -29,6 +48,7 @@ const toStringArrayNullable = (v: unknown): string[] | null => const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({ id: r.id, studentId: r.studentId, + classId: r.classId, generatedBy: r.generatedBy, reportType: r.reportType, period: r.period, @@ -49,19 +69,15 @@ export async function generateDiagnosticReport( generatedBy: string ): Promise { 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 - 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("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。") + // P2-6 修复:当学生存在但无任何掌握度数据时,拒绝生成误导性报告 + if (summary.totalKnowledgePoints === 0) { + throw new DiagnosticReportError("NO_MASTERY_DATA", "学生暂无掌握度数据,无法生成报告") } - 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() await db.insert(learningDiagnosticReports).values({ @@ -87,24 +103,15 @@ export async function generateClassDiagnosticReport( generatedBy: string ): Promise { 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 - .filter((k) => k.averageMastery < 60) - .sort((a, b) => a.averageMastery - b.averageMastery) - .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("班级整体掌握情况良好,建议保持当前教学节奏。") + // P2-6 修复:当班级存在但无任何掌握度数据时,拒绝生成误导性报告 + if (summary.studentCount === 0 || summary.knowledgePointStats.length === 0) { + throw new DiagnosticReportError("CLASS_NO_MASTERY_DATA", "班级暂无掌握度数据,无法生成报告") } - 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() await db.insert(learningDiagnosticReports).values({ @@ -117,26 +124,73 @@ export async function generateClassDiagnosticReport( strengths, weaknesses, recommendations, - overallScore: String(summary.averageMastery), + overallScore: String(overallScore), status: "draft", }) return id } -/** 查询诊断报告列表 */ +/** 查询诊断报告列表(P3-15 修复:支持分页) */ export const getDiagnosticReports = cache( - async (filters: DiagnosticReportQueryParams): Promise => { + async ( + filters: DiagnosticReportQueryParams, + scope?: DataScope, + ): Promise => { const conditions: SQL[] = [] if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId)) if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType)) if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status)) if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period)) - const rows = await db - .select({ report: learningDiagnosticReports }) - .from(learningDiagnosticReports) - .where(conditions.length > 0 ? and(...conditions) : undefined) - .orderBy(desc(learningDiagnosticReports.createdAt)) + // v4-P1-1: 应用 DataScope 行级权限过滤 + // - class_taught: 仅返回所教班级学生的个人报告 + 班级报告(班级报告 studentId 为 null,需通过 classId 关联) + // 由于当前 schema 班级报告 studentId=null,无法直接按 classId 过滤,因此对 class_taught scope: + // 个人报告按所教班级学生 ID 过滤;班级报告(studentId=null)保留(教师可查看自己生成的班级报告) + // - 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 统一获取 const userIds = new Set() @@ -146,7 +200,7 @@ export const getDiagnosticReports = cache( } const userMap = await getUserNamesByIds(Array.from(userIds)) - return rows.map((r) => ({ + const reports: DiagnosticReportWithDetails[] = rows.map((r) => ({ ...serializeReport(r.report), studentName: r.report.studentId ? userMap.get(r.report.studentId)?.name ?? "Unknown" @@ -155,6 +209,8 @@ export const getDiagnosticReports = cache( ? userMap.get(r.report.generatedBy)?.name ?? "Unknown" : null, })) + + return { reports, total } }, ) diff --git a/src/modules/diagnostic/data-access.ts b/src/modules/diagnostic/data-access.ts index 8606b62..ad9b64b 100644 --- a/src/modules/diagnostic/data-access.ts +++ b/src/modules/diagnostic/data-access.ts @@ -1,45 +1,35 @@ import "server-only" 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 { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema" 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 { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access" +import { + aggregateClassMastery, + buildClassMasterySummary, + buildStudentMasterySummary, + computeKpStats, + computeMasteryLevel, + serializeMasteryWithKp, + type RawClassMasteryRow, + type RawMasteryWithKpRow, +} from "./stats-service" import type { ClassMasterySummary, - KnowledgePointMastery, KnowledgePointStat, MasteryWithKnowledgePoint, StudentMasterySummary, } 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 => { +const getStudentMastery = cache(async (studentId: string): Promise => { const rows = await db .select({ mastery: knowledgePointMastery, @@ -51,45 +41,29 @@ export const getStudentMastery = cache(async (studentId: string): Promise ({ - ...serializeMastery(r.mastery), - knowledgePointName: r.kpName ?? "Unknown", - knowledgePointDescription: r.kpDescription, - })) + return rows.map((r) => + serializeMasteryWithKp({ + mastery: r.mastery, + kpName: r.kpName, + kpDescription: r.kpDescription, + } satisfies RawMasteryWithKpRow), + ) }) /** 获取学生掌握度摘要(含强项/弱项分析) */ export const getStudentMasterySummary = cache(async (studentId: string): Promise => { - const userMap = await getUserNamesByIds([studentId]) + // P3-18 修复:用户名查询与掌握度查询相互独立,并行执行 + const [userMap, allMastery] = await Promise.all([ + getUserNamesByIds([studentId]), + getStudentMastery(studentId), + ]) const student = userMap.get(studentId) if (!student) return null - const allMastery = await getStudentMastery(studentId) - 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, - } + return buildStudentMasterySummary(studentId, student.name ?? "Unknown", allMastery) }) -/** 从提交答案更新掌握度(正确率作为掌握度) */ +/** 从提交答案更新掌握度(累积模式:在历史基础上累加,正确率作为掌握度) */ export async function updateMasteryFromSubmission(submissionId: string): Promise { const submission = await getExamSubmissionWithAnswers(submissionId) 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() + for (const row of existingRows) { + existingByKp.set(row.knowledgePointId, { + total: row.totalQuestions, + correct: row.correctQuestions, + }) + } + const now = new Date() - await Promise.all( - Array.from(kpStats.entries()).map(async ([kpId, stat]) => { - const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0 - await db - .insert(knowledgePointMastery) - .values({ - studentId: submission.studentId, - knowledgePointId: kpId, - masteryLevel: String(masteryLevel), - totalQuestions: stat.total, - correctQuestions: stat.correct, - lastAssessedAt: now, - }) - .onDuplicateKeyUpdate({ - set: { + // 使用事务保证多个 upsert 的原子性 + await db.transaction(async (tx) => { + await Promise.all( + Array.from(kpStats.entries()).map(async ([kpId, stat]) => { + const existing = existingByKp.get(kpId) + const totalQuestions = (existing?.total ?? 0) + stat.total + const correctQuestions = (existing?.correct ?? 0) + stat.correct + const masteryLevel = computeMasteryLevel(correctQuestions, totalQuestions) + await tx + .insert(knowledgePointMastery) + .values({ + studentId: submission.studentId, + knowledgePointId: kpId, masteryLevel: String(masteryLevel), - totalQuestions: stat.total, - correctQuestions: stat.correct, + totalQuestions, + correctQuestions, 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 { + 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() + 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() + 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 ({ id, name: userMap.get(id)?.name ?? null })) .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")) - const byKp = new Map() - const byStudent = new Map() - for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 }) - - for (const r of masteryRows) { - 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 >= 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 rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({ + mastery: { + studentId: r.mastery.studentId, + knowledgePointId: r.mastery.knowledgePointId, + masteryLevel: r.mastery.masteryLevel, + }, + kpName: r.kpName, })) - const allLevels = masteryRows.map((r) => toNumber(r.mastery.masteryLevel)) - 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 } + return buildClassMasterySummary(classId, className, students, rawRows) }) /** 获取知识点统计(按班级或年级聚合) */ @@ -235,23 +291,101 @@ export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: s .leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId)) .where(inArray(knowledgePointMastery.studentId, studentIds)) - const byKp = new Map() - for (const r of masteryRows) { - const level = toNumber(r.mastery.masteryLevel) - const kpId = r.mastery.knowledgePointId - const e = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 } - e.levels.push(level) - if (level >= 80) e.mastered += 1 - 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 rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({ + mastery: { + studentId: r.mastery.studentId, + knowledgePointId: r.mastery.knowledgePointId, + masteryLevel: r.mastery.masteryLevel, + }, + kpName: r.kpName, })) + + 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() + 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 + } +) diff --git a/src/modules/diagnostic/export.ts b/src/modules/diagnostic/export.ts new file mode 100644 index 0000000..f4209ff --- /dev/null +++ b/src/modules/diagnostic/export.ts @@ -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 { + 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` +} diff --git a/src/modules/diagnostic/schema.ts b/src/modules/diagnostic/schema.ts index f722d67..57e1f4e 100644 --- a/src/modules/diagnostic/schema.ts +++ b/src/modules/diagnostic/schema.ts @@ -29,20 +29,3 @@ export const DeleteReportSchema = z.object({ }) export type DeleteReportInput = z.infer - -/** 查询诊断报告列表 */ -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 - -/** 获取诊断报告详情 */ -export const GetDiagnosticReportByIdSchema = z.object({ - id: z.string().min(1), -}) - -export type GetDiagnosticReportByIdInput = z.infer diff --git a/src/modules/diagnostic/stats-service.ts b/src/modules/diagnostic/stats-service.ts new file mode 100644 index 0000000..58db9b3 --- /dev/null +++ b/src/modules/diagnostic/stats-service.ts @@ -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 + byStudent: Map +} { + const byKp = new Map() + const byStudent = new Map() + 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): 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, +): 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, +): 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(), + } +} diff --git a/src/modules/diagnostic/types.ts b/src/modules/diagnostic/types.ts index 555648b..f666fb7 100644 --- a/src/modules/diagnostic/types.ts +++ b/src/modules/diagnostic/types.ts @@ -29,7 +29,7 @@ export interface StudentMasterySummary { averageMastery: number totalKnowledgePoints: number strengths: MasteryWithKnowledgePoint[] // 掌握度 >= 80 - weaknesses: MasteryWithKnowledgePoint[] // 掌握度 < 60 + weaknesses: MasteryWithKnowledgePoint[] // 掌握度 < 80(P3-16 修复:消除 60-79 盲区) allMastery: MasteryWithKnowledgePoint[] } @@ -37,6 +37,8 @@ export interface StudentMasterySummary { export interface DiagnosticReport { id: string studentId: string | null + /** v4-P1-4: 班级报告关联的 classId(个人报告为 null) */ + classId: string | null generatedBy: string | null reportType: DiagnosticReportType period: string | null @@ -87,6 +89,16 @@ export interface DiagnosticReportQueryParams { reportType?: DiagnosticReportType status?: DiagnosticReportStatus period?: string + /** 分页:每页数量(默认 100) */ + limit?: number + /** 分页:偏移量(默认 0) */ + offset?: number +} + +/** 分页查询诊断报告结果(P3-15 修复:支持分页) */ +export interface DiagnosticReportListResult { + reports: DiagnosticReportWithDetails[] + total: number } /** 雷达图数据点 */
学情诊断报告列表{t("reportList.caption")}