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
+4
View File
@@ -39,6 +39,7 @@ export class DescriptiveGradingRecord {
"evaluation": string;
"username": string;
"createdAt": string;
"ocrImageUrl": string;
/** Creates a new DescriptiveGradingRecord instance. */
constructor($$source: Partial<DescriptiveGradingRecord> = {}) {
@@ -66,6 +67,9 @@ export class DescriptiveGradingRecord {
if (!("createdAt" in $$source)) {
this["createdAt"] = "";
}
if (!("ocrImageUrl" in $$source)) {
this["ocrImageUrl"] = "";
}
Object.assign(this, $$source);
}
+2 -2
View File
@@ -168,8 +168,8 @@ export function GetUsers(): $CancellablePromise<$models.User[]> {
});
}
export function GradeDescriptiveAnswer(username: string, source: string, questionID: number, userAnswer: string): $CancellablePromise<$models.DescriptiveGradingRecord | null> {
return $Call.ByID(642001377, username, source, questionID, userAnswer).then(($result: any) => {
export function GradeDescriptiveAnswer(username: string, source: string, questionID: number, userAnswer: string, ocrImageBase64: string): $CancellablePromise<$models.DescriptiveGradingRecord | null> {
return $Call.ByID(642001377, username, source, questionID, userAnswer, ocrImageBase64).then(($result: any) => {
return $$createType11($result);
});
}
+115
View File
@@ -13,6 +13,7 @@
"lucide-vue-next": "^1.0.0",
"marked": "^18.0.5",
"pinia": "^3.0.4",
"tesseract.js": "^7.0.0",
"vue": "^3.2.45",
"vue-pdf-embed": "^2.1.4",
"vue-router": "^4.6.4"
@@ -1084,6 +1085,12 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
@@ -1499,6 +1506,12 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/idb-keyval": {
"version": "6.2.6",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.6.tgz",
"integrity": "sha512-FY64UEhw+5liMzMQ1R9Mw6AF0+wyBrg1CIA1z4CjI/EvT5ty/SvQcWZgd8s9sgaNhX10Y8UzScTh89tEAls5nA==",
"license": "Apache-2.0"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1561,6 +1574,12 @@
"node": ">=0.12.0"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@@ -2016,6 +2035,26 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
@@ -2056,6 +2095,15 @@
"node": ">= 6"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2367,6 +2415,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -2581,6 +2635,30 @@
"node": ">=14.0.0"
}
},
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -2634,6 +2712,12 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -2861,6 +2945,37 @@
"peerDependencies": {
"typescript": "*"
}
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
}
}
}
+1
View File
@@ -15,6 +15,7 @@
"lucide-vue-next": "^1.0.0",
"marked": "^18.0.5",
"pinia": "^3.0.4",
"tesseract.js": "^7.0.0",
"vue": "^3.2.45",
"vue-pdf-embed": "^2.1.4",
"vue-router": "^4.6.4"
+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()
}