Files
NextEdu/src/modules/grades/components/grade-trend-card.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

180 lines
5.7 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 { useEffect, useMemo, useState } from "react"
import { BarChart3 } from "lucide-react"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
import type { ClassAverageTrendResult } from "../types"
import type { StudentGradeSummary } from "../types"
/** recharts 图表 margin 常量(避免每次渲染创建新对象引用) */
const CHART_MARGIN = { left: 12, right: 12, top: 12, bottom: 12 } as const
interface GradeTrendCardProps {
summary: StudentGradeSummary
/** v3-P2-2: 班级平均趋势数据,传入后会在趋势图中叠加对比线 */
classAverageData?: ClassAverageTrendResult | null
}
export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProps): JSX.Element {
const t = useTranslations("grades")
// v3-P3-4: 日期范围选择器,通过 URL 参数持久化
const [trendRange, setTrendRange] = useQueryState(
"trendRange",
parseAsString.withDefault("all"),
)
// v3-P3-4: 在 useEffect 中计算日期截止时间,避免在渲染阶段调用 Date.now()
const [cutoff, setCutoff] = useState<number | null>(null)
useEffect(() => {
const days = trendRange === "all" ? null : Number(trendRange)
const next =
days !== null && Number.isFinite(days) && days > 0
? Date.now() - days * 24 * 60 * 60 * 1000
: null
// eslint-disable-next-line react-hooks/set-state-in-effect -- 日期范围变更时需重新计算截止时间戳
setCutoff(next)
}, [trendRange])
const { chartData, hasClassAverage } = useMemo(() => {
// v3-P3-4: 根据日期范围筛选记录
const records =
cutoff !== null
? summary.records.filter((r) => {
const time = new Date(r.createdAt).getTime()
return !Number.isNaN(time) && time >= cutoff
})
: summary.records
// 学生个人趋势:按时间升序
const studentPoints = [...records]
.sort((a, b) => {
const timeA = new Date(a.createdAt).getTime()
const timeB = new Date(b.createdAt).getTime()
if (Number.isNaN(timeA) && Number.isNaN(timeB)) return 0
if (Number.isNaN(timeA)) return 1
if (Number.isNaN(timeB)) return -1
return timeA - timeB
})
.map((r) => ({
title: r.title,
// P3 修复:添加 fullScore > 0 校验,避免除零
score: r.fullScore > 0 ? Math.round((r.score / r.fullScore) * 100) : 0,
fullTitle: r.title,
submittedAt: formatDate(r.createdAt),
rawScore: r.score,
maxScore: r.fullScore,
}))
// v3-P2-2: 合并班级平均数据(按 title 对齐)
const classAvgByTitle = new Map<string, number>()
if (classAverageData?.points?.length) {
for (const p of classAverageData.points) {
classAvgByTitle.set(p.title, p.averageScore)
}
}
// 将班级平均分合并到 chartData缺失的 title 不显示对比点)
const merged = studentPoints.map((p) => ({
...p,
classAverage: classAvgByTitle.has(p.title)
? classAvgByTitle.get(p.title)
: undefined,
}))
return {
chartData: merged,
hasClassAverage: classAvgByTitle.size > 0,
}
}, [summary.records, classAverageData, cutoff])
const hasData = chartData.length > 0
// v3-P2-2: 动态构建 series有班级平均时叠加第二条线
const series = hasClassAverage
? [
{
dataKey: "score",
name: t("chart.scorePercent"),
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
{
dataKey: "classAverage",
name: t("chart.classAverage"),
color: "hsl(var(--muted-foreground))",
dotRadius: 3,
activeDotRadius: 5,
},
]
: [
{
dataKey: "score",
name: t("chart.scorePercent"),
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
]
return (
<ChartCardShell
title={t("analytics.trend")}
icon={BarChart3}
iconClassName="text-muted-foreground"
isEmpty={!hasData}
emptyTitle={t("trend.empty")}
emptyDescription={t("trendCard.emptyDescription")}
emptyClassName="h-64"
action={
<Select value={trendRange} onValueChange={setTrendRange}>
<SelectTrigger
className="w-32"
aria-label={t("trendCard.rangeAriaLabel")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("trendRange.all")}</SelectItem>
<SelectItem value="7">{t("trendRange.days7")}</SelectItem>
<SelectItem value="30">{t("trendRange.days30")}</SelectItem>
<SelectItem value="90">{t("trendRange.days90")}</SelectItem>
</SelectContent>
</Select>
}
>
<div
className="rounded-md border bg-card p-4"
role="img"
aria-label={
hasData
? t("trendCard.ariaLabelNonEmpty", { count: chartData.length })
: t("trendCard.ariaLabelEmpty")
}
>
<TrendLineChart
data={chartData}
series={series}
heightClassName="h-60"
margin={CHART_MARGIN}
yWidth={30}
tooltipClassName="w-52"
/>
</div>
</ChartCardShell>
)
}