成绩复盘独立导航页,柱状图点击展示各科考试明细。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
-525
File diff suppressed because one or more lines are too long
+518
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<meta name="author" content="马建军" />
|
<meta name="author" content="马建军" />
|
||||||
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
||||||
<title>中学成绩档案</title>
|
<title>中学成绩档案</title>
|
||||||
<script type="module" crossorigin src="/assets/index-9cr1FyU2.js"></script>
|
<script type="module" crossorigin src="/assets/index-DP70ZAE9.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { examApi } from '../api/client'
|
import { examApi } from '../api/client'
|
||||||
import type { Exam, ReviewStatus } from '../types'
|
import type { Exam, ReviewStatus } from '../types'
|
||||||
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
||||||
import ReviewBarChart from './ReviewBarChart'
|
import ReviewBarChart from './ReviewBarChart'
|
||||||
|
import ReviewSubjectDetail from './ReviewSubjectDetail'
|
||||||
|
|
||||||
function apiErrorMessage(err: unknown, fallback: string): string {
|
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||||
if (err && typeof err === 'object' && 'response' in err) {
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
@@ -16,16 +17,29 @@ function apiErrorMessage(err: unknown, fallback: string): string {
|
|||||||
return fallback
|
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 {
|
interface Props {
|
||||||
exams: Exam[]
|
exams: Exam[]
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExamReviewPanel({ exams, onRefresh }: Props) {
|
export default function ExamReviewPanel({ exams, onRefresh }: Props) {
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [examId, setExamId] = useState<string>()
|
const [examId, setExamId] = useState<string>()
|
||||||
const [statusMap, setStatusMap] = useState<Record<number, ReviewStatus[]>>({})
|
const [statusMap, setStatusMap] = useState<Record<number, ReviewStatus[]>>({})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [selectedSubject, setSelectedSubject] = useState<string | null>(null)
|
||||||
|
|
||||||
const examOptions = useMemo(
|
const examOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -39,11 +53,17 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
|
|||||||
const selectedExam = exams.find((e) => e.id === examId)
|
const selectedExam = exams.find((e) => e.id === examId)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
|
||||||
if (!examId && exams.length) {
|
if (!examId && exams.length) {
|
||||||
setExamId(exams[0].id)
|
setExamId(exams[0].id)
|
||||||
}
|
}
|
||||||
}, [open, examId, exams])
|
}, [examId, exams])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const defaultSubject = firstSubjectWithReview(exams)
|
||||||
|
if (defaultSubject) {
|
||||||
|
setSelectedSubject((prev) => prev ?? defaultSubject)
|
||||||
|
}
|
||||||
|
}, [exams])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedExam) {
|
if (!selectedExam) {
|
||||||
@@ -81,24 +101,21 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exams.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Typography.Text type="secondary">请先录入至少一次考试成绩,再进行复盘</Typography.Text>
|
||||||
style={{ marginTop: 16 }}
|
)
|
||||||
activeKey={open ? ['review'] : []}
|
}
|
||||||
onChange={(keys) => setOpen(keys.includes('review'))}
|
|
||||||
items={[
|
return (
|
||||||
{
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
key: 'review',
|
<div>
|
||||||
label: '复盘',
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
children: (
|
填写复盘
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
</Typography.Title>
|
||||||
{exams.length === 0 ? (
|
<Space wrap style={{ width: '100%', marginBottom: 12 }}>
|
||||||
<Typography.Text type="secondary">请先录入至少一次考试成绩</Typography.Text>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Space wrap style={{ width: '100%' }}>
|
|
||||||
<Select
|
<Select
|
||||||
style={{ minWidth: 260, flex: 1 }}
|
style={{ minWidth: 280 }}
|
||||||
placeholder="选择考试"
|
placeholder="选择考试"
|
||||||
value={examId}
|
value={examId}
|
||||||
onChange={setExamId}
|
onChange={setExamId}
|
||||||
@@ -144,14 +161,21 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
<Typography.Text strong>复盘统计</Typography.Text>
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
<ReviewBarChart exams={exams} />
|
|
||||||
</Space>
|
<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 ReactECharts from 'echarts-for-react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
import type { Exam, ReviewStatus } from '../types'
|
import type { Exam, ReviewStatus } from '../types'
|
||||||
import { REVIEW_STATUS_LABELS } from '../types'
|
import { REVIEW_STATUS_LABELS } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exams: Exam[]
|
exams: Exam[]
|
||||||
|
selectedSubject?: string | null
|
||||||
|
onSubjectSelect?: (subject: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_ORDER: ReviewStatus[] = ['careless', 'unknown', 'nervous', 'normal']
|
const STATUS_ORDER: ReviewStatus[] = ['careless', 'unknown', 'nervous', 'normal']
|
||||||
@@ -38,39 +41,42 @@ function buildChartData(exams: Exam[]) {
|
|||||||
return { subjects, counts }
|
return { subjects, counts }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReviewBarChart({ exams }: Props) {
|
export default function ReviewBarChart({ exams, selectedSubject, onSubjectSelect }: Props) {
|
||||||
const hasData = STATUS_ORDER.some((status) =>
|
const hasData = STATUS_ORDER.some((status) =>
|
||||||
exams.some((exam) =>
|
exams.some((exam) =>
|
||||||
exam.scores.some((score) => (score.review_statuses || []).includes(status)),
|
exam.scores.some((score) => (score.review_statuses || []).includes(status)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { subjects, counts } = useMemo(() => buildChartData(exams), [exams])
|
||||||
|
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: 32, color: '#999' }}>
|
<div style={{ textAlign: 'center', padding: 32, color: '#999' }}>
|
||||||
暂无复盘数据,请在录入成绩或下方复盘中填写考试状态
|
暂无复盘数据,请在下方填写各科考试状态
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { subjects, counts } = buildChartData(exams)
|
|
||||||
|
|
||||||
const option = {
|
const option = {
|
||||||
title: {
|
title: {
|
||||||
text: '各科考试状态统计',
|
text: '各科考试状态统计',
|
||||||
|
subtext: selectedSubject ? `当前选中:${selectedSubject}` : '点击柱子查看明细',
|
||||||
left: 'center',
|
left: 'center',
|
||||||
textStyle: { fontSize: 15, fontWeight: 500 },
|
textStyle: { fontSize: 15, fontWeight: 500 },
|
||||||
|
subtextStyle: { fontSize: 12, color: '#888' },
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: { type: 'shadow' },
|
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)
|
const rows = params.filter((p) => p.value > 0)
|
||||||
if (!rows.length) return ''
|
if (!rows.length) return ''
|
||||||
const total = rows.reduce((sum, p) => sum + p.value, 0)
|
const total = rows.reduce((sum, p) => sum + p.value, 0)
|
||||||
return (
|
return (
|
||||||
|
`<strong>${params[0]?.name || ''}</strong><br/>` +
|
||||||
rows.map((p) => `${p.marker}${p.seriesName}: ${p.value} 次`).join('<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,
|
bottom: 0,
|
||||||
data: STATUS_ORDER.map((s) => REVIEW_STATUS_LABELS[s]),
|
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: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: subjects,
|
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: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
@@ -96,16 +107,41 @@ export default function ReviewBarChart({ exams }: Props) {
|
|||||||
stack: 'review',
|
stack: 'review',
|
||||||
barMaxWidth: 48,
|
barMaxWidth: 48,
|
||||||
emphasis: { focus: 'series' },
|
emphasis: { focus: 'series' },
|
||||||
itemStyle: { color: STATUS_COLORS[status], borderRadius: [2, 2, 0, 0] },
|
data: subjects.map((subject) => {
|
||||||
data: subjects.map((subject) => counts[status][subject] || 0),
|
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 (
|
return (
|
||||||
<div>
|
<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 style={{ color: '#888', fontSize: 12, marginTop: 8 }}>
|
||||||
柱状图按科目展示各状态次数(分色堆叠);同一科可多选状态,分别计数
|
柱状图按科目展示各状态次数(分色堆叠);点击科目查看每次考试详情,存在问题标红
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 { examApi } from '../api/client'
|
||||||
import type { Exam, ExamType, ReviewStatus, ScoreInput, Subject } from '../types'
|
import type { Exam, ExamType, ReviewStatus, ScoreInput, Subject } from '../types'
|
||||||
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
||||||
import ExamReviewPanel from './ExamReviewPanel'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
studentId: string
|
studentId: string
|
||||||
@@ -166,7 +165,6 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
|
|||||||
录入成绩
|
录入成绩
|
||||||
</Button>
|
</Button>
|
||||||
<Table rowKey="id" columns={columns} dataSource={exams} pagination={{ pageSize: 10 }} scroll={{ x: 600 }} />
|
<Table rowKey="id" columns={columns} dataSource={exams} pagination={{ pageSize: 10 }} scroll={{ x: 600 }} />
|
||||||
<ExamReviewPanel exams={exams} onRefresh={onRefresh} />
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? '编辑考试' : '录入成绩'}
|
title={editing ? '编辑考试' : '录入成绩'}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import ScoreOverview from '../components/ScoreOverview'
|
|||||||
import TrendChart from '../components/TrendChart'
|
import TrendChart from '../components/TrendChart'
|
||||||
import WrongQuestionList from '../components/WrongQuestionList'
|
import WrongQuestionList from '../components/WrongQuestionList'
|
||||||
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
|
import WrongQuestionUpload, { WrongQuestionFilters } from '../components/WrongQuestionUpload'
|
||||||
|
import ExamReviewPanel from '../components/ExamReviewPanel'
|
||||||
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
import { formatStudentMeta, SCHOOL_LEVEL_LABELS } from '../constants/school'
|
||||||
import type { Exam, Student, Subject, TrendResponse, WrongQuestion } from '../types'
|
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]
|
type TabKey = (typeof TAB_KEYS)[number]
|
||||||
|
|
||||||
export default function StudentDetailPage() {
|
export default function StudentDetailPage() {
|
||||||
@@ -192,6 +193,11 @@ export default function StudentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'review',
|
||||||
|
label: '成绩复盘',
|
||||||
|
children: <ExamReviewPanel exams={exams} onRefresh={loadExams} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'wrong',
|
key: 'wrong',
|
||||||
label: '错题库',
|
label: '错题库',
|
||||||
|
|||||||
@@ -77,6 +77,17 @@ export const REVIEW_STATUS_OPTIONS = (
|
|||||||
Object.entries(REVIEW_STATUS_LABELS) as [ReviewStatus, string][]
|
Object.entries(REVIEW_STATUS_LABELS) as [ReviewStatus, string][]
|
||||||
).map(([value, label]) => ({ value, label }))
|
).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 type ExamType = 'weekly' | 'monthly' | 'final'
|
||||||
|
|
||||||
export interface Exam {
|
export interface Exam {
|
||||||
|
|||||||
Reference in New Issue
Block a user