Spaces:
Paused
Paused
| <template> | |
| <div | |
| class="p-4" | |
| :class="[ | |
| speakingCompact ? 'rounded-none border-0 bg-transparent p-4 shadow-none' : 'card', | |
| isCurrent && !speakingCompact ? 'ring-2 ring-[rgba(124,106,247,0.35)]' : '', | |
| ]" | |
| > | |
| <div class="flex items-start gap-2"> | |
| <!-- Question number + audio play button (listening) --> | |
| <div class="flex shrink-0 items-center gap-1.5"> | |
| <button | |
| v-if="canJumpToAudio" | |
| class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#34d399] text-white transition-colors hover:bg-[#059669]" | |
| :title="`Play from ${Math.floor(question.listen_from)}s`" | |
| @click.stop="$emit('jump-audio', { time: question.listen_from, locateInfo: question.locate_info })" | |
| > | |
| <svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> | |
| </button> | |
| <div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--bg2)] text-[11px] font-bold text-[var(--ink2)]"> | |
| {{ question.order }} | |
| </div> | |
| </div> | |
| <div class="flex-1" :class="speakingCompact ? '[&_.text-sm.font-semibold]:text-lg [&_.text-sm.font-semibold]:font-semibold [&_.text-sm.font-semibold]:leading-normal [&_.text-sm.font-semibold]:text-[var(--ink)]' : ''"> | |
| <div class="text-sm font-semibold" v-if="question.text || question.title">{{ question.text || question.title }}</div> | |
| <div class="text-sm" v-else-if="question.content" v-html="safeContent"></div> | |
| </div> | |
| </div> | |
| <div class="mt-3" v-if="mode === 'single'"> | |
| <div class="grid gap-2"> | |
| <label | |
| v-for="opt in singleOptions" | |
| :key="opt.option" | |
| class="flex items-center gap-2 rounded-xl border border-[var(--border2)] bg-[var(--surface)] px-3 py-2 cursor-pointer hover:border-[var(--ink3)]" | |
| > | |
| <input type="radio" :name="`q_${question.id}`" :value="opt.option" v-model="singleValue" /> | |
| <div class="text-sm"> | |
| <span class="font-semibold mr-2">{{ opt.option }}</span> | |
| <span>{{ opt.text }}</span> | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| <div class="mt-3" v-else-if="mode === 'multi'"> | |
| <div class="text-xs text-[var(--ink2)] mb-2">Choose {{ maxSelections }} answers</div> | |
| <div class="grid gap-2"> | |
| <label | |
| v-for="opt in multiOptions" | |
| :key="opt.option" | |
| class="flex items-center gap-2 rounded-xl border border-[var(--border2)] bg-[var(--surface)] px-3 py-2 cursor-pointer hover:border-[var(--ink3)]" | |
| > | |
| <input type="checkbox" :value="opt.option" v-model="multiValue" :disabled="isMultiDisabled(opt.option)" /> | |
| <div class="text-sm"> | |
| <span class="font-semibold mr-2">{{ opt.option }}</span> | |
| <span>{{ opt.text }}</span> | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| <div class="mt-3" v-else-if="mode === 'select'"> | |
| <div class="text-xs text-[var(--ink2)] mb-2">Select answer</div> | |
| <select class="w-full rounded-xl border border-[var(--border2)] bg-[var(--surface)] px-3 py-2" v-model="singleValue"> | |
| <option value="">—</option> | |
| <option v-for="opt in selectOptions" :key="opt.option" :value="opt.option">{{ opt.option }}</option> | |
| </select> | |
| </div> | |
| <div class="mt-3" v-else-if="mode === 'text'"> | |
| <input | |
| v-model="singleValue" | |
| class="w-full rounded-xl border border-[var(--border2)] bg-[var(--surface)] px-3 py-2 text-sm outline-none focus:border-[#34d399]" | |
| placeholder="Type your answer" | |
| /> | |
| </div> | |
| <!-- Speaking question: record button --> | |
| <div class="mt-5" v-else-if="mode === 'speaking'"> | |
| <div | |
| class="rounded-xl border border-[var(--border)] bg-[var(--bg)] p-5" | |
| :class="speakingCompact ? 'flex flex-col items-center gap-4 text-center' : 'flex items-center justify-between gap-4'" | |
| > | |
| <div :class="speakingCompact ? 'w-full' : ''"> | |
| <div class="text-[11px] font-semibold uppercase tracking-wider text-[var(--ink3)]"> | |
| No time limit | |
| </div> | |
| <div class="mt-1 text-[13px] text-[var(--ink2)]"> | |
| {{ isRecording ? `Đang ghi âm... ${elapsed}s` : recordedBlob ? 'Đã ghi xong' : 'Nhấn micro để bắt đầu ghi âm' }} | |
| </div> | |
| </div> | |
| <div class="flex flex-col items-center gap-1.5"> | |
| <button | |
| type="button" | |
| @click="toggleRecord" | |
| class="flex items-center justify-center rounded-full border-2 transition-all" | |
| :class="[ | |
| speakingCompact ? 'h-16 w-16' : 'h-12 w-12', | |
| isRecording | |
| ? 'border-[#e11d48] bg-[#e11d48] text-white animate-pulse' | |
| : 'border-[#34d399] bg-[#f0fdf4] text-[#34d399] hover:bg-[#34d399] hover:text-white', | |
| ]" | |
| > | |
| <svg v-if="!isRecording" :width="speakingCompact ? 22 : 18" :height="speakingCompact ? 22 : 18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/> | |
| <path d="M19 10v2a7 7 0 0 1-14 0v-2"/> | |
| <line x1="12" y1="19" x2="12" y2="23"/> | |
| <line x1="8" y1="23" x2="16" y2="23"/> | |
| </svg> | |
| <svg v-else width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> | |
| </button> | |
| <div v-if="recordedBlob" class="text-[10px] font-medium text-[#059669]">✓ Đã ghi</div> | |
| </div> | |
| </div> | |
| <!-- Playback + delete --> | |
| <div v-if="recordedUrl" class="mt-2 flex items-center gap-2"> | |
| <audio :src="recordedUrl" controls class="min-w-0 flex-1" style="height:32px"></audio> | |
| <button | |
| type="button" | |
| class="flex shrink-0 items-center gap-1 rounded-lg border border-[#fecaca] bg-[#fef2f2] px-2.5 py-1.5 text-[11px] font-semibold text-[#dc2626] transition-colors hover:bg-[#fee2e2]" | |
| title="Xóa bản ghi âm" | |
| @click="clearRecording" | |
| > | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/> | |
| </svg> | |
| Xóa | |
| </button> | |
| </div> | |
| <!-- Min-duration hint --> | |
| <div | |
| v-if="recordedBlob && !isRecording && recordedElapsed < MIN_RECORD_SECONDS" | |
| class="mt-2 rounded-lg border border-[#f59e0b33] bg-[#fef9ee] px-3 py-2 text-[11px] text-[#b45309]" | |
| > | |
| Bài ghi quá ngắn ({{ recordedElapsed }}s). Ghi lại ít nhất {{ MIN_RECORD_SECONDS }} giây để đánh giá chính xác. | |
| </div> | |
| <!-- Evaluate button — only active when recording is long enough --> | |
| <button | |
| v-if="recordedBlob && !isRecording" | |
| :disabled="recordedElapsed < MIN_RECORD_SECONDS" | |
| class="mt-2 flex w-full items-center justify-center gap-2 rounded-xl border-none py-2.5 text-sm font-semibold text-white transition-all" | |
| :class="evalReady | |
| ? 'cursor-pointer bg-emerald-400 hover:bg-emerald-600' | |
| : 'cursor-not-allowed bg-emerald-200 opacity-60'" | |
| @click="onEvalBtnClick" | |
| > | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/></svg> | |
| {{ recordedElapsed < MIN_RECORD_SECONDS ? `Ghi thêm ${MIN_RECORD_SECONDS - recordedElapsed}s...` : 'Đánh giá bài nói' }} | |
| </button> | |
| </div> | |
| <div class="mt-3" v-else> | |
| <div class="text-xs text-[var(--ink2)]">Unsupported question type: {{ question.question_type }}</div> | |
| </div> | |
| </div> | |
| </template> | |
| <script setup> | |
| import { computed, onUnmounted, ref, watch } from 'vue' | |
| import { sanitizeHtml, sanitizeQuizHtml } from '@/utils/sanitizeHtml.js' | |
| import { resolveChoiceOptions, canUseSetOptionFallback } from '@/utils/mockQuiz.js' | |
| const props = defineProps({ | |
| item: { type: Object, required: true }, // from flattenQuizQuestions() | |
| answer: { type: [String, Array], default: undefined }, | |
| isCurrent: { type: Boolean, default: false }, | |
| /** Practice speaking: centered stack layout without outer card chrome */ | |
| speakingCompact: { type: Boolean, default: false }, | |
| }) | |
| const emit = defineEmits(['update:answer', 'jump-audio', 'evaluate-speaking']) | |
| const question = computed(() => props.item.question || {}) | |
| const safeContent = computed(() => sanitizeQuizHtml(question.value.content)) | |
| const questionSetType = computed(() => props.item.questionSetType || '') | |
| const questionSetOptions = computed(() => props.item.questionSetOptions || []) | |
| const maxSelections = computed(() => props.item.questionSetMaxSelections || 0) | |
| const mode = computed(() => { | |
| const t = String(questionSetType.value || question.value.question_type || '').toUpperCase() | |
| if (t === 'SPEAKING' || t === 'speaking' || String(question.value.type || '').toLowerCase() === 'speaking') return 'speaking' | |
| if (t === 'MULTIPLE_CHOICE_MANY') return 'multi' | |
| if ( | |
| t === 'TABLE_SELECTION' || | |
| t === 'MATCHING' || | |
| t === 'MATCHING_FEATURES' || | |
| t === 'MATCHING_INFO' || | |
| t === 'MATCHING_HEADING' || | |
| t === 'MATCHING_HEADINGS' || | |
| t === 'MATCHING_ENDINGS' | |
| ) return 'select' | |
| if (t === 'SINGLE_SELECTION' || t === 'SINGLE_CHOICE') return 'single' | |
| if (t === 'MULTIPLE_CHOICE_ONE') return 'single' | |
| if ( | |
| t === 'SHORT_ANSWER' || | |
| t === 'SENTENCE_COMPLETION' || | |
| t === 'SUMMARY_COMPLETION' || | |
| t === 'NOTE_COMPLETION' || | |
| t === 'MAP_DIAGRAM_LABEL' | |
| ) return 'text' | |
| return 'unknown' | |
| }) | |
| // ── Speaking recorder ──────────────────────────────────────────────── | |
| const MIN_RECORD_SECONDS = 2 | |
| const isRecording = ref(false) | |
| const recordedUrl = ref(null) | |
| const recordedBlob = ref(null) | |
| const elapsed = ref(0) // live counter while recording | |
| const recordedElapsed = ref(0) // duration of the completed recording | |
| let mediaRecorder = null | |
| let chunks = [] | |
| let elapsedTimer = null | |
| async function toggleRecord() { | |
| if (isRecording.value) { | |
| recordedElapsed.value = elapsed.value | |
| mediaRecorder?.stop() | |
| isRecording.value = false | |
| clearInterval(elapsedTimer) | |
| return | |
| } | |
| // Starting a new recording clears any previous one | |
| if (recordedUrl.value) { | |
| URL.revokeObjectURL(recordedUrl.value) | |
| recordedUrl.value = null | |
| recordedBlob.value = null | |
| } | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) | |
| chunks = [] | |
| elapsed.value = 0 | |
| recordedElapsed.value = 0 | |
| mediaRecorder = new MediaRecorder(stream) | |
| mediaRecorder.ondataavailable = (e) => chunks.push(e.data) | |
| mediaRecorder.onstop = () => { | |
| const blob = new Blob(chunks, { type: 'audio/webm' }) | |
| recordedBlob.value = blob | |
| recordedUrl.value = URL.createObjectURL(blob) | |
| stream.getTracks().forEach(t => t.stop()) | |
| emit('update:answer', 'recorded') | |
| } | |
| mediaRecorder.start() | |
| isRecording.value = true | |
| elapsedTimer = setInterval(() => elapsed.value++, 1000) | |
| } catch (err) { | |
| console.warn('Microphone access denied:', err) | |
| } | |
| } | |
| onUnmounted(() => { | |
| if (isRecording.value) mediaRecorder?.stop() | |
| clearInterval(elapsedTimer) | |
| if (recordedUrl.value) URL.revokeObjectURL(recordedUrl.value) | |
| }) | |
| function clearRecording() { | |
| if (isRecording.value) { | |
| mediaRecorder?.stop() | |
| isRecording.value = false | |
| clearInterval(elapsedTimer) | |
| } | |
| if (recordedUrl.value) URL.revokeObjectURL(recordedUrl.value) | |
| recordedUrl.value = null | |
| recordedBlob.value = null | |
| elapsed.value = 0 | |
| recordedElapsed.value = 0 | |
| emit('update:answer', '') | |
| } | |
| // ── Evaluate button helpers ─────────────────────────────────────────── | |
| const evalReady = computed(() => recordedElapsed.value >= MIN_RECORD_SECONDS) | |
| function onEvalBtnClick() { | |
| if (!evalReady.value) return | |
| emit('evaluate-speaking', { | |
| blob: recordedBlob.value, | |
| questionText: question.value.text || question.value.title || '', | |
| questionId: question.value.id, | |
| }) | |
| } | |
| const canJumpToAudio = computed(() => Number.isFinite(question.value.listen_from)) | |
| const singleOptions = computed(() => { | |
| const perQ = Array.isArray(question.value.options) ? question.value.options : [] | |
| const setOpts = Array.isArray(questionSetOptions.value) ? questionSetOptions.value : [] | |
| return resolveChoiceOptions(perQ, setOpts) | |
| }) | |
| const multiOptions = computed(() => { | |
| const perQ = Array.isArray(question.value.options) ? question.value.options : [] | |
| const setOpts = Array.isArray(questionSetOptions.value) ? questionSetOptions.value : [] | |
| const useSet = canUseSetOptionFallback(perQ, setOpts) | |
| return resolveChoiceOptions(perQ, useSet ? setOpts : []) | |
| }) | |
| const selectOptions = computed(() => { | |
| const perQ = Array.isArray(question.value.options) ? question.value.options : [] | |
| const setOpts = Array.isArray(questionSetOptions.value) ? questionSetOptions.value : [] | |
| return resolveChoiceOptions(perQ, setOpts) | |
| }) | |
| const singleValue = ref('') | |
| const multiValue = ref([]) | |
| watch( | |
| () => props.answer, | |
| (v) => { | |
| if (Array.isArray(v)) { | |
| multiValue.value = v | |
| singleValue.value = '' | |
| } else if (typeof v === 'string') { | |
| singleValue.value = v | |
| multiValue.value = [] | |
| } else { | |
| singleValue.value = '' | |
| multiValue.value = [] | |
| } | |
| }, | |
| { immediate: true } | |
| ) | |
| watch(singleValue, (v) => { | |
| if (mode.value === 'single' || mode.value === 'select' || mode.value === 'text') emit('update:answer', v) | |
| }) | |
| watch(multiValue, (v) => { | |
| if (mode.value === 'multi') emit('update:answer', v) | |
| }, { deep: true }) | |
| function isMultiDisabled(opt) { | |
| if (!maxSelections.value) return false | |
| if (multiValue.value.includes(opt)) return false | |
| return multiValue.value.length >= maxSelections.value | |
| } | |
| </script> | |