Files
secondary-school-grade-archive/frontend/src/components/TrendChart.tsx
T
dekun e329d3398a Initial commit: secondary school grade archive system.
Add FastAPI/React app with Docker deployment, Ubuntu one-click install, and docs for junior/senior high score tracking and mistake bank.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 11:18:58 +08:00

133 lines
3.9 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.
import ReactECharts from 'echarts-for-react'
import type { TrendPoint } from '../types'
import { EXAM_TYPE_LABELS } from '../types'
interface Props {
points: TrendPoint[]
subjectName: string
threshold: number
}
const COLORS = {
up: '#52c41a',
down: '#ff4d4f',
flat: '#8c8c8c',
volatile: '#fa8c16',
}
export default function TrendChart({ points, subjectName, threshold }: Props) {
if (points.length === 0) {
return <div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
}
const dates = points.map((p) => p.exam_date)
const values = points.map((p) => p.ratio_percent)
const lineSeries = points.slice(1).map((point, i) => {
let color = COLORS.flat
if (point.direction === 'up') color = COLORS.up
if (point.direction === 'down') color = COLORS.down
return {
type: 'line' as const,
data: dates.map((_, idx) => (idx === i || idx === i + 1 ? values[idx] : null)),
connectNulls: false,
showSymbol: false,
lineStyle: { width: 3, color },
tooltip: { show: false },
silent: true,
}
})
const markPoints = points
.map((point, i) => ({ point, i }))
.filter(({ point }) => point.is_volatile)
.map(({ i }) => ({
coord: [dates[i], values[i]],
symbol: 'circle',
symbolSize: 18,
itemStyle: {
color: COLORS.volatile,
borderColor: '#fff',
borderWidth: 2,
},
label: { show: false },
}))
const option = {
title: {
text: `${subjectName} 成绩占比趋势`,
left: 'center',
textStyle: { fontSize: 16 },
},
tooltip: {
trigger: 'axis',
formatter: (params: Array<{ dataIndex: number; value: number }>) => {
const idx = params[0]?.dataIndex ?? 0
const p = points[idx]
if (!p) return ''
const typeLabel = EXAM_TYPE_LABELS[p.exam_type]
let html = `<strong>${p.exam_date}</strong> (${typeLabel})<br/>占比: ${p.ratio_percent}%`
if (p.title) html += `<br/>${p.title}`
if (p.delta_percent !== null) {
const sign = p.delta_percent > 0 ? '+' : ''
html += `<br/>较上次: ${sign}${p.delta_percent}%`
if (p.is_volatile) html += ' <span style="color:#fa8c16">[大幅波动]</span>'
}
return html
},
},
grid: { left: 50, right: 30, top: 60, bottom: 50 },
xAxis: {
type: 'category',
data: dates,
axisLabel: { rotate: 30 },
},
yAxis: {
type: 'value',
name: '占比 (%)',
min: 0,
max: 100,
},
series: [
{
type: 'line',
data: values,
symbol: 'circle',
symbolSize: (_val: number, params: { dataIndex: number }) =>
points[params.dataIndex]?.is_volatile ? 14 : 8,
itemStyle: {
color: (params: { dataIndex: number }) => {
const p = points[params.dataIndex]
if (p?.is_volatile) return COLORS.volatile
if (p?.direction === 'up') return COLORS.up
if (p?.direction === 'down') return COLORS.down
return '#1677ff'
},
},
lineStyle: { opacity: 0 },
markPoint: markPoints.length ? { data: markPoints } : undefined,
z: 10,
},
...lineSeries,
],
legend: {
bottom: 0,
data: [
{ name: '上升', itemStyle: { color: COLORS.up } },
{ name: '下降', itemStyle: { color: COLORS.down } },
{ name: '大幅波动', itemStyle: { color: COLORS.volatile } },
],
},
}
return (
<div>
<ReactECharts option={option} style={{ height: 400, width: '100%' }} notMerge />
<p style={{ color: '#888', fontSize: 12, marginTop: 8 }}>
: {(threshold * 100).toFixed(0)}%
</p>
</div>
)
}