All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 33s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 2s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
228 lines
9.5 KiB
Vue
228 lines
9.5 KiB
Vue
<template>
|
|
<q-dialog v-model="isOpen" maximizable>
|
|
<q-card class="column" style="width: 900px; max-width: 95vw; height: 90vh">
|
|
<q-card-section class="row items-center q-pb-none bg-gray-50 border-b">
|
|
<div class="text-h6 font-bold text-gray-900 flex items-center gap-2">
|
|
<q-icon :name="getIcon(lesson.type)" color="primary" />
|
|
{{ lesson.title.th }}
|
|
</div>
|
|
<q-space />
|
|
<q-btn icon="close" flat round dense v-close-popup />
|
|
</q-card-section>
|
|
|
|
<q-card-section class="col q-pa-none scroll relative-position">
|
|
<div v-if="loading" class="absolute-center">
|
|
<q-spinner-dots size="40px" color="primary" />
|
|
</div>
|
|
|
|
<div v-else-if="lessonDetail" class="p-6 space-y-6">
|
|
<!-- Video Player -->
|
|
<div v-if="lesson.type === 'VIDEO' && lessonDetail.video_url" class="aspect-video bg-black rounded-lg overflow-hidden">
|
|
<video
|
|
:src="lessonDetail.video_url"
|
|
controls
|
|
class="w-full h-full object-contain"
|
|
></video>
|
|
</div>
|
|
|
|
<!-- Content (Document/Text) -->
|
|
<div v-if="lessonDetail.content" class="prose max-w-none">
|
|
<div v-html="lessonDetail.content.th"></div>
|
|
</div>
|
|
|
|
<!-- Attachments -->
|
|
<div v-if="lessonDetail.attachments && lessonDetail.attachments.length > 0">
|
|
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
|
|
<q-icon name="attach_file" />
|
|
เอกสารแนบ
|
|
</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<a
|
|
v-for="file in lessonDetail.attachments"
|
|
:key="file.id"
|
|
:href="file.file_path"
|
|
target="_blank"
|
|
class="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
<q-icon name="description" color="primary" size="24px" />
|
|
<div class="overflow-hidden">
|
|
<div class="font-medium truncate">{{ file.file_name }}</div>
|
|
<div class="text-xs text-gray-500">{{ formatFileSize(file.file_size) }}</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quiz Info -->
|
|
<div v-if="lesson.type === 'QUIZ'">
|
|
<div v-if="lessonDetail.quiz" class="bg-blue-50 p-6 rounded-lg border border-blue-100">
|
|
<h3 class="text-lg font-bold text-blue-900 mb-2">{{ lessonDetail.quiz.title?.th || 'แบบทดสอบ' }}</h3>
|
|
<p class="text-blue-800 mb-4">{{ lessonDetail.quiz.description?.th || '-' }}</p>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
<div class="bg-white p-3 rounded border border-blue-100">
|
|
<div class="text-gray-500 mb-1">เกณฑ์ผ่าน</div>
|
|
<div class="font-semibold">{{ lessonDetail.quiz.passing_score }}%</div>
|
|
</div>
|
|
<div class="bg-white p-3 rounded border border-blue-100">
|
|
<div class="text-gray-500 mb-1">เวลาทำข้อสอบ</div>
|
|
<div class="font-semibold">{{ lessonDetail.quiz.time_limit ? lessonDetail.quiz.time_limit + ' นาที' : '-' }}</div>
|
|
</div>
|
|
<div class="bg-white p-3 rounded border border-blue-100">
|
|
<div class="text-gray-500 mb-1">จำนวนข้อ</div>
|
|
<div class="font-semibold">{{ lessonDetail.quiz.questions?.length || 0 }} ข้อ</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Questions List -->
|
|
<div v-if="lessonDetail.quiz.questions && lessonDetail.quiz.questions.length > 0" class="mt-6 space-y-6">
|
|
<!-- ... (questions rendering code unchanged) ... -->
|
|
<div v-for="(question, index) in lessonDetail.quiz.questions" :key="question.id" class="bg-white p-4 rounded-lg border border-blue-100">
|
|
<div class="flex gap-3">
|
|
<span class="font-bold text-blue-600 text-lg">{{ index + 1 }}.</span>
|
|
<div class="flex-1">
|
|
<p class="font-medium text-gray-900 text-lg mb-2">{{ question.question?.th || 'คำถาม' }}</p>
|
|
|
|
<!-- Choices -->
|
|
<div class="space-y-2 mt-3">
|
|
<div
|
|
v-for="choice in question.choices"
|
|
:key="choice.id"
|
|
class="p-3 rounded border flex items-center gap-3"
|
|
:class="choice.is_correct ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200'"
|
|
>
|
|
<div
|
|
class="w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0"
|
|
:class="choice.is_correct ? 'border-green-500' : 'border-gray-400'"
|
|
>
|
|
<div v-if="choice.is_correct" class="w-2.5 h-2.5 rounded-full bg-green-500"></div>
|
|
</div>
|
|
<div :class="choice.is_correct ? 'text-green-700 font-medium' : 'text-gray-700'">
|
|
<div>{{ choice.text?.th || '-' }}</div>
|
|
<div v-if="choice.text?.en" class="text-xs opacity-75">{{ choice.text.en }}</div>
|
|
</div>
|
|
<q-icon v-if="choice.is_correct" name="check_circle" color="green" class="ml-auto" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Explanation -->
|
|
<div v-if="question.explanation?.th" class="mt-4 bg-yellow-50 p-3 rounded text-sm text-yellow-800 border border-yellow-100">
|
|
<div class="font-semibold mb-1 flex items-center gap-1">
|
|
<q-icon name="lightbulb" />
|
|
คำอธิบาย:
|
|
</div>
|
|
{{ question.explanation.th }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fallback for missing Quiz Data -->
|
|
<div v-else class="flex flex-col items-center justify-center p-10 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
|
|
<q-icon name="assignment_late" size="48px" class="mb-2" />
|
|
<p>ไม่พบข้อมูลแบบทดสอบ หรือแบบทดสอบยังไม่สมบูรณ์</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col items-center justify-center h-full text-gray-500">
|
|
<q-icon name="error_outline" size="48px" class="mb-2" />
|
|
<p>ไม่พบข้อมูลบทเรียน</p>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, computed } from 'vue';
|
|
import { useQuasar } from 'quasar';
|
|
import { instructorService, type LessonResponse, type LessonDetailResponse } from '~/services/instructor.service';
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean;
|
|
lesson: LessonResponse;
|
|
courseId: number;
|
|
}>();
|
|
|
|
const emit = defineEmits(['update:modelValue']);
|
|
const $q = useQuasar();
|
|
|
|
const loading = ref(false);
|
|
const lessonDetail = ref<LessonDetailResponse | null>(null);
|
|
|
|
const isOpen = computed({
|
|
get: () => props.modelValue,
|
|
set: (val) => emit('update:modelValue', val)
|
|
});
|
|
|
|
const getIcon = (type: string) => {
|
|
const icons: Record<string, string> = {
|
|
VIDEO: 'play_circle',
|
|
DOCUMENT: 'description',
|
|
QUIZ: 'quiz'
|
|
};
|
|
return icons[type] || 'article';
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const fetchLessonDetail = async () => {
|
|
// Always verify lesson and courseId exist
|
|
if (!props.lesson || !props.courseId) return;
|
|
|
|
loading.value = true;
|
|
try {
|
|
const data = await instructorService.getLesson(
|
|
props.courseId,
|
|
props.lesson.chapter_id,
|
|
props.lesson.id
|
|
);
|
|
console.log('Fetched Lesson Details:', data);
|
|
|
|
// FIX: Handle case where API returns Quiz object directly instead of LessonDetailResponse
|
|
if (props.lesson.type === 'QUIZ' && !data.quiz && (data as any).questions) {
|
|
console.warn('API returned Quiz object directly, wrapping it.');
|
|
lessonDetail.value = {
|
|
...props.lesson,
|
|
attachments: [], // Assume no attachments if structure is unexpected
|
|
quiz: data as any
|
|
};
|
|
} else {
|
|
lessonDetail.value = data;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch lesson detail:', error);
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: 'ไม่สามารถโหลดข้อมูลบทเรียนได้'
|
|
});
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
watch(() => props.modelValue, (val) => {
|
|
if (val) {
|
|
fetchLessonDetail();
|
|
} else {
|
|
// Optional: clear detail to avoid flashing old content next time
|
|
// lessonDetail.value = null;
|
|
}
|
|
}, { immediate: true });
|
|
|
|
// Also watch lesson ID in case dialog stays open but lesson changes
|
|
watch(() => props.lesson.id, () => {
|
|
if (props.modelValue) {
|
|
fetchLessonDetail();
|
|
}
|
|
});
|
|
</script>
|