考试明细单行展示,选中科目后增加 AI 解读与建议。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 17:32:28 +08:00
parent 5f00f07dbe
commit 02e7ba055a
10 changed files with 341 additions and 125 deletions
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-DP70ZAE9.js"></script>
<script type="module" crossorigin src="/assets/index-CmdQeYPX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BSTLtBBx.css">
</head>
<body>
+4
View File
@@ -131,6 +131,10 @@ export const examApi = {
}),
exportCsv: (studentId: string) =>
api.get(`/students/${studentId}/scores/export`, { responseType: 'blob' }),
reviewInsight: (studentId: string, subjectName: string) =>
api.post<{ insight: string }>(`/students/${studentId}/review-insight`, {
subject_name: subjectName,
}),
}
export const wrongQuestionApi = {
+4 -1
View File
@@ -4,6 +4,7 @@ import { examApi } from '../api/client'
import type { Exam, ReviewStatus } from '../types'
import { EXAM_TYPE_LABELS, REVIEW_STATUS_OPTIONS } from '../types'
import ReviewBarChart from './ReviewBarChart'
import ReviewAiInsight from './ReviewAiInsight'
import ReviewSubjectDetail from './ReviewSubjectDetail'
function apiErrorMessage(err: unknown, fallback: string): string {
@@ -31,11 +32,12 @@ function firstSubjectWithReview(exams: Exam[]): string | null {
}
interface Props {
studentId: string
exams: Exam[]
onRefresh: () => void
}
export default function ExamReviewPanel({ exams, onRefresh }: Props) {
export default function ExamReviewPanel({ studentId, exams, onRefresh }: Props) {
const [examId, setExamId] = useState<string>()
const [statusMap, setStatusMap] = useState<Record<number, ReviewStatus[]>>({})
const [saving, setSaving] = useState(false)
@@ -175,6 +177,7 @@ export default function ExamReviewPanel({ exams, onRefresh }: Props) {
onSubjectSelect={setSelectedSubject}
/>
<ReviewSubjectDetail exams={exams} subjectName={selectedSubject} />
<ReviewAiInsight studentId={studentId} subjectName={selectedSubject} />
</div>
</Space>
)
@@ -0,0 +1,83 @@
import { ReloadOutlined } from '@ant-design/icons'
import { Alert, Button, Spin, Typography } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { examApi } from '../api/client'
interface Props {
studentId: string
subjectName: string | null
}
export default function ReviewAiInsight({ studentId, subjectName }: Props) {
const [insight, setInsight] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadInsight = useCallback(async () => {
if (!subjectName) {
setInsight(null)
setError(null)
return
}
setLoading(true)
setError(null)
try {
const { data } = await examApi.reviewInsight(studentId, subjectName)
setInsight(data.insight)
} catch (err: unknown) {
const detail =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null
setError(typeof detail === 'string' ? detail : 'AI 解读生成失败,请检查模型配置')
setInsight(null)
} finally {
setLoading(false)
}
}, [studentId, subjectName])
useEffect(() => {
loadInsight()
}, [loadInsight])
if (!subjectName) return null
return (
<div style={{ marginTop: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 15 }}>
AI · {subjectName}
</Typography.Text>
<Button
size="small"
icon={<ReloadOutlined />}
loading={loading}
onClick={loadInsight}
>
</Button>
</div>
{loading && !insight && (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin tip="AI 正在分析复盘数据…" />
</div>
)}
{error && (
<Alert type="error" message={error} showIcon style={{ marginBottom: 12 }} />
)}
{insight && (
<div
style={{
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 8,
padding: 16,
}}
>
<ReactMarkdown>{insight}</ReactMarkdown>
</div>
)}
</div>
)
}
+38 -33
View File
@@ -80,50 +80,55 @@ export default function ReviewSubjectDetail({ exams, subjectName }: Props) {
<Typography.Text type="secondary" style={{ marginLeft: 12, fontSize: 12 }}>
{rows.length} {problemCount}
</Typography.Text>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map((row) => (
<div
key={row.key}
style={{
padding: '12px 14px',
display: 'flex',
alignItems: 'center',
gap: 10,
flexWrap: 'nowrap',
overflowX: 'auto',
whiteSpace: 'nowrap',
padding: '10px 14px',
borderRadius: 8,
border: `1px solid ${row.isProblem ? '#ffccc7' : '#f0f0f0'}`,
background: row.isProblem ? '#fff2f0' : '#fafafa',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<Typography.Text strong style={{ color: row.isProblem ? '#cf1322' : undefined }}>
{row.examDate}
<Typography.Text strong style={{ color: row.isProblem ? '#cf1322' : undefined }}>
{row.examDate}
</Typography.Text>
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#666' }}>
{row.examType}
</Typography.Text>
{row.title && (
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#888' }}>
{row.title}
</Typography.Text>
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#666' }}>
{row.examType}
</Typography.Text>
{row.title && (
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : '#888' }}>
{row.title}
</Typography.Text>
)}
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : undefined }}>
{row.scoreText}{row.ratioText}
</Typography.Text>
</div>
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
<Typography.Text
type={row.isProblem ? 'danger' : 'secondary'}
style={{ fontSize: 13 }}
)}
<Typography.Text style={{ color: row.isProblem ? '#cf1322' : undefined }}>
{row.scoreText}{row.ratioText}
</Typography.Text>
<Typography.Text
type={row.isProblem ? 'danger' : 'secondary'}
style={{ fontSize: 13 }}
>
</Typography.Text>
{row.statuses.map((status) => (
<Tag
key={status}
color={STATUS_TAG_COLOR[status]}
style={{
marginInlineEnd: 0,
...(row.isProblem && status !== 'normal' ? { fontWeight: 600 } : {}),
}}
>
</Typography.Text>
{row.statuses.map((status) => (
<Tag
key={status}
color={STATUS_TAG_COLOR[status]}
style={row.isProblem && status !== 'normal' ? { fontWeight: 600 } : undefined}
>
{REVIEW_STATUS_LABELS[status]}
</Tag>
))}
</div>
{REVIEW_STATUS_LABELS[status]}
</Tag>
))}
</div>
))}
</div>
+1 -1
View File
@@ -196,7 +196,7 @@ export default function StudentDetailPage() {
{
key: 'review',
label: '成绩复盘',
children: <ExamReviewPanel exams={exams} onRefresh={loadExams} />,
children: <ExamReviewPanel studentId={id!} exams={exams} onRefresh={loadExams} />,
},
{
key: 'wrong',