feat(grades): add ranking trend, school-wide summary, score cell, and scope filter

- Add ranking-trend-card and school-wide-summary-card for broader analytics

- Add score-cell and grade-filters components for table rendering

- Add scope-filter and type-guards lib utilities for grade data filtering

- Update actions, data-access (analytics, ranking, main), stats-service, export

- Update schema, types, and grade-utils lib

- Update all grade chart and report components (distribution, trend, comparison, query)
This commit is contained in:
SpecialX
2026-06-23 17:37:32 +08:00
parent 2197e68069
commit 95145cd03b
32 changed files with 3202 additions and 682 deletions

View File

@@ -1,19 +1,83 @@
"use client"
import { PieChart as PieChartIcon } from "lucide-react"
import { useTranslations } from "next-intl"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart"
import type { GradeDistributionResult } from "@/modules/grades/types"
const BUCKET_COLORS: Record<string, string> = {
"90-100": "hsl(142, 71%, 45%)",
"80-89": "hsl(217, 91%, 60%)",
"70-79": "hsl(43, 96%, 56%)",
"60-69": "hsl(25, 95%, 53%)",
"<60": "hsl(0, 84%, 60%)",
/**
* v4-P3-4: 色盲友好的双重编码。
* 每个分数段使用不同的 SVG pattern条纹/点状/交叉线等)+ 颜色,
* 确保色觉障碍用户能通过纹理区分各分数段。
*/
const BUCKET_FILLS: Record<string, string> = {
"90-100": "url(#grade-pattern-90-100)",
"80-89": "url(#grade-pattern-80-89)",
"70-79": "url(#grade-pattern-70-79)",
"60-69": "url(#grade-pattern-60-69)",
"<60": "url(#grade-pattern-lt60)",
}
/** v4-P3-4: SVG 图案定义,为每个分数段提供独特的纹理 */
const PATTERN_DEFS = (
<defs>
{/* 90-100: 正向斜条纹 */}
<pattern
id="grade-pattern-90-100"
patternUnits="userSpaceOnUse"
width="8"
height="8"
patternTransform="rotate(45)"
>
<rect width="8" height="8" fill="hsl(142, 71%, 45%)" />
<line x1="0" y1="0" x2="0" y2="8" stroke="hsl(142, 71%, 25%)" strokeWidth="3" />
</pattern>
{/* 80-89: 圆点 */}
<pattern
id="grade-pattern-80-89"
patternUnits="userSpaceOnUse"
width="10"
height="10"
>
<rect width="10" height="10" fill="hsl(217, 91%, 60%)" />
<circle cx="5" cy="5" r="2.5" fill="hsl(217, 91%, 35%)" />
</pattern>
{/* 70-79: 交叉线 */}
<pattern
id="grade-pattern-70-79"
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect width="8" height="8" fill="hsl(43, 96%, 56%)" />
<path d="M0,0 L8,8 M8,0 L0,8" stroke="hsl(43, 96%, 31%)" strokeWidth="1.5" />
</pattern>
{/* 60-69: 反向斜条纹 */}
<pattern
id="grade-pattern-60-69"
patternUnits="userSpaceOnUse"
width="8"
height="8"
patternTransform="rotate(-45)"
>
<rect width="8" height="8" fill="hsl(25, 95%, 53%)" />
<line x1="0" y1="0" x2="0" y2="8" stroke="hsl(25, 95%, 33%)" strokeWidth="3" />
</pattern>
{/* <60: 网格 */}
<pattern
id="grade-pattern-lt60"
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect width="8" height="8" fill="hsl(0, 84%, 60%)" />
<path d="M0,0 L8,0 M0,0 L0,8" stroke="hsl(0, 84%, 40%)" strokeWidth="1.5" />
</pattern>
</defs>
)
interface GradeDistributionChartProps {
data: GradeDistributionResult | null
}
@@ -43,6 +107,7 @@ function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPaylo
}
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
const t = useTranslations("grades")
const isEmpty = !data || data.totalCount === 0
const chartData = isEmpty
@@ -58,25 +123,25 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
return (
<ChartCardShell
title="Score Distribution"
title={t("distribution.title")}
description={
isEmpty
? "Number of students in each score range (normalized to 0-100)."
: `${data.totalCount} grade record${data.totalCount === 1 ? "" : "s"} across score ranges.`
? t("distribution.descriptionEmpty")
: t("distribution.descriptionNonEmpty", { count: data.totalCount })
}
icon={PieChartIcon}
isEmpty={isEmpty}
emptyTitle="No distribution data"
emptyDescription="Select a class and subject to view score distribution."
emptyTitle={t("distribution.emptyTitle")}
emptyDescription={t("distribution.emptyDescription")}
emptyClassName="h-60"
>
<div role="img" aria-label={`分数分布柱状图:${isEmpty ? "暂无数据" : `${data.totalCount} 条成绩记录分布在 5 个分数区间`}`}>
<div role="img" aria-label={isEmpty ? t("distribution.ariaLabelEmpty") : t("distribution.ariaLabelNonEmpty", { count: data.totalCount })}>
<SimpleBarChart
data={chartData}
bars={[
{
dataKey: "count",
name: "Students",
name: t("chart.students"),
color: "hsl(var(--primary))",
},
]}
@@ -87,7 +152,8 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
heightClassName="h-[280px]"
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
tooltipClassName="w-[200px]"
cellColors={BUCKET_COLORS}
defs={PATTERN_DEFS}
cellColors={BUCKET_FILLS}
tooltipFormatter={(payload: unknown) => {
if (!isDistributionTooltipPayload(payload)) return null
const item = payload.payload
@@ -95,9 +161,9 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
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"}
{item.label}: {t("distribution.tooltipStudents", { count: item.count })}
</span>
<span className="text-xs text-muted-foreground">{item.percentage}% of total</span>
<span className="text-xs text-muted-foreground">{t("distribution.tooltipOfTotal", { percentage: item.percentage })}</span>
</div>
)
}}