成绩复盘独立导航页,柱状图点击展示各科考试明细。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:26:53 +08:00
parent b4df6e5e18
commit 5f00f07dbe
9 changed files with 816 additions and 616 deletions
+99 -75
View File
@@ -1,9 +1,10 @@
import { Button, Checkbox, Collapse, Select, Space, Table, Typography, message } from 'antd'
import { Button, Checkbox, Divider, Select, Space, Table, Typography, message } from 'antd'
import { useEffect, useMemo, useState } from 'react'
import { examApi } from '../api/client'
import type { Exam, ReviewStatus } from '../types'
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
import ReviewBarChart from './ReviewBarChart'
import ReviewSubjectDetail from './ReviewSubjectDetail'
function apiErrorMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object' && 'response' in err) {
@@ -16,16 +17,29 @@ function apiErrorMessage(err: unknown, fallback: string): string {
return fallback
}
function firstSubjectWithReview(exams: Exam[]): string | null {
const names = new Set<string>()
for (const exam of exams) {
for (const score of exam.scores) {
if (score.review_statuses?.length) {
names.add(score.subject_name || `科目${score.subject_id}`)
}
}
}
const sorted = Array.from(names).sort((a, b) => a.localeCompare(b, 'zh-CN'))
return sorted[0] || null
}
interface Props {
exams: Exam[]
onRefresh: () => void
}
export default function ExamReviewPanel({ exams, onRefresh }: Props) {
const [open, setOpen] = useState(false)
const [examId, setExamId] = useState<string>()
const [statusMap, setStatusMap] = useState<Record<number, ReviewStatus[]>>({})
const [saving, setSaving] = useState(false)
const [selectedSubject, setSelectedSubject] = useState<string | null>(null)
const examOptions = useMemo(
() =>
@@ -39,11 +53,17 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
const selectedExam = exams.find((e) => e.id === examId)
useEffect(() => {
if (!open) return
if (!examId && exams.length) {
setExamId(exams[0].id)
}
}, [open, examId, exams])
}, [examId, exams])
useEffect(() => {
const defaultSubject = firstSubjectWithReview(exams)
if (defaultSubject) {
setSelectedSubject((prev) => prev ?? defaultSubject)
}
}, [exams])
useEffect(() => {
if (!selectedExam) {
@@ -81,77 +101,81 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
}
}
if (exams.length === 0) {
return (
<Typography.Text type="secondary"></Typography.Text>
)
}
return (
<Collapse
style={{ marginTop: 16 }}
activeKey={open ? ['review'] : []}
onChange={(keys) => setOpen(keys.includes('review'))}
items={[
{
key: 'review',
label: '复盘',
children: (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{exams.length === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<>
<Space wrap style={{ width: '100%' }}>
<Select
style={{ minWidth: 260, flex: 1 }}
placeholder="选择考试"
value={examId}
onChange={setExamId}
options={examOptions}
/>
<Button type="primary" loading={saving} onClick={handleSave}>
</Button>
</Space>
{selectedExam && (
<Table
size="small"
pagination={false}
rowKey="subject_id"
dataSource={selectedExam.scores}
scroll={{ x: 480 }}
columns={[
{
title: '科目',
dataIndex: 'subject_name',
width: 80,
},
{
title: '得分',
width: 100,
render: (_, row) => `${row.obtained_score}/${row.total_score}`,
},
{
title: '考试状态(可多选)',
render: (_, row) => (
<Checkbox.Group
options={REVIEW_STATUS_OPTIONS}
value={statusMap[row.subject_id] || []}
onChange={(values) =>
setStatusMap((prev) => ({
...prev,
[row.subject_id]: values as ReviewStatus[],
}))
}
/>
),
},
]}
/>
)}
</>
)}
<Typography.Text strong></Typography.Text>
<ReviewBarChart exams={exams} />
</Space>
),
},
]}
/>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<Space wrap style={{ width: '100%', marginBottom: 12 }}>
<Select
style={{ minWidth: 280 }}
placeholder="选择考试"
value={examId}
onChange={setExamId}
options={examOptions}
/>
<Button type="primary" loading={saving} onClick={handleSave}>
</Button>
</Space>
{selectedExam && (
<Table
size="small"
pagination={false}
rowKey="subject_id"
dataSource={selectedExam.scores}
scroll={{ x: 480 }}
columns={[
{
title: '科目',
dataIndex: 'subject_name',
width: 80,
},
{
title: '得分',
width: 100,
render: (_, row) => `${row.obtained_score}/${row.total_score}`,
},
{
title: '考试状态(可多选)',
render: (_, row) => (
<Checkbox.Group
options={REVIEW_STATUS_OPTIONS}
value={statusMap[row.subject_id] || []}
onChange={(values) =>
setStatusMap((prev) => ({
...prev,
[row.subject_id]: values as ReviewStatus[],
}))
}
/>
),
},
]}
/>
)}
</div>
<Divider style={{ margin: '8px 0' }} />
<div>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<ReviewBarChart
exams={exams}
selectedSubject={selectedSubject}
onSubjectSelect={setSelectedSubject}
/>
<ReviewSubjectDetail exams={exams} subjectName={selectedSubject} />
</div>
</Space>
)
}
+48 -12
View File
@@ -1,9 +1,12 @@
import ReactECharts from 'echarts-for-react'
import { useMemo } from 'react'
import type { Exam, ReviewStatus } from '../types'
import { REVIEW_STATUS_LABELS } from '../types'
interface Props {
exams: Exam[]
selectedSubject?: string | null
onSubjectSelect?: (subject: string) => void
}
const STATUS_ORDER: ReviewStatus[] = ['careless', 'unknown', 'nervous', 'normal']
@@ -38,39 +41,42 @@ function buildChartData(exams: Exam[]) {
return { subjects, counts }
}
export default function ReviewBarChart({ exams }: Props) {
export default function ReviewBarChart({ exams, selectedSubject, onSubjectSelect }: Props) {
const hasData = STATUS_ORDER.some((status) =>
exams.some((exam) =>
exam.scores.some((score) => (score.review_statuses || []).includes(status)),
),
)
const { subjects, counts } = useMemo(() => buildChartData(exams), [exams])
if (!hasData) {
return (
<div style={{ textAlign: 'center', padding: 32, color: '#999' }}>
</div>
)
}
const { subjects, counts } = buildChartData(exams)
const option = {
title: {
text: '各科考试状态统计',
subtext: selectedSubject ? `当前选中:${selectedSubject}` : '点击柱子查看明细',
left: 'center',
textStyle: { fontSize: 15, fontWeight: 500 },
subtextStyle: { fontSize: 12, color: '#888' },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: Array<{ seriesName: string; value: number; marker: string }>) => {
formatter: (params: Array<{ seriesName: string; value: number; marker: string; name: string }>) => {
const rows = params.filter((p) => p.value > 0)
if (!rows.length) return ''
const total = rows.reduce((sum, p) => sum + p.value, 0)
return (
`<strong>${params[0]?.name || ''}</strong><br/>` +
rows.map((p) => `${p.marker}${p.seriesName}: ${p.value}`).join('<br/>') +
`<br/><strong>合计: ${total}</strong>`
`<br/>合计: ${total}`
)
},
},
@@ -78,11 +84,16 @@ export default function ReviewBarChart({ exams }: Props) {
bottom: 0,
data: STATUS_ORDER.map((s) => REVIEW_STATUS_LABELS[s]),
},
grid: { left: 48, right: 24, top: 48, bottom: 56 },
grid: { left: 48, right: 24, top: 64, bottom: 56 },
xAxis: {
type: 'category',
data: subjects,
axisLabel: { interval: 0, rotate: subjects.length > 6 ? 30 : 0 },
axisLabel: {
interval: 0,
rotate: subjects.length > 6 ? 30 : 0,
color: (value: string) => (value === selectedSubject ? '#1677ff' : '#666'),
fontWeight: (value: string) => (value === selectedSubject ? ('bold' as const) : ('normal' as const)),
},
},
yAxis: {
type: 'value',
@@ -96,16 +107,41 @@ export default function ReviewBarChart({ exams }: Props) {
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),
data: subjects.map((subject) => {
const value = counts[status][subject] || 0
const selected = subject === selectedSubject
return {
value,
itemStyle: {
color: STATUS_COLORS[status],
borderRadius: status === 'normal' ? [2, 2, 0, 0] : 0,
opacity: selectedSubject && !selected ? 0.45 : 1,
borderColor: selected ? '#1677ff' : 'transparent',
borderWidth: selected ? 2 : 0,
},
}
}),
})),
}
const onEvents = {
click: (params: { componentType?: string; name?: string }) => {
if (params.componentType === 'series' && params.name) {
onSubjectSelect?.(params.name)
}
},
}
return (
<div>
<ReactECharts option={option} style={{ height: 360, width: '100%' }} notMerge />
<ReactECharts
option={option}
style={{ height: 360, width: '100%' }}
notMerge
onEvents={onEvents}
/>
<p style={{ color: '#888', fontSize: 12, marginTop: 8 }}>
</p>
</div>
)
@@ -0,0 +1,132 @@
import { Tag, Typography } from 'antd'
import { useMemo } from 'react'
import type { Exam } from '../types'
import {
EXAM_TYPE_LABELS,
REVIEW_STATUS_LABELS,
hasReviewProblem,
} from '../types'
import type { ReviewStatus } from '../types'
interface Props {
exams: Exam[]
subjectName: string | null
}
interface DetailRow {
key: string
examDate: string
examType: string
title: string | null
scoreText: string
ratioText: string
statuses: ReviewStatus[]
isProblem: boolean
}
const STATUS_TAG_COLOR: Record<ReviewStatus, string> = {
careless: 'orange',
unknown: 'red',
nervous: 'purple',
normal: 'green',
}
export default function ReviewSubjectDetail({ exams, subjectName }: Props) {
const rows = useMemo(() => {
if (!subjectName) return []
const result: DetailRow[] = []
for (const exam of exams) {
const score = exam.scores.find(
(s) => (s.subject_name || `科目${s.subject_id}`) === subjectName,
)
if (!score || !score.review_statuses?.length) continue
result.push({
key: exam.id,
examDate: exam.exam_date,
examType: EXAM_TYPE_LABELS[exam.exam_type],
title: exam.title,
scoreText: `${score.obtained_score}/${score.total_score}`,
ratioText: `${(score.ratio * 100).toFixed(1)}%`,
statuses: score.review_statuses,
isProblem: hasReviewProblem(score.review_statuses),
})
}
return result.sort((a, b) => b.examDate.localeCompare(a.examDate))
}, [exams, subjectName])
if (!subjectName) {
return (
<Typography.Text type="secondary" style={{ display: 'block', padding: '12px 0' }}>
</Typography.Text>
)
}
if (rows.length === 0) {
return (
<Typography.Text type="secondary" style={{ display: 'block', padding: '12px 0' }}>
{subjectName}
</Typography.Text>
)
}
const problemCount = rows.filter((r) => r.isProblem).length
return (
<div style={{ marginTop: 16 }}>
<Typography.Text strong style={{ fontSize: 15 }}>
{subjectName} ·
</Typography.Text>
<Typography.Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}>
{rows.length} {problemCount}
</Typography.Text>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
{rows.map((row) => (
<div
key={row.key}
style={{
padding: '12px 14px',
borderRadius: 8,
border: `1px solid ${row.isProblem ? '#ffccc7' : '#f0f0f0'}`,
background: row.isProblem ? '#fff2f0' : '#fafafa',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<Typography.Text strong style={{ color: row.isProblem ? '#cf1322' : undefined }}>
{row.examDate}
</Typography.Text>
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#666' }}>
{row.examType}
</Typography.Text>
{row.title && (
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#888' }}>
{row.title}
</Typography.Text>
)}
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : undefined }}>
{row.scoreText}{row.ratioText}
</Typography.Text>
</div>
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
<Typography.Text
type={row.isProblem ? 'danger' : 'secondary'}
style={{ fontSize: 13 }}
>
</Typography.Text>
{row.statuses.map((status) => (
<Tag
key={status}
color={STATUS_TAG_COLOR[status]}
style={row.isProblem && status !== 'normal' ? { fontWeight: 600 } : undefined}
>
{REVIEW_STATUS_LABELS[status]}
</Tag>
))}
</div>
</div>
))}
</div>
</div>
)
}
-2
View File
@@ -5,7 +5,6 @@ import { useEffect, useState } from 'react'
import { examApi } from '../api/client'
import type { Exam, ExamType, ReviewStatus, ScoreInput, Subject } from '../types'
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
import ExamReviewPanel from './ExamReviewPanel'
interface Props {
studentId: string
@@ -166,7 +165,6 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
</Button>
<Table rowKey="id" columns={columns} dataSource={exams} pagination={{ pageSize: 10 }} scroll={{ x: 600 }} />
<ExamReviewPanel exams={exams} onRefresh={onRefresh} />
<Modal
title={editing ? '编辑考试' : '录入成绩'}
+7 -1
View File
@@ -8,10 +8,11 @@ import ScoreOverview from '../components/ScoreOverview'
import TrendChart from '../components/TrendChart'
import WrongQuestionList from '../components/WrongQuestionList'
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
import ExamReviewPanel from '../components/ExamReviewPanel'
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
const TAB_KEYS = ['scores', 'overview', 'trend', 'wrong', 'olympiad'] as const
const TAB_KEYS = ['scores', 'overview', 'trend', 'review', 'wrong', 'olympiad'] as const
type TabKey = (typeof TAB_KEYS)[number]
export default function StudentDetailPage() {
@@ -192,6 +193,11 @@ export default function StudentDetailPage() {
</div>
),
},
{
key: 'review',
label: '成绩复盘',
children: <ExamReviewPanel exams={exams} onRefresh={loadExams} />,
},
{
key: 'wrong',
label: '错题库',
+11
View File
@@ -77,6 +77,17 @@ export const REVIEW_STATUS_OPTIONS = (
Object.entries(REVIEW_STATUS_LABELS) as [ReviewStatus, string][]
).map(([value, label]) => ({ value, label }))
export const PROBLEM_REVIEW_STATUSES: ReviewStatus[] = ['careless', 'unknown', 'nervous']
export function hasReviewProblem(statuses: ReviewStatus[] | undefined): boolean {
return (statuses || []).some((s) => PROBLEM_REVIEW_STATUSES.includes(s))
}
export function formatReviewStatuses(statuses: ReviewStatus[] | undefined): string {
if (!statuses?.length) return '-'
return statuses.map((s) => REVIEW_STATUS_LABELS[s]).join('、')
}
export type ExamType = 'weekly' | 'monthly' | 'final'
export interface Exam {