上传前人工裁剪错题区域,OCR 原文排除手写作答。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-28 16:01:46 +08:00
parent 23be608521
commit acfe002fbf
18 changed files with 975 additions and 448 deletions
@@ -3,7 +3,7 @@ import api from '../api/client'
interface Props {
questionId: string
variant?: 'original' | 'annotated'
variant?: 'original' | 'annotated' | 'cropped'
className?: string
alt?: string
style?: React.CSSProperties
@@ -40,10 +40,13 @@ export default function AuthenticatedImage({
}
const annotatedPath = `/wrong-questions/${questionId}/annotated-image`
const croppedPath = `/wrong-questions/${questionId}/cropped-image`
const originalPath = `/wrong-questions/${questionId}/image`
if (variant === 'annotated') {
load(annotatedPath, originalPath)
} else if (variant === 'cropped') {
load(croppedPath, annotatedPath)
} else {
load(originalPath)
}
+104
View File
@@ -0,0 +1,104 @@
import { Button, Modal, Slider, Space } from 'antd'
import { useCallback, useState } from 'react'
import Cropper, { type Area } from 'react-easy-crop'
import { blobToFile, cropImageToBlob } from '../utils/cropImage'
interface Props {
open: boolean
imageSrc: string | null
filename: string
onCancel: () => void
onConfirm: (file: File) => void
onSkip?: (file: File) => void
originalFile?: File | null
}
export default function ImageCropModal({
open,
imageSrc,
filename,
onCancel,
onConfirm,
onSkip,
originalFile,
}: Props) {
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
const [submitting, setSubmitting] = useState(false)
const onCropComplete = useCallback((_area: Area, pixels: Area) => {
setCroppedArea(pixels)
}, [])
const handleConfirm = async () => {
if (!imageSrc || !croppedArea) return
setSubmitting(true)
try {
const blob = await cropImageToBlob(imageSrc, croppedArea)
onConfirm(blobToFile(blob, filename.replace(/\.\w+$/, '') + '.jpg'))
} finally {
setSubmitting(false)
}
}
const handleSkip = () => {
if (originalFile && onSkip) {
onSkip(originalFile)
}
}
return (
<Modal
title="裁剪题目区域"
open={open}
onCancel={onCancel}
width="92%"
style={{ maxWidth: 560 }}
destroyOnHidden
footer={
<Space wrap>
<Button onClick={onCancel}></Button>
{onSkip && originalFile && (
<Button onClick={handleSkip} disabled={submitting}>
</Button>
)}
<Button type="primary" loading={submitting} onClick={handleConfirm}>
</Button>
</Space>
}
>
<p style={{ margin: '0 0 12px', color: '#666', fontSize: 13 }}>
</p>
<div
style={{
position: 'relative',
width: '100%',
height: 320,
background: '#111',
borderRadius: 8,
overflow: 'hidden',
}}
>
{imageSrc && (
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={undefined}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
)}
</div>
<div style={{ marginTop: 12 }}>
<span style={{ fontSize: 12, color: '#666' }}></span>
<Slider min={1} max={3} step={0.05} value={zoom} onChange={setZoom} />
</div>
</Modal>
)
}
@@ -3,6 +3,7 @@ import { Button, Input, Select, Space, Typography, Upload, message } from 'antd'
import { useEffect, useMemo, useRef, useState } from 'react'
import { wrongQuestionApi } from '../api/client'
import type { Subject, WrongQuestionCategory } from '../types'
import ImageCropModal from './ImageCropModal'
interface Props {
studentId: string
@@ -19,6 +20,9 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
)
const [subjectId, setSubjectId] = useState<number | undefined>()
const [uploading, setUploading] = useState(false)
const [cropOpen, setCropOpen] = useState(false)
const [cropSrc, setCropSrc] = useState<string | null>(null)
const [pendingFile, setPendingFile] = useState<File | null>(null)
const cameraRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@@ -27,6 +31,23 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
}
}, [availableSubjects])
const closeCrop = () => {
if (cropSrc) URL.revokeObjectURL(cropSrc)
setCropOpen(false)
setCropSrc(null)
setPendingFile(null)
}
const openCrop = (file: File) => {
if (!subjectId) {
message.warning(isOlympiad ? '未找到数学科目' : '请选择科目')
return
}
setCropSrc(URL.createObjectURL(file))
setPendingFile(file)
setCropOpen(true)
}
const doUpload = async (file: File) => {
if (!subjectId) {
message.warning(isOlympiad ? '未找到数学科目' : '请选择科目')
@@ -44,15 +65,25 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
}
}
const handleUpload = async (file: File) => {
await doUpload(file)
const handlePickFile = (file: File) => {
openCrop(file)
return false
}
const handleCamera = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleCamera = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (file) await doUpload(file)
if (file) openCrop(file)
}
const handleCropConfirm = async (file: File) => {
closeCrop()
await doUpload(file)
}
const handleSkipCrop = async (file: File) => {
closeCrop()
await doUpload(file)
}
return (
@@ -69,7 +100,7 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
/>
)}
<Space wrap className="upload-actions">
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*">
<Button icon={<PictureOutlined />} loading={uploading} type="primary" size="large">
</Button>
@@ -91,7 +122,7 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
onChange={handleCamera}
/>
{!isOlympiad && (
<Upload beforeUpload={handleUpload} showUploadList={false} accept="image/*">
<Upload beforeUpload={handlePickFile} showUploadList={false} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploading} size="large">
</Button>
@@ -103,6 +134,15 @@ export default function WrongQuestionUpload({ studentId, subjects, category, onU
/
</span>
)}
<ImageCropModal
open={cropOpen}
imageSrc={cropSrc}
filename={pendingFile?.name || 'question.jpg'}
originalFile={pendingFile}
onCancel={closeCrop}
onConfirm={handleCropConfirm}
onSkip={handleSkipCrop}
/>
</Space>
)
}
+1 -1
View File
@@ -203,7 +203,7 @@ export default function WrongQuestionDetail({
/>
{wq.ocr_raw_text && (
<div style={{ marginTop: 12 }}>
<Typography.Text strong>OCR </Typography.Text>
<Typography.Text strong>OCR </Typography.Text>
<pre
style={{
background: '#fafafa',
+1
View File
@@ -115,6 +115,7 @@ export interface WrongQuestion {
solution_text: string | null
mark_regions: MarkRegion[] | null
has_annotated_image: boolean
has_cropped_image: boolean
error_message: string | null
status: WrongQuestionStatus
created_at: string
+53
View File
@@ -0,0 +1,53 @@
export interface CropArea {
x: number
y: number
width: number
height: number
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
export async function cropImageToBlob(
imageSrc: string,
area: CropArea,
mimeType = 'image/jpeg',
quality = 0.92,
): Promise<Blob> {
const image = await loadImage(imageSrc)
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(area.width))
canvas.height = Math.max(1, Math.round(area.height))
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('无法创建画布')
ctx.drawImage(
image,
area.x,
area.y,
area.width,
area.height,
0,
0,
area.width,
area.height,
)
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('裁剪失败'))),
mimeType,
quality,
)
})
}
export function blobToFile(blob: Blob, filename: string): File {
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
}