e329d3398a
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>
133 lines
3.9 KiB
TypeScript
133 lines
3.9 KiB
TypeScript
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>
|
||
)
|
||
}
|