考试明细单行展示,选中科目后增加 AI 解读与建议。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+79
-79
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user