feat(diagnostic): add export, stats service, and confidence utils

- Add export module for diagnostic report data export

- Add stats-service for diagnostic analytics aggregation

- Add confidence-utils for diagnostic confidence score calculations
This commit is contained in:
SpecialX
2026-06-23 17:37:58 +08:00
parent 1abf58c0b6
commit 9ceb2b7b67
12 changed files with 1717 additions and 436 deletions

View File

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