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