成绩复盘独立导航页,柱状图点击展示各科考试明细。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? '编辑考试' : '录入成绩'}
|
||||
|
||||
@@ -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: '错题库',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user