Files
secondary-school-grade-archive/frontend/src/components/ExamReviewPanel.tsx
T

182 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
const detail = (err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail.map((item) => item?.msg || String(item)).join('')
}
}
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 [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(
() =>
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 (!examId && exams.length) {
setExamId(exams[0].id)
}
}, [examId, exams])
useEffect(() => {
const defaultSubject = firstSubjectWithReview(exams)
if (defaultSubject) {
setSelectedSubject((prev) => prev ?? defaultSubject)
}
}, [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: Number(score.total_score),
obtained_score: Number(score.obtained_score),
review_statuses: statusMap[score.subject_id] || [],
})),
})
message.success('复盘已保存')
onRefresh()
} catch (err) {
message.error(apiErrorMessage(err, '保存失败'))
} finally {
setSaving(false)
}
}
if (exams.length === 0) {
return (
<Typography.Text type="secondary"></Typography.Text>
)
}
return (
<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>
)
}