V3-5: exam-actions.tsx 集成 useExamHomeworkFeatures hook,按角色控制菜单项可见性 V3-7: 批量批改 — 新增 batchAutoGradeSubmissions data-access + Server Action + HomeworkBatchGradingView 组件 V3-8: 考试分析仪表盘 — 新增 getExamAnalytics stats-service + ExamAnalyticsDashboard 组件 + /teacher/exams/[id]/analytics 路由 V3-9: 提交后即时反馈页 — 新增 HomeworkSubmissionResult 组件 + /student/learning/assignments/[id]/result 路由 V3-11: 家长考试详情 — 新增 ChildExamDetail 组件 + getStudentExamResults data-access + child-detail-panel exams Tab V3-12: 移动端触控优化 — 题目导航与考试操作按钮 44px 最小触控目标 修复: instrumentation.ts 适配器补全 questionCount/averageScore/overdueCount 字段 修复: exam-homework-port.ts 类型导入对齐 ExamWithQuestionsForHomework 修复: trend-line-chart.tsx 数据类型允许 undefined(classAverage 可选场景) 同步更新 004/005 架构文档
154 lines
4.8 KiB
TypeScript
154 lines
4.8 KiB
TypeScript
"use client"
|
||
|
||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||
|
||
import {
|
||
ChartContainer,
|
||
ChartTooltip,
|
||
ChartTooltipContent,
|
||
type ChartConfig,
|
||
} from "@/shared/components/ui/chart"
|
||
import { cn } from "@/shared/lib/utils"
|
||
|
||
/**
|
||
* 趋势折线图:统一的 LineChart 配置(CartesianGrid + XAxis + YAxis + ChartTooltip + Line)。
|
||
*
|
||
* 覆盖以下重复模式(4 个文件几乎逐行相同):
|
||
* - GradeTrendChart(dataKey=normalizedScore, height=h-[280px])
|
||
* - TeacherGradeTrends(dataKey=score, height=h-[200px])
|
||
* - StudentGradesCard(dataKey=score, height=h-[200px])
|
||
* - ChildGradeSummary(dataKey=score, xKey=date, height=h-[160px])
|
||
*
|
||
* 默认配置:
|
||
* - CartesianGrid: vertical=false, strokeDasharray="4 4", strokeOpacity=0.4
|
||
* - XAxis: tickLine=false, axisLine=false, tickMargin=8, 默认截断到 10 字符
|
||
* - YAxis: domain=[0,100], tickLine=false, axisLine=false, 默认百分比格式化, width=36
|
||
* - ChartTooltip: cursor 虚线, content=ChartTooltipContent indicator="line" labelKey="fullTitle"
|
||
* - Line: type="monotone", strokeWidth=2
|
||
*/
|
||
export interface TrendLineSeries {
|
||
/** 数据字段名(对应 data 中的 key) */
|
||
dataKey: string
|
||
/** 图例名称 */
|
||
name: string
|
||
/** 颜色(CSS 变量或 hsl 值,如 "hsl(var(--primary))") */
|
||
color: string
|
||
/** 数据点半径(默认 3) */
|
||
dotRadius?: number
|
||
/** 激活数据点半径(默认 5) */
|
||
activeDotRadius?: number
|
||
}
|
||
|
||
interface TrendLineChartProps {
|
||
/** 图表数据 */
|
||
data: Array<Record<string, string | number | undefined>>
|
||
/** 折线系列配置(支持单条或多条) */
|
||
series: TrendLineSeries[]
|
||
/** X 轴数据字段名(默认 "title") */
|
||
xKey?: string
|
||
/** Y 轴定义域(默认 [0, 100]) */
|
||
yDomain?: [number, number]
|
||
/** Y 轴刻度格式化(默认百分比 `${value}%`) */
|
||
yTickFormatter?: (value: number) => string
|
||
/** X 轴刻度格式化(默认截断到 10 字符;设为 null 则不格式化) */
|
||
xTickFormatter?: ((value: string) => string) | null
|
||
/** 图表高度类名(默认 "h-[280px]") */
|
||
heightClassName?: string
|
||
/** 图表 margin(默认 { left: 8, right: 8, top: 8, bottom: 8 }) */
|
||
margin?: { left: number; right: number; top: number; bottom: number }
|
||
/** Y 轴宽度(默认 36) */
|
||
yWidth?: number
|
||
/** Tooltip 内容宽度类名(默认 "w-[220px]") */
|
||
tooltipClassName?: string
|
||
/** Tooltip labelKey(默认 "fullTitle") */
|
||
tooltipLabelKey?: string
|
||
/** 容器额外类名 */
|
||
className?: string
|
||
}
|
||
|
||
const DEFAULT_X_TICK_TRUNCATE_LENGTH = 10
|
||
|
||
function defaultXTickFormatter(value: string): string {
|
||
return value.length > DEFAULT_X_TICK_TRUNCATE_LENGTH
|
||
? `${value.slice(0, DEFAULT_X_TICK_TRUNCATE_LENGTH)}...`
|
||
: value
|
||
}
|
||
|
||
function defaultYTickFormatter(value: number): string {
|
||
return `${value}%`
|
||
}
|
||
|
||
export function TrendLineChart({
|
||
data,
|
||
series,
|
||
xKey = "title",
|
||
yDomain = [0, 100],
|
||
yTickFormatter = defaultYTickFormatter,
|
||
xTickFormatter = defaultXTickFormatter,
|
||
heightClassName = "h-[280px]",
|
||
margin = { left: 8, right: 8, top: 8, bottom: 8 },
|
||
yWidth = 36,
|
||
tooltipClassName = "w-[220px]",
|
||
tooltipLabelKey = "fullTitle",
|
||
className,
|
||
}: TrendLineChartProps) {
|
||
const chartConfig: ChartConfig = {}
|
||
for (const s of series) {
|
||
chartConfig[s.dataKey] = {
|
||
label: s.name,
|
||
color: s.color,
|
||
}
|
||
}
|
||
|
||
return (
|
||
<ChartContainer config={chartConfig} className={cn(heightClassName, "w-full", className)}>
|
||
<LineChart data={data} margin={margin}>
|
||
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||
<XAxis
|
||
dataKey={xKey}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickMargin={8}
|
||
tickFormatter={xTickFormatter ?? undefined}
|
||
/>
|
||
<YAxis
|
||
domain={yDomain}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickFormatter={yTickFormatter}
|
||
width={yWidth}
|
||
/>
|
||
<ChartTooltip
|
||
cursor={{
|
||
stroke: "hsl(var(--muted-foreground))",
|
||
strokeWidth: 1,
|
||
strokeDasharray: "4 4",
|
||
}}
|
||
content={
|
||
<ChartTooltipContent
|
||
indicator="line"
|
||
labelKey={tooltipLabelKey}
|
||
className={tooltipClassName}
|
||
/>
|
||
}
|
||
/>
|
||
{series.map((s) => (
|
||
<Line
|
||
key={s.dataKey}
|
||
dataKey={s.dataKey}
|
||
type="monotone"
|
||
stroke={`var(--color-${s.dataKey})`}
|
||
strokeWidth={2}
|
||
dot={{
|
||
fill: `var(--color-${s.dataKey})`,
|
||
r: s.dotRadius ?? 3,
|
||
strokeWidth: 2,
|
||
}}
|
||
activeDot={{ r: s.activeDotRadius ?? 5, strokeWidth: 0 }}
|
||
/>
|
||
))}
|
||
</LineChart>
|
||
</ChartContainer>
|
||
)
|
||
}
|