成绩复盘与 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
View File
@@ -115,6 +115,7 @@ class SubjectScore(Base):
total_score: Mapped[float] = mapped_column(Numeric(8, 2)) total_score: Mapped[float] = mapped_column(Numeric(8, 2))
obtained_score: Mapped[float] = mapped_column(Numeric(8, 2)) obtained_score: Mapped[float] = mapped_column(Numeric(8, 2))
ratio: Mapped[float] = mapped_column(Numeric(8, 4)) ratio: Mapped[float] = mapped_column(Numeric(8, 4))
review_statuses_json: Mapped[str | None] = mapped_column(Text, nullable=True)
exam_record: Mapped["ExamRecord"] = relationship(back_populates="scores") exam_record: Mapped["ExamRecord"] = relationship(back_populates="scores")
subject: Mapped["Subject"] = relationship(back_populates="scores") subject: Mapped["Subject"] = relationship(back_populates="scores")
+28 -1
View File
@@ -1,3 +1,4 @@
import json
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -6,13 +7,37 @@ from sqlalchemy.orm import Session, joinedload
from app.core.database import get_db from app.core.database import get_db
from app.core.deps import get_current_user from app.core.deps import get_current_user
from app.models.user import ExamRecord, SubjectScore, User from app.models.user import ExamRecord, SubjectScore, User
from app.schemas import ExamCreate, ExamOut, ExamUpdate, ScoreOut, TrendResponse from app.schemas import ExamCreate, ExamOut, ExamUpdate, ReviewStatusEnum, ScoreOut, TrendResponse
from app.services.score_trend import build_trend from app.services.score_trend import build_trend
from app.services.student_access import get_student_for_user from app.services.student_access import get_student_for_user
router = APIRouter(tags=["exams"]) router = APIRouter(tags=["exams"])
def _parse_review_statuses(raw: str | None) -> list[ReviewStatusEnum]:
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(data, list):
return []
result: list[ReviewStatusEnum] = []
for item in data:
try:
result.append(ReviewStatusEnum(str(item)))
except ValueError:
continue
return result
def _serialize_review_statuses(statuses: list[ReviewStatusEnum] | None) -> str | None:
if not statuses:
return None
return json.dumps([s.value for s in statuses], ensure_ascii=False)
def _score_to_out(score: SubjectScore) -> ScoreOut: def _score_to_out(score: SubjectScore) -> ScoreOut:
return ScoreOut( return ScoreOut(
id=score.id, id=score.id,
@@ -21,6 +46,7 @@ def _score_to_out(score: SubjectScore) -> ScoreOut:
total_score=float(score.total_score), total_score=float(score.total_score),
obtained_score=float(score.obtained_score), obtained_score=float(score.obtained_score),
ratio=float(score.ratio), ratio=float(score.ratio),
review_statuses=_parse_review_statuses(score.review_statuses_json),
) )
@@ -45,6 +71,7 @@ def _apply_scores(db: Session, exam: ExamRecord, scores_data):
total_score=item.total_score, total_score=item.total_score,
obtained_score=item.obtained_score, obtained_score=item.obtained_score,
ratio=ratio, ratio=ratio,
review_statuses_json=_serialize_review_statuses(item.review_statuses),
) )
) )
+24
View File
@@ -11,6 +11,16 @@ class ExamTypeEnum(str, Enum):
final = "final" final = "final"
class ReviewStatusEnum(str, Enum):
careless = "careless"
unknown = "unknown"
nervous = "nervous"
normal = "normal"
REVIEW_STATUS_VALUES = {s.value for s in ReviewStatusEnum}
class WrongQuestionStatusEnum(str, Enum): class WrongQuestionStatusEnum(str, Enum):
pending = "pending" pending = "pending"
ocr_done = "ocr_done" ocr_done = "ocr_done"
@@ -151,6 +161,7 @@ class ScoreInput(BaseModel):
subject_id: int subject_id: int
total_score: float total_score: float
obtained_score: float obtained_score: float
review_statuses: list[ReviewStatusEnum] = []
@field_validator("total_score") @field_validator("total_score")
@classmethod @classmethod
@@ -169,6 +180,18 @@ class ScoreInput(BaseModel):
raise ValueError("得分不能为负") raise ValueError("得分不能为负")
return v return v
@field_validator("review_statuses")
@classmethod
def validate_review_statuses(cls, v: list[ReviewStatusEnum]) -> list[ReviewStatusEnum]:
seen: set[str] = set()
unique: list[ReviewStatusEnum] = []
for item in v:
key = item.value if isinstance(item, ReviewStatusEnum) else str(item)
if key not in seen:
seen.add(key)
unique.append(item if isinstance(item, ReviewStatusEnum) else ReviewStatusEnum(key))
return unique
class ScoreOut(BaseModel): class ScoreOut(BaseModel):
id: UUID id: UUID
@@ -177,6 +200,7 @@ class ScoreOut(BaseModel):
total_score: float total_score: float
obtained_score: float obtained_score: float
ratio: float ratio: float
review_statuses: list[ReviewStatusEnum] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+6
View File
@@ -85,3 +85,9 @@ def run_migrations() -> None:
with engine.begin() as conn: with engine.begin() as conn:
for clause in wq_alters: for clause in wq_alters:
conn.execute(text(f"ALTER TABLE wrong_questions {clause}")) conn.execute(text(f"ALTER TABLE wrong_questions {clause}"))
if "subject_scores" in tables:
ss_columns = {col["name"] for col in inspector.get_columns("subject_scores")}
if "review_statuses_json" not in ss_columns:
with engine.begin() as conn:
conn.execute(text("ALTER TABLE subject_scores ADD COLUMN review_statuses_json TEXT"))
@@ -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="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-CCD3wnmu.js"></script> <script type="module" crossorigin src="/assets/index-Db1YZbA8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css"> <link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
</head> </head>
<body> <body>
<div id="root"></div> <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 { 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 dayjs from 'dayjs'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { examApi } from '../api/client' import { examApi } from '../api/client'
import type { Exam, ExamType, ScoreInput, Subject } from '../types' import type { Exam, ExamType, ReviewStatus, ScoreInput, Subject } from '../types'
import { EXAM_TYPE_LABELS } from '../types' import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
import ExamReviewPanel from './ExamReviewPanel'
interface Props { interface Props {
studentId: string studentId: string
@@ -28,15 +29,20 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
scores: subjects.map((s) => { scores: subjects.map((s) => {
const found = editing.scores.find((sc) => sc.subject_id === s.id) const found = editing.scores.find((sc) => sc.subject_id === s.id)
return found 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) { } else if (modalOpen) {
form.setFieldsValue({ form.setFieldsValue({
exam_type: 'weekly', exam_type: 'weekly',
exam_date: dayjs(), 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]) }, [modalOpen, editing, subjects, form])
@@ -55,10 +61,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
try { try {
const values = await form.validateFields() const values = await form.validateFields()
const scores: ScoreInput[] = (values.scores || []) 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, subject_id: subjects[idx]?.id ?? s.subject_id,
total_score: s.total_score, total_score: s.total_score,
obtained_score: s.obtained_score, obtained_score: s.obtained_score,
review_statuses: s.review_statuses || [],
})) }))
.filter( .filter(
(s: ScoreInput) => (s: ScoreInput) =>
@@ -71,6 +78,7 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
subject_id: s.subject_id, subject_id: s.subject_id,
total_score: Number(s.total_score), total_score: Number(s.total_score),
obtained_score: Number(s.obtained_score), obtained_score: Number(s.obtained_score),
review_statuses: s.review_statuses || [],
})) }))
if (scores.length === 0) { if (scores.length === 0) {
@@ -158,6 +166,7 @@ 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 ? '编辑考试' : '录入成绩'}
@@ -165,11 +174,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
onCancel={() => setModalOpen(false)} onCancel={() => setModalOpen(false)}
onOk={handleSubmit} onOk={handleSubmit}
confirmLoading={loading} confirmLoading={loading}
width={720} width={900}
destroyOnHidden destroyOnHidden
> >
<Form form={form} layout="vertical"> <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 }]}> <Form.Item name="exam_type" label="考试类型" rules={[{ required: true }]}>
<Select <Select
style={{ width: 120 }} style={{ width: 120 }}
@@ -191,9 +200,11 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
pagination={false} pagination={false}
dataSource={fields.map((f, i) => ({ ...f, subject: subjects[i] }))} dataSource={fields.map((f, i) => ({ ...f, subject: subjects[i] }))}
rowKey="key" rowKey="key"
scroll={{ x: 720 }}
columns={[ columns={[
{ {
title: '科目', title: '科目',
width: 70,
render: (_, row) => ( render: (_, row) => (
<> <>
<Form.Item name={[row.name, 'subject_id']} hidden initialValue={row.subject?.id}> <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: '总分', title: '总分',
width: 100,
render: (_, row) => ( render: (_, row) => (
<Form.Item name={[row.name, 'total_score']} noStyle> <Form.Item name={[row.name, 'total_score']} noStyle>
<InputNumber min={0} placeholder="总分" style={{ width: 100 }} /> <InputNumber min={0} placeholder="总分" style={{ width: 90 }} />
</Form.Item> </Form.Item>
), ),
}, },
{ {
title: '得分', title: '得分',
width: 100,
render: (_, row) => ( render: (_, row) => (
<Form.Item name={[row.name, 'obtained_score']} noStyle> <Form.Item name={[row.name, 'obtained_score']} noStyle>
<InputNumber min={0} placeholder="得分" style={{ width: 100 }} /> <InputNumber min={0} placeholder="得分" style={{ width: 90 }} />
</Form.Item> </Form.Item>
), ),
}, },
{ {
title: '占比', title: '占比',
width: 70,
render: (_, row) => { render: (_, row) => {
const total = form.getFieldValue(['scores', row.name, 'total_score']) const total = form.getFieldValue(['scores', row.name, 'total_score'])
const obtained = form.getFieldValue(['scores', row.name, 'obtained_score']) const obtained = form.getFieldValue(['scores', row.name, 'obtained_score'])
@@ -230,6 +244,14 @@ export default function ScoreForm({ studentId, subjects, exams, onRefresh }: Pro
return '-' 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"> <Space wrap className="upload-actions">
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*"> <span className="upload-mobile-only">
<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 && (
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*"> <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> </Button>
</Upload> </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> </Space>
{isOlympiad && ( {isOlympiad && (
+19
View File
@@ -82,6 +82,14 @@ a {
min-height: 44px; min-height: 44px;
} }
.upload-mobile-only {
display: none;
}
.upload-desktop-only {
display: inline-flex;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.page-container { .page-container {
padding: 12px 12px 24px; padding: 12px 12px 24px;
@@ -105,6 +113,17 @@ a {
width: 100%; width: 100%;
} }
.upload-mobile-only {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.upload-desktop-only {
display: none;
}
.upload-actions .ant-btn { .upload-actions .ant-btn {
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
+15
View File
@@ -61,8 +61,22 @@ export interface Score {
total_score: number total_score: number
obtained_score: number obtained_score: number
ratio: 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 type ExamType = 'weekly' | 'monthly' | 'final'
export interface Exam { export interface Exam {
@@ -78,6 +92,7 @@ export interface ScoreInput {
subject_id: number subject_id: number
total_score: number total_score: number
obtained_score: number obtained_score: number
review_statuses?: ReviewStatus[]
} }
export interface TrendPoint { export interface TrendPoint {