551 lines
19 KiB
Vue
551 lines
19 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Header -->
|
|
<div class="flex items-center gap-4 mb-6">
|
|
<q-btn
|
|
flat
|
|
round
|
|
icon="arrow_back"
|
|
@click="navigateTo(`/instructor/courses/${courseId}`)"
|
|
/>
|
|
<div class="flex-1">
|
|
<h1 class="text-2xl font-bold text-primary-600">จัดการโครงสร้างหลักสูตร</h1>
|
|
<p class="text-gray-600 mt-1">เพิ่ม แก้ไข และจัดเรียงบทเรียน</p>
|
|
</div>
|
|
<q-btn
|
|
color="primary"
|
|
icon="add"
|
|
label="เพิ่มบท"
|
|
@click="openChapterDialog()"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="loading" class="flex justify-center py-20">
|
|
<q-spinner-dots size="50px" color="primary" />
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="chapters.length === 0" class="bg-white rounded-xl shadow-sm p-10 text-center">
|
|
<q-icon name="folder_open" size="60px" color="grey-4" class="mb-4" />
|
|
<p class="text-gray-500 text-lg mb-4">ยังไม่มีบทเรียน</p>
|
|
<q-btn
|
|
color="primary"
|
|
icon="add"
|
|
label="เพิ่มบทแรก"
|
|
@click="openChapterDialog()"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Chapters List -->
|
|
<draggable
|
|
v-else
|
|
v-model="chapters"
|
|
item-key="id"
|
|
handle=".chapter-handle"
|
|
animation="200"
|
|
ghost-class="opacity-50"
|
|
class="space-y-4"
|
|
@end="onChapterDragEnd"
|
|
>
|
|
<template #item="{ element: chapter, index: chapterIndex }">
|
|
<q-card flat bordered class="rounded-lg">
|
|
<!-- Chapter Header -->
|
|
<q-card-section class="bg-gray-50">
|
|
<div class="flex items-center gap-3">
|
|
<q-icon name="drag_indicator" class="chapter-handle cursor-move text-gray-400 hover:text-gray-600" />
|
|
<div class="flex-1">
|
|
<div class="font-semibold text-gray-900">
|
|
บทที่ {{ chapterIndex + 1 }}: {{ chapter.title.th }}
|
|
</div>
|
|
<div class="text-sm text-gray-500">
|
|
{{ chapter.lessons.length }} บทเรียน
|
|
</div>
|
|
</div>
|
|
<q-btn flat dense icon="add" color="primary" @click="openLessonDialog(chapter)">
|
|
<q-tooltip>เพิ่มบทเรียน</q-tooltip>
|
|
</q-btn>
|
|
<q-btn flat dense icon="edit" color="grey" @click="openChapterDialog(chapter)">
|
|
<q-tooltip>แก้ไข</q-tooltip>
|
|
</q-btn>
|
|
<q-btn flat dense icon="delete" color="negative" @click="confirmDeleteChapter(chapter)">
|
|
<q-tooltip>ลบ</q-tooltip>
|
|
</q-btn>
|
|
<q-btn
|
|
flat
|
|
dense
|
|
:icon="chapter.expanded ? 'expand_less' : 'expand_more'"
|
|
@click="chapter.expanded = !chapter.expanded"
|
|
/>
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<!-- Lessons List -->
|
|
<q-slide-transition>
|
|
<div v-show="chapter.expanded !== false">
|
|
<draggable
|
|
v-model="chapter.lessons"
|
|
item-key="id"
|
|
handle=".lesson-handle"
|
|
animation="200"
|
|
ghost-class="opacity-50"
|
|
@end="(event: any) => onLessonDragEnd(chapter, event)"
|
|
>
|
|
<template #item="{ element: lesson, index: lessonIndex }">
|
|
<q-item class="py-3 border-b">
|
|
<q-item-section avatar>
|
|
<q-icon name="drag_indicator" class="lesson-handle cursor-move text-gray-300 hover:text-gray-500" />
|
|
</q-item-section>
|
|
<q-item-section avatar>
|
|
<q-icon
|
|
:name="getLessonIcon(lesson.type)"
|
|
:color="getLessonIconColor(lesson.type)"
|
|
/>
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>
|
|
{{ lessonIndex + 1 }}. {{ lesson.title.th }}
|
|
</q-item-label>
|
|
<q-item-label caption>
|
|
{{ getLessonTypeLabel(lesson.type) }}
|
|
</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side>
|
|
<div class="flex gap-1">
|
|
<q-btn flat dense icon="edit" size="sm" @click="navigateToLessonEdit(chapter, lesson)">
|
|
<q-tooltip>แก้ไข</q-tooltip>
|
|
</q-btn>
|
|
<q-btn flat dense icon="delete" size="sm" color="negative" @click="confirmDeleteLesson(chapter, lesson)">
|
|
<q-tooltip>ลบ</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</q-item-section>
|
|
</q-item>
|
|
</template>
|
|
</draggable>
|
|
|
|
<!-- Empty Lessons -->
|
|
<div v-if="chapter.lessons.length === 0" class="p-4 text-center text-gray-400">
|
|
ยังไม่มีบทเรียนในบทนี้
|
|
</div>
|
|
</div>
|
|
</q-slide-transition>
|
|
</q-card>
|
|
</template>
|
|
</draggable>
|
|
|
|
<!-- Chapter Dialog -->
|
|
<q-dialog v-model="chapterDialog" persistent>
|
|
<q-card style="min-width: 450px">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<div class="text-h6">{{ editingChapter ? 'แก้ไขบท' : 'เพิ่มบทใหม่' }}</div>
|
|
<q-space />
|
|
<q-btn icon="close" flat round dense @click="chapterDialog = false" />
|
|
</q-card-section>
|
|
|
|
<q-card-section>
|
|
<q-form @submit="saveChapter">
|
|
<q-input
|
|
v-model="chapterForm.title.th"
|
|
label="ชื่อบท (ภาษาไทย) *"
|
|
outlined
|
|
:rules="[val => !!val || 'กรุณากรอกชื่อบท']"
|
|
/>
|
|
<q-input
|
|
v-model="chapterForm.title.en"
|
|
label="ชื่อบท (English)"
|
|
outlined
|
|
class="mb-4"
|
|
/>
|
|
<q-input
|
|
v-model="chapterForm.description.th"
|
|
label="คำอธิบาย (ภาษาไทย)"
|
|
type="textarea"
|
|
outlined
|
|
autogrow
|
|
class="mb-4"
|
|
/>
|
|
<q-input
|
|
v-model="chapterForm.description.en"
|
|
label="คำอธิบาย (English)"
|
|
type="textarea"
|
|
outlined
|
|
autogrow
|
|
class="mb-4"
|
|
/>
|
|
|
|
<div class="flex justify-end gap-2 mt-4">
|
|
<q-btn flat label="ยกเลิก" color="grey-7" @click="chapterDialog = false" />
|
|
<q-btn type="submit" label="บันทึก" color="primary" :loading="saving" />
|
|
</div>
|
|
</q-form>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<!-- Lesson Dialog -->
|
|
<q-dialog v-model="lessonDialog" persistent>
|
|
<q-card style="min-width: 500px">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<div class="text-h6">{{ editingLesson ? 'แก้ไขบทเรียน' : 'เพิ่มบทเรียน' }}</div>
|
|
<q-space />
|
|
<q-btn icon="close" flat round dense @click="lessonDialog = false" />
|
|
</q-card-section>
|
|
|
|
<q-card-section>
|
|
<q-form @submit="saveLesson">
|
|
<q-input
|
|
v-model="lessonForm.title.th"
|
|
label="ชื่อบทเรียน (ภาษาไทย) *"
|
|
outlined
|
|
:rules="[val => !!val || 'กรุณากรอกชื่อบทเรียน']"
|
|
/>
|
|
<q-input
|
|
v-model="lessonForm.title.en"
|
|
label="ชื่อบทเรียน (English)"
|
|
outlined
|
|
class="mb-4"
|
|
/>
|
|
|
|
<q-select
|
|
v-if="!editingLesson"
|
|
v-model="lessonForm.type"
|
|
:options="lessonTypeOptions"
|
|
label="ประเภท *"
|
|
outlined
|
|
emit-value
|
|
map-options
|
|
class="mb-4"
|
|
/>
|
|
|
|
<!-- <q-input
|
|
v-model.number="lessonForm.duration_minutes"
|
|
label="ระยะเวลา (นาที)"
|
|
type="number"
|
|
outlined
|
|
class="mb-4"
|
|
/> -->
|
|
|
|
<q-input
|
|
v-if="lessonForm.type"
|
|
v-model="lessonForm.content.th"
|
|
label="เนื้อหา (ภาษาไทย)"
|
|
type="textarea"
|
|
outlined
|
|
autogrow
|
|
rows="3"
|
|
class="mb-4"
|
|
/>
|
|
|
|
<q-input
|
|
v-if="lessonForm.type"
|
|
v-model="lessonForm.content.en"
|
|
label="เนื้อหา (English)"
|
|
type="textarea"
|
|
outlined
|
|
autogrow
|
|
rows="3"
|
|
/>
|
|
|
|
<div class="flex justify-end gap-2 mt-4">
|
|
<q-btn flat label="ยกเลิก" color="grey-7" @click="lessonDialog = false" />
|
|
<q-btn type="submit" label="บันทึก" color="primary" :loading="saving" />
|
|
</div>
|
|
</q-form>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useQuasar } from 'quasar';
|
|
import draggable from 'vuedraggable';
|
|
import { instructorService, type ChapterResponse, type LessonResponse } from '~/services/instructor.service';
|
|
|
|
definePageMeta({
|
|
layout: 'instructor',
|
|
middleware: ['auth']
|
|
});
|
|
|
|
const $q = useQuasar();
|
|
const route = useRoute();
|
|
const courseId = computed(() => parseInt(route.params.id as string));
|
|
|
|
// Data
|
|
const loading = ref(true);
|
|
const saving = ref(false);
|
|
const chapters = ref<(ChapterResponse & { expanded?: boolean })[]>([]);
|
|
|
|
// Computed: sorted chapters for display
|
|
const sortedChapters = computed(() => {
|
|
return chapters.value.slice().sort((a, b) => a.sort_order - b.sort_order);
|
|
});
|
|
|
|
// Dialogs
|
|
const chapterDialog = ref(false);
|
|
const lessonDialog = ref(false);
|
|
const editingChapter = ref<ChapterResponse | null>(null);
|
|
const editingLesson = ref<LessonResponse | null>(null);
|
|
const selectedChapter = ref<ChapterResponse | null>(null);
|
|
|
|
// Drag and Drop handlers (vuedraggable)
|
|
const onChapterDragEnd = async (event: { oldIndex: number; newIndex: number }) => {
|
|
const { oldIndex, newIndex } = event;
|
|
if (oldIndex === newIndex) return;
|
|
|
|
const chapter = chapters.value[newIndex];
|
|
if (!chapter) return;
|
|
|
|
try {
|
|
await instructorService.reorderChapter(courseId.value, chapter.id, newIndex + 1);
|
|
$q.notify({ type: 'positive', message: 'เรียงลำดับบทสำเร็จ', position: 'top' });
|
|
} catch (error) {
|
|
console.error('Failed to reorder chapter:', error);
|
|
// Revert
|
|
const [item] = chapters.value.splice(newIndex, 1);
|
|
chapters.value.splice(oldIndex, 0, item);
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
|
}
|
|
};
|
|
|
|
const onLessonDragEnd = async (chapter: ChapterResponse & { expanded?: boolean }, event: { oldIndex: number; newIndex: number }) => {
|
|
const { oldIndex, newIndex } = event;
|
|
if (oldIndex === newIndex) return;
|
|
|
|
const lesson = chapter.lessons[newIndex];
|
|
if (!lesson) return;
|
|
|
|
try {
|
|
await instructorService.reorderLesson(courseId.value, chapter.id, lesson.id, newIndex + 1);
|
|
$q.notify({ type: 'positive', message: 'เรียงลำดับบทเรียนสำเร็จ', position: 'top' });
|
|
} catch (error) {
|
|
console.error('Failed to reorder lesson:', error);
|
|
// Revert
|
|
const [item] = chapter.lessons.splice(newIndex, 1);
|
|
chapter.lessons.splice(oldIndex, 0, item);
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
|
}
|
|
};
|
|
|
|
// Forms
|
|
const chapterForm = ref({
|
|
title: { th: '', en: '' },
|
|
description: { th: '', en: '' }
|
|
});
|
|
|
|
const lessonForm = ref({
|
|
title: { th: '', en: '' },
|
|
content: { th: '', en: '' },
|
|
type: 'VIDEO' as 'VIDEO' | 'QUIZ'
|
|
});
|
|
|
|
const lessonTypeOptions = [
|
|
{ label: 'วิดีโอ', value: 'VIDEO' },
|
|
{ label: 'แบบทดสอบ', value: 'QUIZ' },
|
|
];
|
|
|
|
// Methods
|
|
const fetchChapters = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const data = await instructorService.getChapters(courseId.value);
|
|
// Sort chapters by sort_order, and lessons within each chapter by sort_order
|
|
chapters.value = data
|
|
.slice()
|
|
.sort((a, b) => a.sort_order - b.sort_order)
|
|
.map(ch => ({
|
|
...ch,
|
|
expanded: true,
|
|
lessons: ch.lessons.slice().sort((a, b) => a.sort_order - b.sort_order)
|
|
}));
|
|
} catch (error) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: 'ไม่สามารถโหลดข้อมูลได้',
|
|
position: 'top'
|
|
});
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Navigate to lesson edit page based on type
|
|
const navigateToLessonEdit = (chapter: ChapterResponse, lesson: LessonResponse) => {
|
|
const lessonType = lesson.type === 'QUIZ' ? 'quiz' : 'video';
|
|
navigateTo(`/instructor/courses/${courseId.value}/chapters/${chapter.id}/lessons/${lesson.id}/${lessonType}`);
|
|
};
|
|
|
|
const getLessonIcon = (type: string) => {
|
|
const icons: Record<string, string> = {
|
|
VIDEO: 'play_circle',
|
|
QUIZ: 'quiz',
|
|
DOCUMENT: 'description'
|
|
};
|
|
return icons[type] || 'article';
|
|
};
|
|
|
|
const getLessonIconColor = (type: string) => {
|
|
const colors: Record<string, string> = {
|
|
VIDEO: 'primary',
|
|
QUIZ: 'orange',
|
|
DOCUMENT: 'grey'
|
|
};
|
|
return colors[type] || 'grey';
|
|
};
|
|
|
|
const getLessonTypeLabel = (type: string) => {
|
|
const labels: Record<string, string> = {
|
|
VIDEO: 'วิดีโอ',
|
|
QUIZ: 'แบบทดสอบ',
|
|
DOCUMENT: 'เอกสาร'
|
|
};
|
|
return labels[type] || type;
|
|
};
|
|
|
|
const getSortedLessons = (lessons: LessonResponse[]) => {
|
|
return [...lessons].sort((a, b) => a.sort_order - b.sort_order);
|
|
};
|
|
|
|
// Chapter CRUD
|
|
const openChapterDialog = (chapter?: ChapterResponse) => {
|
|
if (chapter) {
|
|
editingChapter.value = chapter;
|
|
chapterForm.value = {
|
|
title: { ...chapter.title },
|
|
description: { ...chapter.description }
|
|
};
|
|
} else {
|
|
editingChapter.value = null;
|
|
chapterForm.value = {
|
|
title: { th: '', en: '' },
|
|
description: { th: '', en: '' }
|
|
};
|
|
}
|
|
chapterDialog.value = true;
|
|
};
|
|
|
|
const saveChapter = async () => {
|
|
saving.value = true;
|
|
try {
|
|
if (editingChapter.value) {
|
|
await instructorService.updateChapter(courseId.value, editingChapter.value.id, chapterForm.value);
|
|
$q.notify({ type: 'positive', message: 'แก้ไขบทสำเร็จ', position: 'top' });
|
|
} else {
|
|
// Calculate sort_order for new chapter
|
|
const sortOrder = chapters.value.length + 1;
|
|
await instructorService.createChapter(courseId.value, {
|
|
...chapterForm.value,
|
|
sort_order: sortOrder
|
|
});
|
|
$q.notify({ type: 'positive', message: 'เพิ่มบทสำเร็จ', position: 'top' });
|
|
}
|
|
chapterDialog.value = false;
|
|
fetchChapters();
|
|
} catch (error) {
|
|
$q.notify({ type: 'negative', message: 'เกิดข้อผิดพลาด', position: 'top' });
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
};
|
|
|
|
const confirmDeleteChapter = (chapter: ChapterResponse) => {
|
|
$q.dialog({
|
|
title: 'ยืนยันการลบ',
|
|
message: `ต้องการลบบท "${chapter.title.th}" หรือไม่? บทเรียนทั้งหมดในบทนี้จะถูกลบด้วย`,
|
|
cancel: true,
|
|
persistent: true
|
|
}).onOk(async () => {
|
|
try {
|
|
await instructorService.deleteChapter(courseId.value, chapter.id);
|
|
$q.notify({ type: 'positive', message: 'ลบบทสำเร็จ', position: 'top' });
|
|
fetchChapters();
|
|
} catch (error) {
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถลบได้', position: 'top' });
|
|
}
|
|
});
|
|
};
|
|
|
|
// Lesson CRUD
|
|
const openLessonDialog = (chapter: ChapterResponse, lesson?: LessonResponse) => {
|
|
selectedChapter.value = chapter;
|
|
if (lesson) {
|
|
editingLesson.value = lesson;
|
|
lessonForm.value = {
|
|
title: { ...lesson.title },
|
|
content: lesson.content ? { ...lesson.content } : { th: '', en: '' },
|
|
type: lesson.type as 'VIDEO' | 'QUIZ'
|
|
};
|
|
} else {
|
|
editingLesson.value = null;
|
|
lessonForm.value = {
|
|
title: { th: '', en: '' },
|
|
content: { th: '', en: '' },
|
|
type: 'VIDEO'
|
|
};
|
|
}
|
|
lessonDialog.value = true;
|
|
};
|
|
|
|
const saveLesson = async () => {
|
|
saving.value = true;
|
|
try {
|
|
if (!selectedChapter.value) return;
|
|
|
|
if (editingLesson.value) {
|
|
// Update - don't send type
|
|
const updateData = {
|
|
title: lessonForm.value.title,
|
|
content: lessonForm.value.content
|
|
};
|
|
await instructorService.updateLesson(
|
|
courseId.value,
|
|
selectedChapter.value.id,
|
|
editingLesson.value.id,
|
|
updateData
|
|
);
|
|
$q.notify({ type: 'positive', message: 'แก้ไขบทเรียนสำเร็จ', position: 'top' });
|
|
} else {
|
|
// Create - include type and sort_order
|
|
const createData = {
|
|
...lessonForm.value,
|
|
sort_order: (selectedChapter.value.lessons?.length || 0) + 1
|
|
};
|
|
await instructorService.createLesson(
|
|
courseId.value,
|
|
selectedChapter.value.id,
|
|
createData
|
|
);
|
|
$q.notify({ type: 'positive', message: 'เพิ่มบทเรียนสำเร็จ', position: 'top' });
|
|
}
|
|
lessonDialog.value = false;
|
|
fetchChapters();
|
|
} catch (error) {
|
|
$q.notify({ type: 'negative', message: 'เกิดข้อผิดพลาด', position: 'top' });
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
};
|
|
|
|
const confirmDeleteLesson = (chapter: ChapterResponse, lesson: LessonResponse) => {
|
|
$q.dialog({
|
|
title: 'ยืนยันการลบ',
|
|
message: `ต้องการลบบทเรียน "${lesson.title.th}" หรือไม่?`,
|
|
cancel: true,
|
|
persistent: true
|
|
}).onOk(async () => {
|
|
try {
|
|
await instructorService.deleteLesson(courseId.value, chapter.id, lesson.id);
|
|
$q.notify({ type: 'positive', message: 'ลบบทเรียนสำเร็จ', position: 'top' });
|
|
fetchChapters();
|
|
} catch (error) {
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถลบได้', position: 'top' });
|
|
}
|
|
});
|
|
};
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
fetchChapters();
|
|
});
|
|
</script>
|