成绩复盘与 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
@@ -1 +1 @@
*{box-sizing:border-box}body{-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;background:#f5f5f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}#root{min-height:100dvh}a{color:inherit}.page-container{max-width:1100px;padding:16px 16px 32px;padding-bottom:max(32px, env(safe-area-inset-bottom));margin:0 auto}.page-header{width:100%;margin-bottom:16px}.student-tabs .ant-tabs-nav{margin-bottom:12px}.wq-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:16px;display:grid}.wq-card{background:#fff;border:1px solid #f0f0f0;border-radius:8px;flex-direction:column;transition:box-shadow .2s;display:flex;overflow:hidden}.wq-card-click{cursor:pointer;flex:1}.wq-card-click:active{opacity:.95}.wq-card-actions{text-align:right;border-top:1px solid #f5f5f5;padding:4px 8px 8px}.wq-card-img{object-fit:cover;background:#fafafa;width:100%;height:140px}.wq-card-body{padding:12px}.upload-actions .ant-btn{min-height:44px}@media (width<=768px){.page-container{padding:12px 12px 24px}.ant-tabs-tab{font-size:14px;padding:8px 10px!important}.ant-modal{max-width:calc(100vw - 16px)!important;margin:8px auto!important}.ant-table{font-size:12px}.upload-actions{width:100%}.upload-actions .ant-btn{flex:1;min-width:120px}}@media (width<=576px){.wq-card-img{height:120px}}
*{box-sizing:border-box}body{-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;background:#f5f5f5;margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}#root{min-height:100dvh}a{color:inherit}.page-container{max-width:1100px;padding:16px 16px 32px;padding-bottom:max(32px, env(safe-area-inset-bottom));margin:0 auto}.page-header{width:100%;margin-bottom:16px}.student-tabs .ant-tabs-nav{margin-bottom:12px}.wq-grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:16px;display:grid}.wq-card{background:#fff;border:1px solid #f0f0f0;border-radius:8px;flex-direction:column;transition:box-shadow .2s;display:flex;overflow:hidden}.wq-card-click{cursor:pointer;flex:1}.wq-card-click:active{opacity:.95}.wq-card-actions{text-align:right;border-top:1px solid #f5f5f5;padding:4px 8px 8px}.wq-card-img{object-fit:cover;background:#fafafa;width:100%;height:140px}.wq-card-body{padding:12px}.upload-actions .ant-btn{min-height:44px}.upload-mobile-only{display:none}.upload-desktop-only{display:inline-flex}@media (width<=768px){.page-container{padding:12px 12px 24px}.ant-tabs-tab{font-size:14px;padding:8px 10px!important}.ant-modal{max-width:calc(100vw - 16px)!important;margin:8px auto!important}.ant-table{font-size:12px}.upload-actions{width:100%}.upload-mobile-only{flex-wrap:wrap;gap:8px;width:100%;display:inline-flex}.upload-desktop-only{display:none}.upload-actions .ant-btn{flex:1;min-width:120px}}@media (width<=576px){.wq-card-img{height:120px}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -9,8 +9,8 @@
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-CCD3wnmu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css">
<script type="module" crossorigin src="/assets/index-Db1YZbA8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
</head>
<body>
<div id="root"></div>
+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>
),
},
]}
/>
)
}
+122
View File
@@ -0,0 +1,122 @@
import ReactECharts from 'echarts-for-react'
import type { Exam, ReviewStatus } from '../types'
import { REVIEW_STATUS_LABELS } from '../types'
interface Props {
exams: Exam[]
}
const STATUS_ORDER: ReviewStatus[] = ['careless', 'unknown', 'nervous', 'normal']
const STATUS_COLORS: Record<ReviewStatus, string> = {
careless: '#fa8c16',
unknown: '#ff4d4f',
nervous: '#722ed1',
normal: '#52c41a',
}
function buildTreeData(exams: Exam[]) {
const byStatus: Record<ReviewStatus, Record<string, number>> = {
careless: {},
unknown: {},
nervous: {},
normal: {},
}
for (const exam of exams) {
for (const score of exam.scores) {
const subjectName = score.subject_name || `科目${score.subject_id}`
for (const status of score.review_statuses || []) {
byStatus[status][subjectName] = (byStatus[status][subjectName] || 0) + 1
}
}
}
const children = STATUS_ORDER.map((status) => {
const subjectMap = byStatus[status]
const subjectChildren = Object.entries(subjectMap).map(([name, value]) => ({
name: `${name} (${value})`,
value,
itemStyle: { color: STATUS_COLORS[status] },
}))
const total = subjectChildren.reduce((sum, item) => sum + item.value, 0)
if (total === 0) return null
return {
name: `${REVIEW_STATUS_LABELS[status]} (${total})`,
value: total,
itemStyle: { color: STATUS_COLORS[status] },
children: subjectChildren,
}
}).filter(Boolean)
return { name: '复盘统计', children: children.length ? children : [{ name: '暂无数据', value: 0 }] }
}
export default function ReviewTreeChart({ exams }: Props) {
const treeData = buildTreeData(exams)
const hasData = STATUS_ORDER.some((status) =>
exams.some((exam) =>
exam.scores.some((score) => (score.review_statuses || []).includes(status)),
),
)
if (!hasData) {
return (
<div style={{ textAlign: 'center', padding: 32, color: '#999' }}>
</div>
)
}
const option = {
tooltip: {
trigger: 'item',
formatter: (params: { name: string; value?: number }) => {
if (params.value != null && params.value > 0) {
return `${params.name}<br/>次数: ${params.value}`
}
return params.name
},
},
series: [
{
type: 'tree',
data: [treeData],
top: 20,
left: 40,
bottom: 20,
right: 120,
symbolSize: 10,
orient: 'LR',
label: {
position: 'left',
verticalAlign: 'middle',
align: 'right',
fontSize: 13,
},
leaves: {
label: {
position: 'right',
verticalAlign: 'middle',
align: 'left',
},
},
emphasis: {
focus: 'descendant',
},
expandAndCollapse: true,
animationDuration: 400,
animationDurationUpdate: 400,
},
],
}
return (
<div>
<ReactECharts option={option} style={{ height: 360, width: '100%' }} notMerge />
<p style={{ color: '#888', fontSize: 12, marginTop: 8 }}>
</p>
</div>
)
}
+33 -11
View File
@@ -1,10 +1,11 @@
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
import { Button, DatePicker, Form, Input, InputNumber, Modal, Select, Space, Table, message } from 'antd'
import { Button, Checkbox, DatePicker, Form, Input, InputNumber, Modal, Select, Space, Table, message } from 'antd'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { examApi } from '../api/client'
import type { Exam, ExamType, ScoreInput, Subject } from '../types'
import { EXAM_TYPE_LABELS } from '../types'
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
@@ -28,15 +29,20 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
scores: subjects.map((s) => {
const found = editing.scores.find((sc) => sc.subject_id === s.id)
return found
? { subject_id: s.id, total_score: found.total_score, obtained_score: found.obtained_score }
: { subject_id: s.id, total_score: undefined, obtained_score: undefined }
? {
subject_id: s.id,
total_score: found.total_score,
obtained_score: found.obtained_score,
review_statuses: found.review_statuses || [],
}
: { subject_id: s.id, total_score: undefined, obtained_score: undefined, review_statuses: [] }
}),
})
} else if (modalOpen) {
form.setFieldsValue({
exam_type: 'weekly',
exam_date: dayjs(),
scores: subjects.map((s) => ({ subject_id: s.id })),
scores: subjects.map((s) => ({ subject_id: s.id, review_statuses: [] })),
})
}
}, [modalOpen, editing, subjects, form])
@@ -55,10 +61,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
try {
const values = await form.validateFields()
const scores: ScoreInput[] = (values.scores || [])
.map((s: ScoreInput, idx: number) => ({
.map((s: ScoreInput & { review_statuses?: ReviewStatus[] }, idx: number) => ({
subject_id: subjects[idx]?.id ?? s.subject_id,
total_score: s.total_score,
obtained_score: s.obtained_score,
review_statuses: s.review_statuses || [],
}))
.filter(
(s: ScoreInput) =>
@@ -71,6 +78,7 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
subject_id: s.subject_id,
total_score: Number(s.total_score),
obtained_score: Number(s.obtained_score),
review_statuses: s.review_statuses || [],
}))
if (scores.length === 0) {
@@ -158,6 +166,7 @@ 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 ? '编辑考试' : '录入成绩'}
@@ -165,11 +174,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
onCancel={() => setModalOpen(false)}
onOk={handleSubmit}
confirmLoading={loading}
width={720}
width={900}
destroyOnHidden
>
<Form form={form} layout="vertical">
<Space style={{ width: '100%' }} size="large">
<Space style={{ width: '100%' }} size="large" wrap>
<Form.Item name="exam_type" label="考试类型" rules={[{ required: true }]}>
<Select
style={{ width: 120 }}
@@ -191,9 +200,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
pagination={false}
dataSource={fields.map((f, i) => ({ ...f, subject: subjects[i] }))}
rowKey="key"
scroll={{ x: 720 }}
columns={[
{
title: '科目',
width: 70,
render: (_, row) => (
<>
<Form.Item name={[row.name, 'subject_id']} hidden initialValue={row.subject?.id}>
@@ -205,22 +216,25 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
},
{
title: '总分',
width: 100,
render: (_, row) => (
<Form.Item name={[row.name, 'total_score']} noStyle>
<InputNumber min={0} placeholder="总分" style={{ width: 100 }} />
<InputNumber min={0} placeholder="总分" style={{ width: 90 }} />
</Form.Item>
),
},
{
title: '得分',
width: 100,
render: (_, row) => (
<Form.Item name={[row.name, 'obtained_score']} noStyle>
<InputNumber min={0} placeholder="得分" style={{ width: 100 }} />
<InputNumber min={0} placeholder="得分" style={{ width: 90 }} />
</Form.Item>
),
},
{
title: '占比',
width: 70,
render: (_, row) => {
const total = form.getFieldValue(['scores', row.name, 'total_score'])
const obtained = form.getFieldValue(['scores', row.name, 'obtained_score'])
@@ -230,6 +244,14 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
return '-'
},
},
{
title: '考试状态',
render: (_, row) => (
<Form.Item name={[row.name, 'review_statuses']} noStyle initialValue={[]}>
<Checkbox.Group options={REVIEW_STATUS_OPTIONS} />
</Form.Item>
),
},
]}
/>
)}
+34 -23
View File
@@ -100,33 +100,44 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
/>
)}
<Space wrap className="upload-actions">
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*">
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
</Button>
</Upload>
<Button
icon={<CameraOutlined />}
loading={uploading}
size="large"
onClick={() => cameraRef.current?.click()}
>
</Button>
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
style={{ display: 'none' }}
onChange={handleCamera}
/>
{!isOlympiad && (
<span className="upload-mobile-only">
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploading} size="large">
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
</Button>
</Upload>
<Button
icon={<CameraOutlined />}
loading={uploading}
size="large"
onClick={() => cameraRef.current?.click()}
>
</Button>
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
style={{ display: 'none' }}
onChange={handleCamera}
/>
</span>
<span className="upload-desktop-only">
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploading} type="primary" size="large">
</Button>
</Upload>
</span>
{!isOlympiad && (
<span className="upload-mobile-only">
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploading} size="large">
</Button>
</Upload>
</span>
)}
</Space>
{isOlympiad && (
+19
View File
@@ -82,6 +82,14 @@ a {
min-height: 44px;
}
.upload-mobile-only {
display: none;
}
.upload-desktop-only {
display: inline-flex;
}
@media (max-width: 768px) {
.page-container {
padding: 12px 12px 24px;
@@ -105,6 +113,17 @@ a {
width: 100%;
}
.upload-mobile-only {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.upload-desktop-only {
display: none;
}
.upload-actions .ant-btn {
flex: 1;
min-width: 120px;
+15
View File
@@ -61,8 +61,22 @@ export interface Score {
total_score: number
obtained_score: number
ratio: number
review_statuses?: ReviewStatus[]
}
export type ReviewStatus = 'careless' | 'unknown' | 'nervous' | 'normal'
export const REVIEW_STATUS_LABELS: Record<ReviewStatus, string> = {
careless: '粗心',
unknown: '不会',
nervous: '紧张',
normal: '正常发挥',
}
export const REVIEW_STATUS_OPTIONS = (
Object.entries(REVIEW_STATUS_LABELS) as [ReviewStatus, string][]
).map(([value, label]) => ({ value, label }))
export type ExamType = 'weekly' | 'monthly' | 'final'
export interface Exam {
@@ -78,6 +92,7 @@ export interface ScoreInput {
subject_id: number
total_score: number
obtained_score: number
review_statuses?: ReviewStatus[]
}
export interface TrendPoint {