refactor(grades,diagnostic): 完成成绩和学情诊断模块审计 P1+P2 改进项

P1-1: 抽取 stats-service.ts,将 8 个统计计算纯函数从 data-access 层分离
P1-5: 创建 WidgetBoundary 组件 + 补齐 teacher 路由 loading.tsx/error.tsx (14 文件)
P1-6: 同步架构图文档 004/005,新增 stats-service 与 widget-boundary 节点
P2-1: 补充 a11y ARIA 属性(5 图表 role=img + aria-label,2 表格 caption,3 列表 role=list,3 按钮 aria-label)
P2-3: 修复班级报告 studentId 字段语义错误(schema 改为可空 + 迁移 + 代码适配)
P2-4: 修复 grade_managed scope 返回空数据(改为子查询 classes 表按 gradeId 过滤)
P2-5: 新增 /parent/diagnostic/ 页面(多子女学情诊断聚合 + loading + error)
P2-6: 统一 SearchParams 工具(student/grades 和 management/grade/insights 改用 @/shared/lib/search-params)
This commit is contained in:
SpecialX
2026-06-22 17:07:32 +08:00
parent e997abaf5e
commit 5f3a1a4662
41 changed files with 9043 additions and 381 deletions

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentDiagnosticError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="子女学情诊断加载失败"
description="抱歉,加载子女诊断数据时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-8 p-6 md:p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<CardTitle className="text-lg">
<Skeleton className="h-5 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-72 w-full" />
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, j) => (
<Skeleton key={j} className="h-40 w-full" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { Stethoscope } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
import {
ParentChildrenDataPage,
ParentNoChildrenPage,
} from "@/modules/parent/components/parent-children-data-page"
export const dynamic = "force-dynamic"
export default async function ParentDiagnosticPage() {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<ParentNoChildrenPage
title="Children Diagnostic"
description="View your children's knowledge point mastery and diagnostic reports."
icon={Stethoscope}
emptyTitle="No children linked"
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
/>
)
}
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
const results = await Promise.allSettled(
ctx.dataScope.childrenIds.map(async (id) => {
const [summary, reports] = await Promise.all([
getStudentMasterySummary(id),
getDiagnosticReports({ studentId: id }),
])
return { summary, reports, studentId: id }
}),
)
const validItems = results
.filter(
(r): r is PromiseFulfilledResult<{
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
reports: Awaited<ReturnType<typeof getDiagnosticReports>>
studentId: string
}> => r.status === "fulfilled",
)
.map((r) => r.value)
return (
<ParentChildrenDataPage
title="Children Diagnostic"
description="View knowledge point mastery and diagnostic reports for all your children."
icon={Stethoscope}
noRecordsTitle="No diagnostic data"
noRecordsDescription="Your children don't have any diagnostic data yet."
items={validItems}
renderItem={({ summary, reports }) => (
<>
<div className="border-b pb-2">
<h3 className="text-lg font-semibold">
{summary?.studentName ?? "Unknown student"}
</h3>
</div>
<StudentDiagnosticView summary={summary} reports={reports} />
</>
)}
/>
)
}