Initial commit: secondary school grade archive system.
Add FastAPI/React app with Docker deployment, Ubuntu one-click install, and docs for junior/senior high score tracking and mistake bank. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { Button, 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'
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
subjects: Subject[]
|
||||
exams: Exam[]
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Exam | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (modalOpen && editing) {
|
||||
form.setFieldsValue({
|
||||
exam_type: editing.exam_type,
|
||||
exam_date: dayjs(editing.exam_date),
|
||||
title: editing.title,
|
||||
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 }
|
||||
}),
|
||||
})
|
||||
} else if (modalOpen) {
|
||||
form.setFieldsValue({
|
||||
exam_type: 'weekly',
|
||||
exam_date: dayjs(),
|
||||
scores: subjects.map((s) => ({ subject_id: s.id })),
|
||||
})
|
||||
}
|
||||
}, [modalOpen, editing, subjects, form])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (exam: Exam) => {
|
||||
setEditing(exam)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const scores: ScoreInput[] = (values.scores || [])
|
||||
.map((s: ScoreInput, idx: number) => ({
|
||||
subject_id: subjects[idx]?.id ?? s.subject_id,
|
||||
total_score: s.total_score,
|
||||
obtained_score: s.obtained_score,
|
||||
}))
|
||||
.filter(
|
||||
(s: ScoreInput) =>
|
||||
s.subject_id != null &&
|
||||
s.total_score != null &&
|
||||
s.obtained_score != null &&
|
||||
s.total_score > 0,
|
||||
)
|
||||
.map((s: ScoreInput) => ({
|
||||
subject_id: s.subject_id,
|
||||
total_score: Number(s.total_score),
|
||||
obtained_score: Number(s.obtained_score),
|
||||
}))
|
||||
|
||||
if (scores.length === 0) {
|
||||
message.warning('请至少录入一科成绩')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const payload = {
|
||||
exam_type: values.exam_type as ExamType,
|
||||
exam_date: values.exam_date.format('YYYY-MM-DD'),
|
||||
title: values.title || undefined,
|
||||
scores,
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
await examApi.update(editing.id, payload)
|
||||
message.success('已更新')
|
||||
} else {
|
||||
await examApi.create(studentId, payload)
|
||||
message.success('已添加')
|
||||
}
|
||||
setModalOpen(false)
|
||||
onRefresh()
|
||||
} catch {
|
||||
/* validation */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (exam: Exam) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除该考试记录?',
|
||||
onOk: async () => {
|
||||
await examApi.remove(exam.id)
|
||||
message.success('已删除')
|
||||
onRefresh()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '日期', dataIndex: 'exam_date', key: 'exam_date', width: 110 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'exam_type',
|
||||
key: 'exam_type',
|
||||
width: 80,
|
||||
render: (t: ExamType) => EXAM_TYPE_LABELS[t],
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||
{
|
||||
title: '科目数',
|
||||
key: 'count',
|
||||
width: 80,
|
||||
render: (_: unknown, r: Exam) => r.scores.length,
|
||||
},
|
||||
{
|
||||
title: '平均占比',
|
||||
key: 'avg',
|
||||
width: 100,
|
||||
render: (_: unknown, r: Exam) => {
|
||||
if (!r.scores.length) return '-'
|
||||
const avg = r.scores.reduce((a, s) => a + s.ratio, 0) / r.scores.length
|
||||
return `${(avg * 100).toFixed(1)}%`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: unknown, r: Exam) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => openEdit(r)} />
|
||||
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(r)} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button type="primary" onClick={openCreate} style={{ marginBottom: 16 }}>
|
||||
录入成绩
|
||||
</Button>
|
||||
<Table rowKey="id" columns={columns} dataSource={exams} pagination={{ pageSize: 10 }} scroll={{ x: 600 }} />
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑考试' : '录入成绩'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
width={720}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="exam_type" label="考试类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={Object.entries(EXAM_TYPE_LABELS).map(([k, v]) => ({ value: k, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="exam_date" label="考试日期" rules={[{ required: true }]}>
|
||||
<DatePicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="备注标题">
|
||||
<Input placeholder="可选" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.List name="scores">
|
||||
{(fields) => (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={fields.map((f, i) => ({ ...f, subject: subjects[i] }))}
|
||||
rowKey="key"
|
||||
columns={[
|
||||
{
|
||||
title: '科目',
|
||||
render: (_, row) => (
|
||||
<>
|
||||
<Form.Item name={[row.name, 'subject_id']} hidden initialValue={row.subject?.id}>
|
||||
<InputNumber />
|
||||
</Form.Item>
|
||||
{row.subject?.name}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '总分',
|
||||
render: (_, row) => (
|
||||
<Form.Item name={[row.name, 'total_score']} noStyle>
|
||||
<InputNumber min={0} placeholder="总分" style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '得分',
|
||||
render: (_, row) => (
|
||||
<Form.Item name={[row.name, 'obtained_score']} noStyle>
|
||||
<InputNumber min={0} placeholder="得分" style={{ width: 100 }} />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '占比',
|
||||
render: (_, row) => {
|
||||
const total = form.getFieldValue(['scores', row.name, 'total_score'])
|
||||
const obtained = form.getFieldValue(['scores', row.name, 'obtained_score'])
|
||||
if (total > 0 && obtained != null) {
|
||||
return `${((obtained / total) * 100).toFixed(1)}%`
|
||||
}
|
||||
return '-'
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Table, Tag } from 'antd'
|
||||
import type { Exam } from '../types'
|
||||
import { EXAM_TYPE_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
exams: Exam[]
|
||||
subjectNames: Record<number, string>
|
||||
}
|
||||
|
||||
export default function ScoreOverview({ exams, subjectNames }: Props) {
|
||||
const subjectIds = new Set<number>()
|
||||
exams.forEach((e) => e.scores.forEach((s) => subjectIds.add(s.subject_id)))
|
||||
const sortedSubjects = [...subjectIds].sort((a, b) => a - b)
|
||||
|
||||
const columns = [
|
||||
{ title: '日期', dataIndex: 'exam_date', key: 'date', width: 110, fixed: 'left' as const },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'exam_type',
|
||||
key: 'type',
|
||||
width: 80,
|
||||
render: (t: keyof typeof EXAM_TYPE_LABELS) => (
|
||||
<Tag>{EXAM_TYPE_LABELS[t]}</Tag>
|
||||
),
|
||||
},
|
||||
...sortedSubjects.map((sid) => ({
|
||||
title: subjectNames[sid] || `科目${sid}`,
|
||||
key: `s${sid}`,
|
||||
width: 100,
|
||||
render: (_: unknown, exam: Exam) => {
|
||||
const score = exam.scores.find((s) => s.subject_id === sid)
|
||||
if (!score) return '-'
|
||||
return `${score.obtained_score}/${score.total_score} (${(score.ratio * 100).toFixed(1)}%)`
|
||||
},
|
||||
})),
|
||||
]
|
||||
|
||||
const volatileExams = exams.filter((exam) => {
|
||||
return exam.scores.some((s) => {
|
||||
const allScores = exams
|
||||
.filter((e) => e.exam_date <= exam.exam_date)
|
||||
.flatMap((e) => e.scores.filter((sc) => sc.subject_id === s.subject_id))
|
||||
.sort((a, b) => {
|
||||
const ea = exams.find((e) => e.scores.includes(a))
|
||||
const eb = exams.find((e) => e.scores.includes(b))
|
||||
return (ea?.exam_date || '').localeCompare(eb?.exam_date || '')
|
||||
})
|
||||
const idx = allScores.findIndex((sc) => sc.id === s.id)
|
||||
if (idx <= 0) return false
|
||||
return Math.abs(allScores[idx].ratio - allScores[idx - 1].ratio) >= 0.08
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{volatileExams.length > 0 && (
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#fff7e6', borderRadius: 8 }}>
|
||||
<strong>波动预警:</strong>
|
||||
{volatileExams.slice(0, 5).map((e) => (
|
||||
<Tag key={e.id} color="orange" style={{ marginTop: 4 }}>
|
||||
{e.exam_date} {EXAM_TYPE_LABELS[e.exam_type]}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={[...exams].sort((a, b) => b.exam_date.localeCompare(a.exam_date))}
|
||||
pagination={{ pageSize: 15 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import type { TrendPoint } from '../types'
|
||||
import { EXAM_TYPE_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
points: TrendPoint[]
|
||||
subjectName: string
|
||||
threshold: number
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
up: '#52c41a',
|
||||
down: '#ff4d4f',
|
||||
flat: '#8c8c8c',
|
||||
volatile: '#fa8c16',
|
||||
}
|
||||
|
||||
export default function TrendChart({ points, subjectName, threshold }: Props) {
|
||||
if (points.length === 0) {
|
||||
return <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无成绩数据</div>
|
||||
}
|
||||
|
||||
const dates = points.map((p) => p.exam_date)
|
||||
const values = points.map((p) => p.ratio_percent)
|
||||
|
||||
const lineSeries = points.slice(1).map((point, i) => {
|
||||
let color = COLORS.flat
|
||||
if (point.direction === 'up') color = COLORS.up
|
||||
if (point.direction === 'down') color = COLORS.down
|
||||
|
||||
return {
|
||||
type: 'line' as const,
|
||||
data: dates.map((_, idx) => (idx === i || idx === i + 1 ? values[idx] : null)),
|
||||
connectNulls: false,
|
||||
showSymbol: false,
|
||||
lineStyle: { width: 3, color },
|
||||
tooltip: { show: false },
|
||||
silent: true,
|
||||
}
|
||||
})
|
||||
|
||||
const markPoints = points
|
||||
.map((point, i) => ({ point, i }))
|
||||
.filter(({ point }) => point.is_volatile)
|
||||
.map(({ i }) => ({
|
||||
coord: [dates[i], values[i]],
|
||||
symbol: 'circle',
|
||||
symbolSize: 18,
|
||||
itemStyle: {
|
||||
color: COLORS.volatile,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: { show: false },
|
||||
}))
|
||||
|
||||
const option = {
|
||||
title: {
|
||||
text: `${subjectName} 成绩占比趋势`,
|
||||
left: 'center',
|
||||
textStyle: { fontSize: 16 },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: Array<{ dataIndex: number; value: number }>) => {
|
||||
const idx = params[0]?.dataIndex ?? 0
|
||||
const p = points[idx]
|
||||
if (!p) return ''
|
||||
const typeLabel = EXAM_TYPE_LABELS[p.exam_type]
|
||||
let html = `<strong>${p.exam_date}</strong> (${typeLabel})<br/>占比: ${p.ratio_percent}%`
|
||||
if (p.title) html += `<br/>${p.title}`
|
||||
if (p.delta_percent !== null) {
|
||||
const sign = p.delta_percent > 0 ? '+' : ''
|
||||
html += `<br/>较上次: ${sign}${p.delta_percent}%`
|
||||
if (p.is_volatile) html += ' <span style="color:#fa8c16">[大幅波动]</span>'
|
||||
}
|
||||
return html
|
||||
},
|
||||
},
|
||||
grid: { left: 50, right: 30, top: 60, bottom: 50 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { rotate: 30 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '占比 (%)',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: values,
|
||||
symbol: 'circle',
|
||||
symbolSize: (_val: number, params: { dataIndex: number }) =>
|
||||
points[params.dataIndex]?.is_volatile ? 14 : 8,
|
||||
itemStyle: {
|
||||
color: (params: { dataIndex: number }) => {
|
||||
const p = points[params.dataIndex]
|
||||
if (p?.is_volatile) return COLORS.volatile
|
||||
if (p?.direction === 'up') return COLORS.up
|
||||
if (p?.direction === 'down') return COLORS.down
|
||||
return '#1677ff'
|
||||
},
|
||||
},
|
||||
lineStyle: { opacity: 0 },
|
||||
markPoint: markPoints.length ? { data: markPoints } : undefined,
|
||||
z: 10,
|
||||
},
|
||||
...lineSeries,
|
||||
],
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: [
|
||||
{ name: '上升', itemStyle: { color: COLORS.up } },
|
||||
{ name: '下降', itemStyle: { color: COLORS.down } },
|
||||
{ name: '大幅波动', itemStyle: { color: COLORS.volatile } },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ReactECharts option={option} style={{ height: 400, width: '100%' }} notMerge />
|
||||
<p style={{ color: '#888', fontSize: 12, marginTop: 8 }}>
|
||||
波动阈值: {(threshold * 100).toFixed(0)}%,超过此变化幅度将高亮显示
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ReloadOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { Button, Input, Select, Space, Upload, message } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { Subject } from '../types'
|
||||
|
||||
interface Props {
|
||||
studentId: string
|
||||
subjects: Subject[]
|
||||
onUploaded: () => void
|
||||
}
|
||||
|
||||
export default function WrongQuestionUpload({ studentId, subjects, onUploaded }: Props) {
|
||||
const [subjectId, setSubjectId] = useState<number | undefined>(subjects[0]?.id)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!subjectId) {
|
||||
message.warning('请选择科目')
|
||||
return false
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
await wrongQuestionApi.upload(studentId, subjectId, file)
|
||||
message.success('上传成功,正在 OCR 识别并生成解法…')
|
||||
onUploaded()
|
||||
} catch {
|
||||
message.error('上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
placeholder="选择科目"
|
||||
value={subjectId}
|
||||
onChange={setSubjectId}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} type="primary">
|
||||
上传错题图片
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
interface SearchProps {
|
||||
subjectId?: number
|
||||
onSubjectChange: (id?: number) => void
|
||||
search: string
|
||||
onSearchChange: (q: string) => void
|
||||
onRefresh: () => void
|
||||
subjects: Subject[]
|
||||
}
|
||||
|
||||
export function WrongQuestionFilters({
|
||||
subjectId,
|
||||
onSubjectChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
onRefresh,
|
||||
subjects,
|
||||
}: SearchProps) {
|
||||
return (
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部科目"
|
||||
value={subjectId}
|
||||
onChange={onSubjectChange}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
<Input.Search
|
||||
placeholder="搜索题目/解法"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onSearch={onRefresh}
|
||||
style={{ width: 220 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user