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 文件)。
191 lines
6.2 KiB
TypeScript
191 lines
6.2 KiB
TypeScript
"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>
|
||
)
|
||
}
|