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:
@@ -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>
|
||||
)
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user