成绩复盘与 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))
|
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")
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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="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>
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user