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:
@@ -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<ActionState<Awaited<ReturnType<typeof getDiagnosticReports>>>> {
|
||||
/**
|
||||
* v3-P2-4: 导出诊断报告为 Excel。
|
||||
* 返回 base64 编码的 buffer 和文件名,前端通过 Blob 下载。
|
||||
*/
|
||||
export async function exportDiagnosticReportAction(
|
||||
reportId: string
|
||||
): Promise<ActionState<{ buffer: string; filename: string }>> {
|
||||
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<ActionState<Awaited<ReturnType<typeof getDiagnosticReportById>>>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Users, AlertTriangle, TrendingUp, FileText } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Users, AlertTriangle, TrendingUp, FileText, Filter } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -12,6 +13,13 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -22,7 +30,7 @@ import {
|
||||
} from "@/shared/components/ui/table"
|
||||
import { usePermission } from "@/shared/hooks"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { generateClassReportAction } from "../actions"
|
||||
import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions"
|
||||
import type { ClassMasterySummary } from "../types"
|
||||
|
||||
interface ClassDiagnosticViewProps {
|
||||
@@ -37,13 +45,29 @@ function masteryColor(level: number): string {
|
||||
return "bg-red-500"
|
||||
}
|
||||
|
||||
type KnowledgePointStudent = {
|
||||
studentId: string
|
||||
studentName: string
|
||||
masteryLevel: number
|
||||
totalQuestions: number
|
||||
correctQuestions: number
|
||||
lastAssessedAt: string | null
|
||||
needsAttention: boolean
|
||||
}
|
||||
|
||||
export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
const t = useTranslations("diagnostic")
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
// v3-P2-5: 知识点筛选状态
|
||||
const [selectedKpId, setSelectedKpId] = useState<string>("all")
|
||||
const [filteredStudents, setFilteredStudents] = useState<KnowledgePointStudent[] | null>(null)
|
||||
const [isFiltering, setIsFiltering] = useState(false)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!summary) return
|
||||
setIsGenerating(true)
|
||||
@@ -56,15 +80,45 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
toast.success(result.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to generate class report")
|
||||
toast.error(result.message || t("error.generateClassFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P2-5: 按知识点筛选学生。
|
||||
* 选择知识点后调用 server action 获取该知识点上所有学生的掌握度。
|
||||
*/
|
||||
const handleKpFilter = async (kpId: string) => {
|
||||
setSelectedKpId(kpId)
|
||||
if (!summary || kpId === "all") {
|
||||
setFilteredStudents(null)
|
||||
return
|
||||
}
|
||||
setIsFiltering(true)
|
||||
try {
|
||||
const result = await getClassStudentsByKnowledgePointAction({
|
||||
classId: summary.classId,
|
||||
knowledgePointId: kpId,
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setFilteredStudents(result.data)
|
||||
} else {
|
||||
toast.error(result.message || t("error.loadFailed"))
|
||||
setFilteredStudents(null)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.loadFailed"))
|
||||
setFilteredStudents(null)
|
||||
} finally {
|
||||
setIsFiltering(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No class data"
|
||||
description="Unable to load class mastery summary."
|
||||
title={t("classDiagnostic.noClassDataTitle")}
|
||||
description={t("empty.noClassData")}
|
||||
icon={Users}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
@@ -77,7 +131,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Class</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.class")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.className}</p>
|
||||
@@ -85,7 +139,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Students</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.students")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.studentCount}</p>
|
||||
@@ -93,7 +147,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Avg Mastery</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.avgMastery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||
@@ -101,7 +155,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Need Attention</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.needAttention")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
|
||||
@@ -114,51 +168,174 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Knowledge Point Mastery Heatmap
|
||||
{t("chart.heatmapTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Average mastery level per knowledge point (green ≥80%, yellow 60-79%, orange 40-59%, red <40%).
|
||||
{t("classDiagnostic.heatmapDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.knowledgePointStats.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No knowledge point data available.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("classDiagnostic.noKnowledgePointData")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{summary.knowledgePointStats.map((kp) => (
|
||||
<>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="img"
|
||||
aria-label={t("classDiagnostic.heatmapAriaLabel", { count: summary.knowledgePointStats.length })}
|
||||
>
|
||||
{summary.knowledgePointStats.map((kp) => {
|
||||
const levelLabel = kp.averageMastery >= 80 ? t("classDiagnostic.masteryLevelExcellent") : kp.averageMastery >= 60 ? t("classDiagnostic.masteryLevelGood") : kp.averageMastery >= 40 ? t("classDiagnostic.masteryLevelNeedsImprovement") : t("classDiagnostic.masteryLevelWeak")
|
||||
return (
|
||||
<div
|
||||
key={kp.knowledgePointId}
|
||||
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
|
||||
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (mastered ${kp.masteredCount}/${kp.totalStudents})`}
|
||||
role="img"
|
||||
aria-label={`${kp.knowledgePointName}:${kp.averageMastery.toFixed(1)}%,${levelLabel},${kp.masteredCount}/${kp.totalStudents}`}
|
||||
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (${kp.masteredCount}/${kp.totalStudents})`}
|
||||
>
|
||||
<span className="max-w-32 truncate text-xs font-medium">
|
||||
{kp.knowledgePointName}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* v4-P1-8: 热力图颜色图例 */}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 border-t pt-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("classDiagnostic.legendLabel")}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-green-500" aria-hidden="true" />
|
||||
<span>{t("classDiagnostic.masteryLevelExcellent")} (≥80%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-yellow-500" aria-hidden="true" />
|
||||
<span>{t("classDiagnostic.masteryLevelGood")} (60-79%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-orange-500" aria-hidden="true" />
|
||||
<span>{t("classDiagnostic.masteryLevelNeedsImprovement")} (40-59%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm bg-red-500" aria-hidden="true" />
|
||||
<span>{t("classDiagnostic.masteryLevelWeak")} (<40%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* v3-P2-5: 按知识点筛选学生 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
{t("classDiagnostic.filterByKpTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("classDiagnostic.filterByKpDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="kp-filter" className="text-xs">
|
||||
{t("classDiagnostic.kpFilterLabel")}
|
||||
</Label>
|
||||
<Select value={selectedKpId} onValueChange={handleKpFilter}>
|
||||
<SelectTrigger id="kp-filter" className="w-full md:w-80">
|
||||
<SelectValue placeholder={t("classDiagnostic.kpFilterPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("classDiagnostic.kpFilterAll")}</SelectItem>
|
||||
{summary.knowledgePointStats.map((kp) => (
|
||||
<SelectItem key={kp.knowledgePointId} value={kp.knowledgePointId}>
|
||||
{kp.knowledgePointName} ({kp.averageMastery.toFixed(0)}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isFiltering ? (
|
||||
<p className="text-sm text-muted-foreground">{t("classDiagnostic.filtering")}</p>
|
||||
) : filteredStudents && filteredStudents.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
{/* v4-P1-11: 移动端表格水平滚动 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("summary.student")}</TableHead>
|
||||
<TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
|
||||
<TableHead className="text-right">{t("classDiagnostic.totalQuestionsColumn")}</TableHead>
|
||||
<TableHead className="text-right">{t("classDiagnostic.correctQuestionsColumn")}</TableHead>
|
||||
<TableHead>{t("classDiagnostic.statusColumn")}</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredStudents.map((s) => (
|
||||
<TableRow key={s.studentId}>
|
||||
<TableCell className="font-medium">{s.studentName}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
<Badge variant={s.masteryLevel >= 80 ? "default" : s.masteryLevel >= 60 ? "secondary" : "destructive"}>
|
||||
{s.masteryLevel.toFixed(0)}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{s.totalQuestions}</TableCell>
|
||||
<TableCell className="text-right">{s.correctQuestions}</TableCell>
|
||||
<TableCell>
|
||||
{s.needsAttention ? (
|
||||
<Badge variant="destructive">{t("classDiagnostic.needsAttention")}</Badge>
|
||||
) : (
|
||||
<Badge variant="default">{t("classDiagnostic.mastered")}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button asChild variant="ghost" size="sm" aria-label={t("classDiagnostic.viewAriaLabel", { studentName: s.studentName })}>
|
||||
<Link href={`/teacher/diagnostic/student/${s.studentId}`}>
|
||||
<FileText className="mr-1 h-3 w-3" />
|
||||
{t("classDiagnostic.viewAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredStudents && filteredStudents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("classDiagnostic.noStudentsForKp")}</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 知识点排名表 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Knowledge Point Ranking</CardTitle>
|
||||
<CardTitle>{t("chart.rankingTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.knowledgePointStats.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No data.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("classDiagnostic.noRankingData")}</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
{/* v4-P1-11: 移动端表格水平滚动 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Knowledge Point</TableHead>
|
||||
<TableHead className="text-right">Avg Mastery</TableHead>
|
||||
<TableHead className="text-right">Mastered (≥80%)</TableHead>
|
||||
<TableHead className="text-right">Not Mastered (<60%)</TableHead>
|
||||
<TableHead>{t("classDiagnostic.knowledgePointColumn")}</TableHead>
|
||||
<TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
|
||||
<TableHead className="text-right">{t("classDiagnostic.masteredColumn")}</TableHead>
|
||||
<TableHead className="text-right">{t("classDiagnostic.notMasteredColumn")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -179,6 +356,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -188,21 +366,23 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||
Students Needing Attention (avg <60%)
|
||||
{t("classDiagnostic.studentsNeedingAttentionTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>Students with low overall mastery.</CardDescription>
|
||||
<CardDescription>{t("classDiagnostic.studentsNeedingAttentionDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.studentsNeedingAttention.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">All students are above the attention threshold.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("classDiagnostic.allStudentsAboveThreshold")}</p>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
{/* v4-P1-11: 移动端表格水平滚动 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
<TableHead className="text-right">Avg Mastery</TableHead>
|
||||
<TableHead className="text-right">Weak Points</TableHead>
|
||||
<TableHead>{t("summary.student")}</TableHead>
|
||||
<TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
|
||||
<TableHead className="text-right">{t("classDiagnostic.weakPointsColumn")}</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -215,10 +395,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-red-600">{s.weakCount}</TableCell>
|
||||
<TableCell>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<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" />
|
||||
View
|
||||
{t("classDiagnostic.viewAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
@@ -227,6 +407,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -237,16 +418,16 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Generate Class Diagnostic Report
|
||||
{t("report.generateClass")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a class-level diagnostic report with aggregated analysis.
|
||||
{t("classDiagnostic.generateDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="class-period" className="text-xs">Period (YYYY-MM)</Label>
|
||||
<Label htmlFor="class-period" className="text-xs">{t("classDiagnostic.periodLabel")}</Label>
|
||||
<Input
|
||||
id="class-period"
|
||||
type="month"
|
||||
@@ -256,7 +437,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleGenerate} disabled={isGenerating}>
|
||||
{isGenerating ? "Generating..." : "Generate Class Report"}
|
||||
{isGenerating ? t("classDiagnostic.generating") : t("classDiagnostic.generateButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
31
src/modules/diagnostic/components/confidence-utils.ts
Normal file
31
src/modules/diagnostic/components/confidence-utils.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* v4-P3-7: 诊断报告数据置信度工具。
|
||||
*
|
||||
* 置信度等级用于指示报告基于的数据量是否充足,帮助教师判断报告可信度。
|
||||
* 提取到独立文件供 report-list 和 student-diagnostic-view 共享,避免重复定义。
|
||||
*/
|
||||
|
||||
import type { DiagnosticReportWithDetails } from "../types"
|
||||
|
||||
export type ConfidenceLevel = "high" | "medium" | "low" | "insufficient"
|
||||
|
||||
/**
|
||||
* 根据报告数据计算置信度。
|
||||
* 简化方案:overallScore === null 表示无数据(insufficient),
|
||||
* 否则视为高置信度(high)。
|
||||
* 后续可扩展为基于 totalQuestions 等数据量字段的多级判断。
|
||||
*/
|
||||
export function getConfidenceLevel(report: DiagnosticReportWithDetails): ConfidenceLevel {
|
||||
if (report.overallScore === null) return "insufficient"
|
||||
return "high"
|
||||
}
|
||||
|
||||
export const confidenceBadgeVariant: Record<
|
||||
ConfidenceLevel,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
high: "default",
|
||||
medium: "secondary",
|
||||
low: "destructive",
|
||||
insufficient: "outline",
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Target } from "lucide-react"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
@@ -11,6 +12,7 @@ interface MasteryRadarChartProps {
|
||||
}
|
||||
|
||||
export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
const t = useTranslations("diagnostic")
|
||||
const isEmpty = !data || data.length === 0
|
||||
|
||||
const chartData = isEmpty
|
||||
@@ -25,17 +27,29 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
|
||||
const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined)
|
||||
|
||||
const ariaLabel = isEmpty
|
||||
? t("chart.radarAriaLabelEmpty")
|
||||
: t("chart.radarAriaLabelNonEmpty", {
|
||||
count: data.length,
|
||||
withClassAverage: hasClassAverage ? t("chart.withClassAverage") : "",
|
||||
})
|
||||
|
||||
return (
|
||||
<ChartCardShell
|
||||
title="Knowledge Point Mastery"
|
||||
description="Radar chart of mastery level (0-100) across knowledge points."
|
||||
title={t("chart.radarTitle")}
|
||||
description={t("chart.radarDescriptionNonEmpty")}
|
||||
icon={Target}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No mastery data"
|
||||
emptyDescription="No knowledge point mastery records found for this student."
|
||||
emptyTitle={t("chart.radarEmptyTitle")}
|
||||
emptyDescription={t("chart.noMasteryDataForStudent")}
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<div role="img" aria-label={`知识点掌握度雷达图:${isEmpty ? "暂无数据" : `共 ${data.length} 个知识点的掌握度${hasClassAverage ? "(含班级平均对比)" : ""}`}`}>
|
||||
<div
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={0}
|
||||
className="rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
|
||||
>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="shortName"
|
||||
@@ -48,7 +62,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
series={[
|
||||
{
|
||||
dataKey: "student",
|
||||
name: "Student",
|
||||
name: t("chart.studentSeries"),
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.35,
|
||||
strokeWidth: 2,
|
||||
@@ -56,7 +70,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
},
|
||||
{
|
||||
dataKey: "classAverage",
|
||||
name: "Class Avg",
|
||||
name: t("chart.classAvgSeries"),
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.15,
|
||||
strokeWidth: 2,
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { FileText, Trash2, Send } from "lucide-react"
|
||||
import { FileText, Trash2, Send, Download, Share2, Copy } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -33,17 +34,18 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { usePermission } from "@/shared/hooks"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { publishReportAction, deleteReportAction } from "../actions"
|
||||
import { publishReportAction, deleteReportAction, exportDiagnosticReportAction } from "../actions"
|
||||
import type { DiagnosticReportWithDetails } from "../types"
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
individual: "Individual",
|
||||
class: "Class",
|
||||
grade: "Grade",
|
||||
}
|
||||
import {
|
||||
getConfidenceLevel,
|
||||
confidenceBadgeVariant,
|
||||
type ConfidenceLevel,
|
||||
} from "./confidence-utils"
|
||||
|
||||
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "secondary",
|
||||
@@ -59,10 +61,12 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { hasPermission } = usePermission()
|
||||
const t = useTranslations("diagnostic")
|
||||
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [publishId, setPublishId] = useState<string | null>(null)
|
||||
const [shareId, setShareId] = useState<string | null>(null)
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
||||
const updateParam = useCallback(
|
||||
@@ -90,7 +94,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
setPublishId(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to publish")
|
||||
toast.error(result.message || t("error.publishFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,42 +110,133 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
setDeleteId(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to delete")
|
||||
toast.error(result.message || t("error.deleteFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P2-4: 导出诊断报告为 Excel。
|
||||
* 调用 server action 获取 base64 buffer,前端转 Blob 下载。
|
||||
*/
|
||||
const handleExport = async (reportId: string) => {
|
||||
setIsBusy(true)
|
||||
try {
|
||||
const result = await exportDiagnosticReportAction(reportId)
|
||||
if (!result.success || !result.data) {
|
||||
toast.error(result.message || t("error.exportFailed"))
|
||||
return
|
||||
}
|
||||
// base64 -> Blob -> 下载
|
||||
const binaryString = atob(result.data.buffer)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i += 1) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
const blob = new Blob([bytes], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = result.data.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(t("reportList.exportSuccess"))
|
||||
} catch {
|
||||
toast.error(t("error.exportFailed"))
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// v3-P3-8: 复制报告分享链接到剪贴板
|
||||
const handleCopyLink = async (): Promise<void> => {
|
||||
if (!shareId) return
|
||||
const url = `${window.location.origin}/teacher/diagnostic/reports/${shareId}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success(t("reportList.copyLinkSuccess"))
|
||||
} catch {
|
||||
toast.error(t("reportList.copyLinkFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
// v4-P3-7: 置信度标签与提示
|
||||
const confidenceLabel = (level: ConfidenceLevel): string => {
|
||||
if (level === "high") return t("reportList.confidenceHigh")
|
||||
if (level === "medium") return t("reportList.confidenceMedium")
|
||||
if (level === "low") return t("reportList.confidenceLow")
|
||||
return t("reportList.confidenceInsufficient")
|
||||
}
|
||||
|
||||
const confidenceHint = (level: ConfidenceLevel): string => {
|
||||
if (level === "high") return t("reportList.confidenceHighHint")
|
||||
if (level === "medium") return t("reportList.confidenceMediumHint")
|
||||
if (level === "low") return t("reportList.confidenceLowHint")
|
||||
return t("reportList.confidenceInsufficient")
|
||||
}
|
||||
|
||||
const reportType = searchParams.get("reportType") ?? "all"
|
||||
const status = searchParams.get("status") ?? "all"
|
||||
|
||||
const typeLabel = (reportType: string): string => {
|
||||
if (reportType === "individual") return t("type.individual")
|
||||
if (reportType === "class") return t("type.class")
|
||||
if (reportType === "grade") return t("type.grade")
|
||||
return reportType
|
||||
}
|
||||
|
||||
const statusLabel = (status: string): string => {
|
||||
if (status === "draft") return t("status.draft")
|
||||
if (status === "published") return t("status.published")
|
||||
if (status === "archived") return t("status.archived")
|
||||
return status
|
||||
}
|
||||
|
||||
const studentTargetDisplay = (r: DiagnosticReportWithDetails): string => {
|
||||
if (r.studentName) return r.studentName
|
||||
if (r.reportType === "class") return t("reportList.classReportPlaceholder")
|
||||
if (r.reportType === "grade") return t("reportList.gradeReportPlaceholder")
|
||||
return "-"
|
||||
}
|
||||
|
||||
// v3-P3-8: 当前分享的报告及链接
|
||||
const sharedReport = shareId ? reports.find((r) => r.id === shareId) ?? null : null
|
||||
const shareUrl = typeof window !== "undefined" && sharedReport
|
||||
? `${window.location.origin}/teacher/diagnostic/reports/${sharedReport.id}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 过滤器 */}
|
||||
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs">Report Type</Label>
|
||||
<Label htmlFor="filter-report-type" className="text-xs">{t("filters.reportType")}</Label>
|
||||
<Select value={reportType} onValueChange={(v) => updateParam("reportType", v)}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All types" />
|
||||
<SelectTrigger id="filter-report-type" className="h-9">
|
||||
<SelectValue placeholder={t("filters.allTypes")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="individual">Individual</SelectItem>
|
||||
<SelectItem value="class">Class</SelectItem>
|
||||
<SelectItem value="grade">Grade</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
|
||||
<SelectItem value="individual">{t("type.individual")}</SelectItem>
|
||||
<SelectItem value="class">{t("type.class")}</SelectItem>
|
||||
<SelectItem value="grade">{t("type.grade")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs">Status</Label>
|
||||
<Label htmlFor="filter-report-status" className="text-xs">{t("filters.status")}</Label>
|
||||
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
<SelectTrigger id="filter-report-status" className="h-9">
|
||||
<SelectValue placeholder={t("filters.allStatuses")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="published">Published</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
|
||||
<SelectItem value="draft">{t("status.draft")}</SelectItem>
|
||||
<SelectItem value="published">{t("status.published")}</SelectItem>
|
||||
<SelectItem value="archived">{t("status.archived")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -149,71 +244,118 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
|
||||
{reports.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No diagnostic reports"
|
||||
description="Generate diagnostic reports to see them here."
|
||||
title={t("empty.noReports")}
|
||||
description={t("reportList.noReportsDescription")}
|
||||
icon={FileText}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<caption className="sr-only">学情诊断报告列表</caption>
|
||||
<caption className="sr-only">{t("reportList.caption")}</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Student / Target</TableHead>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead className="text-right">Score</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Generated By</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
{canManage ? <TableHead className="w-24">Actions</TableHead> : null}
|
||||
<TableHead>{t("reportList.typeColumn")}</TableHead>
|
||||
<TableHead>{t("reportList.studentTargetColumn")}</TableHead>
|
||||
<TableHead>{t("reportList.periodColumn")}</TableHead>
|
||||
<TableHead className="text-right">{t("reportList.scoreColumn")}</TableHead>
|
||||
<TableHead>{t("reportList.confidenceColumn")}</TableHead>
|
||||
<TableHead>{t("reportList.statusColumn")}</TableHead>
|
||||
<TableHead>{t("reportList.generatedByColumn")}</TableHead>
|
||||
<TableHead>{t("reportList.dateColumn")}</TableHead>
|
||||
<TableHead className="w-40">{t("reportList.actionsColumn")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reports.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge>
|
||||
<Badge variant="outline">{typeLabel(r.reportType)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{r.studentName ?? (r.reportType === "class" ? "(班级报告)" : r.reportType === "grade" ? "(年级报告)" : "-")}</TableCell>
|
||||
<TableCell className="font-medium">{studentTargetDisplay(r)}</TableCell>
|
||||
<TableCell>{r.period ?? "-"}</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[r.status] ?? "secondary"}>{r.status}</Badge>
|
||||
{(() => {
|
||||
const level = getConfidenceLevel(r)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={confidenceBadgeVariant[level]}
|
||||
aria-label={t("reportList.confidenceAriaLabel", { level: confidenceLabel(level) })}
|
||||
>
|
||||
{confidenceLabel(level)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{confidenceHint(level)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[r.status] ?? "secondary"}>{statusLabel(r.status)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.generatedByName ?? "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
|
||||
{canManage ? (
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{r.status === "draft" ? (
|
||||
{/* v3-P2-4: 导出按钮(所有角色可见,受 exportDiagnosticReportAction 权限校验保护) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleExport(r.id)}
|
||||
disabled={isBusy}
|
||||
title={t("report.export")}
|
||||
aria-label={t("reportList.exportAriaLabel", { studentName: r.studentName ?? "" })}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* v3-P3-8: 分享按钮(仅教师可见) */}
|
||||
{canManage ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShareId(r.id)}
|
||||
disabled={isBusy}
|
||||
title={t("reportList.share")}
|
||||
aria-label={t("reportList.shareAriaLabel", { studentName: r.studentName ?? "" })}
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage && r.status === "draft" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-green-600"
|
||||
onClick={() => setPublishId(r.id)}
|
||||
title="Publish"
|
||||
aria-label={`发布报告 ${r.studentName}`}
|
||||
disabled={isBusy}
|
||||
title={t("report.publish")}
|
||||
aria-label={t("reportList.publishAriaLabel", { studentName: r.studentName ?? "" })}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
title="Delete"
|
||||
aria-label={`删除报告 ${r.studentName}`}
|
||||
disabled={isBusy}
|
||||
title={t("report.delete")}
|
||||
aria-label={t("reportList.deleteAriaLabel", { studentName: r.studentName ?? "" })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -225,17 +367,17 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
<Dialog open={publishId !== null} onOpenChange={(open) => !open && setPublishId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish Report</DialogTitle>
|
||||
<DialogTitle>{t("report.publishTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Once published, the report will be visible to students. Continue?
|
||||
{t("reportList.publishConfirmation")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPublishId(null)} disabled={isBusy}>
|
||||
Cancel
|
||||
{t("report.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handlePublish} disabled={isBusy}>
|
||||
{isBusy ? "Publishing..." : "Publish"}
|
||||
{isBusy ? t("report.publishing") : t("report.publish")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -245,17 +387,55 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Report</DialogTitle>
|
||||
<DialogTitle>{t("report.deleteTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this diagnostic report? This action cannot be undone.
|
||||
{t("report.deleteConfirmation")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isBusy}>
|
||||
Cancel
|
||||
{t("report.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isBusy}>
|
||||
{isBusy ? "Deleting..." : "Delete"}
|
||||
{isBusy ? t("report.deleting") : t("report.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* v3-P3-8: 分享报告 */}
|
||||
<Dialog open={shareId !== null} onOpenChange={(open) => !open && setShareId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("reportList.shareTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("reportList.shareDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{sharedReport?.summary ? (
|
||||
<div className="rounded-md border bg-muted/50 p-3">
|
||||
<p className="text-sm">{sharedReport.summary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="share-link" className="text-xs">{t("reportList.shareLinkLabel")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="share-link"
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
aria-label={t("reportList.shareLinkAriaLabel")}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button onClick={handleCopyLink} className="shrink-0">
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
{t("reportList.copyLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShareId(null)}>
|
||||
{t("report.cancel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,28 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Award, AlertTriangle, Lightbulb, FileText, History, ArrowRight } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { MasteryRadarChart } from "./mastery-radar-chart"
|
||||
import {
|
||||
getConfidenceLevel,
|
||||
confidenceBadgeVariant,
|
||||
type ConfidenceLevel,
|
||||
} from "./confidence-utils"
|
||||
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
|
||||
|
||||
interface StudentDiagnosticViewProps {
|
||||
summary: StudentMasterySummary | null
|
||||
reports: DiagnosticReportWithDetails[]
|
||||
classAverageMastery?: MasteryRadarPoint[]
|
||||
/**
|
||||
* v3-P2-6: "练习"按钮的跳转基础路径。
|
||||
* - 学生视角:默认 `/student/learning/assignments`
|
||||
* - 教师视角:传入 `/teacher/questions`(题目库支持 kp 查询参数筛选)
|
||||
* - 家长视角:传入 `null` 隐藏练习按钮(家长无练习入口)
|
||||
* 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。
|
||||
*/
|
||||
practiceHrefBase?: string | null
|
||||
}
|
||||
|
||||
export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) {
|
||||
export function StudentDiagnosticView({
|
||||
summary,
|
||||
reports,
|
||||
classAverageMastery,
|
||||
practiceHrefBase = "/student/learning/assignments",
|
||||
}: StudentDiagnosticViewProps) {
|
||||
const t = useTranslations("diagnostic")
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No diagnostic data"
|
||||
description="Unable to load student mastery data."
|
||||
title={t("empty.noData")}
|
||||
description={t("studentDiagnostic.noDataDescription")}
|
||||
icon={FileText}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
@@ -39,7 +61,38 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
})
|
||||
|
||||
const publishedReports = reports.filter((r) => r.status === "published")
|
||||
const latestReport = publishedReports[0] ?? reports[0] ?? null
|
||||
// v4-P1-3: 移除草稿回退逻辑,仅展示已发布报告
|
||||
// 调用方(学生/家长页面)已传 status: "published" 过滤,此处双重保障
|
||||
const latestReport = publishedReports[0] ?? null
|
||||
|
||||
const statusLabel = (status: string): string => {
|
||||
if (status === "draft") return t("status.draft")
|
||||
if (status === "published") return t("status.published")
|
||||
if (status === "archived") return t("status.archived")
|
||||
return status
|
||||
}
|
||||
|
||||
const typeLabel = (reportType: string): string => {
|
||||
if (reportType === "individual") return t("type.individual")
|
||||
if (reportType === "class") return t("type.class")
|
||||
if (reportType === "grade") return t("type.grade")
|
||||
return reportType
|
||||
}
|
||||
|
||||
// v4-P3-7: 置信度标签与提示
|
||||
const confidenceLabel = (level: ConfidenceLevel): string => {
|
||||
if (level === "high") return t("reportList.confidenceHigh")
|
||||
if (level === "medium") return t("reportList.confidenceMedium")
|
||||
if (level === "low") return t("reportList.confidenceLow")
|
||||
return t("reportList.confidenceInsufficient")
|
||||
}
|
||||
|
||||
const confidenceHint = (level: ConfidenceLevel): string => {
|
||||
if (level === "high") return t("reportList.confidenceHighHint")
|
||||
if (level === "medium") return t("reportList.confidenceMediumHint")
|
||||
if (level === "low") return t("reportList.confidenceLowHint")
|
||||
return t("reportList.confidenceInsufficient")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -47,7 +100,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.student")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.studentName}</p>
|
||||
@@ -55,7 +108,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Overall Mastery</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.overallMastery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||
@@ -63,7 +116,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Strengths</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.strengths")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-green-600">{summary.strengths.length}</p>
|
||||
@@ -71,7 +124,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Weaknesses</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.weaknesses")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.weaknesses.length}</p>
|
||||
@@ -88,15 +141,15 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Award className="h-4 w-4 text-green-600" />
|
||||
Strengths (≥80%)
|
||||
{t("strengths.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>Knowledge points with high mastery.</CardDescription>
|
||||
<CardDescription>{t("studentDiagnostic.strengthsDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.strengths.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No strengths identified yet.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("studentDiagnostic.noStrengths")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2" role="list" aria-label="优势知识点列表">
|
||||
<ul className="space-y-2" role="list" aria-label={t("studentDiagnostic.strengthsListAriaLabel")}>
|
||||
{summary.strengths.map((m) => (
|
||||
<li key={m.knowledgePointId} className="flex items-center justify-between">
|
||||
<span className="text-sm">{m.knowledgePointName}</span>
|
||||
@@ -111,27 +164,29 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||
Weaknesses (<60%)
|
||||
{t("weaknesses.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>Knowledge points needing attention.</CardDescription>
|
||||
<CardDescription>{t("studentDiagnostic.weaknessesDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{summary.weaknesses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No weaknesses identified.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("studentDiagnostic.noWeaknesses")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2" role="list" aria-label="薄弱知识点列表">
|
||||
<ul className="space-y-2" role="list" aria-label={t("studentDiagnostic.weaknessesListAriaLabel")}>
|
||||
{summary.weaknesses.map((m) => (
|
||||
<li key={m.knowledgePointId} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm truncate">{m.knowledgePointName}</span>
|
||||
<Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs">
|
||||
<Link href="/student/learning/assignments">
|
||||
Practice
|
||||
{practiceHrefBase ? (
|
||||
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs" aria-label={t("studentDiagnostic.practiceAriaLabel", { name: m.knowledgePointName })}>
|
||||
<Link href={`${practiceHrefBase}?kp=${m.knowledgePointId}`}>
|
||||
{t("weaknesses.practice")}
|
||||
<ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -146,13 +201,32 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
Diagnostic Report
|
||||
{t("studentDiagnostic.diagnosticReportTitle")}
|
||||
<Badge variant={latestReport.status === "published" ? "default" : "secondary"}>
|
||||
{latestReport.status}
|
||||
{statusLabel(latestReport.status)}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const level = getConfidenceLevel(latestReport)
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={confidenceBadgeVariant[level]}
|
||||
aria-label={t("reportList.confidenceAriaLabel", { level: confidenceLabel(level) })}
|
||||
>
|
||||
{confidenceLabel(level)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{confidenceHint(level)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Period: {latestReport.period ?? "-"} · Overall: {latestReport.overallScore?.toFixed(1) ?? "-"}%
|
||||
{t("studentDiagnostic.reportMeta", {
|
||||
period: latestReport.period ?? "-",
|
||||
score: latestReport.overallScore?.toFixed(1) ?? "-",
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -161,10 +235,10 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
) : null}
|
||||
{latestReport.recommendations && latestReport.recommendations.length > 0 ? (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold">Recommendations</h4>
|
||||
<ul className="space-y-1.5" role="list" aria-label="学习建议列表">
|
||||
<h4 className="mb-2 text-sm font-semibold">{t("report.recommendations")}</h4>
|
||||
<ul className="space-y-1.5" role="list" aria-label={t("studentDiagnostic.recommendationsListAriaLabel")}>
|
||||
{latestReport.recommendations.map((rec, i) => (
|
||||
<li key={i} className="text-sm text-muted-foreground">• {rec}</li>
|
||||
<li key={rec || `rec-${i}`} className="text-sm text-muted-foreground">• {rec}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -179,9 +253,9 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
Report History
|
||||
{t("report.history")}
|
||||
</CardTitle>
|
||||
<CardDescription>Past diagnostic reports (newest first).</CardDescription>
|
||||
<CardDescription>{t("studentDiagnostic.historyDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
@@ -193,15 +267,19 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{r.period ?? "Untitled period"}
|
||||
{r.period ?? t("studentDiagnostic.untitledPeriod")}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{r.reportType}
|
||||
{typeLabel(r.reportType)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(r.createdAt)}
|
||||
{r.overallScore !== null ? ` · Overall: ${r.overallScore.toFixed(1)}%` : ""}
|
||||
{r.overallScore !== null
|
||||
? t("studentDiagnostic.historyReportMeta", {
|
||||
date: formatDate(r.createdAt),
|
||||
score: r.overallScore.toFixed(1),
|
||||
})
|
||||
: formatDate(r.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<DiagnosticReportWithDetails[]> => {
|
||||
async (
|
||||
filters: DiagnosticReportQueryParams,
|
||||
scope?: DataScope,
|
||||
): Promise<DiagnosticReportListResult> => {
|
||||
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
|
||||
// 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(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(learningDiagnosticReports.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
])
|
||||
|
||||
const total = totalRows[0]?.total ?? 0
|
||||
|
||||
// 收集所有需要查询姓名的用户 ID(学生 + 生成者),通过 users data-access 统一获取
|
||||
const userIds = new Set<string>()
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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<MasteryWithKnowledgePoint[]> => {
|
||||
const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
mastery: knowledgePointMastery,
|
||||
@@ -51,45 +41,29 @@ export const getStudentMastery = cache(async (studentId: string): Promise<Master
|
||||
.where(eq(knowledgePointMastery.studentId, studentId))
|
||||
.orderBy(desc(knowledgePointMastery.masteryLevel))
|
||||
|
||||
return rows.map((r) => ({
|
||||
...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<StudentMasterySummary | null> => {
|
||||
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<void> {
|
||||
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<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(kpStats.entries()).map(async ([kpId, stat]) => {
|
||||
const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0
|
||||
await db
|
||||
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,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
masteryLevel: String(masteryLevel),
|
||||
totalQuestions: stat.total,
|
||||
correctQuestions: stat.correct,
|
||||
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 }))
|
||||
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
|
||||
|
||||
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
|
||||
const byStudent = new Map<string, { levels: number[]; weakCount: number }>()
|
||||
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<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
|
||||
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)
|
||||
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<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,
|
||||
})
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
)
|
||||
|
||||
122
src/modules/diagnostic/export.ts
Normal file
122
src/modules/diagnostic/export.ts
Normal 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`
|
||||
}
|
||||
@@ -29,20 +29,3 @@ export const DeleteReportSchema = z.object({
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
388
src/modules/diagnostic/stats-service.ts
Normal file
388
src/modules/diagnostic/stats-service.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 雷达图数据点 */
|
||||
|
||||
Reference in New Issue
Block a user