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"
|
"use server"
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
|
import { createNotification } from "@/modules/notifications/data-access"
|
||||||
|
import { getStudentIdsByClassId } from "@/modules/classes/data-access"
|
||||||
|
import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
generateDiagnosticReport,
|
generateDiagnosticReport,
|
||||||
generateClassDiagnosticReport,
|
generateClassDiagnosticReport,
|
||||||
getDiagnosticReports,
|
|
||||||
getDiagnosticReportById,
|
|
||||||
publishDiagnosticReport,
|
publishDiagnosticReport,
|
||||||
deleteDiagnosticReport,
|
deleteDiagnosticReport,
|
||||||
|
getDiagnosticReportById,
|
||||||
} from "./data-access-reports"
|
} from "./data-access-reports"
|
||||||
|
import { getClassStudentsByKnowledgePoint } from "./data-access"
|
||||||
|
import {
|
||||||
|
exportDiagnosticReportToExcel,
|
||||||
|
buildDiagnosticReportFilename,
|
||||||
|
} from "./export"
|
||||||
import {
|
import {
|
||||||
GenerateStudentReportSchema,
|
GenerateStudentReportSchema,
|
||||||
GenerateClassReportSchema,
|
GenerateClassReportSchema,
|
||||||
PublishReportSchema,
|
PublishReportSchema,
|
||||||
DeleteReportSchema,
|
DeleteReportSchema,
|
||||||
GetDiagnosticReportsSchema,
|
|
||||||
GetDiagnosticReportByIdSchema,
|
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import type { DiagnosticReportQueryParams } from "./types"
|
|
||||||
|
|
||||||
/** 生成学生个人诊断报告 */
|
/** 生成学生个人诊断报告 */
|
||||||
export async function generateStudentReportAction(
|
export async function generateStudentReportAction(
|
||||||
@@ -45,9 +50,7 @@ export async function generateStudentReportAction(
|
|||||||
revalidatePath(`/teacher/diagnostic/student/${studentId}`)
|
revalidatePath(`/teacher/diagnostic/student/${studentId}`)
|
||||||
return { success: true, message: "Diagnostic report generated", data: id }
|
return { success: true, message: "Diagnostic report generated", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +76,7 @@ export async function generateClassReportAction(
|
|||||||
revalidatePath(`/teacher/diagnostic/class/${classId}`)
|
revalidatePath(`/teacher/diagnostic/class/${classId}`)
|
||||||
return { success: true, message: "Class diagnostic report generated", data: id }
|
return { success: true, message: "Class diagnostic report generated", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +96,72 @@ export async function publishReportAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await publishDiagnosticReport(parsed.data.id)
|
await publishDiagnosticReport(parsed.data.id)
|
||||||
|
|
||||||
|
// v3-P1-4 + v4-P1-4 + v4-P1-5:发布报告后发送通知
|
||||||
|
// - 个人报告:通知学生本人 + 其家长
|
||||||
|
// - 班级报告:通知全班学生 + 全班学生家长
|
||||||
|
const report = await getDiagnosticReportById(parsed.data.id)
|
||||||
|
if (!report) {
|
||||||
|
revalidatePath("/teacher/diagnostic")
|
||||||
|
return { success: true, message: "Report published" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = `诊断报告已发布:${report.period ?? "本期"}`
|
||||||
|
const content = report.summary ?? "您有一份新的学情诊断报告,请查看详情。"
|
||||||
|
const link = "/student/diagnostic"
|
||||||
|
|
||||||
|
// 收集需要通知的学生 ID 列表
|
||||||
|
const studentIdsToNotify: string[] = []
|
||||||
|
if (report.studentId) {
|
||||||
|
// 个人报告:通知单个学生
|
||||||
|
studentIdsToNotify.push(report.studentId)
|
||||||
|
} else if (report.classId) {
|
||||||
|
// v4-P1-4: 班级报告(有 classId):通知全班学生
|
||||||
|
const classStudentIds = await getStudentIdsByClassId(report.classId)
|
||||||
|
studentIdsToNotify.push(...classStudentIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知学生本人
|
||||||
|
for (const studentId of studentIdsToNotify) {
|
||||||
|
try {
|
||||||
|
await createNotification({
|
||||||
|
userId: studentId,
|
||||||
|
type: "grade",
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
link,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 单条通知失败不阻断整体流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4-P1-5: 通知所有相关家长
|
||||||
|
if (studentIdsToNotify.length > 0) {
|
||||||
|
try {
|
||||||
|
const parentIds = await getParentIdsByStudentIds(studentIdsToNotify)
|
||||||
|
for (const parentId of parentIds) {
|
||||||
|
try {
|
||||||
|
await createNotification({
|
||||||
|
userId: parentId,
|
||||||
|
type: "grade",
|
||||||
|
title,
|
||||||
|
content: report.summary ?? "您的孩子有一份新的学情诊断报告,请查看详情。",
|
||||||
|
link: "/parent/diagnostic",
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 单条通知失败不阻断整体流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 家长查询失败不阻断整体流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/teacher/diagnostic")
|
revalidatePath("/teacher/diagnostic")
|
||||||
return { success: true, message: "Report published" }
|
return { success: true, message: "Report published" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,50 +184,91 @@ export async function deleteReportAction(
|
|||||||
revalidatePath("/teacher/diagnostic")
|
revalidatePath("/teacher/diagnostic")
|
||||||
return { success: true, message: "Report deleted" }
|
return { success: true, message: "Report deleted" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询诊断报告列表(读权限) */
|
/**
|
||||||
export async function getDiagnosticReportsAction(
|
* v3-P2-4: 导出诊断报告为 Excel。
|
||||||
params: DiagnosticReportQueryParams
|
* 返回 base64 编码的 buffer 和文件名,前端通过 Blob 下载。
|
||||||
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReports>>>> {
|
*/
|
||||||
|
export async function exportDiagnosticReportAction(
|
||||||
|
reportId: string
|
||||||
|
): Promise<ActionState<{ buffer: string; filename: string }>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.DIAGNOSTIC_READ)
|
await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||||
|
|
||||||
const parsed = GetDiagnosticReportsSchema.safeParse(params)
|
if (!reportId || typeof reportId !== "string") {
|
||||||
if (!parsed.success) {
|
|
||||||
return { success: false, message: "Invalid query params" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const reports = await getDiagnosticReports(parsed.data)
|
|
||||||
return { success: true, data: reports }
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
|
||||||
return { success: false, message: "Unexpected error" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取诊断报告详情(读权限) */
|
|
||||||
export async function getDiagnosticReportByIdAction(
|
|
||||||
id: string
|
|
||||||
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReportById>>>> {
|
|
||||||
try {
|
|
||||||
await requirePermission(Permissions.DIAGNOSTIC_READ)
|
|
||||||
|
|
||||||
const parsed = GetDiagnosticReportByIdSchema.safeParse({ id })
|
|
||||||
if (!parsed.success) {
|
|
||||||
return { success: false, message: "Missing report id" }
|
return { success: false, message: "Missing report id" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const report = await getDiagnosticReportById(parsed.data.id)
|
const report = await getDiagnosticReportById(reportId)
|
||||||
return { success: true, data: report }
|
if (!report) {
|
||||||
|
return { success: false, message: "Report not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await exportDiagnosticReportToExcel({ reportId })
|
||||||
|
const filename = buildDiagnosticReportFilename(report.period)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
buffer: buffer.toString("base64"),
|
||||||
|
filename,
|
||||||
|
},
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
return handleActionError(e)
|
||||||
if (e instanceof Error) return { success: false, message: e.message }
|
}
|
||||||
return { success: false, message: "Unexpected error" }
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v3-P2-5: 获取班级学生在指定知识点上的掌握度列表。
|
||||||
|
* 用于班级诊断页面的"按知识点筛选学生"功能。
|
||||||
|
*/
|
||||||
|
export async function getClassStudentsByKnowledgePointAction(params: {
|
||||||
|
classId: string
|
||||||
|
knowledgePointId: string
|
||||||
|
threshold?: number
|
||||||
|
}): Promise<
|
||||||
|
ActionState<
|
||||||
|
Array<{
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
masteryLevel: number
|
||||||
|
totalQuestions: number
|
||||||
|
correctQuestions: number
|
||||||
|
lastAssessedAt: string | null
|
||||||
|
needsAttention: boolean
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||||
|
|
||||||
|
if (!params.classId || !params.knowledgePointId) {
|
||||||
|
return { success: false, message: "Missing classId or knowledgePointId" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 教师只能查看所教班级
|
||||||
|
if (
|
||||||
|
ctx.dataScope.type === "class_taught" &&
|
||||||
|
!ctx.dataScope.classIds.includes(params.classId)
|
||||||
|
) {
|
||||||
|
return { success: false, message: "You can only access classes you teach" }
|
||||||
|
}
|
||||||
|
// 学生/家长不可访问
|
||||||
|
if (ctx.dataScope.type === "class_members" || ctx.dataScope.type === "children") {
|
||||||
|
return { success: false, message: "Access denied" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getClassStudentsByKnowledgePoint(
|
||||||
|
params.classId,
|
||||||
|
params.knowledgePointId,
|
||||||
|
{ threshold: params.threshold }
|
||||||
|
)
|
||||||
|
return { success: true, data: result }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useState } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Users, AlertTriangle, TrendingUp, FileText } from "lucide-react"
|
import { useTranslations } from "next-intl"
|
||||||
|
import { Users, AlertTriangle, TrendingUp, FileText, Filter } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
@@ -12,6 +13,13 @@ import { Button } from "@/shared/components/ui/button"
|
|||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -22,7 +30,7 @@ import {
|
|||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
import { usePermission } from "@/shared/hooks"
|
import { usePermission } from "@/shared/hooks"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { generateClassReportAction } from "../actions"
|
import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions"
|
||||||
import type { ClassMasterySummary } from "../types"
|
import type { ClassMasterySummary } from "../types"
|
||||||
|
|
||||||
interface ClassDiagnosticViewProps {
|
interface ClassDiagnosticViewProps {
|
||||||
@@ -37,13 +45,29 @@ function masteryColor(level: number): string {
|
|||||||
return "bg-red-500"
|
return "bg-red-500"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KnowledgePointStudent = {
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
masteryLevel: number
|
||||||
|
totalQuestions: number
|
||||||
|
correctQuestions: number
|
||||||
|
lastAssessedAt: string | null
|
||||||
|
needsAttention: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||||
|
const t = useTranslations("diagnostic")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { hasPermission } = usePermission()
|
const { hasPermission } = usePermission()
|
||||||
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||||
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
|
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
|
||||||
|
// v3-P2-5: 知识点筛选状态
|
||||||
|
const [selectedKpId, setSelectedKpId] = useState<string>("all")
|
||||||
|
const [filteredStudents, setFilteredStudents] = useState<KnowledgePointStudent[] | null>(null)
|
||||||
|
const [isFiltering, setIsFiltering] = useState(false)
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!summary) return
|
if (!summary) return
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
@@ -56,15 +80,45 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
toast.success(result.message)
|
toast.success(result.message)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to generate class report")
|
toast.error(result.message || t("error.generateClassFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v3-P2-5: 按知识点筛选学生。
|
||||||
|
* 选择知识点后调用 server action 获取该知识点上所有学生的掌握度。
|
||||||
|
*/
|
||||||
|
const handleKpFilter = async (kpId: string) => {
|
||||||
|
setSelectedKpId(kpId)
|
||||||
|
if (!summary || kpId === "all") {
|
||||||
|
setFilteredStudents(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsFiltering(true)
|
||||||
|
try {
|
||||||
|
const result = await getClassStudentsByKnowledgePointAction({
|
||||||
|
classId: summary.classId,
|
||||||
|
knowledgePointId: kpId,
|
||||||
|
})
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setFilteredStudents(result.data)
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || t("error.loadFailed"))
|
||||||
|
setFilteredStudents(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error.loadFailed"))
|
||||||
|
setFilteredStudents(null)
|
||||||
|
} finally {
|
||||||
|
setIsFiltering(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No class data"
|
title={t("classDiagnostic.noClassDataTitle")}
|
||||||
description="Unable to load class mastery summary."
|
description={t("empty.noClassData")}
|
||||||
icon={Users}
|
icon={Users}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
@@ -77,7 +131,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Class</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.class")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold">{summary.className}</p>
|
<p className="text-2xl font-bold">{summary.className}</p>
|
||||||
@@ -85,7 +139,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Students</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.students")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold">{summary.studentCount}</p>
|
<p className="text-2xl font-bold">{summary.studentCount}</p>
|
||||||
@@ -93,7 +147,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Avg Mastery</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.avgMastery")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||||
@@ -101,7 +155,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Need Attention</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.needAttention")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
|
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
|
||||||
@@ -114,51 +168,174 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<TrendingUp className="h-4 w-4" />
|
<TrendingUp className="h-4 w-4" />
|
||||||
Knowledge Point Mastery Heatmap
|
{t("chart.heatmapTitle")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Average mastery level per knowledge point (green ≥80%, yellow 60-79%, orange 40-59%, red <40%).
|
{t("classDiagnostic.heatmapDescription")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{summary.knowledgePointStats.length === 0 ? (
|
{summary.knowledgePointStats.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No knowledge point data available.</p>
|
<p className="text-sm text-muted-foreground">{t("classDiagnostic.noKnowledgePointData")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-2">
|
<>
|
||||||
{summary.knowledgePointStats.map((kp) => (
|
<div
|
||||||
|
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
|
<div
|
||||||
key={kp.knowledgePointId}
|
key={kp.knowledgePointId}
|
||||||
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
|
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">
|
<span className="max-w-32 truncate text-xs font-medium">
|
||||||
{kp.knowledgePointName}
|
{kp.knowledgePointName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
|
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* v3-P2-5: 按知识点筛选学生 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
{t("classDiagnostic.filterByKpTitle")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("classDiagnostic.filterByKpDescription")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="kp-filter" className="text-xs">
|
||||||
|
{t("classDiagnostic.kpFilterLabel")}
|
||||||
|
</Label>
|
||||||
|
<Select value={selectedKpId} onValueChange={handleKpFilter}>
|
||||||
|
<SelectTrigger id="kp-filter" className="w-full md:w-80">
|
||||||
|
<SelectValue placeholder={t("classDiagnostic.kpFilterPlaceholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("classDiagnostic.kpFilterAll")}</SelectItem>
|
||||||
|
{summary.knowledgePointStats.map((kp) => (
|
||||||
|
<SelectItem key={kp.knowledgePointId} value={kp.knowledgePointId}>
|
||||||
|
{kp.knowledgePointName} ({kp.averageMastery.toFixed(0)}%)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFiltering ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("classDiagnostic.filtering")}</p>
|
||||||
|
) : filteredStudents && filteredStudents.length > 0 ? (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
{/* v4-P1-11: 移动端表格水平滚动 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("summary.student")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("classDiagnostic.totalQuestionsColumn")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("classDiagnostic.correctQuestionsColumn")}</TableHead>
|
||||||
|
<TableHead>{t("classDiagnostic.statusColumn")}</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredStudents.map((s) => (
|
||||||
|
<TableRow key={s.studentId}>
|
||||||
|
<TableCell className="font-medium">{s.studentName}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
<Badge variant={s.masteryLevel >= 80 ? "default" : s.masteryLevel >= 60 ? "secondary" : "destructive"}>
|
||||||
|
{s.masteryLevel.toFixed(0)}%
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{s.totalQuestions}</TableCell>
|
||||||
|
<TableCell className="text-right">{s.correctQuestions}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{s.needsAttention ? (
|
||||||
|
<Badge variant="destructive">{t("classDiagnostic.needsAttention")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="default">{t("classDiagnostic.mastered")}</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button asChild variant="ghost" size="sm" aria-label={t("classDiagnostic.viewAriaLabel", { studentName: s.studentName })}>
|
||||||
|
<Link href={`/teacher/diagnostic/student/${s.studentId}`}>
|
||||||
|
<FileText className="mr-1 h-3 w-3" />
|
||||||
|
{t("classDiagnostic.viewAction")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : filteredStudents && filteredStudents.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("classDiagnostic.noStudentsForKp")}</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 知识点排名表 */}
|
{/* 知识点排名表 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Knowledge Point Ranking</CardTitle>
|
<CardTitle>{t("chart.rankingTitle")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{summary.knowledgePointStats.length === 0 ? (
|
{summary.knowledgePointStats.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No data.</p>
|
<p className="text-sm text-muted-foreground">{t("classDiagnostic.noRankingData")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
|
{/* v4-P1-11: 移动端表格水平滚动 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Knowledge Point</TableHead>
|
<TableHead>{t("classDiagnostic.knowledgePointColumn")}</TableHead>
|
||||||
<TableHead className="text-right">Avg Mastery</TableHead>
|
<TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
|
||||||
<TableHead className="text-right">Mastered (≥80%)</TableHead>
|
<TableHead className="text-right">{t("classDiagnostic.masteredColumn")}</TableHead>
|
||||||
<TableHead className="text-right">Not Mastered (<60%)</TableHead>
|
<TableHead className="text-right">{t("classDiagnostic.notMasteredColumn")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -179,6 +356,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -188,21 +366,23 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||||
Students Needing Attention (avg <60%)
|
{t("classDiagnostic.studentsNeedingAttentionTitle")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Students with low overall mastery.</CardDescription>
|
<CardDescription>{t("classDiagnostic.studentsNeedingAttentionDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{summary.studentsNeedingAttention.length === 0 ? (
|
{summary.studentsNeedingAttention.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">All students are above the attention threshold.</p>
|
<p className="text-sm text-muted-foreground">{t("classDiagnostic.allStudentsAboveThreshold")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
|
{/* v4-P1-11: 移动端表格水平滚动 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Student</TableHead>
|
<TableHead>{t("summary.student")}</TableHead>
|
||||||
<TableHead className="text-right">Avg Mastery</TableHead>
|
<TableHead className="text-right">{t("classDiagnostic.avgMasteryColumn")}</TableHead>
|
||||||
<TableHead className="text-right">Weak Points</TableHead>
|
<TableHead className="text-right">{t("classDiagnostic.weakPointsColumn")}</TableHead>
|
||||||
<TableHead></TableHead>
|
<TableHead></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -215,10 +395,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-red-600">{s.weakCount}</TableCell>
|
<TableCell className="text-right text-red-600">{s.weakCount}</TableCell>
|
||||||
<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}`}>
|
<Link href={`/teacher/diagnostic/student/${s.studentId}`}>
|
||||||
<FileText className="mr-1 h-3 w-3" />
|
<FileText className="mr-1 h-3 w-3" />
|
||||||
View
|
{t("classDiagnostic.viewAction")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -227,6 +407,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -237,16 +418,16 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
Generate Class Diagnostic Report
|
{t("report.generateClass")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Generate a class-level diagnostic report with aggregated analysis.
|
{t("classDiagnostic.generateDescription")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-end gap-3">
|
<div className="flex items-end gap-3">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="class-period" className="text-xs">Period (YYYY-MM)</Label>
|
<Label htmlFor="class-period" className="text-xs">{t("classDiagnostic.periodLabel")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="class-period"
|
id="class-period"
|
||||||
type="month"
|
type="month"
|
||||||
@@ -256,7 +437,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleGenerate} disabled={isGenerating}>
|
<Button onClick={handleGenerate} disabled={isGenerating}>
|
||||||
{isGenerating ? "Generating..." : "Generate Class Report"}
|
{isGenerating ? t("classDiagnostic.generating") : t("classDiagnostic.generateButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
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"
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Target } from "lucide-react"
|
import { Target } from "lucide-react"
|
||||||
|
|
||||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||||
@@ -11,6 +12,7 @@ interface MasteryRadarChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||||
|
const t = useTranslations("diagnostic")
|
||||||
const isEmpty = !data || data.length === 0
|
const isEmpty = !data || data.length === 0
|
||||||
|
|
||||||
const chartData = isEmpty
|
const chartData = isEmpty
|
||||||
@@ -25,17 +27,29 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
|||||||
|
|
||||||
const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined)
|
const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined)
|
||||||
|
|
||||||
|
const ariaLabel = isEmpty
|
||||||
|
? t("chart.radarAriaLabelEmpty")
|
||||||
|
: t("chart.radarAriaLabelNonEmpty", {
|
||||||
|
count: data.length,
|
||||||
|
withClassAverage: hasClassAverage ? t("chart.withClassAverage") : "",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCardShell
|
<ChartCardShell
|
||||||
title="Knowledge Point Mastery"
|
title={t("chart.radarTitle")}
|
||||||
description="Radar chart of mastery level (0-100) across knowledge points."
|
description={t("chart.radarDescriptionNonEmpty")}
|
||||||
icon={Target}
|
icon={Target}
|
||||||
isEmpty={isEmpty}
|
isEmpty={isEmpty}
|
||||||
emptyTitle="No mastery data"
|
emptyTitle={t("chart.radarEmptyTitle")}
|
||||||
emptyDescription="No knowledge point mastery records found for this student."
|
emptyDescription={t("chart.noMasteryDataForStudent")}
|
||||||
emptyClassName="h-60"
|
emptyClassName="h-60"
|
||||||
>
|
>
|
||||||
<div role="img" aria-label={`知识点掌握度雷达图:${isEmpty ? "暂无数据" : `共 ${data.length} 个知识点的掌握度${hasClassAverage ? "(含班级平均对比)" : ""}`}`}>
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
tabIndex={0}
|
||||||
|
className="rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring"
|
||||||
|
>
|
||||||
<ComparisonRadarChart
|
<ComparisonRadarChart
|
||||||
data={chartData}
|
data={chartData}
|
||||||
angleKey="shortName"
|
angleKey="shortName"
|
||||||
@@ -48,7 +62,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
|||||||
series={[
|
series={[
|
||||||
{
|
{
|
||||||
dataKey: "student",
|
dataKey: "student",
|
||||||
name: "Student",
|
name: t("chart.studentSeries"),
|
||||||
color: "hsl(var(--primary))",
|
color: "hsl(var(--primary))",
|
||||||
fillOpacity: 0.35,
|
fillOpacity: 0.35,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
@@ -56,7 +70,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
dataKey: "classAverage",
|
dataKey: "classAverage",
|
||||||
name: "Class Avg",
|
name: t("chart.classAvgSeries"),
|
||||||
color: "hsl(var(--chart-2))",
|
color: "hsl(var(--chart-2))",
|
||||||
fillOpacity: 0.15,
|
fillOpacity: 0.15,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { useCallback } from "react"
|
import { useCallback } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { FileText, Trash2, Send } from "lucide-react"
|
import { FileText, Trash2, Send, Download, Share2, Copy } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -33,17 +34,18 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/shared/components/ui/dialog"
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import { usePermission } from "@/shared/hooks"
|
import { usePermission } from "@/shared/hooks"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { publishReportAction, deleteReportAction } from "../actions"
|
import { publishReportAction, deleteReportAction, exportDiagnosticReportAction } from "../actions"
|
||||||
import type { DiagnosticReportWithDetails } from "../types"
|
import type { DiagnosticReportWithDetails } from "../types"
|
||||||
|
import {
|
||||||
const typeLabels: Record<string, string> = {
|
getConfidenceLevel,
|
||||||
individual: "Individual",
|
confidenceBadgeVariant,
|
||||||
class: "Class",
|
type ConfidenceLevel,
|
||||||
grade: "Grade",
|
} from "./confidence-utils"
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||||
draft: "secondary",
|
draft: "secondary",
|
||||||
@@ -59,10 +61,12 @@ export function ReportList({ reports }: ReportListProps) {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const { hasPermission } = usePermission()
|
const { hasPermission } = usePermission()
|
||||||
|
const t = useTranslations("diagnostic")
|
||||||
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||||
|
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const [publishId, setPublishId] = useState<string | null>(null)
|
const [publishId, setPublishId] = useState<string | null>(null)
|
||||||
|
const [shareId, setShareId] = useState<string | null>(null)
|
||||||
const [isBusy, setIsBusy] = useState(false)
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
|
|
||||||
const updateParam = useCallback(
|
const updateParam = useCallback(
|
||||||
@@ -90,7 +94,7 @@ export function ReportList({ reports }: ReportListProps) {
|
|||||||
setPublishId(null)
|
setPublishId(null)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to publish")
|
toast.error(result.message || t("error.publishFailed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,42 +110,133 @@ export function ReportList({ reports }: ReportListProps) {
|
|||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to delete")
|
toast.error(result.message || t("error.deleteFailed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v3-P2-4: 导出诊断报告为 Excel。
|
||||||
|
* 调用 server action 获取 base64 buffer,前端转 Blob 下载。
|
||||||
|
*/
|
||||||
|
const handleExport = async (reportId: string) => {
|
||||||
|
setIsBusy(true)
|
||||||
|
try {
|
||||||
|
const result = await exportDiagnosticReportAction(reportId)
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
toast.error(result.message || t("error.exportFailed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// base64 -> Blob -> 下载
|
||||||
|
const binaryString = atob(result.data.buffer)
|
||||||
|
const bytes = new Uint8Array(binaryString.length)
|
||||||
|
for (let i = 0; i < binaryString.length; i += 1) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = url
|
||||||
|
a.download = result.data.filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success(t("reportList.exportSuccess"))
|
||||||
|
} catch {
|
||||||
|
toast.error(t("error.exportFailed"))
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3-P3-8: 复制报告分享链接到剪贴板
|
||||||
|
const handleCopyLink = async (): Promise<void> => {
|
||||||
|
if (!shareId) return
|
||||||
|
const url = `${window.location.origin}/teacher/diagnostic/reports/${shareId}`
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
toast.success(t("reportList.copyLinkSuccess"))
|
||||||
|
} catch {
|
||||||
|
toast.error(t("reportList.copyLinkFailed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4-P3-7: 置信度标签与提示
|
||||||
|
const confidenceLabel = (level: ConfidenceLevel): string => {
|
||||||
|
if (level === "high") return t("reportList.confidenceHigh")
|
||||||
|
if (level === "medium") return t("reportList.confidenceMedium")
|
||||||
|
if (level === "low") return t("reportList.confidenceLow")
|
||||||
|
return t("reportList.confidenceInsufficient")
|
||||||
|
}
|
||||||
|
|
||||||
|
const confidenceHint = (level: ConfidenceLevel): string => {
|
||||||
|
if (level === "high") return t("reportList.confidenceHighHint")
|
||||||
|
if (level === "medium") return t("reportList.confidenceMediumHint")
|
||||||
|
if (level === "low") return t("reportList.confidenceLowHint")
|
||||||
|
return t("reportList.confidenceInsufficient")
|
||||||
|
}
|
||||||
|
|
||||||
const reportType = searchParams.get("reportType") ?? "all"
|
const reportType = searchParams.get("reportType") ?? "all"
|
||||||
const status = searchParams.get("status") ?? "all"
|
const status = searchParams.get("status") ?? "all"
|
||||||
|
|
||||||
|
const typeLabel = (reportType: string): string => {
|
||||||
|
if (reportType === "individual") return t("type.individual")
|
||||||
|
if (reportType === "class") return t("type.class")
|
||||||
|
if (reportType === "grade") return t("type.grade")
|
||||||
|
return reportType
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = (status: string): string => {
|
||||||
|
if (status === "draft") return t("status.draft")
|
||||||
|
if (status === "published") return t("status.published")
|
||||||
|
if (status === "archived") return t("status.archived")
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
const studentTargetDisplay = (r: DiagnosticReportWithDetails): string => {
|
||||||
|
if (r.studentName) return r.studentName
|
||||||
|
if (r.reportType === "class") return t("reportList.classReportPlaceholder")
|
||||||
|
if (r.reportType === "grade") return t("reportList.gradeReportPlaceholder")
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3-P3-8: 当前分享的报告及链接
|
||||||
|
const sharedReport = shareId ? reports.find((r) => r.id === shareId) ?? null : null
|
||||||
|
const shareUrl = typeof window !== "undefined" && sharedReport
|
||||||
|
? `${window.location.origin}/teacher/diagnostic/reports/${sharedReport.id}`
|
||||||
|
: ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 过滤器 */}
|
{/* 过滤器 */}
|
||||||
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-xs">Report Type</Label>
|
<Label htmlFor="filter-report-type" className="text-xs">{t("filters.reportType")}</Label>
|
||||||
<Select value={reportType} onValueChange={(v) => updateParam("reportType", v)}>
|
<Select value={reportType} onValueChange={(v) => updateParam("reportType", v)}>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger id="filter-report-type" className="h-9">
|
||||||
<SelectValue placeholder="All types" />
|
<SelectValue placeholder={t("filters.allTypes")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All types</SelectItem>
|
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
|
||||||
<SelectItem value="individual">Individual</SelectItem>
|
<SelectItem value="individual">{t("type.individual")}</SelectItem>
|
||||||
<SelectItem value="class">Class</SelectItem>
|
<SelectItem value="class">{t("type.class")}</SelectItem>
|
||||||
<SelectItem value="grade">Grade</SelectItem>
|
<SelectItem value="grade">{t("type.grade")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label className="text-xs">Status</Label>
|
<Label htmlFor="filter-report-status" className="text-xs">{t("filters.status")}</Label>
|
||||||
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
|
||||||
<SelectTrigger className="h-9">
|
<SelectTrigger id="filter-report-status" className="h-9">
|
||||||
<SelectValue placeholder="All statuses" />
|
<SelectValue placeholder={t("filters.allStatuses")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
<SelectItem value="draft">{t("status.draft")}</SelectItem>
|
||||||
<SelectItem value="published">Published</SelectItem>
|
<SelectItem value="published">{t("status.published")}</SelectItem>
|
||||||
<SelectItem value="archived">Archived</SelectItem>
|
<SelectItem value="archived">{t("status.archived")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,71 +244,118 @@ export function ReportList({ reports }: ReportListProps) {
|
|||||||
|
|
||||||
{reports.length === 0 ? (
|
{reports.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No diagnostic reports"
|
title={t("empty.noReports")}
|
||||||
description="Generate diagnostic reports to see them here."
|
description={t("reportList.noReportsDescription")}
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border bg-card">
|
<div className="rounded-md border bg-card">
|
||||||
<Table>
|
<Table>
|
||||||
<caption className="sr-only">学情诊断报告列表</caption>
|
<caption className="sr-only">{t("reportList.caption")}</caption>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>{t("reportList.typeColumn")}</TableHead>
|
||||||
<TableHead>Student / Target</TableHead>
|
<TableHead>{t("reportList.studentTargetColumn")}</TableHead>
|
||||||
<TableHead>Period</TableHead>
|
<TableHead>{t("reportList.periodColumn")}</TableHead>
|
||||||
<TableHead className="text-right">Score</TableHead>
|
<TableHead className="text-right">{t("reportList.scoreColumn")}</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>{t("reportList.confidenceColumn")}</TableHead>
|
||||||
<TableHead>Generated By</TableHead>
|
<TableHead>{t("reportList.statusColumn")}</TableHead>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>{t("reportList.generatedByColumn")}</TableHead>
|
||||||
{canManage ? <TableHead className="w-24">Actions</TableHead> : null}
|
<TableHead>{t("reportList.dateColumn")}</TableHead>
|
||||||
|
<TableHead className="w-40">{t("reportList.actionsColumn")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{reports.map((r) => (
|
{reports.map((r) => (
|
||||||
<TableRow key={r.id}>
|
<TableRow key={r.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge>
|
<Badge variant="outline">{typeLabel(r.reportType)}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{r.studentName ?? (r.reportType === "class" ? "(班级报告)" : r.reportType === "grade" ? "(年级报告)" : "-")}</TableCell>
|
<TableCell className="font-medium">{studentTargetDisplay(r)}</TableCell>
|
||||||
<TableCell>{r.period ?? "-"}</TableCell>
|
<TableCell>{r.period ?? "-"}</TableCell>
|
||||||
<TableCell className="text-right font-mono">
|
<TableCell className="text-right font-mono">
|
||||||
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
|
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={statusColors[r.status] ?? "secondary"}>{r.status}</Badge>
|
{(() => {
|
||||||
|
const level = getConfidenceLevel(r)
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant={confidenceBadgeVariant[level]}
|
||||||
|
aria-label={t("reportList.confidenceAriaLabel", { level: confidenceLabel(level) })}
|
||||||
|
>
|
||||||
|
{confidenceLabel(level)}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{confidenceHint(level)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusColors[r.status] ?? "secondary"}>{statusLabel(r.status)}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{r.generatedByName ?? "-"}</TableCell>
|
<TableCell className="text-muted-foreground">{r.generatedByName ?? "-"}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
|
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
|
||||||
{canManage ? (
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-green-600"
|
className="h-8 w-8 text-green-600"
|
||||||
onClick={() => setPublishId(r.id)}
|
onClick={() => setPublishId(r.id)}
|
||||||
title="Publish"
|
disabled={isBusy}
|
||||||
aria-label={`发布报告 ${r.studentName}`}
|
title={t("report.publish")}
|
||||||
|
aria-label={t("reportList.publishAriaLabel", { studentName: r.studentName ?? "" })}
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canManage ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-destructive"
|
className="h-8 w-8 text-destructive"
|
||||||
onClick={() => setDeleteId(r.id)}
|
onClick={() => setDeleteId(r.id)}
|
||||||
title="Delete"
|
disabled={isBusy}
|
||||||
aria-label={`删除报告 ${r.studentName}`}
|
title={t("report.delete")}
|
||||||
|
aria-label={t("reportList.deleteAriaLabel", { studentName: r.studentName ?? "" })}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
) : null}
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -225,17 +367,17 @@ export function ReportList({ reports }: ReportListProps) {
|
|||||||
<Dialog open={publishId !== null} onOpenChange={(open) => !open && setPublishId(null)}>
|
<Dialog open={publishId !== null} onOpenChange={(open) => !open && setPublishId(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Publish Report</DialogTitle>
|
<DialogTitle>{t("report.publishTitle")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Once published, the report will be visible to students. Continue?
|
{t("reportList.publishConfirmation")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setPublishId(null)} disabled={isBusy}>
|
<Button variant="outline" onClick={() => setPublishId(null)} disabled={isBusy}>
|
||||||
Cancel
|
{t("report.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handlePublish} disabled={isBusy}>
|
<Button onClick={handlePublish} disabled={isBusy}>
|
||||||
{isBusy ? "Publishing..." : "Publish"}
|
{isBusy ? t("report.publishing") : t("report.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -245,17 +387,55 @@ export function ReportList({ reports }: ReportListProps) {
|
|||||||
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Report</DialogTitle>
|
<DialogTitle>{t("report.deleteTitle")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete this diagnostic report? This action cannot be undone.
|
{t("report.deleteConfirmation")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isBusy}>
|
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isBusy}>
|
||||||
Cancel
|
{t("report.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete} disabled={isBusy}>
|
<Button variant="destructive" onClick={handleDelete} disabled={isBusy}>
|
||||||
{isBusy ? "Deleting..." : "Delete"}
|
{isBusy ? t("report.deleting") : t("report.delete")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* v3-P3-8: 分享报告 */}
|
||||||
|
<Dialog open={shareId !== null} onOpenChange={(open) => !open && setShareId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("reportList.shareTitle")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("reportList.shareDescription")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sharedReport?.summary ? (
|
||||||
|
<div className="rounded-md border bg-muted/50 p-3">
|
||||||
|
<p className="text-sm">{sharedReport.summary}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="share-link" className="text-xs">{t("reportList.shareLinkLabel")}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="share-link"
|
||||||
|
readOnly
|
||||||
|
value={shareUrl}
|
||||||
|
aria-label={t("reportList.shareLinkAriaLabel")}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleCopyLink} className="shrink-0">
|
||||||
|
<Copy className="mr-1 h-4 w-4" />
|
||||||
|
{t("reportList.copyLink")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShareId(null)}>
|
||||||
|
{t("report.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,28 +1,50 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Award, AlertTriangle, Lightbulb, FileText, History, ArrowRight } from "lucide-react"
|
import { Award, AlertTriangle, Lightbulb, FileText, History, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import { MasteryRadarChart } from "./mastery-radar-chart"
|
import { MasteryRadarChart } from "./mastery-radar-chart"
|
||||||
|
import {
|
||||||
|
getConfidenceLevel,
|
||||||
|
confidenceBadgeVariant,
|
||||||
|
type ConfidenceLevel,
|
||||||
|
} from "./confidence-utils"
|
||||||
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
|
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
|
||||||
|
|
||||||
interface StudentDiagnosticViewProps {
|
interface StudentDiagnosticViewProps {
|
||||||
summary: StudentMasterySummary | null
|
summary: StudentMasterySummary | null
|
||||||
reports: DiagnosticReportWithDetails[]
|
reports: DiagnosticReportWithDetails[]
|
||||||
classAverageMastery?: MasteryRadarPoint[]
|
classAverageMastery?: MasteryRadarPoint[]
|
||||||
|
/**
|
||||||
|
* v3-P2-6: "练习"按钮的跳转基础路径。
|
||||||
|
* - 学生视角:默认 `/student/learning/assignments`
|
||||||
|
* - 教师视角:传入 `/teacher/questions`(题目库支持 kp 查询参数筛选)
|
||||||
|
* - 家长视角:传入 `null` 隐藏练习按钮(家长无练习入口)
|
||||||
|
* 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。
|
||||||
|
*/
|
||||||
|
practiceHrefBase?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) {
|
export function StudentDiagnosticView({
|
||||||
|
summary,
|
||||||
|
reports,
|
||||||
|
classAverageMastery,
|
||||||
|
practiceHrefBase = "/student/learning/assignments",
|
||||||
|
}: StudentDiagnosticViewProps) {
|
||||||
|
const t = useTranslations("diagnostic")
|
||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No diagnostic data"
|
title={t("empty.noData")}
|
||||||
description="Unable to load student mastery data."
|
description={t("studentDiagnostic.noDataDescription")}
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
@@ -39,7 +61,38 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
})
|
})
|
||||||
|
|
||||||
const publishedReports = reports.filter((r) => r.status === "published")
|
const publishedReports = reports.filter((r) => r.status === "published")
|
||||||
const latestReport = publishedReports[0] ?? reports[0] ?? null
|
// v4-P1-3: 移除草稿回退逻辑,仅展示已发布报告
|
||||||
|
// 调用方(学生/家长页面)已传 status: "published" 过滤,此处双重保障
|
||||||
|
const latestReport = publishedReports[0] ?? null
|
||||||
|
|
||||||
|
const statusLabel = (status: string): string => {
|
||||||
|
if (status === "draft") return t("status.draft")
|
||||||
|
if (status === "published") return t("status.published")
|
||||||
|
if (status === "archived") return t("status.archived")
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = (reportType: string): string => {
|
||||||
|
if (reportType === "individual") return t("type.individual")
|
||||||
|
if (reportType === "class") return t("type.class")
|
||||||
|
if (reportType === "grade") return t("type.grade")
|
||||||
|
return reportType
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4-P3-7: 置信度标签与提示
|
||||||
|
const confidenceLabel = (level: ConfidenceLevel): string => {
|
||||||
|
if (level === "high") return t("reportList.confidenceHigh")
|
||||||
|
if (level === "medium") return t("reportList.confidenceMedium")
|
||||||
|
if (level === "low") return t("reportList.confidenceLow")
|
||||||
|
return t("reportList.confidenceInsufficient")
|
||||||
|
}
|
||||||
|
|
||||||
|
const confidenceHint = (level: ConfidenceLevel): string => {
|
||||||
|
if (level === "high") return t("reportList.confidenceHighHint")
|
||||||
|
if (level === "medium") return t("reportList.confidenceMediumHint")
|
||||||
|
if (level === "low") return t("reportList.confidenceLowHint")
|
||||||
|
return t("reportList.confidenceInsufficient")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -47,7 +100,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.student")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold">{summary.studentName}</p>
|
<p className="text-2xl font-bold">{summary.studentName}</p>
|
||||||
@@ -55,7 +108,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Overall Mastery</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.overallMastery")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||||
@@ -63,7 +116,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Strengths</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.strengths")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold text-green-600">{summary.strengths.length}</p>
|
<p className="text-2xl font-bold text-green-600">{summary.strengths.length}</p>
|
||||||
@@ -71,7 +124,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Weaknesses</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.weaknesses")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-2xl font-bold text-red-600">{summary.weaknesses.length}</p>
|
<p className="text-2xl font-bold text-red-600">{summary.weaknesses.length}</p>
|
||||||
@@ -88,15 +141,15 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Award className="h-4 w-4 text-green-600" />
|
<Award className="h-4 w-4 text-green-600" />
|
||||||
Strengths (≥80%)
|
{t("strengths.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Knowledge points with high mastery.</CardDescription>
|
<CardDescription>{t("studentDiagnostic.strengthsDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{summary.strengths.length === 0 ? (
|
{summary.strengths.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No strengths identified yet.</p>
|
<p className="text-sm text-muted-foreground">{t("studentDiagnostic.noStrengths")}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2" role="list" aria-label="优势知识点列表">
|
<ul className="space-y-2" role="list" aria-label={t("studentDiagnostic.strengthsListAriaLabel")}>
|
||||||
{summary.strengths.map((m) => (
|
{summary.strengths.map((m) => (
|
||||||
<li key={m.knowledgePointId} className="flex items-center justify-between">
|
<li key={m.knowledgePointId} className="flex items-center justify-between">
|
||||||
<span className="text-sm">{m.knowledgePointName}</span>
|
<span className="text-sm">{m.knowledgePointName}</span>
|
||||||
@@ -111,27 +164,29 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||||
Weaknesses (<60%)
|
{t("weaknesses.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Knowledge points needing attention.</CardDescription>
|
<CardDescription>{t("studentDiagnostic.weaknessesDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{summary.weaknesses.length === 0 ? (
|
{summary.weaknesses.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No weaknesses identified.</p>
|
<p className="text-sm text-muted-foreground">{t("studentDiagnostic.noWeaknesses")}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2" role="list" aria-label="薄弱知识点列表">
|
<ul className="space-y-2" role="list" aria-label={t("studentDiagnostic.weaknessesListAriaLabel")}>
|
||||||
{summary.weaknesses.map((m) => (
|
{summary.weaknesses.map((m) => (
|
||||||
<li key={m.knowledgePointId} className="flex items-center justify-between gap-2">
|
<li key={m.knowledgePointId} className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm truncate">{m.knowledgePointName}</span>
|
<span className="text-sm truncate">{m.knowledgePointName}</span>
|
||||||
<Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge>
|
<Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs">
|
{practiceHrefBase ? (
|
||||||
<Link href="/student/learning/assignments">
|
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs" aria-label={t("studentDiagnostic.practiceAriaLabel", { name: m.knowledgePointName })}>
|
||||||
Practice
|
<Link href={`${practiceHrefBase}?kp=${m.knowledgePointId}`}>
|
||||||
|
{t("weaknesses.practice")}
|
||||||
<ArrowRight className="ml-1 h-3 w-3" />
|
<ArrowRight className="ml-1 h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -146,13 +201,32 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Lightbulb className="h-4 w-4" />
|
<Lightbulb className="h-4 w-4" />
|
||||||
Diagnostic Report
|
{t("studentDiagnostic.diagnosticReportTitle")}
|
||||||
<Badge variant={latestReport.status === "published" ? "default" : "secondary"}>
|
<Badge variant={latestReport.status === "published" ? "default" : "secondary"}>
|
||||||
{latestReport.status}
|
{statusLabel(latestReport.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{(() => {
|
||||||
|
const level = getConfidenceLevel(latestReport)
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant={confidenceBadgeVariant[level]}
|
||||||
|
aria-label={t("reportList.confidenceAriaLabel", { level: confidenceLabel(level) })}
|
||||||
|
>
|
||||||
|
{confidenceLabel(level)}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{confidenceHint(level)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Period: {latestReport.period ?? "-"} · Overall: {latestReport.overallScore?.toFixed(1) ?? "-"}%
|
{t("studentDiagnostic.reportMeta", {
|
||||||
|
period: latestReport.period ?? "-",
|
||||||
|
score: latestReport.overallScore?.toFixed(1) ?? "-",
|
||||||
|
})}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@@ -161,10 +235,10 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
) : null}
|
) : null}
|
||||||
{latestReport.recommendations && latestReport.recommendations.length > 0 ? (
|
{latestReport.recommendations && latestReport.recommendations.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-2 text-sm font-semibold">Recommendations</h4>
|
<h4 className="mb-2 text-sm font-semibold">{t("report.recommendations")}</h4>
|
||||||
<ul className="space-y-1.5" role="list" aria-label="学习建议列表">
|
<ul className="space-y-1.5" role="list" aria-label={t("studentDiagnostic.recommendationsListAriaLabel")}>
|
||||||
{latestReport.recommendations.map((rec, i) => (
|
{latestReport.recommendations.map((rec, i) => (
|
||||||
<li key={i} className="text-sm text-muted-foreground">• {rec}</li>
|
<li key={rec || `rec-${i}`} className="text-sm text-muted-foreground">• {rec}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,9 +253,9 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
Report History
|
{t("report.history")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Past diagnostic reports (newest first).</CardDescription>
|
<CardDescription>{t("studentDiagnostic.historyDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -193,15 +267,19 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{r.period ?? "Untitled period"}
|
{r.period ?? t("studentDiagnostic.untitledPeriod")}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{r.reportType}
|
{typeLabel(r.reportType)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formatDate(r.createdAt)}
|
{r.overallScore !== null
|
||||||
{r.overallScore !== null ? ` · Overall: ${r.overallScore.toFixed(1)}%` : ""}
|
? t("studentDiagnostic.historyReportMeta", {
|
||||||
|
date: formatDate(r.createdAt),
|
||||||
|
score: r.overallScore.toFixed(1),
|
||||||
|
})
|
||||||
|
: formatDate(r.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { and, desc, eq, type SQL } from "drizzle-orm"
|
import { and, count, desc, eq, inArray, type SQL } from "drizzle-orm"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { learningDiagnosticReports } from "@/shared/db/schema"
|
import { learningDiagnosticReports } from "@/shared/db/schema"
|
||||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||||
|
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
||||||
|
import { toNumber } from "@/modules/grades/lib/grade-utils"
|
||||||
|
import { BusinessError } from "@/shared/lib/action-utils"
|
||||||
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
|
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
|
||||||
|
import { buildClassReportContent, buildStudentReportContent } from "./stats-service"
|
||||||
import type {
|
import type {
|
||||||
DiagnosticReport,
|
DiagnosticReport,
|
||||||
|
DiagnosticReportListResult,
|
||||||
DiagnosticReportQueryParams,
|
DiagnosticReportQueryParams,
|
||||||
DiagnosticReportWithDetails,
|
DiagnosticReportWithDetails,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
const toNumber = (v: unknown): number => {
|
/**
|
||||||
const n = typeof v === "number" ? v : Number(v)
|
* 诊断报告业务错误(P3-27 修复:结构化错误码,避免直接暴露内部错误)。
|
||||||
return Number.isFinite(n) ? n : 0
|
* 继承 BusinessError 以便 handleActionError 安全地将 message 返回给客户端。
|
||||||
|
*/
|
||||||
|
export class DiagnosticReportError extends BusinessError {
|
||||||
|
constructor(
|
||||||
|
public readonly code:
|
||||||
|
| "STUDENT_NOT_FOUND"
|
||||||
|
| "NO_MASTERY_DATA"
|
||||||
|
| "CLASS_NOT_FOUND"
|
||||||
|
| "CLASS_NO_MASTERY_DATA",
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message, code)
|
||||||
|
this.name = "DiagnosticReportError"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStringArray = (v: unknown): v is string[] =>
|
const isStringArray = (v: unknown): v is string[] =>
|
||||||
@@ -29,6 +48,7 @@ const toStringArrayNullable = (v: unknown): string[] | null =>
|
|||||||
const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
|
const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
studentId: r.studentId,
|
studentId: r.studentId,
|
||||||
|
classId: r.classId,
|
||||||
generatedBy: r.generatedBy,
|
generatedBy: r.generatedBy,
|
||||||
reportType: r.reportType,
|
reportType: r.reportType,
|
||||||
period: r.period,
|
period: r.period,
|
||||||
@@ -49,19 +69,15 @@ export async function generateDiagnosticReport(
|
|||||||
generatedBy: string
|
generatedBy: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const summary = await getStudentMasterySummary(studentId)
|
const summary = await getStudentMasterySummary(studentId)
|
||||||
if (!summary) throw new Error("Student not found")
|
if (!summary) throw new DiagnosticReportError("STUDENT_NOT_FOUND", "学生不存在")
|
||||||
|
|
||||||
const overallScore = summary.averageMastery
|
// P2-6 修复:当学生存在但无任何掌握度数据时,拒绝生成误导性报告
|
||||||
const strengths = summary.strengths.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`)
|
if (summary.totalKnowledgePoints === 0) {
|
||||||
const weaknesses = summary.weaknesses.map((m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`)
|
throw new DiagnosticReportError("NO_MASTERY_DATA", "学生暂无掌握度数据,无法生成报告")
|
||||||
const recommendations = summary.weaknesses.map(
|
|
||||||
(m) => `建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。`
|
|
||||||
)
|
|
||||||
if (recommendations.length === 0) {
|
|
||||||
recommendations.push("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryText = `学生 ${summary.studentName} 在 ${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。`
|
const { summaryText, strengths, weaknesses, recommendations, overallScore } =
|
||||||
|
buildStudentReportContent(summary, period)
|
||||||
|
|
||||||
const id = createId()
|
const id = createId()
|
||||||
await db.insert(learningDiagnosticReports).values({
|
await db.insert(learningDiagnosticReports).values({
|
||||||
@@ -87,24 +103,15 @@ export async function generateClassDiagnosticReport(
|
|||||||
generatedBy: string
|
generatedBy: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const summary = await getClassMasterySummary(classId)
|
const summary = await getClassMasterySummary(classId)
|
||||||
if (!summary) throw new Error("Class not found")
|
if (!summary) throw new DiagnosticReportError("CLASS_NOT_FOUND", "班级不存在")
|
||||||
|
|
||||||
const topWeak = summary.knowledgePointStats
|
// P2-6 修复:当班级存在但无任何掌握度数据时,拒绝生成误导性报告
|
||||||
.filter((k) => k.averageMastery < 60)
|
if (summary.studentCount === 0 || summary.knowledgePointStats.length === 0) {
|
||||||
.sort((a, b) => a.averageMastery - b.averageMastery)
|
throw new DiagnosticReportError("CLASS_NO_MASTERY_DATA", "班级暂无掌握度数据,无法生成报告")
|
||||||
.slice(0, 5)
|
|
||||||
const strengths = summary.knowledgePointStats
|
|
||||||
.filter((k) => k.averageMastery >= 80)
|
|
||||||
.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
|
|
||||||
const weaknesses = topWeak.map((k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`)
|
|
||||||
const recommendations = topWeak.map(
|
|
||||||
(k) => `班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。`
|
|
||||||
)
|
|
||||||
if (recommendations.length === 0) {
|
|
||||||
recommendations.push("班级整体掌握情况良好,建议保持当前教学节奏。")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryText = `班级 ${summary.className} 在 ${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。`
|
const { summaryText, strengths, weaknesses, recommendations, overallScore } =
|
||||||
|
buildClassReportContent(summary, period)
|
||||||
|
|
||||||
const id = createId()
|
const id = createId()
|
||||||
await db.insert(learningDiagnosticReports).values({
|
await db.insert(learningDiagnosticReports).values({
|
||||||
@@ -117,26 +124,73 @@ export async function generateClassDiagnosticReport(
|
|||||||
strengths,
|
strengths,
|
||||||
weaknesses,
|
weaknesses,
|
||||||
recommendations,
|
recommendations,
|
||||||
overallScore: String(summary.averageMastery),
|
overallScore: String(overallScore),
|
||||||
status: "draft",
|
status: "draft",
|
||||||
})
|
})
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询诊断报告列表 */
|
/** 查询诊断报告列表(P3-15 修复:支持分页) */
|
||||||
export const getDiagnosticReports = cache(
|
export const getDiagnosticReports = cache(
|
||||||
async (filters: DiagnosticReportQueryParams): Promise<DiagnosticReportWithDetails[]> => {
|
async (
|
||||||
|
filters: DiagnosticReportQueryParams,
|
||||||
|
scope?: DataScope,
|
||||||
|
): Promise<DiagnosticReportListResult> => {
|
||||||
const conditions: SQL[] = []
|
const conditions: SQL[] = []
|
||||||
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
|
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
|
||||||
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
|
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
|
||||||
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
|
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
|
||||||
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
|
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
|
||||||
|
|
||||||
const rows = await db
|
// v4-P1-1: 应用 DataScope 行级权限过滤
|
||||||
|
// - 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 })
|
.select({ report: learningDiagnosticReports })
|
||||||
.from(learningDiagnosticReports)
|
.from(learningDiagnosticReports)
|
||||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
.where(whereClause)
|
||||||
.orderBy(desc(learningDiagnosticReports.createdAt))
|
.orderBy(desc(learningDiagnosticReports.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset),
|
||||||
|
])
|
||||||
|
|
||||||
|
const total = totalRows[0]?.total ?? 0
|
||||||
|
|
||||||
// 收集所有需要查询姓名的用户 ID(学生 + 生成者),通过 users data-access 统一获取
|
// 收集所有需要查询姓名的用户 ID(学生 + 生成者),通过 users data-access 统一获取
|
||||||
const userIds = new Set<string>()
|
const userIds = new Set<string>()
|
||||||
@@ -146,7 +200,7 @@ export const getDiagnosticReports = cache(
|
|||||||
}
|
}
|
||||||
const userMap = await getUserNamesByIds(Array.from(userIds))
|
const userMap = await getUserNamesByIds(Array.from(userIds))
|
||||||
|
|
||||||
return rows.map((r) => ({
|
const reports: DiagnosticReportWithDetails[] = rows.map((r) => ({
|
||||||
...serializeReport(r.report),
|
...serializeReport(r.report),
|
||||||
studentName: r.report.studentId
|
studentName: r.report.studentId
|
||||||
? userMap.get(r.report.studentId)?.name ?? "Unknown"
|
? userMap.get(r.report.studentId)?.name ?? "Unknown"
|
||||||
@@ -155,6 +209,8 @@ export const getDiagnosticReports = cache(
|
|||||||
? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
|
? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
|
||||||
: null,
|
: null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
return { reports, total }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,35 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { desc, eq, inArray } from "drizzle-orm"
|
import { and, desc, eq, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema"
|
import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema"
|
||||||
|
|
||||||
import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access"
|
import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access"
|
||||||
import { getExamSubmissionWithAnswers } from "@/modules/exams/data-access"
|
import { getExamSubmissionWithAnswers, getExamWithQuestionsForHomework } from "@/modules/exams/data-access"
|
||||||
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
|
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
|
||||||
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
||||||
|
|
||||||
|
import {
|
||||||
|
aggregateClassMastery,
|
||||||
|
buildClassMasterySummary,
|
||||||
|
buildStudentMasterySummary,
|
||||||
|
computeKpStats,
|
||||||
|
computeMasteryLevel,
|
||||||
|
serializeMasteryWithKp,
|
||||||
|
type RawClassMasteryRow,
|
||||||
|
type RawMasteryWithKpRow,
|
||||||
|
} from "./stats-service"
|
||||||
import type {
|
import type {
|
||||||
ClassMasterySummary,
|
ClassMasterySummary,
|
||||||
KnowledgePointMastery,
|
|
||||||
KnowledgePointStat,
|
KnowledgePointStat,
|
||||||
MasteryWithKnowledgePoint,
|
MasteryWithKnowledgePoint,
|
||||||
StudentMasterySummary,
|
StudentMasterySummary,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
const toNumber = (v: unknown): number => {
|
|
||||||
const n = typeof v === "number" ? v : Number(v)
|
|
||||||
return Number.isFinite(n) ? n : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const round2 = (n: number): number => Math.round(n * 100) / 100
|
|
||||||
|
|
||||||
const serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): KnowledgePointMastery => ({
|
|
||||||
id: r.id,
|
|
||||||
studentId: r.studentId,
|
|
||||||
knowledgePointId: r.knowledgePointId,
|
|
||||||
masteryLevel: toNumber(r.masteryLevel),
|
|
||||||
totalQuestions: r.totalQuestions,
|
|
||||||
correctQuestions: r.correctQuestions,
|
|
||||||
lastAssessedAt: r.lastAssessedAt.toISOString(),
|
|
||||||
createdAt: r.createdAt.toISOString(),
|
|
||||||
updatedAt: r.updatedAt.toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 获取学生在所有知识点的掌握度(含知识点名称) */
|
/** 获取学生在所有知识点的掌握度(含知识点名称) */
|
||||||
export const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
|
const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
mastery: knowledgePointMastery,
|
mastery: knowledgePointMastery,
|
||||||
@@ -51,45 +41,29 @@ export const getStudentMastery = cache(async (studentId: string): Promise<Master
|
|||||||
.where(eq(knowledgePointMastery.studentId, studentId))
|
.where(eq(knowledgePointMastery.studentId, studentId))
|
||||||
.orderBy(desc(knowledgePointMastery.masteryLevel))
|
.orderBy(desc(knowledgePointMastery.masteryLevel))
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) =>
|
||||||
...serializeMastery(r.mastery),
|
serializeMasteryWithKp({
|
||||||
knowledgePointName: r.kpName ?? "Unknown",
|
mastery: r.mastery,
|
||||||
knowledgePointDescription: r.kpDescription,
|
kpName: r.kpName,
|
||||||
}))
|
kpDescription: r.kpDescription,
|
||||||
|
} satisfies RawMasteryWithKpRow),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 获取学生掌握度摘要(含强项/弱项分析) */
|
/** 获取学生掌握度摘要(含强项/弱项分析) */
|
||||||
export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
|
export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
|
||||||
const userMap = await getUserNamesByIds([studentId])
|
// P3-18 修复:用户名查询与掌握度查询相互独立,并行执行
|
||||||
|
const [userMap, allMastery] = await Promise.all([
|
||||||
|
getUserNamesByIds([studentId]),
|
||||||
|
getStudentMastery(studentId),
|
||||||
|
])
|
||||||
const student = userMap.get(studentId)
|
const student = userMap.get(studentId)
|
||||||
if (!student) return null
|
if (!student) return null
|
||||||
|
|
||||||
const allMastery = await getStudentMastery(studentId)
|
return buildStudentMasterySummary(studentId, student.name ?? "Unknown", allMastery)
|
||||||
const averageMastery =
|
|
||||||
allMastery.length > 0
|
|
||||||
? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length)
|
|
||||||
: 0
|
|
||||||
|
|
||||||
// Single-pass classification: strengths (>=80) and weaknesses (<60)
|
|
||||||
const strengths: MasteryWithKnowledgePoint[] = []
|
|
||||||
const weaknesses: MasteryWithKnowledgePoint[] = []
|
|
||||||
for (const m of allMastery) {
|
|
||||||
if (m.masteryLevel >= 80) strengths.push(m)
|
|
||||||
if (m.masteryLevel < 60) weaknesses.push(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
studentId,
|
|
||||||
studentName: student.name ?? "Unknown",
|
|
||||||
averageMastery,
|
|
||||||
totalKnowledgePoints: allMastery.length,
|
|
||||||
strengths,
|
|
||||||
weaknesses,
|
|
||||||
allMastery,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 从提交答案更新掌握度(正确率作为掌握度) */
|
/** 从提交答案更新掌握度(累积模式:在历史基础上累加,正确率作为掌握度) */
|
||||||
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
|
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
|
||||||
const submission = await getExamSubmissionWithAnswers(submissionId)
|
const submission = await getExamSubmissionWithAnswers(submissionId)
|
||||||
if (!submission) return
|
if (!submission) return
|
||||||
@@ -115,31 +89,147 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 读取已有掌握度记录,累积计算(而非覆盖)
|
||||||
|
const existingRows = await db
|
||||||
|
.select()
|
||||||
|
.from(knowledgePointMastery)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(knowledgePointMastery.studentId, submission.studentId),
|
||||||
|
inArray(knowledgePointMastery.knowledgePointId, Array.from(kpStats.keys())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const existingByKp = new Map<string, { total: number; correct: number }>()
|
||||||
|
for (const row of existingRows) {
|
||||||
|
existingByKp.set(row.knowledgePointId, {
|
||||||
|
total: row.totalQuestions,
|
||||||
|
correct: row.correctQuestions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
// 使用事务保证多个 upsert 的原子性
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(kpStats.entries()).map(async ([kpId, stat]) => {
|
Array.from(kpStats.entries()).map(async ([kpId, stat]) => {
|
||||||
const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0
|
const existing = existingByKp.get(kpId)
|
||||||
await db
|
const totalQuestions = (existing?.total ?? 0) + stat.total
|
||||||
|
const correctQuestions = (existing?.correct ?? 0) + stat.correct
|
||||||
|
const masteryLevel = computeMasteryLevel(correctQuestions, totalQuestions)
|
||||||
|
await tx
|
||||||
.insert(knowledgePointMastery)
|
.insert(knowledgePointMastery)
|
||||||
.values({
|
.values({
|
||||||
studentId: submission.studentId,
|
studentId: submission.studentId,
|
||||||
knowledgePointId: kpId,
|
knowledgePointId: kpId,
|
||||||
masteryLevel: String(masteryLevel),
|
masteryLevel: String(masteryLevel),
|
||||||
totalQuestions: stat.total,
|
totalQuestions,
|
||||||
correctQuestions: stat.correct,
|
correctQuestions,
|
||||||
lastAssessedAt: now,
|
lastAssessedAt: now,
|
||||||
})
|
})
|
||||||
.onDuplicateKeyUpdate({
|
.onDuplicateKeyUpdate({
|
||||||
set: {
|
set: {
|
||||||
masteryLevel: String(masteryLevel),
|
masteryLevel: String(masteryLevel),
|
||||||
totalQuestions: stat.total,
|
totalQuestions,
|
||||||
correctQuestions: stat.correct,
|
correctQuestions,
|
||||||
lastAssessedAt: now,
|
lastAssessedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v3-P1-5:从手动录入的成绩更新掌握度。
|
||||||
|
*
|
||||||
|
* 当教师手动录入关联了 examId 的成绩时,根据得分率(score/fullScore)
|
||||||
|
* 更新该考试涉及的所有知识点的掌握度。采用累积模式,与 updateMasteryFromSubmission 一致。
|
||||||
|
*
|
||||||
|
* 注意:此函数假设学生在所有知识点上的掌握度等于整体得分率,
|
||||||
|
* 这是一种近似(无法区分知识点级别的强弱),适用于无题目级别答案的场景。
|
||||||
|
*/
|
||||||
|
export async function updateMasteryFromExamScore(
|
||||||
|
studentId: string,
|
||||||
|
examId: string,
|
||||||
|
score: number,
|
||||||
|
fullScore: number,
|
||||||
|
): Promise<void> {
|
||||||
|
if (fullScore <= 0) return
|
||||||
|
|
||||||
|
// 获取考试的所有题目
|
||||||
|
const examWithQuestions = await getExamWithQuestionsForHomework(examId)
|
||||||
|
if (!examWithQuestions || examWithQuestions.questions.length === 0) return
|
||||||
|
|
||||||
|
// 获取题目关联的知识点
|
||||||
|
const questionIds = examWithQuestions.questions.map((q) => q.questionId)
|
||||||
|
const kpMap = await getKnowledgePointsForQuestions(questionIds)
|
||||||
|
|
||||||
|
// 收集所有涉及的知识点 ID
|
||||||
|
const kpIds = new Set<string>()
|
||||||
|
for (const links of kpMap.values()) {
|
||||||
|
for (const link of links) {
|
||||||
|
kpIds.add(link.knowledgePointId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kpIds.size === 0) return
|
||||||
|
|
||||||
|
// 计算得分率作为掌握度
|
||||||
|
const masteryLevel = computeMasteryLevel(score, fullScore)
|
||||||
|
|
||||||
|
// 读取已有掌握度记录,累积计算
|
||||||
|
const existingRows = await db
|
||||||
|
.select()
|
||||||
|
.from(knowledgePointMastery)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(knowledgePointMastery.studentId, studentId),
|
||||||
|
inArray(knowledgePointMastery.knowledgePointId, Array.from(kpIds)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const existingByKp = new Map<string, { total: number; correct: number }>()
|
||||||
|
for (const row of existingRows) {
|
||||||
|
existingByKp.set(row.knowledgePointId, {
|
||||||
|
total: row.totalQuestions,
|
||||||
|
correct: row.correctQuestions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
// 使用事务保证多个 upsert 的原子性
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(kpIds).map(async (kpId) => {
|
||||||
|
const existing = existingByKp.get(kpId)
|
||||||
|
// 累积:将本次成绩视为 1 道题,得分率作为掌握度
|
||||||
|
const totalQuestions = (existing?.total ?? 0) + 1
|
||||||
|
const correctQuestions = (existing?.correct ?? 0) + Math.round(masteryLevel / 100)
|
||||||
|
const newMasteryLevel = computeMasteryLevel(correctQuestions, totalQuestions)
|
||||||
|
await tx
|
||||||
|
.insert(knowledgePointMastery)
|
||||||
|
.values({
|
||||||
|
studentId,
|
||||||
|
knowledgePointId: kpId,
|
||||||
|
masteryLevel: String(newMasteryLevel),
|
||||||
|
totalQuestions,
|
||||||
|
correctQuestions,
|
||||||
|
lastAssessedAt: now,
|
||||||
|
})
|
||||||
|
.onDuplicateKeyUpdate({
|
||||||
|
set: {
|
||||||
|
masteryLevel: String(newMasteryLevel),
|
||||||
|
totalQuestions,
|
||||||
|
correctQuestions,
|
||||||
|
lastAssessedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取班级掌握度摘要 */
|
/** 获取班级掌握度摘要 */
|
||||||
@@ -172,50 +262,16 @@ export const getClassMasterySummary = cache(async (classId: string): Promise<Cla
|
|||||||
.map((id) => ({ id, name: userMap.get(id)?.name ?? null }))
|
.map((id) => ({ id, name: userMap.get(id)?.name ?? null }))
|
||||||
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
|
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
|
||||||
|
|
||||||
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
|
const rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({
|
||||||
const byStudent = new Map<string, { levels: number[]; weakCount: number }>()
|
mastery: {
|
||||||
for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 })
|
studentId: r.mastery.studentId,
|
||||||
|
knowledgePointId: r.mastery.knowledgePointId,
|
||||||
for (const r of masteryRows) {
|
masteryLevel: r.mastery.masteryLevel,
|
||||||
const level = toNumber(r.mastery.masteryLevel)
|
},
|
||||||
const kpId = r.mastery.knowledgePointId
|
kpName: r.kpName,
|
||||||
const kpEntry = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
|
|
||||||
kpEntry.levels.push(level)
|
|
||||||
if (level >= 80) kpEntry.mastered += 1
|
|
||||||
if (level < 60) kpEntry.notMastered += 1
|
|
||||||
byKp.set(kpId, kpEntry)
|
|
||||||
|
|
||||||
const stuEntry = byStudent.get(r.mastery.studentId)
|
|
||||||
if (stuEntry) {
|
|
||||||
stuEntry.levels.push(level)
|
|
||||||
if (level < 60) stuEntry.weakCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const knowledgePointStats: KnowledgePointStat[] = Array.from(byKp.entries()).map(([kpId, e]) => ({
|
|
||||||
knowledgePointId: kpId,
|
|
||||||
knowledgePointName: e.name,
|
|
||||||
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
|
|
||||||
masteredCount: e.mastered,
|
|
||||||
notMasteredCount: e.notMastered,
|
|
||||||
totalStudents: students.length,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const allLevels = masteryRows.map((r) => toNumber(r.mastery.masteryLevel))
|
return buildClassMasterySummary(classId, className, students, rawRows)
|
||||||
const averageMastery = allLevels.length > 0 ? round2(allLevels.reduce((a, b) => a + b, 0) / allLevels.length) : 0
|
|
||||||
|
|
||||||
const studentsNeedingAttention = students
|
|
||||||
.map((s) => {
|
|
||||||
const e = byStudent.get(s.id)
|
|
||||||
if (!e) return null
|
|
||||||
const avg = e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0
|
|
||||||
return { studentId: s.id, studentName: s.name ?? "Unknown", averageMastery: avg, weakCount: e.weakCount }
|
|
||||||
})
|
|
||||||
.filter((s): s is { studentId: string; studentName: string; averageMastery: number; weakCount: number } => s !== null)
|
|
||||||
.filter((s) => s.averageMastery < 60)
|
|
||||||
.sort((a, b) => a.averageMastery - b.averageMastery)
|
|
||||||
|
|
||||||
return { classId, className, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 获取知识点统计(按班级或年级聚合) */
|
/** 获取知识点统计(按班级或年级聚合) */
|
||||||
@@ -235,23 +291,101 @@ export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: s
|
|||||||
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||||
.where(inArray(knowledgePointMastery.studentId, studentIds))
|
.where(inArray(knowledgePointMastery.studentId, studentIds))
|
||||||
|
|
||||||
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
|
const rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({
|
||||||
for (const r of masteryRows) {
|
mastery: {
|
||||||
const level = toNumber(r.mastery.masteryLevel)
|
studentId: r.mastery.studentId,
|
||||||
const kpId = r.mastery.knowledgePointId
|
knowledgePointId: r.mastery.knowledgePointId,
|
||||||
const e = byKp.get(kpId) ?? { name: r.kpName ?? "Unknown", levels: [], mastered: 0, notMastered: 0 }
|
masteryLevel: r.mastery.masteryLevel,
|
||||||
e.levels.push(level)
|
},
|
||||||
if (level >= 80) e.mastered += 1
|
kpName: r.kpName,
|
||||||
if (level < 60) e.notMastered += 1
|
}))
|
||||||
byKp.set(kpId, e)
|
|
||||||
|
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]) => ({
|
const result = studentIds.map((id) => {
|
||||||
knowledgePointId: kpId,
|
const info = userMap.get(id)
|
||||||
knowledgePointName: e.name,
|
const mastery = masteryByStudent.get(id)
|
||||||
averageMastery: e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0,
|
const masteryLevel = mastery?.masteryLevel ?? 0
|
||||||
masteredCount: e.mastered,
|
return {
|
||||||
notMasteredCount: e.notMastered,
|
studentId: id,
|
||||||
totalStudents: studentIds.length,
|
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 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
|
averageMastery: number
|
||||||
totalKnowledgePoints: number
|
totalKnowledgePoints: number
|
||||||
strengths: MasteryWithKnowledgePoint[] // 掌握度 >= 80
|
strengths: MasteryWithKnowledgePoint[] // 掌握度 >= 80
|
||||||
weaknesses: MasteryWithKnowledgePoint[] // 掌握度 < 60
|
weaknesses: MasteryWithKnowledgePoint[] // 掌握度 < 80(P3-16 修复:消除 60-79 盲区)
|
||||||
allMastery: MasteryWithKnowledgePoint[]
|
allMastery: MasteryWithKnowledgePoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +37,8 @@ export interface StudentMasterySummary {
|
|||||||
export interface DiagnosticReport {
|
export interface DiagnosticReport {
|
||||||
id: string
|
id: string
|
||||||
studentId: string | null
|
studentId: string | null
|
||||||
|
/** v4-P1-4: 班级报告关联的 classId(个人报告为 null) */
|
||||||
|
classId: string | null
|
||||||
generatedBy: string | null
|
generatedBy: string | null
|
||||||
reportType: DiagnosticReportType
|
reportType: DiagnosticReportType
|
||||||
period: string | null
|
period: string | null
|
||||||
@@ -87,6 +89,16 @@ export interface DiagnosticReportQueryParams {
|
|||||||
reportType?: DiagnosticReportType
|
reportType?: DiagnosticReportType
|
||||||
status?: DiagnosticReportStatus
|
status?: DiagnosticReportStatus
|
||||||
period?: string
|
period?: string
|
||||||
|
/** 分页:每页数量(默认 100) */
|
||||||
|
limit?: number
|
||||||
|
/** 分页:偏移量(默认 0) */
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询诊断报告结果(P3-15 修复:支持分页) */
|
||||||
|
export interface DiagnosticReportListResult {
|
||||||
|
reports: DiagnosticReportWithDetails[]
|
||||||
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 雷达图数据点 */
|
/** 雷达图数据点 */
|
||||||
|
|||||||
Reference in New Issue
Block a user