feat(dashboard,diagnostic,elective): add widgets, layout, parent dashboard, role-config, services, elective components
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
This commit is contained in:
@@ -12,6 +12,7 @@ import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
|
||||
import {
|
||||
generateDiagnosticReport,
|
||||
generateClassDiagnosticReport,
|
||||
generateGradeDiagnosticReport,
|
||||
publishDiagnosticReport,
|
||||
deleteDiagnosticReport,
|
||||
getDiagnosticReportById,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import {
|
||||
GenerateStudentReportSchema,
|
||||
GenerateClassReportSchema,
|
||||
GenerateGradeReportSchema,
|
||||
PublishReportSchema,
|
||||
DeleteReportSchema,
|
||||
} from "./schema"
|
||||
@@ -80,6 +82,32 @@ export async function generateClassReportAction(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -126,7 +154,7 @@ export async function publishReportAction(
|
||||
try {
|
||||
await createNotification({
|
||||
userId: studentId,
|
||||
type: "grade",
|
||||
type: "diagnostic",
|
||||
title,
|
||||
content,
|
||||
link,
|
||||
@@ -144,7 +172,7 @@ export async function publishReportAction(
|
||||
try {
|
||||
await createNotification({
|
||||
userId: parentId,
|
||||
type: "grade",
|
||||
type: "diagnostic",
|
||||
title,
|
||||
content: report.summary ?? "您的孩子有一份新的学情诊断报告,请查看详情。",
|
||||
link: "/parent/diagnostic",
|
||||
@@ -208,7 +236,7 @@ export async function exportDiagnosticReportAction(
|
||||
}
|
||||
|
||||
const buffer = await exportDiagnosticReportToExcel({ reportId })
|
||||
const filename = buildDiagnosticReportFilename(report.period)
|
||||
const filename = await buildDiagnosticReportFilename(report.period)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
} from "@/shared/components/ui/table"
|
||||
import { usePermission } from "@/shared/hooks"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { generateClassReportAction, getClassStudentsByKnowledgePointAction } from "../actions"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import { useDiagnosticService } from "../services/diagnostic-service-context"
|
||||
import type { ClassMasterySummary } from "../types"
|
||||
|
||||
interface ClassDiagnosticViewProps {
|
||||
@@ -60,6 +61,8 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = usePermission()
|
||||
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
// v2-P1-4: 通过 Context 注入服务,不直接 import actions
|
||||
const service = useDiagnosticService()
|
||||
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
@@ -71,10 +74,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
const handleGenerate = async () => {
|
||||
if (!summary) return
|
||||
setIsGenerating(true)
|
||||
const formData = new FormData()
|
||||
formData.set("classId", summary.classId)
|
||||
formData.set("period", period)
|
||||
const result = await generateClassReportAction(null, formData)
|
||||
const result = await service.generateClassReport(summary.classId, period)
|
||||
setIsGenerating(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
@@ -86,7 +86,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
|
||||
/**
|
||||
* v3-P2-5: 按知识点筛选学生。
|
||||
* 选择知识点后调用 server action 获取该知识点上所有学生的掌握度。
|
||||
* 选择知识点后调用服务获取该知识点上所有学生的掌握度。
|
||||
*/
|
||||
const handleKpFilter = async (kpId: string) => {
|
||||
setSelectedKpId(kpId)
|
||||
@@ -96,10 +96,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
}
|
||||
setIsFiltering(true)
|
||||
try {
|
||||
const result = await getClassStudentsByKnowledgePointAction({
|
||||
classId: summary.classId,
|
||||
knowledgePointId: kpId,
|
||||
})
|
||||
const result = await service.getClassStudentsByKp(
|
||||
summary.classId,
|
||||
kpId,
|
||||
)
|
||||
if (result.success && result.data) {
|
||||
setFilteredStudents(result.data)
|
||||
} else {
|
||||
@@ -127,43 +127,46 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 概览 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.class")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.className}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.students")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.studentCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.avgMastery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.needAttention")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* v2-P1-6: 概览区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("summary.class")} skeletonHeight={140}>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.class")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.className}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.students")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.studentCount}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.avgMastery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{summary.averageMastery.toFixed(1)}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.needAttention")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{summary.studentsNeedingAttention.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 知识点掌握度热力图 */}
|
||||
{/* v2-P1-6: 知识点掌握度热力图区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("chart.heatmapTitle")} skeletonHeight={300}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -181,7 +184,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
<>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="img"
|
||||
role="group"
|
||||
aria-label={t("classDiagnostic.heatmapAriaLabel", { count: summary.knowledgePointStats.length })}
|
||||
>
|
||||
{summary.knowledgePointStats.map((kp) => {
|
||||
@@ -189,9 +192,16 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
return (
|
||||
<div
|
||||
key={kp.knowledgePointId}
|
||||
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
|
||||
tabIndex={0}
|
||||
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white outline-none transition focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${masteryColor(kp.averageMastery)}`}
|
||||
role="img"
|
||||
aria-label={`${kp.knowledgePointName}:${kp.averageMastery.toFixed(1)}%,${levelLabel},${kp.masteredCount}/${kp.totalStudents}`}
|
||||
aria-label={t("classDiagnostic.heatmapCellAriaLabel", {
|
||||
name: kp.knowledgePointName,
|
||||
level: kp.averageMastery.toFixed(1),
|
||||
label: levelLabel,
|
||||
mastered: kp.masteredCount,
|
||||
total: kp.totalStudents,
|
||||
})}
|
||||
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (${kp.masteredCount}/${kp.totalStudents})`}
|
||||
>
|
||||
<span className="max-w-32 truncate text-xs font-medium">
|
||||
@@ -230,8 +240,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* v3-P2-5: 按知识点筛选学生 */}
|
||||
{/* v2-P1-6: 按知识点筛选学生区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("classDiagnostic.filterByKpTitle")} skeletonHeight={200}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -316,8 +328,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 知识点排名表 */}
|
||||
{/* v2-P1-6: 知识点排名表区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("chart.rankingTitle")} skeletonHeight={240}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("chart.rankingTitle")}</CardTitle>
|
||||
@@ -360,8 +374,10 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 需重点关注的学生 */}
|
||||
{/* v2-P1-6: 需重点关注的学生区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("classDiagnostic.studentsNeedingAttentionTitle")} skeletonHeight={240}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -411,9 +427,11 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 生成班级报告 */}
|
||||
{/* v2-P1-6: 生成班级报告区块独立 Error Boundary */}
|
||||
{canManage ? (
|
||||
<WidgetBoundary title={t("report.generateClass")} skeletonHeight={160}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -442,6 +460,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* v4-P3-7: 诊断报告数据置信度工具。
|
||||
* v2-P1-5: 改进为基于知识点数量的多级置信度计算。
|
||||
*
|
||||
* 置信度等级用于指示报告基于的数据量是否充足,帮助教师判断报告可信度。
|
||||
* 提取到独立文件供 report-list 和 student-diagnostic-view 共享,避免重复定义。
|
||||
@@ -9,14 +10,39 @@ import type { DiagnosticReportWithDetails } from "../types"
|
||||
|
||||
export type ConfidenceLevel = "high" | "medium" | "low" | "insufficient"
|
||||
|
||||
/** 置信度阈值(基于知识点数量 = strengths.length + weaknesses.length) */
|
||||
const CONFIDENCE_INSUFFICIENT_MAX = 0
|
||||
const CONFIDENCE_LOW_MAX = 3
|
||||
const CONFIDENCE_MEDIUM_MAX = 8
|
||||
|
||||
/**
|
||||
* 根据报告数据计算置信度。
|
||||
* 简化方案:overallScore === null 表示无数据(insufficient),
|
||||
* 否则视为高置信度(high)。
|
||||
* 后续可扩展为基于 totalQuestions 等数据量字段的多级判断。
|
||||
* v2-P1-5: 根据报告数据计算置信度。
|
||||
*
|
||||
* 置信度基于报告中涉及的知识点数量(strengths + weaknesses 数组长度之和):
|
||||
* - 0 个知识点 → insufficient(数据不足)
|
||||
* - 1-3 个 → low(数据较少,结论仅供参考)
|
||||
* - 4-8 个 → medium(数据量一般,建议结合其他信息参考)
|
||||
* - >8 个 → high(数据充足,报告结论可靠)
|
||||
*
|
||||
* 若 overallScore 为 null 也视为 insufficient。
|
||||
*
|
||||
* @param report 诊断报告(含详情)
|
||||
* @param totalKnowledgePoints 可选:显式传入知识点总数(优先于数组长度推断)
|
||||
*/
|
||||
export function getConfidenceLevel(report: DiagnosticReportWithDetails): ConfidenceLevel {
|
||||
export function getConfidenceLevel(
|
||||
report: DiagnosticReportWithDetails,
|
||||
totalKnowledgePoints?: number,
|
||||
): ConfidenceLevel {
|
||||
if (report.overallScore === null) return "insufficient"
|
||||
|
||||
// 优先使用显式传入的知识点数;否则从 strengths + weaknesses 数组推断
|
||||
const kpCount =
|
||||
totalKnowledgePoints ??
|
||||
(report.strengths?.length ?? 0) + (report.weaknesses?.length ?? 0)
|
||||
|
||||
if (kpCount <= CONFIDENCE_INSUFFICIENT_MAX) return "insufficient"
|
||||
if (kpCount <= CONFIDENCE_LOW_MAX) return "low"
|
||||
if (kpCount <= CONFIDENCE_MEDIUM_MAX) return "medium"
|
||||
return "high"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,15 +15,17 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
const t = useTranslations("diagnostic")
|
||||
const isEmpty = !data || data.length === 0
|
||||
|
||||
// v2-P2-5: 保留完整 knowledgePoint 作为 angleKey,使 Tooltip 显示完整名称;
|
||||
// 通过 angleTickFormatter 截断轴上显示文本,避免长名称溢出图表区域。
|
||||
const MAX_AXIS_LABEL_LENGTH = 8
|
||||
const truncateAxisLabel = (value: string): string =>
|
||||
value.length > MAX_AXIS_LABEL_LENGTH
|
||||
? `${value.slice(0, MAX_AXIS_LABEL_LENGTH)}...`
|
||||
: value
|
||||
|
||||
const chartData = isEmpty
|
||||
? []
|
||||
: data.map((d) => ({
|
||||
...d,
|
||||
shortName:
|
||||
d.knowledgePoint.length > 8
|
||||
? `${d.knowledgePoint.slice(0, 8)}...`
|
||||
: d.knowledgePoint,
|
||||
}))
|
||||
: data.map((d) => ({ ...d }))
|
||||
|
||||
const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined)
|
||||
|
||||
@@ -52,7 +54,8 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="shortName"
|
||||
angleKey="knowledgePoint"
|
||||
angleTickFormatter={truncateAxisLabel}
|
||||
angleTickFontSize={11}
|
||||
domain={[0, 100]}
|
||||
tickCount={5}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { FileText, Trash2, Send, Download, Share2, Copy } from "lucide-react"
|
||||
import { FileText, Trash2, Send, Download } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -34,12 +34,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { usePermission } from "@/shared/hooks"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { publishReportAction, deleteReportAction, exportDiagnosticReportAction } from "../actions"
|
||||
import { useDiagnosticService } from "../services/diagnostic-service-context"
|
||||
import type { DiagnosticReportWithDetails } from "../types"
|
||||
import {
|
||||
getConfidenceLevel,
|
||||
@@ -63,10 +62,11 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const { hasPermission } = usePermission()
|
||||
const t = useTranslations("diagnostic")
|
||||
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
// v2-P1-4: 通过 Context 注入服务,不直接 import actions
|
||||
const service = useDiagnosticService()
|
||||
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [publishId, setPublishId] = useState<string | null>(null)
|
||||
const [shareId, setShareId] = useState<string | null>(null)
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
||||
const updateParam = useCallback(
|
||||
@@ -85,9 +85,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const handlePublish = async () => {
|
||||
if (!publishId) return
|
||||
setIsBusy(true)
|
||||
const formData = new FormData()
|
||||
formData.set("id", publishId)
|
||||
const result = await publishReportAction(null, formData)
|
||||
const result = await service.publishReport(publishId)
|
||||
setIsBusy(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
@@ -101,9 +99,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return
|
||||
setIsBusy(true)
|
||||
const formData = new FormData()
|
||||
formData.set("id", deleteId)
|
||||
const result = await deleteReportAction(null, formData)
|
||||
const result = await service.deleteReport(deleteId)
|
||||
setIsBusy(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
@@ -121,7 +117,7 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
const handleExport = async (reportId: string) => {
|
||||
setIsBusy(true)
|
||||
try {
|
||||
const result = await exportDiagnosticReportAction(reportId)
|
||||
const result = await service.exportReport(reportId)
|
||||
if (!result.success || !result.data) {
|
||||
toast.error(result.message || t("error.exportFailed"))
|
||||
return
|
||||
@@ -151,18 +147,6 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -202,12 +186,6 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
// v3-P3-8: 当前分享的报告及链接
|
||||
const sharedReport = shareId ? reports.find((r) => r.id === shareId) ?? null : null
|
||||
const shareUrl = typeof window !== "undefined" && sharedReport
|
||||
? `${window.location.origin}/teacher/diagnostic/reports/${sharedReport.id}`
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 过滤器 */}
|
||||
@@ -314,20 +292,6 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* v3-P3-8: 分享按钮(仅教师可见) */}
|
||||
{canManage ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setShareId(r.id)}
|
||||
disabled={isBusy}
|
||||
title={t("reportList.share")}
|
||||
aria-label={t("reportList.shareAriaLabel", { studentName: r.studentName ?? "" })}
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage && r.status === "draft" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -402,44 +366,6 @@ export function ReportList({ reports }: ReportListProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* v3-P3-8: 分享报告 */}
|
||||
<Dialog open={shareId !== null} onOpenChange={(open) => !open && setShareId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("reportList.shareTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("reportList.shareDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{sharedReport?.summary ? (
|
||||
<div className="rounded-md border bg-muted/50 p-3">
|
||||
<p className="text-sm">{sharedReport.summary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="share-link" className="text-xs">{t("reportList.shareLinkLabel")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="share-link"
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
aria-label={t("reportList.shareLinkAriaLabel")}
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button onClick={handleCopyLink} className="shrink-0">
|
||||
<Copy className="mr-1 h-4 w-4" />
|
||||
{t("reportList.copyLink")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShareId(null)}>
|
||||
{t("report.cancel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { MasteryRadarChart } from "./mastery-radar-chart"
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
confidenceBadgeVariant,
|
||||
type ConfidenceLevel,
|
||||
} from "./confidence-utils"
|
||||
import { getDiagnosticRoleConfig, type DiagnosticRole } from "../role-config"
|
||||
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
|
||||
|
||||
interface StudentDiagnosticViewProps {
|
||||
@@ -23,11 +25,13 @@ interface StudentDiagnosticViewProps {
|
||||
reports: DiagnosticReportWithDetails[]
|
||||
classAverageMastery?: MasteryRadarPoint[]
|
||||
/**
|
||||
* v3-P2-6: "练习"按钮的跳转基础路径。
|
||||
* - 学生视角:默认 `/student/learning/assignments`
|
||||
* - 教师视角:传入 `/teacher/questions`(题目库支持 kp 查询参数筛选)
|
||||
* - 家长视角:传入 `null` 隐藏练习按钮(家长无练习入口)
|
||||
* 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。
|
||||
* v4-P2-2: 角色配置驱动。
|
||||
* 组件内部根据 role 查找 DIAGNOSTIC_ROLE_CONFIG 获取 practiceHrefBase 等角色差异配置。
|
||||
* 新增角色只需在 role-config.ts 中添加配置项,无需修改组件 props。
|
||||
*/
|
||||
role?: DiagnosticRole
|
||||
/**
|
||||
* @deprecated v4-P2-2: 请改用 `role` prop。保留向后兼容,若同时传入则 role 优先。
|
||||
*/
|
||||
practiceHrefBase?: string | null
|
||||
}
|
||||
@@ -36,9 +40,12 @@ export function StudentDiagnosticView({
|
||||
summary,
|
||||
reports,
|
||||
classAverageMastery,
|
||||
practiceHrefBase = "/student/learning/assignments",
|
||||
role = "student",
|
||||
practiceHrefBase,
|
||||
}: StudentDiagnosticViewProps) {
|
||||
const t = useTranslations("diagnostic")
|
||||
// v4-P2-2: 角色配置驱动,role prop 优先于 deprecated practiceHrefBase
|
||||
const resolvedPracticeHrefBase = practiceHrefBase ?? getDiagnosticRoleConfig(role).practiceHrefBase
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
@@ -132,11 +139,14 @@ export function StudentDiagnosticView({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 雷达图 */}
|
||||
<MasteryRadarChart data={radarData} />
|
||||
{/* v2-P1-6: 雷达图区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("chart.radarTitle")} skeletonHeight={384}>
|
||||
<MasteryRadarChart data={radarData} />
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 强项 / 弱项 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* v2-P1-6: 强项 / 弱项区块独立 Error Boundary */}
|
||||
<WidgetBoundary title={t("strengths.title")} skeletonHeight={300}>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -179,9 +189,9 @@ export function StudentDiagnosticView({
|
||||
<span className="text-sm truncate">{m.knowledgePointName}</span>
|
||||
<Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge>
|
||||
</div>
|
||||
{practiceHrefBase ? (
|
||||
{resolvedPracticeHrefBase ? (
|
||||
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs" aria-label={t("studentDiagnostic.practiceAriaLabel", { name: m.knowledgePointName })}>
|
||||
<Link href={`${practiceHrefBase}?kp=${m.knowledgePointId}`}>
|
||||
<Link href={`${resolvedPracticeHrefBase}?kp=${m.knowledgePointId}`}>
|
||||
{t("weaknesses.practice")}
|
||||
<ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
@@ -194,9 +204,11 @@ export function StudentDiagnosticView({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 最新报告 / 建议 */}
|
||||
{/* v2-P1-6: 最新报告区块独立 Error Boundary */}
|
||||
{latestReport ? (
|
||||
<WidgetBoundary title={t("studentDiagnostic.diagnosticReportTitle")} skeletonHeight={200}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -245,10 +257,12 @@ export function StudentDiagnosticView({
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
|
||||
{/* 历史报告列表 */}
|
||||
{/* v2-P1-6: 历史报告区块独立 Error Boundary */}
|
||||
{publishedReports.length > 1 ? (
|
||||
<WidgetBoundary title={t("report.history")} skeletonHeight={200}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -287,6 +301,7 @@ export function StudentDiagnosticView({
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,17 +3,23 @@ import "server-only"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, inArray, type SQL } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { learningDiagnosticReports } from "@/shared/db/schema"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import { getUserNamesByIds, getUserIdsByGradeId } from "@/modules/users/data-access"
|
||||
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import { toNumber } from "@/modules/grades/lib/grade-utils"
|
||||
import { BusinessError } from "@/shared/lib/action-utils"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
|
||||
import { buildClassReportContent, buildStudentReportContent } from "./stats-service"
|
||||
import { getClassMasterySummary, getGradeMasterySummary, getStudentMasterySummary } from "./data-access"
|
||||
import {
|
||||
buildClassReportContent,
|
||||
buildGradeReportContent,
|
||||
buildStudentReportContent,
|
||||
type ReportContentTranslations,
|
||||
} from "./stats-service"
|
||||
import type {
|
||||
DiagnosticReport,
|
||||
DiagnosticReportListResult,
|
||||
@@ -21,6 +27,25 @@ import type {
|
||||
DiagnosticReportWithDetails,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Build report content translations from next-intl.
|
||||
* Keeps stats-service free of i18n framework dependencies.
|
||||
*/
|
||||
async function getReportContentTranslations(): Promise<ReportContentTranslations> {
|
||||
const t = await getTranslations("diagnostic.reportContent")
|
||||
return {
|
||||
studentSummary: (vars) => t("studentSummary", vars),
|
||||
studentRecommendation: (vars) => t("studentRecommendation", vars),
|
||||
studentNoWeakness: t("studentNoWeakness"),
|
||||
classSummary: (vars) => t("classSummary", vars),
|
||||
classRecommendation: (vars) => t("classRecommendation", vars),
|
||||
classNoWeakness: t("classNoWeakness"),
|
||||
gradeSummary: (vars) => t("gradeSummary", vars),
|
||||
gradeRecommendation: (vars) => t("gradeRecommendation", vars),
|
||||
gradeNoWeakness: t("gradeNoWeakness"),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断报告业务错误(P3-27 修复:结构化错误码,避免直接暴露内部错误)。
|
||||
* 继承 BusinessError 以便 handleActionError 安全地将 message 返回给客户端。
|
||||
@@ -31,7 +56,9 @@ export class DiagnosticReportError extends BusinessError {
|
||||
| "STUDENT_NOT_FOUND"
|
||||
| "NO_MASTERY_DATA"
|
||||
| "CLASS_NOT_FOUND"
|
||||
| "CLASS_NO_MASTERY_DATA",
|
||||
| "CLASS_NO_MASTERY_DATA"
|
||||
| "GRADE_NOT_FOUND"
|
||||
| "GRADE_NO_MASTERY_DATA",
|
||||
message: string,
|
||||
) {
|
||||
super(message, code)
|
||||
@@ -49,6 +76,7 @@ const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): Diag
|
||||
id: r.id,
|
||||
studentId: r.studentId,
|
||||
classId: r.classId,
|
||||
gradeId: r.gradeId,
|
||||
generatedBy: r.generatedBy,
|
||||
reportType: r.reportType,
|
||||
period: r.period,
|
||||
@@ -76,8 +104,9 @@ export async function generateDiagnosticReport(
|
||||
throw new DiagnosticReportError("NO_MASTERY_DATA", "学生暂无掌握度数据,无法生成报告")
|
||||
}
|
||||
|
||||
const translations = await getReportContentTranslations()
|
||||
const { summaryText, strengths, weaknesses, recommendations, overallScore } =
|
||||
buildStudentReportContent(summary, period)
|
||||
buildStudentReportContent(summary, period, translations)
|
||||
|
||||
const id = createId()
|
||||
await db.insert(learningDiagnosticReports).values({
|
||||
@@ -110,8 +139,9 @@ export async function generateClassDiagnosticReport(
|
||||
throw new DiagnosticReportError("CLASS_NO_MASTERY_DATA", "班级暂无掌握度数据,无法生成报告")
|
||||
}
|
||||
|
||||
const translations = await getReportContentTranslations()
|
||||
const { summaryText, strengths, weaknesses, recommendations, overallScore } =
|
||||
buildClassReportContent(summary, period)
|
||||
buildClassReportContent(summary, period, translations)
|
||||
|
||||
const id = createId()
|
||||
await db.insert(learningDiagnosticReports).values({
|
||||
@@ -130,6 +160,43 @@ export async function generateClassDiagnosticReport(
|
||||
return id
|
||||
}
|
||||
|
||||
/** v4-P2-3: 生成年级诊断报告 */
|
||||
export async function generateGradeDiagnosticReport(
|
||||
gradeId: string,
|
||||
period: string,
|
||||
generatedBy: string
|
||||
): Promise<string> {
|
||||
const summary = await getGradeMasterySummary(gradeId)
|
||||
if (!summary) throw new DiagnosticReportError("GRADE_NOT_FOUND", "年级不存在")
|
||||
|
||||
// 当年级存在但无任何掌握度数据时,拒绝生成误导性报告
|
||||
if (summary.studentCount === 0 || summary.knowledgePointStats.length === 0) {
|
||||
throw new DiagnosticReportError("GRADE_NO_MASTERY_DATA", "年级暂无掌握度数据,无法生成报告")
|
||||
}
|
||||
|
||||
const translations = await getReportContentTranslations()
|
||||
const { summaryText, strengths, weaknesses, recommendations, overallScore } =
|
||||
buildGradeReportContent(summary, period, translations)
|
||||
|
||||
const id = createId()
|
||||
await db.insert(learningDiagnosticReports).values({
|
||||
id,
|
||||
studentId: null,
|
||||
classId: null,
|
||||
gradeId,
|
||||
generatedBy,
|
||||
reportType: "grade",
|
||||
period,
|
||||
summary: summaryText,
|
||||
strengths,
|
||||
weaknesses,
|
||||
recommendations,
|
||||
overallScore: String(overallScore),
|
||||
status: "draft",
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
/** 查询诊断报告列表(P3-15 修复:支持分页) */
|
||||
export const getDiagnosticReports = cache(
|
||||
async (
|
||||
@@ -142,14 +209,14 @@ export const getDiagnosticReports = cache(
|
||||
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
|
||||
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
|
||||
|
||||
// v4-P1-1: 应用 DataScope 行级权限过滤
|
||||
// v4-P1-1 + v2-P1-1: 应用 DataScope 行级权限过滤
|
||||
// - class_taught: 仅返回所教班级学生的个人报告 + 班级报告(班级报告 studentId 为 null,需通过 classId 关联)
|
||||
// 由于当前 schema 班级报告 studentId=null,无法直接按 classId 过滤,因此对 class_taught scope:
|
||||
// 个人报告按所教班级学生 ID 过滤;班级报告(studentId=null)保留(教师可查看自己生成的班级报告)
|
||||
// - class_members: 学生角色,调用方已在 filters.studentId 中传入 ctx.userId,无需在此重复过滤
|
||||
// - class_members: 学生角色,调用方应在 filters.studentId 中传入 ctx.userId,此处兜底过滤
|
||||
// - children: 仅返回子女的报告
|
||||
// - grade_managed: 返回所辖年级所有学生的报告(通过 studentId IN 所辖年级学生)
|
||||
// - all: 不过滤
|
||||
// - grade_managed: v2-P1-1 修复,返回所辖年级所有学生的报告(通过 getUserIdsByGradeId 查询年级学生 ID)
|
||||
// - all: 不过滤(admin)
|
||||
if (scope) {
|
||||
if (scope.type === "children") {
|
||||
if (scope.childrenIds.length === 0) {
|
||||
@@ -167,8 +234,21 @@ export const getDiagnosticReports = cache(
|
||||
// 个人报告按学生 ID 过滤;班级报告(studentId=null)由 generatedBy 限制为当前教师
|
||||
// 这里简化:仅返回所教班级学生的个人报告
|
||||
conditions.push(inArray(learningDiagnosticReports.studentId, studentIds))
|
||||
} else if (scope.type === "grade_managed") {
|
||||
// v2-P1-1: 年级主任仅返回所辖年级学生的报告
|
||||
if (scope.gradeIds.length === 0) {
|
||||
return { reports: [], total: 0 }
|
||||
}
|
||||
const gradeStudentIds = (
|
||||
await Promise.all(scope.gradeIds.map((gid) => getUserIdsByGradeId(gid)))
|
||||
).flat()
|
||||
if (gradeStudentIds.length === 0) {
|
||||
return { reports: [], total: 0 }
|
||||
}
|
||||
conditions.push(inArray(learningDiagnosticReports.studentId, gradeStudentIds))
|
||||
}
|
||||
// grade_managed 和 all 不在此过滤(grade_managed 需要跨模块查询年级学生,由调用方自行过滤)
|
||||
// class_members scope: 调用方应在 filters.studentId 中传入 ctx.userId(学生页已正确传入)
|
||||
// owned 和 all 不在此过滤
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
@@ -11,10 +11,12 @@ import { getExamSubmissionWithAnswers, getExamWithQuestionsForHomework } from "@
|
||||
import { getHomeworkSubmissionWithAnswersForMastery } from "@/modules/homework/data-access-error-collection"
|
||||
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
|
||||
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import { getGradeNameById } from "@/modules/school/data-access"
|
||||
|
||||
import {
|
||||
aggregateClassMastery,
|
||||
buildClassMasterySummary,
|
||||
buildGradeMasterySummary,
|
||||
buildStudentMasterySummary,
|
||||
computeKpStats,
|
||||
computeMasteryLevel,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
} from "./stats-service"
|
||||
import type {
|
||||
ClassMasterySummary,
|
||||
GradeMasterySummary,
|
||||
KnowledgePointStat,
|
||||
MasteryWithKnowledgePoint,
|
||||
StudentMasterySummary,
|
||||
@@ -360,6 +363,45 @@ export const getClassMasterySummary = cache(async (classId: string): Promise<Cla
|
||||
return buildClassMasterySummary(classId, className, students, rawRows)
|
||||
})
|
||||
|
||||
/** v4-P2-3: 获取年级掌握度摘要 */
|
||||
export const getGradeMasterySummary = cache(async (gradeId: string): Promise<GradeMasterySummary | null> => {
|
||||
// 年级名称 与 学生列表 相互独立,并行拉取
|
||||
const [gradeNameResult, studentIds] = await Promise.all([
|
||||
getGradeNameById(gradeId),
|
||||
getUserIdsByGradeId(gradeId),
|
||||
])
|
||||
const gradeName = gradeNameResult ?? "Unknown"
|
||||
|
||||
if (studentIds.length === 0) {
|
||||
return { gradeId, gradeName, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
|
||||
}
|
||||
|
||||
// 学生姓名 与 掌握度记录 相互独立,并行拉取
|
||||
const [userMap, masteryRows] = await Promise.all([
|
||||
getUserNamesByIds(studentIds),
|
||||
db
|
||||
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
|
||||
.from(knowledgePointMastery)
|
||||
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||
.where(inArray(knowledgePointMastery.studentId, studentIds)),
|
||||
])
|
||||
|
||||
const students = studentIds
|
||||
.map((id) => ({ id, name: userMap.get(id)?.name ?? null }))
|
||||
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
|
||||
|
||||
const rawRows: RawClassMasteryRow[] = masteryRows.map((r) => ({
|
||||
mastery: {
|
||||
studentId: r.mastery.studentId,
|
||||
knowledgePointId: r.mastery.knowledgePointId,
|
||||
masteryLevel: r.mastery.masteryLevel,
|
||||
},
|
||||
kpName: r.kpName,
|
||||
}))
|
||||
|
||||
return buildGradeMasterySummary(gradeId, gradeName, students, rawRows)
|
||||
})
|
||||
|
||||
/** 获取知识点统计(按班级或年级聚合) */
|
||||
export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> => {
|
||||
let studentIds: string[] = []
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import "server-only"
|
||||
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { exportToExcel } from "@/shared/lib/excel"
|
||||
import { formatDateForFile } from "@/shared/lib/utils"
|
||||
import { BusinessError } from "@/shared/lib/action-utils"
|
||||
|
||||
import { getDiagnosticReportById } from "./data-access-reports"
|
||||
import { getStudentMasterySummary, getClassMasterySummary } from "./data-access"
|
||||
|
||||
/**
|
||||
* v2-P2-3: 导出报告不存在的结构化错误。
|
||||
*/
|
||||
export class DiagnosticExportError extends BusinessError {
|
||||
constructor(code: "REPORT_NOT_FOUND", message: string) {
|
||||
super(message, code)
|
||||
this.name = "DiagnosticExportError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v3-P2-4: 导出诊断报告为 Excel。
|
||||
*
|
||||
@@ -23,10 +36,13 @@ export async function exportDiagnosticReportToExcel(params: {
|
||||
}): Promise<Buffer> {
|
||||
const report = await getDiagnosticReportById(params.reportId)
|
||||
if (!report) {
|
||||
throw new Error("Report not found")
|
||||
// v2-P2-3: 使用结构化错误码,由调用方 i18n 化
|
||||
throw new DiagnosticExportError("REPORT_NOT_FOUND", "Report not found")
|
||||
}
|
||||
|
||||
const periodLabel = report.period ?? "本期"
|
||||
const t = await getTranslations("diagnostic")
|
||||
|
||||
const periodLabel = report.period ?? t("parent.selectChild")
|
||||
const overallScore = report.overallScore ?? "-"
|
||||
const strengths = (report.strengths ?? []).join("\n") || "-"
|
||||
const weaknesses = (report.weaknesses ?? []).join("\n") || "-"
|
||||
@@ -37,16 +53,16 @@ export async function exportDiagnosticReportToExcel(params: {
|
||||
// 个人报告
|
||||
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 },
|
||||
{ metric: t("exportContent.metricStudent"), value: report.studentName ?? "-" },
|
||||
{ metric: t("exportContent.metricPeriod"), value: periodLabel },
|
||||
{ metric: t("exportContent.metricScore"), value: overallScore },
|
||||
{ metric: t("exportContent.metricStatus"), value: report.status },
|
||||
{ metric: t("exportContent.metricGeneratedBy"), value: report.generatedByName ?? "-" },
|
||||
{ metric: t("exportContent.metricCreatedAt"), value: report.createdAt.split("T")[0] },
|
||||
{ metric: t("exportContent.metricSummary"), value: summary },
|
||||
{ metric: t("exportContent.metricStrengths"), value: strengths },
|
||||
{ metric: t("exportContent.metricWeaknesses"), value: weaknesses },
|
||||
{ metric: t("exportContent.metricRecommendations"), value: recommendations },
|
||||
]
|
||||
|
||||
const masteryRows = (mastery?.allMastery ?? []).map((m) => ({
|
||||
@@ -60,21 +76,21 @@ export async function exportDiagnosticReportToExcel(params: {
|
||||
return exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: "报告概览",
|
||||
name: t("exportContent.sheetOverview"),
|
||||
columns: [
|
||||
{ header: "指标", key: "metric", width: 20 },
|
||||
{ header: "数值", key: "value", width: 60 },
|
||||
{ header: t("exportContent.metricStudent"), key: "metric", width: 20 },
|
||||
{ header: "", key: "value", width: 60 },
|
||||
],
|
||||
rows: overviewRows,
|
||||
},
|
||||
{
|
||||
name: "知识点掌握度",
|
||||
name: t("exportContent.sheetMastery"),
|
||||
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 },
|
||||
{ header: t("exportContent.colKnowledgePoint"), key: "knowledgePoint", width: 28 },
|
||||
{ header: t("exportContent.colMasteryLevel"), key: "masteryLevel", width: 12 },
|
||||
{ header: t("exportContent.colTotalQuestions"), key: "totalQuestions", width: 10 },
|
||||
{ header: t("exportContent.colCorrectQuestions"), key: "correctQuestions", width: 10 },
|
||||
{ header: t("exportContent.colLastAssessed"), key: "lastAssessedAt", width: 14 },
|
||||
],
|
||||
rows: masteryRows,
|
||||
},
|
||||
@@ -83,40 +99,89 @@ export async function exportDiagnosticReportToExcel(params: {
|
||||
}
|
||||
|
||||
// 班级报告(reportType === "class")
|
||||
// 班级报告的 studentId 为 null,需要从 period 反查 classId 不现实,
|
||||
// 这里仅导出报告概览(知识点统计需要 classId,但报告本身未存储 classId)。
|
||||
// 如需导出班级明细,应通过 generateClassDiagnosticReport 时记录 classId。
|
||||
// v4-P2-1: 利用 classId 字段查询班级掌握度,导出知识点统计+需关注学生明细
|
||||
const classSummary = report.classId ? await getClassMasterySummary(report.classId) : null
|
||||
|
||||
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 },
|
||||
{ metric: t("exportContent.metricReportType"), value: t("type.class") },
|
||||
...(classSummary ? [{ metric: t("exportContent.metricClass"), value: classSummary.className }] : []),
|
||||
{ metric: t("exportContent.metricPeriod"), value: periodLabel },
|
||||
{ metric: t("exportContent.metricScore"), value: overallScore },
|
||||
...(classSummary ? [{ metric: t("exportContent.metricStudentCount"), value: classSummary.studentCount }] : []),
|
||||
...(classSummary ? [{ metric: t("exportContent.metricAttentionCount"), value: classSummary.studentsNeedingAttention.length }] : []),
|
||||
{ metric: t("exportContent.metricStatus"), value: report.status },
|
||||
{ metric: t("exportContent.metricGeneratedBy"), value: report.generatedByName ?? "-" },
|
||||
{ metric: t("exportContent.metricCreatedAt"), value: report.createdAt.split("T")[0] },
|
||||
{ metric: t("exportContent.metricSummary"), value: summary },
|
||||
{ metric: t("exportContent.metricStrengths"), value: strengths },
|
||||
{ metric: t("exportContent.metricWeaknesses"), value: weaknesses },
|
||||
{ metric: t("exportContent.metricRecommendations"), value: recommendations },
|
||||
]
|
||||
|
||||
return exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: "报告概览",
|
||||
columns: [
|
||||
{ header: "指标", key: "metric", width: 20 },
|
||||
{ header: "数值", key: "value", width: 60 },
|
||||
],
|
||||
rows: overviewRows,
|
||||
},
|
||||
],
|
||||
})
|
||||
const sheets: Array<{
|
||||
name: string
|
||||
columns: Array<{ header: string; key: string; width: number }>
|
||||
rows: Array<Record<string, string | number>>
|
||||
}> = [
|
||||
{
|
||||
name: t("exportContent.sheetOverview"),
|
||||
columns: [
|
||||
{ header: t("exportContent.metricStudent"), key: "metric", width: 20 },
|
||||
{ header: "", key: "value", width: 60 },
|
||||
],
|
||||
rows: overviewRows,
|
||||
},
|
||||
]
|
||||
|
||||
// v4-P2-1: 知识点统计 Sheet
|
||||
if (classSummary && classSummary.knowledgePointStats.length > 0) {
|
||||
const classStatsRows = classSummary.knowledgePointStats.map((kp) => ({
|
||||
knowledgePoint: kp.knowledgePointName,
|
||||
averageMastery: kp.averageMastery.toFixed(1),
|
||||
masteredCount: kp.masteredCount,
|
||||
notMasteredCount: kp.notMasteredCount,
|
||||
totalStudents: kp.totalStudents,
|
||||
}))
|
||||
sheets.push({
|
||||
name: t("exportContent.sheetClassStats"),
|
||||
columns: [
|
||||
{ header: t("exportContent.colKnowledgePoint"), key: "knowledgePoint", width: 28 },
|
||||
{ header: t("exportContent.colAverageMastery"), key: "averageMastery", width: 14 },
|
||||
{ header: t("exportContent.colMasteredCount"), key: "masteredCount", width: 16 },
|
||||
{ header: t("exportContent.colNotMasteredCount"), key: "notMasteredCount", width: 16 },
|
||||
{ header: t("exportContent.colTotalStudents"), key: "totalStudents", width: 12 },
|
||||
],
|
||||
rows: classStatsRows,
|
||||
})
|
||||
}
|
||||
|
||||
// v4-P2-1: 需关注学生 Sheet
|
||||
if (classSummary && classSummary.studentsNeedingAttention.length > 0) {
|
||||
const attentionRows = classSummary.studentsNeedingAttention.map((s) => ({
|
||||
studentName: s.studentName,
|
||||
averageMastery: s.averageMastery.toFixed(1),
|
||||
weakCount: s.weakCount,
|
||||
}))
|
||||
sheets.push({
|
||||
name: t("exportContent.sheetAttentionStudents"),
|
||||
columns: [
|
||||
{ header: t("exportContent.colStudentName"), key: "studentName", width: 24 },
|
||||
{ header: t("exportContent.colAverageMastery"), key: "averageMastery", width: 14 },
|
||||
{ header: t("exportContent.colWeakCount"), key: "weakCount", width: 12 },
|
||||
],
|
||||
rows: attentionRows,
|
||||
})
|
||||
}
|
||||
|
||||
return exportToExcel({ sheets })
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成诊断报告导出文件名。
|
||||
*/
|
||||
export function buildDiagnosticReportFilename(period: string | null): string {
|
||||
export async function buildDiagnosticReportFilename(period: string | null): Promise<string> {
|
||||
const t = await getTranslations("diagnostic.exportContent")
|
||||
const safePeriod = (period ?? "report").replace(/[\\/:*?"<>|]/g, "_")
|
||||
return `诊断报告_${safePeriod}_${formatDateForFile()}.xlsx`
|
||||
const date = formatDateForFile()
|
||||
return t("filename", { period: safePeriod, date })
|
||||
}
|
||||
|
||||
40
src/modules/diagnostic/role-config.ts
Normal file
40
src/modules/diagnostic/role-config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 学情诊断模块角色配置(v4-P2-2)
|
||||
*
|
||||
* 通过配置驱动角色差异,新增角色只需在此添加配置项,
|
||||
* 无需修改组件 props 传递逻辑。
|
||||
*/
|
||||
|
||||
export type DiagnosticRole = "student" | "teacher" | "parent"
|
||||
|
||||
export interface DiagnosticRoleConfig {
|
||||
/**
|
||||
* "练习"按钮跳转基础路径。
|
||||
* - 学生视角:跳转到学生作业页,支持 kp 参数筛选
|
||||
* - 教师视角:跳转到题目库,支持 kp 参数筛选
|
||||
* - 家长视角:null 表示隐藏练习按钮(家长无练习入口)
|
||||
*
|
||||
* 最终链接会附加 `?kp={knowledgePointId}` 实现个性化练习推荐。
|
||||
*/
|
||||
practiceHrefBase: string | null
|
||||
}
|
||||
|
||||
export const DIAGNOSTIC_ROLE_CONFIG: Record<DiagnosticRole, DiagnosticRoleConfig> = {
|
||||
student: {
|
||||
practiceHrefBase: "/student/learning/assignments",
|
||||
},
|
||||
teacher: {
|
||||
practiceHrefBase: "/teacher/questions",
|
||||
},
|
||||
parent: {
|
||||
practiceHrefBase: null,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定角色的诊断模块配置。
|
||||
* 新增角色时只需在 DIAGNOSTIC_ROLE_CONFIG 中添加配置项。
|
||||
*/
|
||||
export function getDiagnosticRoleConfig(role: DiagnosticRole): DiagnosticRoleConfig {
|
||||
return DIAGNOSTIC_ROLE_CONFIG[role]
|
||||
}
|
||||
@@ -16,6 +16,14 @@ export const GenerateClassReportSchema = z.object({
|
||||
|
||||
export type GenerateClassReportInput = z.infer<typeof GenerateClassReportSchema>
|
||||
|
||||
/** v4-P2-3: 生成年级诊断报告 */
|
||||
export const GenerateGradeReportSchema = z.object({
|
||||
gradeId: z.string().min(1),
|
||||
period: z.string().min(1),
|
||||
})
|
||||
|
||||
export type GenerateGradeReportInput = z.infer<typeof GenerateGradeReportSchema>
|
||||
|
||||
/** 发布诊断报告 */
|
||||
export const PublishReportSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import {
|
||||
generateStudentReportAction,
|
||||
generateClassReportAction,
|
||||
generateGradeReportAction,
|
||||
publishReportAction,
|
||||
deleteReportAction,
|
||||
exportDiagnosticReportAction,
|
||||
getClassStudentsByKnowledgePointAction,
|
||||
} from "../actions"
|
||||
import type {
|
||||
DiagnosticService,
|
||||
ExportResult,
|
||||
KnowledgePointStudent,
|
||||
} from "./diagnostic-service"
|
||||
|
||||
/**
|
||||
* v2-P1-4: 诊断模块默认服务实现。
|
||||
*
|
||||
* 绑定现有 Server Actions,作为 DiagnosticServiceProvider 的默认注入值。
|
||||
* 测试时可替换为 mock 实现以隔离组件测试。
|
||||
*/
|
||||
export const defaultDiagnosticService: DiagnosticService = {
|
||||
async generateStudentReport(
|
||||
studentId: string,
|
||||
period: string,
|
||||
): Promise<ActionState<string>> {
|
||||
const formData = new FormData()
|
||||
formData.set("studentId", studentId)
|
||||
formData.set("period", period)
|
||||
return generateStudentReportAction(null, formData)
|
||||
},
|
||||
|
||||
async generateClassReport(
|
||||
classId: string,
|
||||
period: string,
|
||||
): Promise<ActionState<string>> {
|
||||
const formData = new FormData()
|
||||
formData.set("classId", classId)
|
||||
formData.set("period", period)
|
||||
return generateClassReportAction(null, formData)
|
||||
},
|
||||
|
||||
async generateGradeReport(
|
||||
gradeId: string,
|
||||
period: string,
|
||||
): Promise<ActionState<string>> {
|
||||
const formData = new FormData()
|
||||
formData.set("gradeId", gradeId)
|
||||
formData.set("period", period)
|
||||
return generateGradeReportAction(null, formData)
|
||||
},
|
||||
|
||||
async publishReport(id: string): Promise<ActionState<null>> {
|
||||
const formData = new FormData()
|
||||
formData.set("id", id)
|
||||
const result = await publishReportAction(null, formData)
|
||||
return { success: result.success, message: result.message }
|
||||
},
|
||||
|
||||
async deleteReport(id: string): Promise<ActionState<null>> {
|
||||
const formData = new FormData()
|
||||
formData.set("id", id)
|
||||
const result = await deleteReportAction(null, formData)
|
||||
return { success: result.success, message: result.message }
|
||||
},
|
||||
|
||||
async exportReport(reportId: string): Promise<ActionState<ExportResult>> {
|
||||
return exportDiagnosticReportAction(reportId)
|
||||
},
|
||||
|
||||
async getClassStudentsByKp(
|
||||
classId: string,
|
||||
knowledgePointId: string,
|
||||
threshold?: number,
|
||||
): Promise<ActionState<KnowledgePointStudent[]>> {
|
||||
return getClassStudentsByKnowledgePointAction({
|
||||
classId,
|
||||
knowledgePointId,
|
||||
threshold,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* v2-P2-7: 诊断模块监控埋点 Context。
|
||||
*
|
||||
* 通过 React Context 注入 DiagnosticMonitor 实现,
|
||||
* 使组件可通过 useDiagnosticMonitor() 获取监控实例,
|
||||
* 而不直接依赖具体埋点 SDK。
|
||||
*
|
||||
* 默认值为 noopDiagnosticMonitor(不发送任何事件),
|
||||
* 确保未注入 Provider 时业务流程不受影响。
|
||||
*
|
||||
* 用法:
|
||||
* ```tsx
|
||||
* <DiagnosticMonitorProvider monitor={postHogMonitor}>
|
||||
* <DiagnosticServiceProvider service={defaultDiagnosticService}>
|
||||
* <ReportList />
|
||||
* </DiagnosticServiceProvider>
|
||||
* </DiagnosticMonitorProvider>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react"
|
||||
|
||||
import {
|
||||
noopDiagnosticMonitor,
|
||||
type DiagnosticMonitor,
|
||||
} from "./diagnostic-monitor"
|
||||
|
||||
const DiagnosticMonitorContext = createContext<DiagnosticMonitor>(
|
||||
noopDiagnosticMonitor,
|
||||
)
|
||||
|
||||
interface DiagnosticMonitorProviderProps {
|
||||
/** 监控实现(默认使用 noop,生产环境注入真实实现) */
|
||||
monitor: DiagnosticMonitor
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function DiagnosticMonitorProvider({
|
||||
monitor,
|
||||
children,
|
||||
}: DiagnosticMonitorProviderProps): ReactNode {
|
||||
return (
|
||||
<DiagnosticMonitorContext.Provider value={monitor}>
|
||||
{children}
|
||||
</DiagnosticMonitorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前注入的 DiagnosticMonitor 实例。
|
||||
*
|
||||
* 若未注入 Provider,返回 no-op 实现,确保调用安全。
|
||||
*/
|
||||
export function useDiagnosticMonitor(): DiagnosticMonitor {
|
||||
return useContext(DiagnosticMonitorContext)
|
||||
}
|
||||
91
src/modules/diagnostic/services/diagnostic-monitor.ts
Normal file
91
src/modules/diagnostic/services/diagnostic-monitor.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* v2-P2-7: 诊断模块监控埋点接口。
|
||||
*
|
||||
* 这是一个预留的扩展点,用于追踪诊断模块的关键操作。
|
||||
* 默认实现为 no-op(不发送任何事件),生产环境可通过
|
||||
* DiagnosticMonitorProvider 注入真实实现(如发送到 Sentry、PostHog、
|
||||
* Mixpanel、自建埋点系统等)。
|
||||
*
|
||||
* 设计原则:
|
||||
* - 接口与实现解耦:组件依赖接口,不依赖具体埋点 SDK。
|
||||
* - 不阻塞主流程:埋点失败不应影响业务操作。
|
||||
* - 客户端与服务端均可使用:事件类型设计为通用,避免环境耦合。
|
||||
*
|
||||
* 用法:
|
||||
* ```tsx
|
||||
* <DiagnosticMonitorProvider monitor={postHogDiagnosticMonitor}>
|
||||
* <DiagnosticServiceProvider service={defaultDiagnosticService}>
|
||||
* <ReportList />
|
||||
* </DiagnosticServiceProvider>
|
||||
* </DiagnosticMonitorProvider>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/** 诊断模块可追踪的事件名称 */
|
||||
export type DiagnosticEventName =
|
||||
| "report_generated"
|
||||
| "report_published"
|
||||
| "report_deleted"
|
||||
| "report_exported"
|
||||
| "class_kp_filtered"
|
||||
|
||||
/** 诊断报告类型(用于事件属性) */
|
||||
export type DiagnosticReportType = "individual" | "class" | "grade"
|
||||
|
||||
/** 事件属性(按事件名称区分的可选字段) */
|
||||
export interface DiagnosticEventProperties {
|
||||
/** 报告类型(report_generated 事件必填) */
|
||||
reportType?: DiagnosticReportType
|
||||
/** 报告 ID(publish/delete/export 事件必填) */
|
||||
reportId?: string
|
||||
/** 学生 ID(individual 报告) */
|
||||
studentId?: string
|
||||
/** 班级 ID(class 报告或 class_kp_filtered 事件) */
|
||||
classId?: string
|
||||
/** 年级 ID(grade 报告) */
|
||||
gradeId?: string
|
||||
/** 知识点 ID(class_kp_filtered 事件) */
|
||||
knowledgePointId?: string
|
||||
/** 报告周期(report_generated 事件,格式 YYYY-MM) */
|
||||
period?: string
|
||||
/** 掌握度阈值(class_kp_filtered 事件) */
|
||||
threshold?: number
|
||||
/** 操作是否成功(便于计算转化率) */
|
||||
success?: boolean
|
||||
/** 错误信息(失败时记录,便于排查) */
|
||||
error?: string
|
||||
/** 操作耗时(毫秒,便于性能监控) */
|
||||
durationMs?: number
|
||||
/** 触发操作的用户 ID */
|
||||
userId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断模块监控接口。
|
||||
*
|
||||
* 所有方法均为 async(即使 no-op 也返回 Promise),
|
||||
* 以便真实实现可异步发送事件而不阻塞调用方。
|
||||
*/
|
||||
export interface DiagnosticMonitor {
|
||||
/**
|
||||
* 追踪诊断模块事件。
|
||||
*
|
||||
* @param eventName 事件名称
|
||||
* @param properties 事件属性(可选)
|
||||
*/
|
||||
track(
|
||||
eventName: DiagnosticEventName,
|
||||
properties?: DiagnosticEventProperties,
|
||||
): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认 no-op 实现:不发送任何事件,仅静默返回。
|
||||
*
|
||||
* 在未注入真实监控实现时使用,确保业务流程不受影响。
|
||||
*/
|
||||
export const noopDiagnosticMonitor: DiagnosticMonitor = {
|
||||
async track(): Promise<void> {
|
||||
// no-op: 默认不发送任何事件
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react"
|
||||
|
||||
import type { DiagnosticService } from "./diagnostic-service"
|
||||
|
||||
/**
|
||||
* v2-P1-4: 诊断模块服务 Context。
|
||||
*
|
||||
* 组件通过 useDiagnosticService() 获取服务实现,
|
||||
* 而非直接 import actions,实现依赖反转。
|
||||
*
|
||||
* 默认由 DefaultDiagnosticServiceProvider 注入真实实现(调用 Server Actions);
|
||||
* 测试时可注入 mock 实现以隔离组件测试。
|
||||
*/
|
||||
const DiagnosticServiceContext = createContext<DiagnosticService | null>(null)
|
||||
|
||||
interface DiagnosticServiceProviderProps {
|
||||
service: DiagnosticService
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function DiagnosticServiceProvider({
|
||||
service,
|
||||
children,
|
||||
}: DiagnosticServiceProviderProps): ReactNode {
|
||||
return (
|
||||
<DiagnosticServiceContext.Provider value={service}>
|
||||
{children}
|
||||
</DiagnosticServiceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取诊断模块服务。
|
||||
* 必须在 DiagnosticServiceProvider 内部使用。
|
||||
*/
|
||||
export function useDiagnosticService(): DiagnosticService {
|
||||
const service = useContext(DiagnosticServiceContext)
|
||||
if (!service) {
|
||||
throw new Error(
|
||||
"useDiagnosticService must be used within a DiagnosticServiceProvider",
|
||||
)
|
||||
}
|
||||
return service
|
||||
}
|
||||
58
src/modules/diagnostic/services/diagnostic-service.ts
Normal file
58
src/modules/diagnostic/services/diagnostic-service.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* v2-P1-4: 诊断模块数据服务接口。
|
||||
*
|
||||
* 通过 TypeScript 接口抽象所有客户端可调用的诊断操作,
|
||||
* 使组件依赖接口而非具体 Server Action 实现,便于测试与替换。
|
||||
*
|
||||
* 默认实现绑定现有 Server Actions;测试时可注入 mock 实现。
|
||||
*/
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
/** 按知识点筛选学生返回项 */
|
||||
export interface KnowledgePointStudent {
|
||||
studentId: string
|
||||
studentName: string
|
||||
masteryLevel: number
|
||||
totalQuestions: number
|
||||
correctQuestions: number
|
||||
lastAssessedAt: string | null
|
||||
needsAttention: boolean
|
||||
}
|
||||
|
||||
/** 导出结果 */
|
||||
export interface ExportResult {
|
||||
buffer: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断模块客户端服务接口。
|
||||
* 所有客户端组件通过 useDiagnosticService() 获取实现,不直接 import actions。
|
||||
*/
|
||||
export interface DiagnosticService {
|
||||
/** 生成学生个人诊断报告 */
|
||||
generateStudentReport(studentId: string, period: string): Promise<ActionState<string>>
|
||||
|
||||
/** 生成班级诊断报告 */
|
||||
generateClassReport(classId: string, period: string): Promise<ActionState<string>>
|
||||
|
||||
/** 生成年级诊断报告 */
|
||||
generateGradeReport(gradeId: string, period: string): Promise<ActionState<string>>
|
||||
|
||||
/** 发布诊断报告 */
|
||||
publishReport(id: string): Promise<ActionState<null>>
|
||||
|
||||
/** 删除诊断报告 */
|
||||
deleteReport(id: string): Promise<ActionState<null>>
|
||||
|
||||
/** 导出诊断报告为 Excel(返回 base64 buffer + 文件名) */
|
||||
exportReport(reportId: string): Promise<ActionState<ExportResult>>
|
||||
|
||||
/** 按知识点筛选班级学生掌握度 */
|
||||
getClassStudentsByKp(
|
||||
classId: string,
|
||||
knowledgePointId: string,
|
||||
threshold?: number,
|
||||
): Promise<ActionState<KnowledgePointStudent[]>>
|
||||
}
|
||||
158
src/modules/diagnostic/services/monitored-diagnostic-service.ts
Normal file
158
src/modules/diagnostic/services/monitored-diagnostic-service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* v2-P2-7: 带监控埋点的诊断服务工厂。
|
||||
*
|
||||
* 通过组合模式包装任意 DiagnosticService 实现,
|
||||
* 在关键操作前后调用 DiagnosticMonitor.track() 发送埋点事件。
|
||||
*
|
||||
* 设计要点:
|
||||
* - 不修改被包装的服务实现,仅添加监控层。
|
||||
* - 埋点失败不阻断业务流程(catch 后静默)。
|
||||
* - 记录操作耗时(durationMs)便于性能监控。
|
||||
* - 通过组合而非继承实现,符合"组合优先"原则。
|
||||
*
|
||||
* 用法:
|
||||
* ```tsx
|
||||
* const monitoredService = createMonitoredDiagnosticService(
|
||||
* defaultDiagnosticService,
|
||||
* noopDiagnosticMonitor,
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import type {
|
||||
DiagnosticService,
|
||||
ExportResult,
|
||||
KnowledgePointStudent,
|
||||
} from "./diagnostic-service"
|
||||
import type {
|
||||
DiagnosticMonitor,
|
||||
DiagnosticEventName,
|
||||
DiagnosticEventProperties,
|
||||
} from "./diagnostic-monitor"
|
||||
|
||||
/**
|
||||
* 包装 DiagnosticService,在每个操作前后发送监控事件。
|
||||
*
|
||||
* @param service 被包装的原始服务实现
|
||||
* @param monitor 监控实现(no-op 或真实埋点 SDK)
|
||||
*/
|
||||
export function createMonitoredDiagnosticService(
|
||||
service: DiagnosticService,
|
||||
monitor: DiagnosticMonitor,
|
||||
): DiagnosticService {
|
||||
/**
|
||||
* 执行操作并追踪事件。
|
||||
* 埋点失败不阻断业务流程。
|
||||
*/
|
||||
const withTracking = async <T>(
|
||||
eventName: DiagnosticEventName,
|
||||
properties: DiagnosticEventProperties,
|
||||
operation: () => Promise<ActionState<T>>,
|
||||
): Promise<ActionState<T>> => {
|
||||
const start = Date.now()
|
||||
let result: ActionState<T>
|
||||
try {
|
||||
result = await operation()
|
||||
} catch (e) {
|
||||
// 埋点:记录失败
|
||||
try {
|
||||
await monitor.track(eventName, {
|
||||
...properties,
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
durationMs: Date.now() - start,
|
||||
})
|
||||
} catch {
|
||||
// 埋点失败不阻断
|
||||
}
|
||||
throw e
|
||||
}
|
||||
// 埋点:记录成功或失败
|
||||
try {
|
||||
await monitor.track(eventName, {
|
||||
...properties,
|
||||
success: result.success,
|
||||
error: result.success ? undefined : result.message,
|
||||
durationMs: Date.now() - start,
|
||||
})
|
||||
} catch {
|
||||
// 埋点失败不阻断
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
async generateStudentReport(
|
||||
studentId: string,
|
||||
period: string,
|
||||
): Promise<ActionState<string>> {
|
||||
return withTracking(
|
||||
"report_generated",
|
||||
{ reportType: "individual", studentId, period },
|
||||
() => service.generateStudentReport(studentId, period),
|
||||
)
|
||||
},
|
||||
|
||||
async generateClassReport(
|
||||
classId: string,
|
||||
period: string,
|
||||
): Promise<ActionState<string>> {
|
||||
return withTracking(
|
||||
"report_generated",
|
||||
{ reportType: "class", classId, period },
|
||||
() => service.generateClassReport(classId, period),
|
||||
)
|
||||
},
|
||||
|
||||
async generateGradeReport(
|
||||
gradeId: string,
|
||||
period: string,
|
||||
): Promise<ActionState<string>> {
|
||||
return withTracking(
|
||||
"report_generated",
|
||||
{ reportType: "grade", gradeId, period },
|
||||
() => service.generateGradeReport(gradeId, period),
|
||||
)
|
||||
},
|
||||
|
||||
async publishReport(id: string): Promise<ActionState<null>> {
|
||||
return withTracking(
|
||||
"report_published",
|
||||
{ reportId: id },
|
||||
() => service.publishReport(id),
|
||||
)
|
||||
},
|
||||
|
||||
async deleteReport(id: string): Promise<ActionState<null>> {
|
||||
return withTracking(
|
||||
"report_deleted",
|
||||
{ reportId: id },
|
||||
() => service.deleteReport(id),
|
||||
)
|
||||
},
|
||||
|
||||
async exportReport(reportId: string): Promise<ActionState<ExportResult>> {
|
||||
return withTracking(
|
||||
"report_exported",
|
||||
{ reportId: reportId },
|
||||
() => service.exportReport(reportId),
|
||||
)
|
||||
},
|
||||
|
||||
async getClassStudentsByKp(
|
||||
classId: string,
|
||||
knowledgePointId: string,
|
||||
threshold?: number,
|
||||
): Promise<ActionState<KnowledgePointStudent[]>> {
|
||||
return withTracking(
|
||||
"class_kp_filtered",
|
||||
{ classId, knowledgePointId, threshold },
|
||||
() => service.getClassStudentsByKp(classId, knowledgePointId, threshold),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import type {
|
||||
ClassMasterySummary,
|
||||
GradeMasterySummary,
|
||||
KnowledgePointMastery,
|
||||
KnowledgePointStat,
|
||||
MasteryWithKnowledgePoint,
|
||||
@@ -273,6 +274,50 @@ export function buildClassMasterySummary(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v4-P2-3: Build GradeMasterySummary from raw data.
|
||||
* Same aggregation logic as buildClassMasterySummary, but returns GradeMasterySummary type.
|
||||
*/
|
||||
export function buildGradeMasterySummary(
|
||||
gradeId: string,
|
||||
gradeName: string,
|
||||
students: Array<{ id: string; name: string | null }>,
|
||||
masteryRows: RawClassMasteryRow[],
|
||||
): GradeMasterySummary {
|
||||
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 {
|
||||
gradeId,
|
||||
gradeName,
|
||||
studentCount: students.length,
|
||||
averageMastery,
|
||||
knowledgePointStats,
|
||||
studentsNeedingAttention,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation strings needed for report content generation.
|
||||
* Actions layer builds this from next-intl and passes it in,
|
||||
* keeping stats-service free of i18n framework dependencies.
|
||||
*/
|
||||
export interface ReportContentTranslations {
|
||||
studentSummary: (vars: { studentName: string; period: string; score: number; total: number; strengths: number; weaknesses: number }) => string
|
||||
studentRecommendation: (vars: { kpName: string; level: number }) => string
|
||||
studentNoWeakness: string
|
||||
classSummary: (vars: { className: string; period: string; score: number; students: number; attention: number }) => string
|
||||
classRecommendation: (vars: { kpName: string; level: number }) => string
|
||||
classNoWeakness: string
|
||||
/** v4-P2-3: 年级报告翻译 */
|
||||
gradeSummary: (vars: { gradeName: string; period: string; score: number; students: number; attention: number }) => string
|
||||
gradeRecommendation: (vars: { kpName: string; level: number }) => string
|
||||
gradeNoWeakness: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build student report content (strengths/weaknesses/recommendations/summary)
|
||||
* from a StudentMasterySummary.
|
||||
@@ -280,6 +325,7 @@ export function buildClassMasterySummary(
|
||||
export function buildStudentReportContent(
|
||||
summary: StudentMasterySummary,
|
||||
period: string,
|
||||
translations: ReportContentTranslations,
|
||||
): {
|
||||
summaryText: string
|
||||
strengths: string[]
|
||||
@@ -295,14 +341,23 @@ export function buildStudentReportContent(
|
||||
(m) => `${m.knowledgePointName} (${m.masteryLevel.toFixed(1)}%)`,
|
||||
)
|
||||
const recommendations = summary.weaknesses.map(
|
||||
(m) =>
|
||||
`建议复习「${m.knowledgePointName}」知识点,多做相关练习以提升掌握度(当前 ${m.masteryLevel.toFixed(1)}%)。`,
|
||||
(m) => translations.studentRecommendation({
|
||||
kpName: m.knowledgePointName,
|
||||
level: m.masteryLevel,
|
||||
}),
|
||||
)
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push("整体掌握情况良好,建议保持当前学习节奏并挑战更高难度题目。")
|
||||
recommendations.push(translations.studentNoWeakness)
|
||||
}
|
||||
|
||||
const summaryText = `学生 ${summary.studentName} 在 ${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。`
|
||||
const summaryText = translations.studentSummary({
|
||||
studentName: summary.studentName,
|
||||
period,
|
||||
score: overallScore,
|
||||
total: summary.totalKnowledgePoints,
|
||||
strengths: strengths.length,
|
||||
weaknesses: weaknesses.length,
|
||||
})
|
||||
|
||||
return { summaryText, strengths, weaknesses, recommendations, overallScore }
|
||||
}
|
||||
@@ -314,6 +369,7 @@ export function buildStudentReportContent(
|
||||
export function buildClassReportContent(
|
||||
summary: ClassMasterySummary,
|
||||
period: string,
|
||||
translations: ReportContentTranslations,
|
||||
): {
|
||||
summaryText: string
|
||||
strengths: string[]
|
||||
@@ -334,14 +390,76 @@ export function buildClassReportContent(
|
||||
(k) => `${k.knowledgePointName} (均 ${k.averageMastery.toFixed(1)}%)`,
|
||||
)
|
||||
const recommendations = topWeak.map(
|
||||
(k) =>
|
||||
`班级在「${k.knowledgePointName}」整体掌握度偏低(${k.averageMastery.toFixed(1)}%),建议安排专项复习与巩固练习。`,
|
||||
(k) => translations.classRecommendation({
|
||||
kpName: k.knowledgePointName,
|
||||
level: k.averageMastery,
|
||||
}),
|
||||
)
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push("班级整体掌握情况良好,建议保持当前教学节奏。")
|
||||
recommendations.push(translations.classNoWeakness)
|
||||
}
|
||||
|
||||
const summaryText = `班级 ${summary.className} 在 ${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。`
|
||||
const summaryText = translations.classSummary({
|
||||
className: summary.className,
|
||||
period,
|
||||
score: summary.averageMastery,
|
||||
students: summary.studentCount,
|
||||
attention: summary.studentsNeedingAttention.length,
|
||||
})
|
||||
|
||||
return {
|
||||
summaryText,
|
||||
strengths,
|
||||
weaknesses,
|
||||
recommendations,
|
||||
overallScore: summary.averageMastery,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v4-P2-3: Build grade report content (strengths/weaknesses/recommendations/summary)
|
||||
* from a GradeMasterySummary. Strengths and weaknesses are limited to top 5.
|
||||
*/
|
||||
export function buildGradeReportContent(
|
||||
summary: GradeMasterySummary,
|
||||
period: string,
|
||||
translations: ReportContentTranslations,
|
||||
): {
|
||||
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) => translations.gradeRecommendation({
|
||||
kpName: k.knowledgePointName,
|
||||
level: k.averageMastery,
|
||||
}),
|
||||
)
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push(translations.gradeNoWeakness)
|
||||
}
|
||||
|
||||
const summaryText = translations.gradeSummary({
|
||||
gradeName: summary.gradeName,
|
||||
period,
|
||||
score: summary.averageMastery,
|
||||
students: summary.studentCount,
|
||||
attention: summary.studentsNeedingAttention.length,
|
||||
})
|
||||
|
||||
return {
|
||||
summaryText,
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface DiagnosticReport {
|
||||
studentId: string | null
|
||||
/** v4-P1-4: 班级报告关联的 classId(个人报告为 null) */
|
||||
classId: string | null
|
||||
/** v4-P2-3: 年级报告关联的 gradeId(个人/班级报告为 null) */
|
||||
gradeId: string | null
|
||||
generatedBy: string | null
|
||||
reportType: DiagnosticReportType
|
||||
period: string | null
|
||||
@@ -73,6 +75,21 @@ export interface ClassMasterySummary {
|
||||
}>
|
||||
}
|
||||
|
||||
/** v4-P2-3: 年级掌握度摘要(结构同 ClassMasterySummary,但标识为年级) */
|
||||
export interface GradeMasterySummary {
|
||||
gradeId: string
|
||||
gradeName: string
|
||||
studentCount: number
|
||||
averageMastery: number
|
||||
knowledgePointStats: KnowledgePointStat[]
|
||||
studentsNeedingAttention: Array<{
|
||||
studentId: string
|
||||
studentName: string
|
||||
averageMastery: number
|
||||
weakCount: number
|
||||
}>
|
||||
}
|
||||
|
||||
/** 知识点统计 */
|
||||
export interface KnowledgePointStat {
|
||||
knowledgePointId: string
|
||||
|
||||
Reference in New Issue
Block a user