成绩复盘与 PC 端上传优化:各科考试状态多选及树状统计。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -115,6 +115,7 @@ class SubjectScore(Base):
|
||||
total_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))
|
||||
review_statuses_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
exam_record: Mapped["ExamRecord"] = relationship(back_populates="scores")
|
||||
subject: Mapped["Subject"] = relationship(back_populates="scores")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
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.deps import get_current_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.student_access import get_student_for_user
|
||||
|
||||
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:
|
||||
return ScoreOut(
|
||||
id=score.id,
|
||||
@@ -21,6 +46,7 @@ def _score_to_out(score: SubjectScore) -> ScoreOut:
|
||||
total_score=float(score.total_score),
|
||||
obtained_score=float(score.obtained_score),
|
||||
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,
|
||||
obtained_score=item.obtained_score,
|
||||
ratio=ratio,
|
||||
review_statuses_json=_serialize_review_statuses(item.review_statuses),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,16 @@ class ExamTypeEnum(str, Enum):
|
||||
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):
|
||||
pending = "pending"
|
||||
ocr_done = "ocr_done"
|
||||
@@ -151,6 +161,7 @@ class ScoreInput(BaseModel):
|
||||
subject_id: int
|
||||
total_score: float
|
||||
obtained_score: float
|
||||
review_statuses: list[ReviewStatusEnum] = []
|
||||
|
||||
@field_validator("total_score")
|
||||
@classmethod
|
||||
@@ -169,6 +180,18 @@ class ScoreInput(BaseModel):
|
||||
raise ValueError("得分不能为负")
|
||||
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):
|
||||
id: UUID
|
||||
@@ -177,6 +200,7 @@ class ScoreOut(BaseModel):
|
||||
total_score: float
|
||||
obtained_score: float
|
||||
ratio: float
|
||||
review_statuses: list[ReviewStatusEnum] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -85,3 +85,9 @@ def run_migrations() -> None:
|
||||
with engine.begin() as conn:
|
||||
for clause in wq_alters:
|
||||
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
@@ -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}}
|
||||
-518
File diff suppressed because one or more lines are too long
+525
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user