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:
@@ -38,23 +38,25 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
|
||||
emptyDescription="Select a grade and subject to compare classes."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{ dataKey: "averageScore", name: "Average (%)", color: "hsl(var(--primary))" },
|
||||
{ dataKey: "passRate", name: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
|
||||
{ dataKey: "excellentRate", name: "Excellent (%)", color: "hsl(var(--chart-3))" },
|
||||
]}
|
||||
xKey="name"
|
||||
xTruncateLength={8}
|
||||
yDomain={[0, 100]}
|
||||
yTickFormatter={(value: number) => `${value}%`}
|
||||
yWidth={36}
|
||||
heightClassName="h-[300px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
showLegend
|
||||
tooltipClassName="w-[240px]"
|
||||
/>
|
||||
<div role="img" aria-label={`班级对比柱状图:${isEmpty ? "暂无数据" : `共 ${data.length} 个班级的均分、及格率与优秀率对比`}`}>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{ dataKey: "averageScore", name: "Average (%)", color: "hsl(var(--primary))" },
|
||||
{ dataKey: "passRate", name: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
|
||||
{ dataKey: "excellentRate", name: "Excellent (%)", color: "hsl(var(--chart-3))" },
|
||||
]}
|
||||
xKey="name"
|
||||
xTruncateLength={8}
|
||||
yDomain={[0, 100]}
|
||||
yTickFormatter={(value: number) => `${value}%`}
|
||||
yWidth={36}
|
||||
heightClassName="h-[300px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
showLegend
|
||||
tooltipClassName="w-[240px]"
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,37 +70,39 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
emptyDescription="Select a class and subject to view score distribution."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{
|
||||
dataKey: "count",
|
||||
name: "Students",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
]}
|
||||
xKey="label"
|
||||
xTickFormatter={null}
|
||||
yAllowDecimals={false}
|
||||
yWidth={32}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
tooltipClassName="w-[200px]"
|
||||
cellColors={BUCKET_COLORS}
|
||||
tooltipFormatter={(payload: unknown) => {
|
||||
if (!isDistributionTooltipPayload(payload)) return null
|
||||
const item = payload.payload
|
||||
if (!item) return null
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{item.label}: {item.count} student{item.count === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{item.percentage}% of total</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div role="img" aria-label={`分数分布柱状图:${isEmpty ? "暂无数据" : `共 ${data.totalCount} 条成绩记录分布在 5 个分数区间`}`}>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{
|
||||
dataKey: "count",
|
||||
name: "Students",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
]}
|
||||
xKey="label"
|
||||
xTickFormatter={null}
|
||||
yAllowDecimals={false}
|
||||
yWidth={32}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
tooltipClassName="w-[200px]"
|
||||
cellColors={BUCKET_COLORS}
|
||||
tooltipFormatter={(payload: unknown) => {
|
||||
if (!isDistributionTooltipPayload(payload)) return null
|
||||
const item = payload.payload
|
||||
if (!item) return null
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{item.label}: {item.count} student{item.count === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{item.percentage}% of total</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
@@ -21,18 +20,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { Trash2 } from "lucide-react"
|
||||
|
||||
import { deleteGradeRecordAction } from "../actions"
|
||||
import type { GradeRecordListItem } from "../types"
|
||||
|
||||
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
exam: "default",
|
||||
quiz: "secondary",
|
||||
homework: "outline",
|
||||
other: "outline",
|
||||
}
|
||||
import { GRADE_TYPE_VARIANT } from "../types"
|
||||
|
||||
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
|
||||
const router = useRouter()
|
||||
@@ -65,6 +59,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
<>
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<caption className="sr-only">成绩记录列表</caption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Student</TableHead>
|
||||
@@ -90,9 +85,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
{r.score} / {r.fullScore}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={typeColors[r.type]} className="capitalize">
|
||||
{r.type}
|
||||
</Badge>
|
||||
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
|
||||
</TableCell>
|
||||
<TableCell>S{r.semester}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
|
||||
@@ -103,6 +96,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => setDeleteId(r.id)}
|
||||
aria-label={`删除 ${r.studentName} 的 ${r.subjectName} 成绩记录`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -40,22 +40,24 @@ export function GradeTrendChart({ data }: GradeTrendChartProps) {
|
||||
emptyDescription="Select a class and subject to view the grade trend."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
{
|
||||
dataKey: "normalizedScore",
|
||||
name: "Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 3,
|
||||
activeDotRadius: 5,
|
||||
},
|
||||
]}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
yWidth={36}
|
||||
tooltipClassName="w-[220px]"
|
||||
/>
|
||||
<div role="img" aria-label={`成绩趋势图:${isEmpty ? "暂无数据" : `${data.label},平均 ${data.averageScore.toFixed(1)}%`}`}>
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
{
|
||||
dataKey: "normalizedScore",
|
||||
name: "Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 3,
|
||||
activeDotRadius: 5,
|
||||
},
|
||||
]}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
yWidth={36}
|
||||
tooltipClassName="w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,28 +37,30 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
|
||||
emptyDescription="Select a class to compare subject performance."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="subject"
|
||||
angleTickFormatter={(value: string) =>
|
||||
value.length > 6 ? `${value.slice(0, 6)}...` : value
|
||||
}
|
||||
heightClassName="h-[300px]"
|
||||
series={[
|
||||
{
|
||||
dataKey: "averageScore",
|
||||
name: "Average",
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.4,
|
||||
},
|
||||
{
|
||||
dataKey: "passRate",
|
||||
name: "Pass Rate",
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div role="img" aria-label={`科目对比雷达图:${isEmpty ? "暂无数据" : `共 ${data.length} 个科目的均分与及格率对比`}`}>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="subject"
|
||||
angleTickFormatter={(value: string) =>
|
||||
value.length > 6 ? `${value.slice(0, 6)}...` : value
|
||||
}
|
||||
heightClassName="h-[300px]"
|
||||
series={[
|
||||
{
|
||||
dataKey: "averageScore",
|
||||
name: "Average",
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.4,
|
||||
},
|
||||
{
|
||||
dataKey: "passRate",
|
||||
name: "Pass Rate",
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
139
src/modules/grades/components/widget-boundary.tsx
Normal file
139
src/modules/grades/components/widget-boundary.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* Grades/Diagnostic 模块通用 Widget 边界组件。
|
||||
*
|
||||
* 组合三个能力:
|
||||
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
|
||||
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
|
||||
* 3. Skeleton — 与 Widget 尺寸匹配的占位
|
||||
*
|
||||
* 用法:
|
||||
* ```tsx
|
||||
* <WidgetBoundary title="成绩趋势">
|
||||
* <GradeTrendChart data={data} />
|
||||
* </WidgetBoundary>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Component, Suspense, type ReactNode } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
interface WidgetBoundaryProps {
|
||||
children: ReactNode
|
||||
/** Widget 标题(用于错误提示和 aria-label) */
|
||||
title?: string
|
||||
/** 骨架屏高度(默认 200px) */
|
||||
skeletonHeight?: number
|
||||
/** 自定义错误描述 */
|
||||
fallbackDescription?: string
|
||||
/** 重试按钮文案 */
|
||||
retryLabel?: string
|
||||
}
|
||||
|
||||
interface WidgetBoundaryState {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
class WidgetErrorBoundary extends Component<
|
||||
Pick<
|
||||
WidgetBoundaryProps,
|
||||
"title" | "fallbackDescription" | "retryLabel" | "children"
|
||||
>,
|
||||
WidgetBoundaryState
|
||||
> {
|
||||
constructor(props: WidgetBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): WidgetBoundaryState {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const title = this.props.title ?? "区块"
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
|
||||
>
|
||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{title}加载失败
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{this.props.fallbackDescription ?? "请重试或刷新页面"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={this.handleReset}
|
||||
aria-label={`重试加载${title}`}
|
||||
>
|
||||
{this.props.retryLabel ?? "重试"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function WidgetSkeleton({
|
||||
height,
|
||||
title,
|
||||
}: {
|
||||
height: number
|
||||
title?: string
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={`${title ?? "区块"}加载中`}
|
||||
aria-live="polite"
|
||||
className="space-y-3 p-4"
|
||||
style={{ minHeight: height }}
|
||||
>
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WidgetBoundary({
|
||||
children,
|
||||
title,
|
||||
skeletonHeight = 200,
|
||||
fallbackDescription,
|
||||
retryLabel,
|
||||
}: WidgetBoundaryProps): ReactNode {
|
||||
return (
|
||||
<WidgetErrorBoundary
|
||||
title={title}
|
||||
fallbackDescription={fallbackDescription}
|
||||
retryLabel={retryLabel}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<WidgetSkeleton height={skeletonHeight} title={title} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</WidgetErrorBoundary>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user