Files
NextEdu/src/modules/grades/components/growth-archive-chart.tsx
SpecialX 94f098b0f2 perf(phase1-4): 补漏性能预算重构专项遗漏项
Phase 1 配置基线:next.config.ts 新增 experimental.optimizePackageImports(lucide-react/recharts/@xyflow/react/@tiptap/* /@radix-ui/*/date-fns)+ serverExternalPackages 追加 tencentcloud-sdk-nodejs + exceljs;providers.tsx 注入 WebVitalsReporter 闭环 RUM 上报链路(之前组件存在但未注入导致生产环境零性能数据)。

Phase 2 Bundle 预算优化补漏:TipTap 三实例(shared/ui/rich-text-editor + exams/editor/exam-rich-editor + lesson-preparation/blocks/rich-text-block)之前仅文件拆分未真正 next/dynamic ssr:false,本次补齐 lazy wrapper + xxx-inner.tsx 拆分模式;ReactFlow knowledge-graph.tsx 改用 next/dynamic 加载 inner;chart.tsx 改 recharts 具名导入替代 import * as RechartsPrimitive barrel。

Phase 4 组件渲染优化补漏:12 个 recharts 图表组件(parent/grades/dashboard/homework 模块下)margin props 提取到模块级 CHART_MARGIN 常量避免 inline 重建;layout.tsx metadata 增强(metadataBase/openGraph/twitter/robots/authors/creator)+ getLocale/getMessages/auth 串行 await 改 Promise.all 并行。

同步架构文档 004/005 + known-issues.md:新增 Phase 1 配置基线规则章节 + Phase 2 文件清单修正(之前引用不存在的 question-rich-editor.tsx 和 paper-rich-editor.tsx,实际是 rich-text-editor.tsx 和 rich-text-block.tsx)+ Phase 4.9 metadata + Promise.all 规则补充。架构图 1.1.5 章节新增未完成项(React Compiler/force-dynamic 评估/Playwright 性能断言)。

验证:npx tsc --noEmit 零错误;npm run lint 零新增错误(3 errors + 12 warnings 均位于未修改的 pre-existing 文件)。
2026-07-07 00:30:47 +08:00

191 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import type { JSX } from "react"
import { useMemo } from "react"
import { TrendingUp, TrendingDown, Minus } from "lucide-react"
import { useTranslations } from "next-intl"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
import type { StudentGrowthArchiveResult } from "../types"
/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */
const CHART_MARGIN = { left: 12, right: 12, top: 12, bottom: 12 } as const
interface GrowthArchiveChartProps {
/** 学生成长档案数据;为 null 时渲染空状态 */
data: StudentGrowthArchiveResult | null
}
/**
* P3-4: 学生纵向成长档案图表。
*
* 展示内容:
* 1. 跨学年/学期的归一化平均分趋势线
* 2. 总览统计:总记录数、总科目数、跨越学年数
* 3. 成长趋势徽章growthDelta > 0 显示"提升"< 0 显示"下降"= 0 显示"持平"
*
* 隐私保护:仅显示聚合并的均分,不暴露个人排名或他人分数。
*/
export function GrowthArchiveChart({ data }: GrowthArchiveChartProps): JSX.Element {
const t = useTranslations("grades")
const { chartData, hasData, GrowthIcon, growthLabel, growthTone } = useMemo(() => {
if (!data || data.points.length === 0) {
return {
chartData: [],
hasData: false,
GrowthIcon: Minus,
growthLabel: "",
growthTone: "muted" as const,
}
}
const points = data.points.map((p) => ({
label: `${p.academicYearName} · ${t(`semester.s${p.semester}`)}`,
fullTitle: `${p.academicYearName} · ${t(`semester.s${p.semester}`)}`,
averageScore: p.averageScore,
passRate: p.passRate,
excellentRate: p.excellentRate,
recordCount: p.recordCount,
subjectCount: p.subjectCount,
}))
const delta = data.growthDelta
let Icon = Minus
let tone: "up" | "down" | "muted" = "muted"
let label = t("growthArchive.deltaStable")
if (delta > 0) {
Icon = TrendingUp
tone = "up"
label = t("growthArchive.deltaUp", { delta: delta.toFixed(1) })
} else if (delta < 0) {
Icon = TrendingDown
tone = "down"
label = t("growthArchive.deltaDown", { delta: Math.abs(delta).toFixed(1) })
}
return {
chartData: points,
hasData: true,
GrowthIcon: Icon,
growthLabel: label,
growthTone: tone,
}
}, [data, t])
const series = [
{
dataKey: "averageScore",
name: t("growthArchive.averageScore"),
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
]
return (
<ChartCardShell
title={t("growthArchive.title")}
description={
hasData && data
? t("growthArchive.description", {
years: data.totalAcademicYears,
records: data.totalRecords,
subjects: data.totalSubjects,
})
: t("growthArchive.descriptionEmpty")
}
icon={TrendingUp}
iconClassName="text-primary"
isEmpty={!hasData}
emptyTitle={t("growthArchive.emptyTitle")}
emptyDescription={t("growthArchive.emptyDescription")}
emptyClassName="h-60"
>
{hasData && data ? (
<div className="space-y-4">
{/* 成长趋势徽章 */}
<div
className="flex items-center gap-2 rounded-md border bg-muted/30 p-3"
role="status"
aria-label={growthLabel}
>
<GrowthIcon
className={
growthTone === "up"
? "h-5 w-5 text-emerald-600"
: growthTone === "down"
? "h-5 w-5 text-destructive"
: "h-5 w-5 text-muted-foreground"
}
aria-hidden="true"
/>
<span
className={
growthTone === "up"
? "text-sm font-medium text-emerald-700"
: growthTone === "down"
? "text-sm font-medium text-destructive"
: "text-sm font-medium text-muted-foreground"
}
>
{growthLabel}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{t("growthArchive.overallAverage", { score: data.overallAverage.toFixed(1) })}
</span>
</div>
{/* 趋势折线图 */}
<div
className="rounded-md border bg-card p-4"
role="img"
aria-label={
hasData
? t("growthArchive.ariaLabelNonEmpty", { count: data.points.length })
: t("growthArchive.ariaLabelEmpty")
}
>
<TrendLineChart
data={chartData}
series={series}
heightClassName="h-64"
margin={CHART_MARGIN}
yWidth={30}
tooltipClassName="w-64"
/>
</div>
{/* 详细统计表a11y sr-only + 可视化卡片) */}
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3" role="list">
{data.points.map((p) => (
<li
key={`${p.academicYearId}-${p.semester}`}
className="rounded-md border bg-background p-3 text-xs"
role="listitem"
>
<div className="font-medium text-foreground">
{p.academicYearName} · {t(`semester.s${p.semester}`)}
</div>
<div className="mt-1 text-muted-foreground">
{t("growthArchive.statsAverage", { score: p.averageScore.toFixed(1) })}
</div>
<div className="text-muted-foreground">
{t("growthArchive.statsPassRate", { rate: p.passRate.toFixed(0) })}
</div>
<div className="text-muted-foreground">
{t("growthArchive.statsRecords", {
records: p.recordCount,
subjects: p.subjectCount,
})}
</div>
</li>
))}
</ul>
</div>
) : null}
</ChartCardShell>
)
}