study
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user