上传前人工裁剪错题区域,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
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-DmCjZu_W.js"></script>
<script type="module" crossorigin src="/assets/index-CCD3wnmu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css">
</head>
<body>
+20
View File
@@ -16,6 +16,7 @@
"echarts-for-react": "^3.0.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.0.2",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.0",
"tslib": "^2.8.1"
@@ -3308,6 +3309,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/normalize-wheel": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
"license": "BSD-3-Clause"
},
"node_modules/oxlint": {
"version": "1.71.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz",
@@ -3471,6 +3478,19 @@
"react": "^19.2.7"
}
},
"node_modules/react-easy-crop": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1"
},
"peerDependencies": {
"react": ">=16.4.0",
"react-dom": ">=16.4.0"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"echarts-for-react": "^3.0.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.0.2",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.0",
"tslib": "^2.8.1"
@@ -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' })
}