修复复盘保存失败:原地更新成绩并新增 review 专用接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 16:38:20 +08:00
parent ff4e0b1d37
commit bec9df5d6f
6 changed files with 128 additions and 37 deletions
+72 -4
View File
@@ -7,7 +7,15 @@ 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, ReviewStatusEnum, ScoreOut, TrendResponse
from app.schemas import (
ExamCreate,
ExamOut,
ExamReviewUpdate,
ExamUpdate,
ReviewStatusEnum,
ScoreOut,
TrendResponse,
)
from app.services.score_trend import build_trend
from app.services.student_access import get_student_for_user
@@ -35,7 +43,13 @@ def _parse_review_statuses(raw: str | None) -> list[ReviewStatusEnum]:
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)
values: list[str] = []
for item in statuses:
if isinstance(item, ReviewStatusEnum):
values.append(item.value)
else:
values.append(str(item))
return json.dumps(values, ensure_ascii=False)
def _score_to_out(score: SubjectScore) -> ScoreOut:
@@ -62,19 +76,35 @@ def _exam_to_out(exam: ExamRecord) -> ExamOut:
def _apply_scores(db: Session, exam: ExamRecord, scores_data):
exam.scores.clear()
existing_by_subject = {s.subject_id: s for s in list(exam.scores)}
keep_subject_ids: set[int] = set()
for item in scores_data:
keep_subject_ids.add(item.subject_id)
ratio = round(item.obtained_score / item.total_score, 4)
review_json = _serialize_review_statuses(item.review_statuses)
existing = existing_by_subject.get(item.subject_id)
if existing is not None:
existing.total_score = item.total_score
existing.obtained_score = item.obtained_score
existing.ratio = ratio
existing.review_statuses_json = review_json
else:
exam.scores.append(
SubjectScore(
subject_id=item.subject_id,
total_score=item.total_score,
obtained_score=item.obtained_score,
ratio=ratio,
review_statuses_json=_serialize_review_statuses(item.review_statuses),
review_statuses_json=review_json,
)
)
for subject_id, score in existing_by_subject.items():
if subject_id not in keep_subject_ids:
exam.scores.remove(score)
db.delete(score)
@router.get("/students/{student_id}/exams", response_model=list[ExamOut])
def list_exams(
@@ -177,6 +207,44 @@ def update_exam(
return _exam_to_out(exam)
@router.patch("/exams/{exam_id}/review", response_model=ExamOut)
def update_exam_review(
exam_id: uuid.UUID,
data: ExamReviewUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
exam = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
.join(ExamRecord.student)
.filter(ExamRecord.id == exam_id)
.first()
)
if exam is None or exam.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考试记录不存在")
by_subject = {s.subject_id: s for s in exam.scores}
for item in data.reviews:
score = by_subject.get(item.subject_id)
if score is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"科目 {item.subject_id} 不在该次考试中",
)
score.review_statuses_json = _serialize_review_statuses(item.review_statuses)
db.commit()
db.refresh(exam)
exam = (
db.query(ExamRecord)
.options(joinedload(ExamRecord.scores).joinedload(SubjectScore.subject))
.filter(ExamRecord.id == exam.id)
.first()
)
return _exam_to_out(exam)
@router.delete("/exams/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_exam(
exam_id: uuid.UUID,
+9
View File
@@ -219,6 +219,15 @@ class ExamUpdate(BaseModel):
scores: list[ScoreInput] | None = None
class ReviewScoreInput(BaseModel):
subject_id: int
review_statuses: list[ReviewStatusEnum] = []
class ExamReviewUpdate(BaseModel):
reviews: list[ReviewScoreInput] = []
class ExamOut(BaseModel):
id: UUID
exam_type: ExamTypeEnum
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-Db1YZbA8.js"></script>
<script type="module" crossorigin src="/assets/index-C801hcLu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
</head>
<body>
+4
View File
@@ -120,6 +120,10 @@ export const examApi = {
examId: string,
data: Partial<{ exam_type: ExamType; exam_date: string; title: string; scores: ScoreInput[] }>,
) => api.patch<Exam>(`/exams/${examId}`, data),
updateReview: (
examId: string,
reviews: { subject_id: number; review_statuses: ScoreInput['review_statuses'] }[],
) => api.patch<Exam>(`/exams/${examId}/review`, { reviews }),
remove: (examId: string) => api.delete(`/exams/${examId}`),
trend: (studentId: string, subjectId: number) =>
api.get<TrendResponse>(`/students/${studentId}/scores/trend`, {
+17 -7
View File
@@ -5,6 +5,17 @@ import type { Exam, ReviewStatus } from '../types'
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
import ReviewTreeChart from './ReviewTreeChart'
function apiErrorMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object' && 'response' in err) {
const detail = (err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail.map((item) => item?.msg || String(item)).join('')
}
}
return fallback
}
interface Props {
exams: Exam[]
onRefresh: () => void
@@ -53,18 +64,17 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
}
setSaving(true)
try {
await examApi.update(selectedExam.id, {
scores: selectedExam.scores.map((score) => ({
await examApi.updateReview(
selectedExam.id,
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('保存失败')
} catch (err) {
message.error(apiErrorMessage(err, '保存失败'))
} finally {
setSaving(false)
}