修复复盘保存失败:原地更新成绩并新增 review 专用接口。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,7 +7,15 @@ 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, 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.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
|
||||||
|
|
||||||
@@ -35,7 +43,13 @@ def _parse_review_statuses(raw: str | None) -> list[ReviewStatusEnum]:
|
|||||||
def _serialize_review_statuses(statuses: list[ReviewStatusEnum] | None) -> str | None:
|
def _serialize_review_statuses(statuses: list[ReviewStatusEnum] | None) -> str | None:
|
||||||
if not statuses:
|
if not statuses:
|
||||||
return None
|
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:
|
def _score_to_out(score: SubjectScore) -> ScoreOut:
|
||||||
@@ -62,18 +76,34 @@ def _exam_to_out(exam: ExamRecord) -> ExamOut:
|
|||||||
|
|
||||||
|
|
||||||
def _apply_scores(db: Session, exam: ExamRecord, scores_data):
|
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:
|
for item in scores_data:
|
||||||
|
keep_subject_ids.add(item.subject_id)
|
||||||
ratio = round(item.obtained_score / item.total_score, 4)
|
ratio = round(item.obtained_score / item.total_score, 4)
|
||||||
exam.scores.append(
|
review_json = _serialize_review_statuses(item.review_statuses)
|
||||||
SubjectScore(
|
existing = existing_by_subject.get(item.subject_id)
|
||||||
subject_id=item.subject_id,
|
if existing is not None:
|
||||||
total_score=item.total_score,
|
existing.total_score = item.total_score
|
||||||
obtained_score=item.obtained_score,
|
existing.obtained_score = item.obtained_score
|
||||||
ratio=ratio,
|
existing.ratio = ratio
|
||||||
review_statuses_json=_serialize_review_statuses(item.review_statuses),
|
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=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])
|
@router.get("/students/{student_id}/exams", response_model=list[ExamOut])
|
||||||
@@ -177,6 +207,44 @@ def update_exam(
|
|||||||
return _exam_to_out(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)
|
@router.delete("/exams/{exam_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_exam(
|
def delete_exam(
|
||||||
exam_id: uuid.UUID,
|
exam_id: uuid.UUID,
|
||||||
|
|||||||
@@ -219,6 +219,15 @@ class ExamUpdate(BaseModel):
|
|||||||
scores: list[ScoreInput] | None = None
|
scores: list[ScoreInput] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewScoreInput(BaseModel):
|
||||||
|
subject_id: int
|
||||||
|
review_statuses: list[ReviewStatusEnum] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ExamReviewUpdate(BaseModel):
|
||||||
|
reviews: list[ReviewScoreInput] = []
|
||||||
|
|
||||||
|
|
||||||
class ExamOut(BaseModel):
|
class ExamOut(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
exam_type: ExamTypeEnum
|
exam_type: ExamTypeEnum
|
||||||
|
|||||||
+18
-18
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
<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-Db1YZbA8.js"></script>
|
<script type="module" crossorigin src="/assets/index-C801hcLu.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export const examApi = {
|
|||||||
examId: string,
|
examId: string,
|
||||||
data: Partial<{ exam_type: ExamType; exam_date: string; title: string; scores: ScoreInput[] }>,
|
data: Partial<{ exam_type: ExamType; exam_date: string; title: string; scores: ScoreInput[] }>,
|
||||||
) => api.patch<Exam>(`/exams/${examId}`, data),
|
) => 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}`),
|
remove: (examId: string) => api.delete(`/exams/${examId}`),
|
||||||
trend: (studentId: string, subjectId: number) =>
|
trend: (studentId: string, subjectId: number) =>
|
||||||
api.get<TrendResponse>(`/students/${studentId}/scores/trend`, {
|
api.get<TrendResponse>(`/students/${studentId}/scores/trend`, {
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ import type { Exam, ReviewStatus } from '../types'
|
|||||||
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
|
||||||
import ReviewTreeChart from './ReviewTreeChart'
|
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 {
|
interface Props {
|
||||||
exams: Exam[]
|
exams: Exam[]
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
@@ -53,18 +64,17 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
|
|||||||
}
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await examApi.update(selectedExam.id, {
|
await examApi.updateReview(
|
||||||
scores: selectedExam.scores.map((score) => ({
|
selectedExam.id,
|
||||||
|
selectedExam.scores.map((score) => ({
|
||||||
subject_id: score.subject_id,
|
subject_id: score.subject_id,
|
||||||
total_score: score.total_score,
|
|
||||||
obtained_score: score.obtained_score,
|
|
||||||
review_statuses: statusMap[score.subject_id] || [],
|
review_statuses: statusMap[score.subject_id] || [],
|
||||||
})),
|
})),
|
||||||
})
|
)
|
||||||
message.success('复盘已保存')
|
message.success('复盘已保存')
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} catch {
|
} catch (err) {
|
||||||
message.error('保存失败')
|
message.error(apiErrorMessage(err, '保存失败'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user