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