5f00f07dbe
Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
5.4 KiB
TypeScript
182 lines
5.4 KiB
TypeScript
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>
|
||
)
|
||
}
|