作业帮式错题标注:OCR 定位错误红框 + 解题思路。
- PaddleOCR 行级坐标 + AI 识别错答区域,生成标注图 - 解法拆分为「解题思路」与「详细解答」 - 详情页标注图/原图切换,列表显示标注缩略图
This commit is contained in:
+432
File diff suppressed because one or more lines are too long
-432
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-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>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function StudentDetailPage() {
|
||||
children: (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
按{stageLabel}课程标准生成解法,严禁超纲
|
||||
上传后自动标注错误位置(红框),并生成解题思路,按{stageLabel}课内标准解题
|
||||
</Typography.Paragraph>
|
||||
<WrongQuestionUpload
|
||||
studentId={id!}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '月考',
|
||||
|
||||
Reference in New Issue
Block a user