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:
dekun
2026-06-28 11:18:58 +08:00
commit e329d3398a
76 changed files with 8506 additions and 0 deletions
+241
View File
@@ -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>
)
}
+76
View File
@@ -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>
)
}
+132
View File
@@ -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>
)
}