+17
-17
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<meta name="author" content="马建军" />
|
<meta name="author" content="马建军" />
|
||||||
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
||||||
<title>中学成绩档案</title>
|
<title>中学成绩档案</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BlbLMdT7.js"></script>
|
<script type="module" crossorigin src="/assets/index-9cr1FyU2.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { examApi } from '../api/client'
|
import { examApi } from '../api/client'
|
||||||
import type { Exam, ReviewStatus } from '../types'
|
import type { Exam, ReviewStatus } from '../types'
|
||||||
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
||||||
import ReviewTreeChart from './ReviewTreeChart'
|
import ReviewBarChart from './ReviewBarChart'
|
||||||
|
|
||||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||||
if (err && typeof err === 'object' && 'response' in err) {
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
@@ -147,7 +147,7 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Typography.Text strong>复盘统计</Typography.Text>
|
<Typography.Text strong>复盘统计</Typography.Text>
|
||||||
<ReviewTreeChart exams={exams} />
|
<ReviewBarChart exams={exams} />
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import ReactECharts from 'echarts-for-react'
|
||||||
|
import type { Exam, ReviewStatus } from '../types'
|
||||||
|
import { REVIEW_STATUS_LABELS } from '../types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
exams: Exam[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ORDER: ReviewStatus[] = ['careless', 'unknown', 'nervous', 'normal']
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<ReviewStatus, string> = {
|
||||||
|
careless: '#fa8c16',
|
||||||
|
unknown: '#ff4d4f',
|
||||||
|
nervous: '#722ed1',
|
||||||
|
normal: '#52c41a',
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartData(exams: Exam[]) {
|
||||||
|
const subjectSet = new Set<string>()
|
||||||
|
const counts: Record<ReviewStatus, Record<string, number>> = {
|
||||||
|
careless: {},
|
||||||
|
unknown: {},
|
||||||
|
nervous: {},
|
||||||
|
normal: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const exam of exams) {
|
||||||
|
for (const score of exam.scores) {
|
||||||
|
const subjectName = score.subject_name || `科目${score.subject_id}`
|
||||||
|
subjectSet.add(subjectName)
|
||||||
|
for (const status of score.review_statuses || []) {
|
||||||
|
counts[status][subjectName] = (counts[status][subjectName] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjects = Array.from(subjectSet).sort((a, b) => a.localeCompare(b, 'zh-CN'))
|
||||||
|
return { subjects, counts }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewBarChart({ exams }: Props) {
|
||||||
|
const hasData = STATUS_ORDER.some((status) =>
|
||||||
|
exams.some((exam) =>
|
||||||
|
exam.scores.some((score) => (score.review_statuses || []).includes(status)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 32, color: '#999' }}>
|
||||||
|
暂无复盘数据,请在录入成绩或下方复盘中填写考试状态
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subjects, counts } = buildChartData(exams)
|
||||||
|
|
||||||
|
const option = {
|
||||||
|
title: {
|
||||||
|
text: '各科考试状态统计',
|
||||||
|
left: 'center',
|
||||||
|
textStyle: { fontSize: 15, fontWeight: 500 },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: { type: 'shadow' },
|
||||||
|
formatter: (params: Array<{ seriesName: string; value: number; marker: string }>) => {
|
||||||
|
const rows = params.filter((p) => p.value > 0)
|
||||||
|
if (!rows.length) return ''
|
||||||
|
const total = rows.reduce((sum, p) => sum + p.value, 0)
|
||||||
|
return (
|
||||||
|
rows.map((p) => `${p.marker}${p.seriesName}: ${p.value} 次`).join('<br/>') +
|
||||||
|
`<br/><strong>合计: ${total} 次</strong>`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
bottom: 0,
|
||||||
|
data: STATUS_ORDER.map((s) => REVIEW_STATUS_LABELS[s]),
|
||||||
|
},
|
||||||
|
grid: { left: 48, right: 24, top: 48, bottom: 56 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: subjects,
|
||||||
|
axisLabel: { interval: 0, rotate: subjects.length > 6 ? 30 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
name: '次数',
|
||||||
|
minInterval: 1,
|
||||||
|
splitLine: { lineStyle: { type: 'dashed' } },
|
||||||
|
},
|
||||||
|
series: STATUS_ORDER.map((status) => ({
|
||||||
|
name: REVIEW_STATUS_LABELS[status],
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'review',
|
||||||
|
barMaxWidth: 48,
|
||||||
|
emphasis: { focus: 'series' },
|
||||||
|
itemStyle: { color: STATUS_COLORS[status], borderRadius: [2, 2, 0, 0] },
|
||||||
|
data: subjects.map((subject) => counts[status][subject] || 0),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ReactECharts option={option} style={{ height: 360, width: '100%' }} notMerge />
|
||||||
|
<p style={{ color: '#888', fontSize: 12, marginTop: 8 }}>
|
||||||
|
柱状图按科目展示各状态次数(分色堆叠);同一科可多选状态,分别计数
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user