This commit is contained in:
root
2026-07-05 12:29:33 +00:00
parent a9bb83b4e5
commit 60c82bd188
3 changed files with 111 additions and 21 deletions
+80 -14
View File
@@ -205,7 +205,7 @@
</div>
<!-- Descriptive / Essay Question -->
<div v-else-if="currentQuestion.correctAnswerStr === '주관식'" class="space-y-4 w-full">
<div v-else-if="isDescriptiveQuestion" class="space-y-4 w-full">
<div class="form-control w-full bg-base-100 rounded-2xl p-4 border border-base-300 shadow-inner">
<label class="w-full label pt-0 pb-2 border-b border-base-200">
<span class="w-full label-text font-bold flex items-center gap-1.5 opacity-80">
@@ -330,16 +330,35 @@
/>
</div>
<!-- Error Banner for AI Grading -->
<div v-if="gradingError" class="alert alert-error shadow-sm text-xs sm:text-sm">
<XCircleIcon :size="20" class="shrink-0" />
<div class="flex-1 min-w-0">
<h4 class="font-bold text-error-content">AI 채점 실패</h4>
<p class="font-mono text-[10px] mt-1 opacity-90 whitespace-pre-wrap break-all">{{ gradingError }}</p>
</div>
</div>
<div v-if="!showFeedback" class="flex flex-col sm:flex-row justify-center items-center gap-3 w-full">
<button
@click="submitTextAnswer"
:disabled="!userTextAnswer.trim()"
:disabled="!userTextAnswer.trim() || gradingDescriptive"
class="btn btn-primary btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-md text-sm sm:text-base whitespace-normal break-keep"
>
제출하기
채점 (텍스트 일치)
</button>
<button
@click="submitTextAnswerWithAi"
:disabled="!userTextAnswer.trim() || gradingDescriptive"
class="btn btn-secondary btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-md gap-2 text-sm sm:text-base whitespace-normal break-keep"
>
<span v-if="gradingDescriptive" class="loading loading-spinner loading-sm"></span>
<SparklesIcon v-else :size="16" />
{{ gradingDescriptive ? 'AI 채점중...' : 'AI 채점' }}
</button>
<button
@click="skipToExplanations"
:disabled="gradingDescriptive"
class="btn btn-outline btn-neutral btn-md sm:btn-lg w-full sm:w-auto sm:flex-1 shadow-sm text-sm sm:text-base whitespace-normal break-keep"
>
정답/해설 바로보기
@@ -350,7 +369,7 @@
<!-- Feedback & Explanations -->
<div v-if="showFeedback" class="bg-base-300/80 border-t border-base-300 p-5 sm:p-6 md:p-8">
<div v-if="currentQuestion.correctAnswerStr !== '주관식'" class="alert shadow-lg mb-6" :class="isCorrect ? 'alert-success' : 'alert-error'">
<div v-if="!isDescriptiveQuestion" class="alert shadow-lg mb-6" :class="isCorrect ? 'alert-success' : 'alert-error'">
<CheckCircleIcon v-if="isCorrect" :size="24" />
<XCircleIcon v-else :size="24" />
<div>
@@ -368,6 +387,9 @@
<div>
<h4 class="font-bold text-base sm:text-lg">주관식/논술형 문제</h4>
<p class="text-xs sm:text-sm">아래 모범답안 AI 상세 해설을 참고하여 학습을 진행하세요.</p>
<div v-if="currentQuestion.correctAnswerStr && currentQuestion.correctAnswerStr.trim() !== '주관식'" class="text-xs sm:text-sm mt-1.5 pt-1.5 border-t border-info-content/10">
정답: <span class="font-bold underline" v-html="formatCorrectAnswer(currentQuestion.correctAnswerStr)"></span>
</div>
</div>
</div>
@@ -768,7 +790,7 @@ const gradingDescriptive = computed({
async function loadGradingHistory() {
const q = currentQuestion.value
if (!q || q.correctAnswerStr !== '주관식') {
if (!q || !isDescriptiveQuestion.value) {
descriptiveHistory.value = []
return
}
@@ -780,6 +802,11 @@ async function loadGradingHistory() {
}
}
function submitTextAnswerWithAi() {
userDescriptiveAnswer.value = userTextAnswer.value
submitDescriptiveGrading()
}
async function submitDescriptiveGrading() {
const q = currentQuestion.value
if (!q || !userDescriptiveAnswer.value.trim() || gradingDescriptive.value) return
@@ -962,6 +989,23 @@ const isOXQuestion = computed(() => {
return ans === 'O' || ans === 'X'
})
// Check if the question is descriptive
const isDescriptiveQuestion = computed(() => {
const q = currentQuestion.value
if (!q) return false
// 객관식 보기가 있는 경우 제외
if (choices.value.length > 0) return false
// OX 문항인 경우 제외
if (isOXQuestion.value) return false
const ans = q.correctAnswerStr?.trim() || ""
// 정답이 '주관식'이거나 10자 이상 긴 문장이거나 공백을 포함하는 경우 주관식 서술형 취급
return ans === '주관식' || ans.length > 8 || ans.includes(' ') || ans.includes('및') || ans.includes('와')
})
const isPdf = computed(() => {
const url = currentQuestion.value.imageUrl
if (!url) return false
@@ -987,11 +1031,20 @@ function decodeHTMLEntities(text: string): string {
function renderMarkdownWithMath(text: string): string {
if (!text) return ''
// 0. Extract and protect Mermaid blocks
const mermaidBlocks: string[] = []
let workingText = text
workingText = workingText.replace(/```mermaid\r?\\n([\s\S]*?)\r?\\n```/g, (match, code) => {
const placeholder = `MERMAIDBLOCKPLACEHOLDER${mermaidBlocks.length}`
mermaidBlocks.push(code.trim())
return placeholder
})
const mathBlocks: { placeholder: string; isBlock: boolean; rawExpr: string; renderedHtml: string }[] = []
let placeholderCount = 0
// Normalize slash/backslash ctdot representations to standard cdots
let workingText = text
workingText = workingText
.replace(/\\ctdot/g, '\\cdots')
.replace(/\/ctdot/g, '\\cdots')
@@ -1050,6 +1103,13 @@ function renderMarkdownWithMath(text: string): string {
html = html.replace(block.placeholder, block.renderedHtml)
}
// 5. Restore protected Mermaid blocks
mermaidBlocks.forEach((code, idx) => {
const placeholder = `MERMAIDBLOCKPLACEHOLDER${idx}`
const replacement = `<div class="mermaid">${code}</div>`
html = html.replace(new RegExp(`<p>\\s*${placeholder}\\s*</p>|${placeholder}`, 'g'), replacement)
})
return html
}
@@ -1565,24 +1625,30 @@ function handleTouchEnd(e: TouchEvent) {
}
/* 마크다운 표가 화면을 벗어나지 않게 하고 반응형 가로스크롤 및 폰트 축소 적용 */
.markdown-content table {
width: 100% !important;
max-width: 100% !important;
.markdown-content table,
.prose table {
min-width: 100% !important;
width: max-content !important;
max-width: none !important;
display: table !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
border-collapse: collapse !important;
margin-top: 1rem !important;
margin-bottom: 1rem !important;
font-size: 0.85em !important;
}
.markdown-content th,
.markdown-content td {
.markdown-content td,
.prose th,
.prose td {
padding: 0.5rem 0.75rem !important;
border: 1px solid var(--fallback-bc, oklch(var(--bc)/0.15)) !important;
white-space: nowrap; /* 셀 내용 잘림 방지하고 깔끔한 스크롤 보장 */
white-space: normal !important;
word-break: keep-all !important;
word-wrap: break-word !important;
min-width: 120px !important;
}
.markdown-content th {
.markdown-content th,
.prose th {
background-color: var(--fallback-b3, oklch(var(--b3)/0.5)) !important;
font-weight: bold !important;
}
+24 -7
View File
@@ -255,7 +255,7 @@
</div>
<!-- Extracted Concepts Markdown viewer -->
<div class="flex-1 overflow-y-auto prose max-w-none text-[13px] leading-relaxed pr-2 text-left" v-html="renderedContent"></div>
<div class="flex-1 overflow-y-auto markdown-content prose max-w-none text-[13px] leading-relaxed pr-2 text-left" v-html="renderedContent"></div>
</div>
<form method="dialog" class="modal-backdrop">
<button @click="closeModal">close</button>
@@ -358,8 +358,16 @@ marked.use({
function renderMarkdown(text: string): string {
if (!text) return ''
// 0. Extract and protect Mermaid blocks
const mermaidBlocks: string[] = []
let processed = text.replace(/```mermaid\r?\\n([\s\S]*?)\r?\\n```/g, (match, code) => {
const placeholder = `MERMAIDBLOCKPLACEHOLDER${mermaidBlocks.length}`
mermaidBlocks.push(code.trim())
return placeholder
})
// 1. Render display math $$...$$
let processed = text.replace(/\$\$([\s\S]+?)\$\$/g, (match, math) => {
processed = processed.replace(/\$\$([\s\S]+?)\$\$/g, (match, math) => {
try {
return `<div class="math-display">${katex.renderToString(math.trim(), { displayMode: true, throwOnError: false })}</div>`
} catch (e) {
@@ -377,7 +385,16 @@ function renderMarkdown(text: string): string {
})
// 3. Render markdown
return marked.parse(processed) as string
let html = marked.parse(processed) as string
// 5. Restore protected Mermaid blocks
mermaidBlocks.forEach((code, idx) => {
const placeholder = `MERMAIDBLOCKPLACEHOLDER${idx}`
const replacement = `<div class="mermaid">${code}</div>`
html = html.replace(new RegExp(`<p>\\s*${placeholder}\\s*</p>|${placeholder}`, 'g'), replacement)
})
return html
}
const renderedContent = computed(() => {
@@ -581,8 +598,8 @@ function printSingleElement(se: StudyElement) {
}
.math-display { margin: 16px 0; overflow-x: auto; text-align: center; }
.math-inline { padding: 0 4px; }
.prose table { width: 100%; border-collapse: collapse; margin: 16px 0; }
.prose th, .prose td { border: 1px solid #e2e8f0; padding: 10px 14px; text-align: left; }
.prose table { min-width: 100%; width: max-content; max-width: none; border-collapse: collapse; margin: 16px 0; }
.prose th, .prose td { border: 1px solid #e2e8f0; padding: 10px 14px; text-align: left; word-break: keep-all; word-wrap: break-word; min-width: 120px; }
.prose th { background-color: #f1f5f9; font-weight: bold; }
.mermaid { margin: 20px 0; text-align: center; }
</style>
@@ -666,8 +683,8 @@ function printAllElements() {
}
.math-display { margin: 16px 0; overflow-x: auto; text-align: center; }
.math-inline { padding: 0 4px; }
.prose table { width: 100%; border-collapse: collapse; margin: 16px 0; }
.prose th, .prose td { border: 1px solid #e2e8f0; padding: 10px 14px; text-align: left; }
.prose table { min-width: 100%; width: max-content; max-width: none; border-collapse: collapse; margin: 16px 0; }
.prose th, .prose td { border: 1px solid #e2e8f0; padding: 10px 14px; text-align: left; word-break: keep-all; word-wrap: break-word; min-width: 120px; }
.prose th { background-color: #f1f5f9; font-weight: bold; }
.mermaid { margin: 20px 0; text-align: center; }
</style>
+7
View File
@@ -1802,6 +1802,13 @@ func (s *QuizService) GenerateGoPracticalExams(count int, useGeminiAPI bool, del
continue
}
// Force correctAnswerStr to "주관식" for descriptive questions to guarantee descriptive UI layout
if examType == "서술형" {
gq.CorrectAnswerStr = "주관식"
} else if examType == "혼합형" && i%2 == 0 {
gq.CorrectAnswerStr = "주관식"
}
// Save to DB (Mutex Lock)
s.dbMu.Lock()
// Generate next ID