成绩复盘与 PC 端上传优化:各科考试状态多选及树状统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 16:33:21 +08:00
parent acfe002fbf
commit ff4e0b1d37
14 changed files with 956 additions and 556 deletions
+146
View File
@@ -0,0 +1,146 @@
import { Button, Checkbox, Collapse, 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 ReviewTreeChart from './ReviewTreeChart'
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 examOptions = useMemo(
() =>
exams.map((exam) => ({
value: exam.id,
label: `${exam.exam_date} · ${EXAM_TYPE_LABELS[exam.exam_type]}${exam.title ? ` · ${exam.title}` : ''}`,
})),
[exams],
)
const selectedExam = exams.find((e) => e.id === examId)
useEffect(() => {
if (!open) return
if (!examId && exams.length) {
setExamId(exams[0].id)
}
}, [open, examId, exams])
useEffect(() => {
if (!selectedExam) {
setStatusMap({})
return
}
const next: Record<number, ReviewStatus[]> = {}
for (const score of selectedExam.scores) {
next[score.subject_id] = [...(score.review_statuses || [])]
}
setStatusMap(next)
}, [selectedExam])
const handleSave = async () => {
if (!selectedExam) {
message.warning('请选择考试')
return
}
setSaving(true)
try {
await examApi.update(selectedExam.id, {
scores: selectedExam.scores.map((score) => ({
subject_id: score.subject_id,
total_score: score.total_score,
obtained_score: score.obtained_score,
review_statuses: statusMap[score.subject_id] || [],
})),
})
message.success('复盘已保存')
onRefresh()
} catch {
message.error('保存失败')
} finally {
setSaving(false)
}
}
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>
<ReviewTreeChart exams={exams} />
</Space>
),
},
]}
/>
)
}