作业帮式错题标注:OCR 定位错误红框 + 解题思路。

- PaddleOCR 行级坐标 + AI 识别错答区域,生成标注图

- 解法拆分为「解题思路」与「详细解答」

- 详情页标注图/原图切换,列表显示标注缩略图
This commit is contained in:
dekun
2026-06-28 13:50:20 +08:00
parent c30e21b51e
commit a2a6d59f7c
16 changed files with 852 additions and 507 deletions
File diff suppressed because one or more lines are too long
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-FkWLM-t9.js"></script>
<script type="module" crossorigin src="/assets/index-CKqFHGFD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css">
</head>
<body>
+36 -12
View File
@@ -3,6 +3,7 @@ import api from '../api/client'
interface Props {
questionId: string
variant?: 'original' | 'annotated'
className?: string
alt?: string
style?: React.CSSProperties
@@ -10,6 +11,7 @@ interface Props {
export default function AuthenticatedImage({
questionId,
variant = 'original',
className,
alt = '题目',
style,
@@ -21,36 +23,58 @@ export default function AuthenticatedImage({
let objectUrl: string | null = null
let cancelled = false
api
.get(`/wrong-questions/${questionId}/image`, { responseType: 'blob' })
.then((res) => {
const load = async (path: string, fallback?: string) => {
try {
const res = await api.get(path, { responseType: 'blob' })
if (cancelled) return
objectUrl = URL.createObjectURL(res.data)
setSrc(objectUrl)
setFailed(false)
})
.catch(() => {
if (!cancelled) setFailed(true)
})
} catch {
if (fallback && !cancelled) {
await load(fallback)
} else if (!cancelled) {
setFailed(true)
}
}
}
const annotatedPath = `/wrong-questions/${questionId}/annotated-image`
const originalPath = `/wrong-questions/${questionId}/image`
if (variant === 'annotated') {
load(annotatedPath, originalPath)
} else {
load(originalPath)
}
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [questionId])
}, [questionId, variant])
if (failed) {
return (
<div className={className} style={{ ...style, background: '#fafafa', color: '#999', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12 }}>
<div
className={className}
style={{
...style,
background: '#fafafa',
color: '#999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
}}
>
</div>
)
}
if (!src) {
return (
<div className={className} style={{ ...style, background: '#fafafa' }} />
)
return <div className={className} style={{ ...style, background: '#fafafa' }} />
}
return <img src={src} alt={alt} className={className} style={style} />
@@ -38,7 +38,7 @@ export default function WrongQuestionList({
{items.map((wq) => (
<div key={wq.id} className="wq-card">
<div className="wq-card-click" onClick={() => onSelect(wq.id)}>
<AuthenticatedImage questionId={wq.id} alt="题目" className="wq-card-img" />
<AuthenticatedImage questionId={wq.id} variant="annotated" alt="题目" className="wq-card-img" />
<div className="wq-card-body">
<Typography.Text strong>{wq.subject_name}</Typography.Text>
{wq.category === 'olympiad' && (
+1 -1
View File
@@ -198,7 +198,7 @@ export default function StudentDetailPage() {
children: (
<div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{stageLabel}
{stageLabel}
</Typography.Paragraph>
<WrongQuestionUpload
studentId={id!}
+59 -11
View File
@@ -1,4 +1,4 @@
import { Alert, Button, Col, Input, Modal, Popconfirm, Row, Space, Spin, Typography, message } from 'antd'
import { Alert, Button, Col, Input, Modal, Popconfirm, Row, Segmented, Space, Spin, Typography, message } from 'antd'
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import AuthenticatedImage from '../components/AuthenticatedImage'
@@ -24,7 +24,9 @@ export default function WrongQuestionDetail({
const [wq, setWq] = useState<WrongQuestion | null>(null)
const [loading, setLoading] = useState(false)
const [questionText, setQuestionText] = useState('')
const [approachText, setApproachText] = useState('')
const [solutionText, setSolutionText] = useState('')
const [imageMode, setImageMode] = useState<'annotated' | 'original'>('annotated')
const [saving, setSaving] = useState(false)
const [regenerating, setRegenerating] = useState(false)
const [deleting, setDeleting] = useState(false)
@@ -35,7 +37,9 @@ export default function WrongQuestionDetail({
const { data } = await wrongQuestionApi.get(questionId)
setWq(data)
setQuestionText(data.question_text || '')
setApproachText(data.solution_approach || '')
setSolutionText(data.solution_text || '')
setImageMode(data.has_annotated_image ? 'annotated' : 'original')
} finally {
setLoading(false)
}
@@ -50,6 +54,7 @@ export default function WrongQuestionDetail({
try {
await wrongQuestionApi.update(questionId, {
question_text: questionText,
solution_approach: approachText,
solution_text: solutionText,
})
message.success('已保存')
@@ -65,8 +70,9 @@ export default function WrongQuestionDetail({
const { data } = await wrongQuestionApi.regenerate(questionId)
setWq(data)
setQuestionText(data.question_text || '')
setApproachText(data.solution_approach || '')
setSolutionText(data.solution_text || '')
message.success('解已重新生成')
message.success('解题思路已重新生成')
onUpdated()
} catch {
message.error('生成失败,请检查 AI 模型配置')
@@ -77,7 +83,7 @@ export default function WrongQuestionDetail({
const handleRetryOcr = async () => {
await wrongQuestionApi.retryOcr(questionId)
message.info('已重新识别,请稍后刷新')
message.info('已重新识别并标注,请稍后刷新')
onUpdated()
onClose()
}
@@ -114,9 +120,9 @@ export default function WrongQuestionDetail({
</Button>
</Popconfirm>
<Button onClick={handleRetryOcr}> OCR</Button>
<Button onClick={handleRetryOcr}></Button>
<Button loading={regenerating} onClick={handleRegenerate}>
</Button>
<Button type="primary" loading={saving} onClick={handleSave}>
@@ -127,10 +133,15 @@ export default function WrongQuestionDetail({
<Spin spinning={loading}>
{wq && (
<>
<Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text>
{wq.solution_text && (
<Space wrap style={{ marginBottom: 8 }}>
<Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text>
{wq.has_annotated_image && (
<Typography.Text type="danger"></Typography.Text>
)}
</Space>
{(wq.solution_approach || wq.solution_text) && (
<Alert
message="AI 生成内容,请核对后再使用"
message="AI 识别与标注,请核对后再使用"
type="warning"
showIcon
style={{ margin: '12px 0' }}
@@ -138,8 +149,21 @@ export default function WrongQuestionDetail({
)}
<Row gutter={16} style={{ marginTop: 12 }}>
<Col xs={24} md={10}>
{wq.has_annotated_image && (
<Segmented
block
style={{ marginBottom: 8 }}
value={imageMode}
onChange={(v) => setImageMode(v as 'annotated' | 'original')}
options={[
{ label: '标注图', value: 'annotated' },
{ label: '原图', value: 'original' },
]}
/>
)}
<AuthenticatedImage
questionId={wq.id}
variant={imageMode}
alt="原题"
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
/>
@@ -164,12 +188,36 @@ export default function WrongQuestionDetail({
<Col xs={24} md={14}>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={6}
rows={5}
value={questionText}
onChange={(e) => setQuestionText(e.target.value)}
style={{ marginTop: 8, marginBottom: 16 }}
/>
<Typography.Text strong></Typography.Text>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={4}
value={approachText}
onChange={(e) => setApproachText(e.target.value)}
placeholder="识别完成后自动生成,类似作业帮「解题思路」"
style={{ marginTop: 8, marginBottom: 16 }}
/>
{approachText && (
<div
style={{
background: '#e6f4ff',
padding: 12,
borderRadius: 8,
marginBottom: 16,
border: '1px solid #91caff',
}}
>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<ReactMarkdown>{approachText}</ReactMarkdown>
</div>
)}
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={8}
value={solutionText}
@@ -179,7 +227,7 @@ export default function WrongQuestionDetail({
{solutionText && (
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<ReactMarkdown>{solutionText}</ReactMarkdown>
</div>
+11
View File
@@ -110,11 +110,22 @@ export interface WrongQuestion {
image_path: string
ocr_raw_text: string | null
question_text: string | null
solution_approach: string | null
solution_text: string | null
mark_regions: MarkRegion[] | null
has_annotated_image: boolean
status: WrongQuestionStatus
created_at: string
}
export interface MarkRegion {
line_id: number
text: string
bbox: number[]
type: string
label: string
}
export const EXAM_TYPE_LABELS: Record<ExamType, string> = {
weekly: '周考',
monthly: '月考',