feat: Introduce admin pages for pending course review and course details, and instructor pages for course management and lesson quizzes.
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

This commit is contained in:
Missez 2026-02-10 15:24:19 +07:00
parent ff91df2bd6
commit 941b195813
6 changed files with 388 additions and 27 deletions

View file

@ -0,0 +1,228 @@
<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>

View file

@ -3,14 +3,15 @@
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-gray-900">โครงสรางบทเรยน</h2>
<q-btn
v-if="course.status === 'DRAFT'"
color="primary"
label="จัดการโครงสร้าง"
@click="navigateTo(`/instructor/courses/${courseId}/structure`)"
@click="navigateTo(`/instructor/courses/${course.id}/structure`)"
/>
</div>
<!-- Chapters -->
<div v-if="chapters.length === 0" class="text-center py-10 text-gray-500">
<div v-if="course.chapters.length === 0" class="text-center py-10 text-gray-500">
งไมบทเรยน
</div>
@ -41,6 +42,10 @@
v-for="lesson in getSortedLessons(chapter)"
:key="lesson.id"
class="py-3"
:class="{ 'cursor-pointer hover:bg-gray-50': course.status === 'APPROVED' }"
:clickable="course.status === 'APPROVED'"
:v-ripple="course.status === 'APPROVED'"
@click="handleLessonClick(lesson)"
>
<q-item-section avatar>
<q-icon
@ -53,26 +58,38 @@
Lesson {{ chapter.sort_order }}.{{ lesson.sort_order }}: {{ lesson.title.th }}
</q-item-label>
</q-item-section>
<q-item-section side v-if="course.status === 'APPROVED'">
<q-icon name="visibility" size="xs" color="grey-5" />
</q-item-section>
</q-item>
</q-list>
</q-card>
</div>
<!-- Lesson Preview Dialog -->
<LessonPreviewDialog
v-if="previewLesson"
v-model="showPreview"
:lesson="previewLesson"
:course-id="course.id"
/>
</div>
</template>
<script setup lang="ts">
import type { ChapterResponse } from '~/services/instructor.service';
import type { CourseDetailResponse, ChapterResponse, LessonResponse } from '~/services/instructor.service';
import LessonPreviewDialog from './LessonPreviewDialog.vue';
interface Props {
courseId: number;
chapters: ChapterResponse[];
course: CourseDetailResponse;
}
const props = defineProps<Props>();
// Computed
const sortedChapters = computed(() => {
return props.chapters.slice().sort((a, b) => a.sort_order - b.sort_order);
return props.course.chapters.slice().sort((a, b) => a.sort_order - b.sort_order);
});
// Methods
@ -101,4 +118,15 @@ const getLessonIconColor = (type: string) => {
};
return colors[type] || 'grey';
};
// Preview
const showPreview = ref(false);
const previewLesson = ref<LessonResponse | null>(null);
const handleLessonClick = (lesson: LessonResponse) => {
if (props.course.status === 'APPROVED') {
previewLesson.value = lesson;
showPreview.value = true;
}
};
</script>

View file

@ -323,7 +323,7 @@ const getLessonIcon = (type: string) => {
const getLessonTypeColor = (type: string) => {
const colors: Record<string, string> = {
VIDEO: 'blue',
QUIZ: 'purple',
QUIZ: 'orange',
DOCUMENT: 'teal'
};
return colors[type] || 'grey';

View file

@ -3,6 +3,7 @@
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-primary-600">คอรสรออน</h1>
<div class="flex gap-4">
<q-btn
outline
color="primary"
@ -12,6 +13,7 @@
@click="fetchPendingCourses"
/>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
@ -47,6 +49,17 @@
</q-input>
</div>
<div class="flex justify-end mb-6">
<q-btn-toggle
v-model="viewMode"
toggle-color="primary"
:options="[
{ label: 'การ์ด', value: 'card' },
{ label: 'ตาราง', value: 'table' }
]"
/>
</div>
<!-- Pending Courses List -->
<div v-if="loading" class="flex justify-center py-12">
<q-spinner color="primary" size="48px" />
@ -57,7 +70,8 @@
<p class="text-gray-500 mt-4">ไมคอรสทรอการอน</p>
</div>
<div v-else class="space-y-4">
<!-- Card View -->
<div v-else-if="viewMode === 'card'" class="space-y-4">
<div
v-for="course in filteredCourses"
:key="course.id"
@ -125,6 +139,78 @@
</div>
</div>
</div>
<!-- Table View -->
<div v-else class="bg-white rounded-xl shadow-sm overflow-hidden">
<q-table
:rows="filteredCourses"
:columns="columns"
row-key="id"
flat
bordered
:pagination="{ rowsPerPage: 10 }"
>
<!-- Thumbnail Slot -->
<template v-slot:body-cell-thumbnail="props">
<q-td :props="props">
<div class="w-16 h-10 rounded overflow-hidden bg-gray-100">
<img
v-if="props.row.thumbnail_url"
:src="props.row.thumbnail_url"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center">
<q-icon name="school" color="grey" />
</div>
</div>
</q-td>
</template>
<!-- Title Slot -->
<template v-slot:body-cell-title="props">
<q-td :props="props">
<div class="font-semibold">{{ props.row.title.th }}</div>
<div class="text-xs text-gray-500">{{ props.row.title.en }}</div>
</q-td>
</template>
<!-- Stats Slot -->
<template v-slot:body-cell-stats="props">
<q-td :props="props">
<div class="text-xs">
<div>{{ props.row.chapters_count }} บท</div>
<div>{{ props.row.lessons_count }} บทเรยน</div>
</div>
</q-td>
</template>
<!-- Submission Slot -->
<template v-slot:body-cell-submitted_at="props">
<q-td :props="props">
<div v-if="props.row.latest_submission">
<div class="text-xs">{{ formatDate(props.row.latest_submission.created_at) }}</div>
<div class="text-xs text-gray-500">โดย {{ props.row.latest_submission.submitter.username }}</div>
</div>
<span v-else>-</span>
</q-td>
</template>
<!-- Actions Slot -->
<template v-slot:body-cell-actions="props">
<q-td :props="props">
<q-btn
flat
round
color="primary"
icon="visibility"
@click="viewCourse(props.row)"
>
<q-tooltip>รายละเอยด</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
</div>
</div>
</template>
@ -144,6 +230,16 @@ const router = useRouter();
const courses = ref<PendingCourse[]>([]);
const loading = ref(true);
const searchQuery = ref('');
const viewMode = ref('table');
const columns = [
{ name: 'thumbnail', label: 'รูปปก', field: 'thumbnail', align: 'left' },
{ name: 'title', label: 'ชื่อคอร์ส', field: (row: PendingCourse) => row.title.th, align: 'left', sortable: true },
{ name: 'instructor', label: 'ผู้สอน', field: (row: PendingCourse) => getPrimaryInstructor(row), align: 'left', sortable: true },
{ name: 'stats', label: 'จำนวนบท', field: 'stats', align: 'center' },
{ name: 'submitted_at', label: 'วันที่ส่ง', field: (row: PendingCourse) => row.latest_submission?.created_at, align: 'left', sortable: true },
{ name: 'actions', label: '', field: 'actions', align: 'center' }
];
// Computed
const totalChapters = computed(() =>

View file

@ -230,9 +230,10 @@
:color="choice.is_correct ? 'positive' : 'grey'"
size="18px"
/>
<span :class="{ 'text-green-600 font-medium': choice.is_correct }">
{{ choice.text.th || `ตัวเลือก ${Number(cIndex) + 1}` }}
</span>
<div :class="{ 'text-green-600 font-medium': choice.is_correct }">
<div>{{ choice.text.th || `ตัวเลือก ${Number(cIndex) + 1}` }}</div>
<div v-if="choice.text.en" class="text-xs opacity-75">{{ choice.text.en }}</div>
</div>
</div>
</div>
</q-card-section>
@ -296,13 +297,21 @@
:val="cIndex"
@update:model-value="setCorrectChoice(cIndex)"
/>
<div class="flex-1">
<q-input
v-model="choice.text.th"
:label="`ตัวเลือก ${cIndex + 1}`"
:label="`ตัวเลือก ${cIndex + 1} (TH)`"
outlined
dense
class="flex-1"
class="mb-2"
/>
<q-input
v-model="choice.text.en"
:label="`ตัวเลือก ${cIndex + 1} (EN)`"
outlined
dense
/>
</div>
<q-btn
v-if="questionForm.choices.length > 2"
flat

View file

@ -117,7 +117,7 @@
<q-tab-panels v-model="activeTab" class="bg-white rounded-b-xl shadow-sm">
<!-- Structure Tab -->
<q-tab-panel name="structure" class="p-6">
<CourseStructureTab :course-id="course.id" :chapters="course.chapters" />
<CourseStructureTab :course="course" />
</q-tab-panel>
<!-- Students Tab -->