- 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)
175 lines
5.4 KiB
TypeScript
175 lines
5.4 KiB
TypeScript
"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"
|
||
|
||
/**
|
||
* 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
|
||
}
|
||
|
||
interface DistributionTooltipItem {
|
||
label: string
|
||
count: number
|
||
percentage: number
|
||
}
|
||
|
||
interface DistributionTooltipPayload {
|
||
payload?: DistributionTooltipItem
|
||
}
|
||
|
||
function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPayload {
|
||
if (typeof v !== "object" || v === null) return false
|
||
const obj = v as Record<string, unknown>
|
||
const inner = obj.payload
|
||
if (inner === undefined || inner === null) return true
|
||
if (typeof inner !== "object") return false
|
||
const item = inner as Record<string, unknown>
|
||
return (
|
||
typeof item.label === "string" &&
|
||
typeof item.count === "number" &&
|
||
typeof item.percentage === "number"
|
||
)
|
||
}
|
||
|
||
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||
const t = useTranslations("grades")
|
||
const isEmpty = !data || data.totalCount === 0
|
||
|
||
const chartData = isEmpty
|
||
? []
|
||
: data.buckets.map((b) => ({
|
||
label: b.label,
|
||
count: b.count,
|
||
percentage:
|
||
data.totalCount > 0
|
||
? Math.round((b.count / data.totalCount) * 1000) / 10
|
||
: 0,
|
||
}))
|
||
|
||
return (
|
||
<ChartCardShell
|
||
title={t("distribution.title")}
|
||
description={
|
||
isEmpty
|
||
? t("distribution.descriptionEmpty")
|
||
: t("distribution.descriptionNonEmpty", { count: data.totalCount })
|
||
}
|
||
icon={PieChartIcon}
|
||
isEmpty={isEmpty}
|
||
emptyTitle={t("distribution.emptyTitle")}
|
||
emptyDescription={t("distribution.emptyDescription")}
|
||
emptyClassName="h-60"
|
||
>
|
||
<div role="img" aria-label={isEmpty ? t("distribution.ariaLabelEmpty") : t("distribution.ariaLabelNonEmpty", { count: data.totalCount })}>
|
||
<SimpleBarChart
|
||
data={chartData}
|
||
bars={[
|
||
{
|
||
dataKey: "count",
|
||
name: t("chart.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]"
|
||
defs={PATTERN_DEFS}
|
||
cellColors={BUCKET_FILLS}
|
||
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}: {t("distribution.tooltipStudents", { count: item.count })}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">{t("distribution.tooltipOfTotal", { percentage: item.percentage })}</span>
|
||
</div>
|
||
)
|
||
}}
|
||
/>
|
||
</div>
|
||
</ChartCardShell>
|
||
)
|
||
}
|