dashboard: - Add comparison-badge, dashboard-notification-widget, dashboard-responsive-layout, dashboard-time-range-filter - Add parent-dashboard components directory - Add config, hooks, and services directories diagnostic: - Add role-config and services directory elective: - Add elective-course-detail, elective-stats-cards, parent-selection-view components - Add data-access-settings and data-access-stats
303 lines
9.1 KiB
TypeScript
303 lines
9.1 KiB
TypeScript
"use server"
|
||
|
||
import { revalidatePath } from "next/cache"
|
||
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,
|
||
generateGradeDiagnosticReport,
|
||
publishDiagnosticReport,
|
||
deleteDiagnosticReport,
|
||
getDiagnosticReportById,
|
||
} from "./data-access-reports"
|
||
import { getClassStudentsByKnowledgePoint } from "./data-access"
|
||
import {
|
||
exportDiagnosticReportToExcel,
|
||
buildDiagnosticReportFilename,
|
||
} from "./export"
|
||
import {
|
||
GenerateStudentReportSchema,
|
||
GenerateClassReportSchema,
|
||
GenerateGradeReportSchema,
|
||
PublishReportSchema,
|
||
DeleteReportSchema,
|
||
} from "./schema"
|
||
|
||
/** 生成学生个人诊断报告 */
|
||
export async function generateStudentReportAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||
|
||
const parsed = GenerateStudentReportSchema.safeParse({
|
||
studentId: formData.get("studentId"),
|
||
period: formData.get("period"),
|
||
})
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Missing studentId or period" }
|
||
}
|
||
|
||
const { studentId, period } = parsed.data
|
||
const id = await generateDiagnosticReport(studentId, period, ctx.userId)
|
||
revalidatePath("/teacher/diagnostic")
|
||
revalidatePath(`/teacher/diagnostic/student/${studentId}`)
|
||
return { success: true, message: "Diagnostic report generated", data: id }
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
/** 生成班级诊断报告 */
|
||
export async function generateClassReportAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||
|
||
const parsed = GenerateClassReportSchema.safeParse({
|
||
classId: formData.get("classId"),
|
||
period: formData.get("period"),
|
||
})
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Missing classId or period" }
|
||
}
|
||
|
||
const { classId, period } = parsed.data
|
||
const id = await generateClassDiagnosticReport(classId, period, ctx.userId)
|
||
revalidatePath("/teacher/diagnostic")
|
||
revalidatePath(`/teacher/diagnostic/class/${classId}`)
|
||
return { success: true, message: "Class diagnostic report generated", data: id }
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
/** v4-P2-3: 生成年级诊断报告 */
|
||
export async function generateGradeReportAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||
|
||
const parsed = GenerateGradeReportSchema.safeParse({
|
||
gradeId: formData.get("gradeId"),
|
||
period: formData.get("period"),
|
||
})
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Missing gradeId or period" }
|
||
}
|
||
|
||
const { gradeId, period } = parsed.data
|
||
const id = await generateGradeDiagnosticReport(gradeId, period, ctx.userId)
|
||
revalidatePath("/teacher/diagnostic")
|
||
revalidatePath("/admin/diagnostic")
|
||
return { success: true, message: "Grade diagnostic report generated", data: id }
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
/** 发布诊断报告 */
|
||
export async function publishReportAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||
|
||
const parsed = PublishReportSchema.safeParse({
|
||
id: formData.get("id"),
|
||
})
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Missing report 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: "diagnostic",
|
||
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: "diagnostic",
|
||
title,
|
||
content: report.summary ?? "您的孩子有一份新的学情诊断报告,请查看详情。",
|
||
link: "/parent/diagnostic",
|
||
})
|
||
} catch {
|
||
// 单条通知失败不阻断整体流程
|
||
}
|
||
}
|
||
} catch {
|
||
// 家长查询失败不阻断整体流程
|
||
}
|
||
}
|
||
|
||
revalidatePath("/teacher/diagnostic")
|
||
return { success: true, message: "Report published" }
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
/** 删除诊断报告 */
|
||
export async function deleteReportAction(
|
||
prevState: ActionState<string> | null,
|
||
formData: FormData
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||
|
||
const parsed = DeleteReportSchema.safeParse({
|
||
id: formData.get("id"),
|
||
})
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Missing report id" }
|
||
}
|
||
|
||
await deleteDiagnosticReport(parsed.data.id)
|
||
revalidatePath("/teacher/diagnostic")
|
||
return { success: true, message: "Report deleted" }
|
||
} catch (e) {
|
||
return handleActionError(e)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
|
||
if (!reportId || typeof reportId !== "string") {
|
||
return { success: false, message: "Missing report id" }
|
||
}
|
||
|
||
const report = await getDiagnosticReportById(reportId)
|
||
if (!report) {
|
||
return { success: false, message: "Report not found" }
|
||
}
|
||
|
||
const buffer = await exportDiagnosticReportToExcel({ reportId })
|
||
const filename = await buildDiagnosticReportFilename(report.period)
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
buffer: buffer.toString("base64"),
|
||
filename,
|
||
},
|
||
}
|
||
} catch (e) {
|
||
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)
|
||
}
|
||
}
|