This commit is contained in:
root
2026-07-04 07:42:19 +00:00
parent 68bb3c9167
commit 73d84169d6
6 changed files with 361 additions and 29 deletions
+174 -3
View File
@@ -208,10 +208,58 @@
placeholder="여기에 생각하신 답안을 자유롭게 적어주세요. 핵심 용어나 개념이 포함되면 더 정확한 채점이 가능합니다."
class="textarea textarea-ghost focus:bg-base-200/30 w-full min-h-[160px] text-sm sm:text-base leading-relaxed resize-none p-2 mt-2 border-0 focus:border-0 focus:outline-none focus:ring-0"
></textarea>
<div class="flex justify-end mt-2 border-t border-base-200 pt-2">
<div class="flex justify-between items-center mt-2 border-t border-base-200 pt-2">
<div class="flex gap-2">
<button
type="button"
@click="triggerOcrUpload"
:disabled="showFeedback || ocrLoading"
class="btn btn-xs btn-outline btn-secondary gap-1"
title="종이에 적은 답안 사진을 찍거나 올려서 텍스트로 인식시킵니다"
>
<CameraIcon :size="12" />
종이 답안 인식 (OCR)
</button>
<input
type="file"
ref="ocrFileInput"
accept="image/*"
class="hidden"
@change="onOcrFileChange"
/>
</div>
<span class="text-xs opacity-40">{{ userDescriptiveAnswer.length }} 입력됨</span>
</div>
</div>
<!-- OCR 진행 상태 표시 -->
<div v-if="ocrLoading" class="card bg-base-300 border border-secondary/20 p-4 rounded-xl space-y-2">
<div class="flex justify-between items-center">
<span class="text-xs font-bold text-secondary flex items-center gap-1.5">
<span class="loading loading-spinner loading-xs"></span>
{{ ocrStatusText }}
</span>
<span class="text-xs font-mono font-bold">{{ ocrProgress }}%</span>
</div>
<progress class="progress progress-secondary w-full" :value="ocrProgress" max="100"></progress>
</div>
<!-- OCR 인식 결과 확인 편집 영역 -->
<div v-if="ocrResultText !== null" class="card bg-base-300 border border-base-300 p-4 rounded-xl space-y-3">
<div class="flex justify-between items-center border-b border-base-100 pb-2">
<span class="text-xs font-bold text-base-content opacity-80">인식된 글자 결과 (확인 수정 가능)</span>
<button @click="discardOcr" class="btn btn-xs btn-circle btn-ghost" title="닫기"></button>
</div>
<textarea
v-model="ocrResultText"
class="textarea textarea-bordered w-full min-h-[100px] text-xs sm:text-sm leading-relaxed"
placeholder="인식된 결과가 비어있습니다. 직접 수정해 주세요."
></textarea>
<div class="flex justify-end gap-2">
<button @click="appendOcrResult" class="btn btn-xs btn-neutral">기존 답안 뒤에 추가</button>
<button @click="applyOcrResult" class="btn btn-xs btn-primary"> 내용으로 답안 덮어쓰기</button>
</div>
</div>
<!-- Error Banner for Grading -->
<div v-if="gradingError" class="alert alert-error shadow-sm text-xs sm:text-sm">
@@ -315,6 +363,13 @@
</div>
<div class="text-xs sm:text-sm md:text-base leading-relaxed text-base-content markdown-content prose prose-sm prose-invert max-w-none" v-html="formatEvaluation(descriptiveGradingResult.evaluation)">
</div>
<!-- 제출한 종이 답안 사진 보기 -->
<div v-if="descriptiveGradingResult.ocrImageUrl" class="mt-4 pt-3 border-t border-secondary/20 flex flex-col gap-1.5">
<span class="text-[10px] sm:text-xs font-bold text-slate-400">제출한 종이 답안:</span>
<a :href="descriptiveGradingResult.ocrImageUrl" target="_blank" class="inline-block hover:opacity-90 max-w-max">
<img :src="descriptiveGradingResult.ocrImageUrl" class="max-w-[240px] max-h-[180px] rounded-lg border border-secondary/30 object-contain bg-base-300" />
</a>
</div>
</div>
</div>
@@ -330,6 +385,12 @@
<span class="badge badge-sm" :class="h.score >= 60 ? 'badge-success' : 'badge-warning'">{{ h.score }}</span>
</div>
<div class="collapse-content text-xs space-y-2.5">
<div v-if="h.ocrImageUrl" class="bg-base-100 p-2.5 rounded-lg border border-base-300">
<p class="font-bold opacity-60 mb-1 text-[10px] uppercase">제출한 종이 답안</p>
<a :href="h.ocrImageUrl" target="_blank" class="inline-block mt-1 hover:opacity-90 max-w-max">
<img :src="h.ocrImageUrl" class="max-w-[180px] max-h-[120px] rounded-lg border border-base-200 object-contain bg-base-300" />
</a>
</div>
<div class="bg-base-100 p-2.5 rounded-lg border border-base-300">
<p class="font-bold opacity-60 mb-1 text-[10px] uppercase"> 제출 답안</p>
<p class="whitespace-pre-wrap leading-relaxed opacity-90">{{ h.userAnswer }}</p>
@@ -446,8 +507,9 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeftIcon, ArrowRightIcon, CheckCircleIcon, XCircleIcon, SparklesIcon, BotIcon, TrophyIcon, FileTextIcon, ImageIcon, ExternalLinkIcon, BookmarkIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-vue-next'
import { ArrowLeftIcon, ArrowRightIcon, CheckCircleIcon, XCircleIcon, SparklesIcon, BotIcon, TrophyIcon, FileTextIcon, ImageIcon, ExternalLinkIcon, BookmarkIcon, ChevronLeftIcon, ChevronRightIcon, CameraIcon } from 'lucide-vue-next'
import VuePdfEmbed from 'vue-pdf-embed'
import Tesseract from 'tesseract.js'
import { marked } from 'marked'
import katex from 'katex'
import 'katex/dist/katex.min.css'
@@ -504,6 +566,106 @@ const descriptiveGradingResult = ref<DescriptiveGradingRecord | null>(null)
const descriptiveHistory = ref<DescriptiveGradingRecord[]>([])
const gradingError = ref('')
// OCR state
const ocrFileInput = ref<HTMLInputElement | null>(null)
const ocrLoading = ref(false)
const ocrProgress = ref(0)
const ocrStatusText = ref('')
const ocrResultText = ref<string | null>(null)
const uploadedOcrImageBase64 = ref('')
function triggerOcrUpload() {
if (ocrFileInput.value) {
ocrFileInput.value.click()
}
}
async function onOcrFileChange(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
ocrLoading.value = true
ocrProgress.value = 0
ocrStatusText.value = '인식 엔진 준비 중...'
ocrResultText.value = null
uploadedOcrImageBase64.value = ''
// Convert file to base64 synchronously
const reader = new FileReader()
reader.onload = (e) => {
uploadedOcrImageBase64.value = e.target?.result as string || ''
}
reader.readAsDataURL(file)
try {
const result = await Tesseract.recognize(
file,
'kor+eng',
{
logger: (m) => {
if (m && m.status) {
switch (m.status) {
case 'loading tesseract core':
ocrStatusText.value = '인식 엔진 불러오는 중...'
break
case 'initializing api':
ocrStatusText.value = '언어 데이터(한/영) 초기화 중...'
break
case 'recognizing text':
ocrStatusText.value = '글자 판독 및 텍스트 변환 중...'
break
default:
ocrStatusText.value = m.status
}
if (typeof m.progress === 'number') {
ocrProgress.value = Math.round(m.progress * 100)
}
}
}
}
)
ocrResultText.value = result.data.text || ''
} catch (err: any) {
console.error('OCR 실패:', err)
uploadedOcrImageBase64.value = ''
alert(`글자 인식에 실패했습니다: ${err.message || err}`)
} finally {
ocrLoading.value = false
if (ocrFileInput.value) {
ocrFileInput.value.value = ''
}
}
}
function clearOcrResult() {
ocrResultText.value = null
}
function discardOcr() {
ocrResultText.value = null
uploadedOcrImageBase64.value = ''
}
function appendOcrResult() {
if (ocrResultText.value) {
if (userDescriptiveAnswer.value.trim()) {
userDescriptiveAnswer.value += ' ' + ocrResultText.value.trim()
} else {
userDescriptiveAnswer.value = ocrResultText.value.trim()
}
}
clearOcrResult()
}
function applyOcrResult() {
if (ocrResultText.value) {
userDescriptiveAnswer.value = ocrResultText.value.trim()
}
clearOcrResult()
}
// Track background task states per-question using source_id keys
const askingGeminiMap = ref<Record<string, boolean>>({})
const gradingDescriptiveMap = ref<Record<string, boolean>>({})
@@ -559,7 +721,13 @@ async function submitDescriptiveGrading() {
gradingError.value = ''
try {
const record = await QuizService.GradeDescriptiveAnswer(authStore.username, q.source, q.id, userDescriptiveAnswer.value)
const record = await QuizService.GradeDescriptiveAnswer(
authStore.username,
q.source,
q.id,
userDescriptiveAnswer.value,
uploadedOcrImageBase64.value
)
if (record) {
descriptiveGradingResult.value = record
@@ -567,6 +735,7 @@ async function submitDescriptiveGrading() {
descriptiveHistory.value.unshift(record)
isCorrect.value = false
uploadedOcrImageBase64.value = ''
} else {
isCorrect.value = true
gradingDescriptive.value = false
@@ -1094,6 +1263,8 @@ function loadState(index: number) {
isCorrect.value = false
userDescriptiveAnswer.value = ''
descriptiveGradingResult.value = null
uploadedOcrImageBase64.value = ''
ocrResultText.value = null
}
loadGradingHistory()
}