feat: Implement instructor course structure management, enabling drag-and-drop reordering of chapters and lessons, and adding support for video and quiz lesson types.

This commit is contained in:
Missez 2026-01-27 09:19:53 +07:00
parent be7348c74d
commit 310a5e7dd7
4 changed files with 1085 additions and 3 deletions

View file

@ -0,0 +1,486 @@
<template>
<div class="p-6 max-w-4xl mx-auto">
<!-- Loading -->
<div v-if="loading" class="text-center py-12">
<q-spinner size="lg" color="primary" />
<p class="mt-4">กำลงโหลด...</p>
</div>
<!-- Content -->
<div v-else-if="lesson">
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<q-btn
flat
round
icon="arrow_back"
@click="navigateTo(`/instructor/courses/${courseId}/structure`)"
/>
<div>
<h1 class="text-2xl font-bold">แกไขบทเรยน (แบบทดสอบ)</h1>
<p class="text-gray-500">{{ lesson.title.th }}</p>
</div>
</div>
<!-- Form -->
<q-card class="mb-6">
<q-card-section>
<h3 class="text-lg font-semibold mb-4">อมลทวไป</h3>
<div class="space-y-4">
<!-- Title Thai -->
<q-input
v-model="form.title.th"
label="ชื่อแบบทดสอบ (ภาษาไทย) *"
outlined
:rules="[val => !!val || 'กรุณากรอกชื่อแบบทดสอบ']"
/>
<!-- Title English -->
<q-input
v-model="form.title.en"
label="ชื่อแบบทดสอบ (English)"
outlined
/>
<!-- Content Thai -->
<q-input
v-model="form.content.th"
label="คำอธิบาย (ภาษาไทย)"
type="textarea"
outlined
autogrow
rows="2"
/>
<!-- Content English -->
<q-input
v-model="form.content.en"
label="คำอธิบาย (English)"
type="textarea"
outlined
autogrow
rows="2"
/>
</div>
</q-card-section>
<!-- Actions -->
<q-separator />
<q-card-section class="flex justify-end gap-2">
<q-btn
flat
label="ยกเลิก"
@click="navigateTo(`/instructor/courses/${courseId}/structure`)"
/>
<q-btn
color="primary"
label="บันทึก"
icon="save"
:loading="saving"
@click="saveLesson"
/>
</q-card-section>
</q-card>
<!-- Quiz Settings -->
<q-card class="mb-6">
<q-card-section>
<h3 class="text-lg font-semibold mb-4">งคาแบบทดสอบ</h3>
<div class="grid grid-cols-2 gap-4">
<q-input
v-model.number="quizSettings.passing_score"
label="คะแนนผ่าน (%)"
type="number"
outlined
min="0"
max="100"
/>
<q-input
v-model.number="quizSettings.time_limit"
label="เวลาจำกัด (นาที)"
type="number"
outlined
min="0"
hint="0 = ไม่จำกัดเวลา"
/>
<q-toggle
v-model="quizSettings.shuffle_questions"
label="สุ่มลำดับคำถาม"
/>
<q-toggle
v-model="quizSettings.shuffle_choices"
label="สุ่มลำดับตัวเลือก"
/>
<q-toggle
v-model="quizSettings.show_answers_after_completion"
label="แสดงเฉลยหลังทำเสร็จ"
/>
</div>
</q-card-section>
</q-card>
<!-- Questions -->
<q-card>
<q-card-section>
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">คำถาม ({{ questions.length }} )</h3>
<q-btn
color="primary"
icon="add"
label="เพิ่มคำถาม"
@click="addQuestion"
/>
</div>
<!-- Questions List -->
<div v-if="questions.length > 0" class="space-y-4">
<q-card
v-for="(question, qIndex) in questions"
:key="qIndex"
flat
bordered
class="bg-gray-50"
>
<q-card-section>
<div class="flex justify-between items-start mb-3">
<span class="font-medium">คำถามท {{ qIndex + 1 }}</span>
<div class="flex gap-1">
<q-btn flat round dense icon="edit" size="sm" @click="editQuestion(qIndex)" />
<q-btn flat round dense icon="delete" color="negative" size="sm" @click="removeQuestion(qIndex)" />
</div>
</div>
<p class="mb-2">{{ question.text.th || '(ยังไม่ได้กรอกคำถาม)' }}</p>
<!-- Choices Preview -->
<div class="pl-4 space-y-1">
<div
v-for="(choice, cIndex) in question.choices"
:key="cIndex"
class="flex items-center gap-2 text-sm"
>
<q-icon
:name="choice.is_correct ? 'check_circle' : 'radio_button_unchecked'"
:color="choice.is_correct ? 'positive' : 'grey'"
size="18px"
/>
<span :class="{ 'text-green-600 font-medium': choice.is_correct }">
{{ choice.text.th || `ตัวเลือก ${cIndex + 1}` }}
</span>
</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- Empty State -->
<div v-else class="text-center py-8 text-gray-500">
<q-icon name="quiz" size="48px" color="grey" />
<p class="mt-2">งไมคำถาม</p>
<p class="text-sm">คล "เพิ่มคำถาม" เพอเรมสรางแบบทดสอบ</p>
</div>
</q-card-section>
</q-card>
</div>
<!-- Error -->
<div v-else class="text-center py-12">
<q-icon name="error" size="48px" color="negative" />
<p class="mt-4 text-gray-500">ไมพบบทเรยน</p>
</div>
<!-- Question Dialog -->
<q-dialog v-model="questionDialog" persistent>
<q-card style="min-width: 500px">
<q-card-section>
<h3 class="text-lg font-semibold">{{ editingQuestionIndex !== null ? 'แก้ไขคำถาม' : 'เพิ่มคำถาม' }}</h3>
</q-card-section>
<q-separator />
<q-card-section class="space-y-4">
<q-input
v-model="questionForm.text.th"
label="คำถาม (ภาษาไทย) *"
outlined
autogrow
/>
<q-input
v-model="questionForm.text.en"
label="คำถาม (English)"
outlined
autogrow
/>
<div class="border-t pt-4 mt-4">
<div class="flex justify-between items-center mb-2">
<span class="font-medium">วเลอก</span>
<q-btn flat size="sm" icon="add" label="เพิ่มตัวเลือก" @click="addChoice" />
</div>
<div class="space-y-3">
<div
v-for="(choice, cIndex) in questionForm.choices"
:key="cIndex"
class="flex items-start gap-2"
>
<q-radio
:model-value="getCorrectIndex()"
:val="cIndex"
@update:model-value="setCorrectChoice(cIndex)"
/>
<q-input
v-model="choice.text.th"
:label="`ตัวเลือก ${cIndex + 1}`"
outlined
dense
class="flex-1"
/>
<q-btn
v-if="questionForm.choices.length > 2"
flat
round
dense
icon="close"
color="negative"
@click="removeChoice(cIndex)"
/>
</div>
</div>
</div>
</q-card-section>
<q-separator />
<q-card-section class="flex justify-end gap-2">
<q-btn flat label="ยกเลิก" @click="questionDialog = false" :disable="savingQuestion" />
<q-btn color="primary" label="บันทึก" @click="saveQuestion" :loading="savingQuestion" />
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useQuasar } from 'quasar';
import { instructorService, type LessonResponse, type CreateQuestionRequest } from '~/services/instructor.service';
const route = useRoute();
const $q = useQuasar();
const courseId = Number(route.params.id);
const chapterId = Number(route.params.chapterId);
const lessonId = Number(route.params.lessonId);
const loading = ref(true);
const saving = ref(false);
const lesson = ref<LessonResponse | null>(null);
const form = ref({
title: { th: '', en: '' },
content: { th: '', en: '' }
});
const quizSettings = ref({
passing_score: 60,
time_limit: 0,
shuffle_questions: false,
shuffle_choices: false,
show_answers_after_completion: true
});
interface QuizChoice {
id?: number;
text: { th: string; en: string };
is_correct: boolean;
}
interface QuizQuestion {
id?: number;
text: { th: string; en: string };
choices: QuizChoice[];
}
const questions = ref<QuizQuestion[]>([]);
const questionDialog = ref(false);
const editingQuestionIndex = ref<number | null>(null);
const questionForm = ref<QuizQuestion>({
text: { th: '', en: '' },
choices: [
{ text: { th: '', en: '' }, is_correct: true },
{ text: { th: '', en: '' }, is_correct: false }
]
});
const fetchLesson = async () => {
loading.value = true;
try {
const data = await instructorService.getLesson(courseId, chapterId, lessonId);
lesson.value = data;
form.value = {
title: { ...data.title },
content: data.content ? { ...data.content } : { th: '', en: '' }
};
// Load quiz settings
if (data.quiz) {
quizSettings.value = {
passing_score: data.quiz.passing_score || 60,
time_limit: data.quiz.time_limit || 0,
shuffle_questions: data.quiz.shuffle_questions || false,
shuffle_choices: data.quiz.shuffle_choices || false,
show_answers_after_completion: data.quiz.show_answers_after_completion !== false
};
// Load questions from API
if (data.quiz.questions) {
questions.value = data.quiz.questions.map(q => ({
id: q.id,
text: { th: q.question.th, en: q.question.en },
choices: q.choices.map(c => ({
id: c.id,
text: { th: c.text.th, en: c.text.en },
is_correct: c.is_correct
}))
}));
}
}
} catch (error) {
console.error('Failed to fetch lesson:', error);
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลได้', position: 'top' });
} finally {
loading.value = false;
}
};
const saveLesson = async () => {
saving.value = true;
try {
await instructorService.updateLesson(courseId, chapterId, lessonId, form.value);
$q.notify({ type: 'positive', message: 'บันทึกสำเร็จ', position: 'top' });
navigateTo(`/instructor/courses/${courseId}/structure`);
} catch (error) {
console.error('Failed to save lesson:', error);
$q.notify({ type: 'negative', message: 'ไม่สามารถบันทึกได้', position: 'top' });
} finally {
saving.value = false;
}
};
const addQuestion = () => {
editingQuestionIndex.value = null;
questionForm.value = {
text: { th: '', en: '' },
choices: [
{ text: { th: '', en: '' }, is_correct: true },
{ text: { th: '', en: '' }, is_correct: false }
]
};
questionDialog.value = true;
};
const editQuestion = (index: number) => {
editingQuestionIndex.value = index;
questionForm.value = JSON.parse(JSON.stringify(questions.value[index]));
questionDialog.value = true;
};
const removeQuestion = async (index: number) => {
const question = questions.value[index];
$q.dialog({
title: 'ยืนยันการลบ',
message: 'ต้องการลบคำถามนี้หรือไม่?',
cancel: true,
persistent: true
}).onOk(async () => {
try {
if (question.id) {
await instructorService.deleteQuestion(courseId, chapterId, lessonId, question.id);
}
questions.value.splice(index, 1);
$q.notify({ type: 'positive', message: 'ลบคำถามสำเร็จ', position: 'top' });
} catch (error) {
console.error('Failed to delete question:', error);
$q.notify({ type: 'negative', message: 'ไม่สามารถลบคำถามได้', position: 'top' });
}
});
};
const savingQuestion = ref(false);
const saveQuestion = async () => {
if (!questionForm.value.text.th) {
$q.notify({ type: 'warning', message: 'กรุณากรอกคำถาม', position: 'top' });
return;
}
savingQuestion.value = true;
try {
const questionData: CreateQuestionRequest = {
question: questionForm.value.text,
explanation: { th: '', en: '' },
question_type: 'MULTIPLE_CHOICE',
sort_order: editingQuestionIndex.value !== null
? questions.value[editingQuestionIndex.value].id ? editingQuestionIndex.value + 1 : questions.value.length + 1
: questions.value.length + 1,
choices: questionForm.value.choices.map((c, i) => ({
text: c.text,
is_correct: c.is_correct,
sort_order: i + 1
}))
};
console.log('Saving question:', JSON.stringify(questionData, null, 2));
if (editingQuestionIndex.value !== null && questions.value[editingQuestionIndex.value].id) {
// Update existing question
await instructorService.updateQuestion(
courseId, chapterId, lessonId,
questions.value[editingQuestionIndex.value].id!,
questionData
);
$q.notify({ type: 'positive', message: 'แก้ไขคำถามสำเร็จ', position: 'top' });
} else {
// Create new question
await instructorService.createQuestion(courseId, chapterId, lessonId, questionData);
$q.notify({ type: 'positive', message: 'เพิ่มคำถามสำเร็จ', position: 'top' });
}
questionDialog.value = false;
// Refresh data
await fetchLesson();
} catch (error) {
console.error('Failed to save question:', error);
$q.notify({ type: 'negative', message: 'ไม่สามารถบันทึกคำถามได้', position: 'top' });
} finally {
savingQuestion.value = false;
}
};
const addChoice = () => {
questionForm.value.choices.push({ text: { th: '', en: '' }, is_correct: false });
};
const removeChoice = (index: number) => {
const wasCorrect = questionForm.value.choices[index].is_correct;
questionForm.value.choices.splice(index, 1);
if (wasCorrect && questionForm.value.choices.length > 0) {
questionForm.value.choices[0].is_correct = true;
}
};
const getCorrectIndex = () => {
return questionForm.value.choices.findIndex(c => c.is_correct);
};
const setCorrectChoice = (index: number) => {
questionForm.value.choices.forEach((c, i) => {
c.is_correct = i === index;
});
};
onMounted(() => {
fetchLesson();
});
</script>

View file

@ -0,0 +1,368 @@
<template>
<div class="p-6 max-w-4xl mx-auto">
<!-- Loading -->
<div v-if="loading" class="text-center py-12">
<q-spinner size="lg" color="primary" />
<p class="mt-4">กำลงโหลด...</p>
</div>
<!-- Content -->
<div v-else-if="lesson">
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<q-btn
flat
round
icon="arrow_back"
@click="navigateTo(`/instructor/courses/${courseId}/structure`)"
/>
<div>
<h1 class="text-2xl font-bold">แกไขบทเรยน (โอ)</h1>
<p class="text-gray-500">{{ lesson.title.th }}</p>
</div>
</div>
<!-- Form -->
<q-card>
<q-card-section>
<div class="space-y-4">
<!-- Title Thai -->
<q-input
v-model="form.title.th"
label="ชื่อบทเรียน (ภาษาไทย) *"
outlined
:rules="[val => !!val || 'กรุณากรอกชื่อบทเรียน']"
/>
<!-- Title English -->
<q-input
v-model="form.title.en"
label="ชื่อบทเรียน (English)"
outlined
/>
<!-- Content Thai -->
<q-input
v-model="form.content.th"
label="คำอธิบาย (ภาษาไทย)"
type="textarea"
outlined
autogrow
rows="3"
/>
<!-- Content English -->
<q-input
v-model="form.content.en"
label="คำอธิบาย (English)"
type="textarea"
outlined
autogrow
rows="3"
/>
</div>
</q-card-section>
<!-- Video Section -->
<q-separator />
<q-card-section>
<h3 class="text-lg font-semibold mb-4">โอ</h3>
<!-- Current Video -->
<div v-if="lesson.video_url" class="mb-4">
<p class="text-sm text-gray-500 mb-2">โอปจจ:</p>
<video
:src="lesson.video_url"
controls
class="w-full max-w-xl rounded-lg"
/>
</div>
<!-- Upload New Video -->
<div
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors"
@click="triggerVideoInput"
@dragover.prevent
@drop.prevent="handleVideoDrop"
>
<input
ref="videoInput"
type="file"
accept="video/mp4,video/webm"
class="hidden"
@change="handleVideoSelect"
/>
<div v-if="selectedVideo">
<q-icon name="videocam" size="48px" color="primary" />
<p class="mt-2 font-medium">{{ selectedVideo.name }}</p>
<p class="text-sm text-gray-400">{{ formatFileSize(selectedVideo.size) }}</p>
<q-btn
class="mt-2"
flat
size="sm"
color="negative"
label="ลบ"
icon="close"
@click.stop="selectedVideo = null"
/>
</div>
<div v-else>
<q-icon name="cloud_upload" size="48px" color="grey" />
<p class="mt-2 text-gray-500">ลากไฟลมาวางท หรอคลกเพอเลอกไฟล</p>
<p class="text-sm text-gray-400">รองร MP4, WebM (งส 500 MB)</p>
<q-btn
class="mt-4"
color="primary"
outline
label="เลือกไฟล์วิดีโอ"
icon="upload"
/>
</div>
</div>
<!-- Upload Button -->
<div v-if="selectedVideo" class="mt-4 text-center">
<q-btn
color="primary"
label="อัพโหลดวิดีโอ"
icon="cloud_upload"
:loading="uploadingVideo"
@click="uploadVideo"
/>
</div>
</q-card-section>
<!-- Attachments Section -->
<q-separator />
<q-card-section>
<h3 class="text-lg font-semibold mb-4">ไฟลแนบ</h3>
<!-- Attachments List -->
<div v-if="lesson.attachments && lesson.attachments.length > 0" class="space-y-2 mb-4">
<div
v-for="attachment in lesson.attachments"
:key="attachment.id"
class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg"
>
<q-icon name="attach_file" color="grey" />
<div class="flex-1">
<p class="font-medium">{{ attachment.file_name }}</p>
<p class="text-sm text-gray-500">{{ formatFileSize(attachment.file_size) }}</p>
</div>
<q-btn
flat
round
icon="delete"
color="negative"
size="sm"
:loading="deletingAttachmentId === attachment.id"
@click="deleteAttachment(attachment.id)"
/>
</div>
</div>
<input
ref="attachmentInput"
type="file"
multiple
class="hidden"
@change="handleAttachmentSelect"
/>
<q-btn
outline
color="primary"
icon="add"
label="เพิ่มไฟล์แนบ"
:loading="uploadingAttachment"
@click="triggerAttachmentInput"
/>
</q-card-section>
<!-- Actions -->
<q-separator />
<q-card-section class="flex justify-end gap-2">
<q-btn
flat
label="ยกเลิก"
@click="navigateTo(`/instructor/courses/${courseId}/structure`)"
/>
<q-btn
color="primary"
label="บันทึก"
icon="save"
:loading="saving"
@click="saveLesson"
/>
</q-card-section>
</q-card>
</div>
<!-- Error -->
<div v-else class="text-center py-12">
<q-icon name="error" size="48px" color="negative" />
<p class="mt-4 text-gray-500">ไมพบบทเรยน</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useQuasar } from 'quasar';
import { instructorService, type LessonResponse } from '~/services/instructor.service';
const route = useRoute();
const $q = useQuasar();
const courseId = Number(route.params.id);
const chapterId = Number(route.params.chapterId);
const lessonId = Number(route.params.lessonId);
const loading = ref(true);
const saving = ref(false);
const lesson = ref<LessonResponse | null>(null);
const form = ref({
title: { th: '', en: '' },
content: { th: '', en: '' }
});
const selectedVideo = ref<File | null>(null);
const uploadingVideo = ref(false);
const videoInput = ref<HTMLInputElement | null>(null);
const triggerVideoInput = () => {
videoInput.value?.click();
};
const fetchLesson = async () => {
loading.value = true;
try {
const data = await instructorService.getLesson(courseId, chapterId, lessonId);
lesson.value = data;
form.value = {
title: { ...data.title },
content: data.content ? { ...data.content } : { th: '', en: '' }
};
} catch (error) {
console.error('Failed to fetch lesson:', error);
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลได้', position: 'top' });
} finally {
loading.value = false;
}
};
const saveLesson = async () => {
saving.value = true;
try {
await instructorService.updateLesson(courseId, chapterId, lessonId, form.value);
$q.notify({ type: 'positive', message: 'บันทึกสำเร็จ', position: 'top' });
navigateTo(`/instructor/courses/${courseId}/structure`);
} catch (error) {
console.error('Failed to save lesson:', error);
$q.notify({ type: 'negative', message: 'ไม่สามารถบันทึกได้', position: 'top' });
} finally {
saving.value = false;
}
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const handleVideoSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
const file = target.files[0];
if (file.size > 500 * 1024 * 1024) {
$q.notify({ type: 'warning', message: 'ขนาดไฟล์เกิน 500 MB', position: 'top' });
return;
}
selectedVideo.value = file;
}
};
const handleVideoDrop = (event: DragEvent) => {
if (event.dataTransfer?.files && event.dataTransfer.files[0]) {
const file = event.dataTransfer.files[0];
if (!['video/mp4', 'video/webm'].includes(file.type)) {
$q.notify({ type: 'warning', message: 'รองรับเฉพาะ MP4 และ WebM', position: 'top' });
return;
}
if (file.size > 500 * 1024 * 1024) {
$q.notify({ type: 'warning', message: 'ขนาดไฟล์เกิน 500 MB', position: 'top' });
return;
}
selectedVideo.value = file;
}
};
const uploadVideo = async () => {
if (!selectedVideo.value) return;
uploadingVideo.value = true;
try {
if (lesson.value?.video_url) {
await instructorService.updateLessonVideo(courseId, chapterId, lessonId, selectedVideo.value);
} else {
await instructorService.uploadLessonVideo(courseId, chapterId, lessonId, selectedVideo.value);
}
$q.notify({ type: 'positive', message: 'อัพโหลดวิดีโอสำเร็จ', position: 'top' });
selectedVideo.value = null;
await fetchLesson();
} catch (error) {
console.error('Failed to upload video:', error);
$q.notify({ type: 'negative', message: 'ไม่สามารถอัพโหลดวิดีโอได้', position: 'top' });
} finally {
uploadingVideo.value = false;
}
};
// Attachment functions
const attachmentInput = ref<HTMLInputElement | null>(null);
const uploadingAttachment = ref(false);
const deletingAttachmentId = ref<number | null>(null);
const triggerAttachmentInput = () => {
attachmentInput.value?.click();
};
const handleAttachmentSelect = async (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
const files = Array.from(target.files);
uploadingAttachment.value = true;
try {
await instructorService.addAttachments(courseId, chapterId, lessonId, files);
$q.notify({ type: 'positive', message: 'อัพโหลดไฟล์แนบสำเร็จ', position: 'top' });
await fetchLesson();
} catch (error) {
console.error('Failed to upload attachments:', error);
$q.notify({ type: 'negative', message: 'ไม่สามารถอัพโหลดไฟล์แนบได้', position: 'top' });
} finally {
uploadingAttachment.value = false;
target.value = '';
}
}
};
const deleteAttachment = async (attachmentId: number) => {
deletingAttachmentId.value = attachmentId;
try {
await instructorService.deleteAttachment(courseId, chapterId, lessonId, attachmentId);
$q.notify({ type: 'positive', message: 'ลบไฟล์แนบสำเร็จ', position: 'top' });
await fetchLesson();
} catch (error) {
console.error('Failed to delete attachment:', error);
$q.notify({ type: 'negative', message: 'ไม่สามารถลบไฟล์แนบได้', position: 'top' });
} finally {
deletingAttachmentId.value = null;
}
};
onMounted(() => {
fetchLesson();
});
</script>

View file

@ -105,7 +105,7 @@
<div v-show="chapter.expanded !== false"> <div v-show="chapter.expanded !== false">
<q-list separator> <q-list separator>
<q-item <q-item
v-for="(lesson, lessonIndex) in chapter.lessons" v-for="(lesson, lessonIndex) in getSortedLessons(chapter.lessons)"
:key="lesson.id" :key="lesson.id"
class="py-3" class="py-3"
:class="{ 'opacity-50': draggedLesson?.id === lesson.id, 'bg-primary-50': dragOverLesson?.id === lesson.id }" :class="{ 'opacity-50': draggedLesson?.id === lesson.id, 'bg-primary-50': dragOverLesson?.id === lesson.id }"
@ -135,7 +135,7 @@
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<div class="flex gap-1"> <div class="flex gap-1">
<q-btn flat dense icon="edit" size="sm" @click="openLessonDialog(chapter, lesson)"> <q-btn flat dense icon="edit" size="sm" @click="navigateToLessonEdit(chapter, lesson)">
<q-tooltip>แกไข</q-tooltip> <q-tooltip>แกไข</q-tooltip>
</q-btn> </q-btn>
<q-btn flat dense icon="delete" size="sm" color="negative" @click="confirmDeleteLesson(chapter, lesson)"> <q-btn flat dense icon="delete" size="sm" color="negative" @click="confirmDeleteLesson(chapter, lesson)">
@ -486,6 +486,12 @@ const fetchChapters = async () => {
} }
}; };
// 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 getLessonIcon = (type: string) => {
const icons: Record<string, string> = { const icons: Record<string, string> = {
VIDEO: 'play_circle', VIDEO: 'play_circle',
@ -513,6 +519,10 @@ const getLessonTypeLabel = (type: string) => {
return labels[type] || type; return labels[type] || type;
}; };
const getSortedLessons = (lessons: LessonResponse[]) => {
return [...lessons].sort((a, b) => a.sort_order - b.sort_order);
};
// Chapter CRUD // Chapter CRUD
const openChapterDialog = (chapter?: ChapterResponse) => { const openChapterDialog = (chapter?: ChapterResponse) => {
if (chapter) { if (chapter) {

View file

@ -417,9 +417,179 @@ export const instructorService = {
} }
await authRequest( await authRequest(
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/reorder`, `/api/instructors/courses/${courseId}/chapters/${chapterId}/reorder-lessons`,
{ method: 'PUT', body: { lesson_id: lessonId, sort_order: sortOrder } } { method: 'PUT', body: { lesson_id: lessonId, sort_order: sortOrder } }
); );
},
// Question CRUD
async createQuestion(courseId: number, chapterId: number, lessonId: number, data: CreateQuestionRequest): Promise<QuizQuestionResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return {
id: Date.now(),
quiz_id: 1,
question: data.question,
explanation: data.explanation || null,
question_type: data.question_type,
score: data.score,
sort_order: data.sort_order || 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
choices: data.choices.map((c, i) => ({
id: Date.now() + i,
question_id: Date.now(),
text: c.text,
is_correct: c.is_correct,
sort_order: c.sort_order || i + 1
}))
};
}
const response = await authRequest<{ code: number; data: QuizQuestionResponse }>(
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/questions`,
{ method: 'POST', body: data }
);
return response.data;
},
async updateQuestion(courseId: number, chapterId: number, lessonId: number, questionId: number, data: CreateQuestionRequest): Promise<QuizQuestionResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return {
id: questionId,
quiz_id: 1,
question: data.question,
explanation: data.explanation || null,
question_type: data.question_type,
score: data.score,
sort_order: data.sort_order || 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
choices: data.choices.map((c, i) => ({
id: Date.now() + i,
question_id: questionId,
text: c.text,
is_correct: c.is_correct,
sort_order: c.sort_order || i + 1
}))
};
}
const response = await authRequest<{ code: number; data: QuizQuestionResponse }>(
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/questions/${questionId}`,
{ method: 'PUT', body: data }
);
return response.data;
},
async deleteQuestion(courseId: number, chapterId: number, lessonId: number, questionId: number): Promise<void> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return;
}
await authRequest(
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/questions/${questionId}`,
{ method: 'DELETE' }
);
},
// Video Upload
async uploadLessonVideo(courseId: number, chapterId: number, lessonId: number, video: File, attachments?: File[]): Promise<LessonResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 1000));
return MOCK_COURSE_DETAIL.chapters[0].lessons[0] as LessonResponse;
}
const formData = new FormData();
formData.append('video', video);
if (attachments) {
attachments.forEach(file => {
formData.append('attachments', file);
});
}
const response = await authRequest<{ code: number; data: LessonResponse }>(
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/video`,
{ method: 'POST', body: formData }
);
return response.data;
},
async updateLessonVideo(courseId: number, chapterId: number, lessonId: number, video?: File, attachments?: File[]): Promise<LessonResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 1000));
return MOCK_COURSE_DETAIL.chapters[0].lessons[0] as LessonResponse;
}
const formData = new FormData();
if (video) {
formData.append('video', video);
}
if (attachments) {
attachments.forEach(file => {
formData.append('attachments', file);
});
}
const response = await authRequest<{ code: number; data: LessonResponse }>(
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/video`,
{ method: 'PUT', body: formData }
);
return response.data;
},
// Attachments
async addAttachments(courseId: number, chapterId: number, lessonId: number, files: File[]): Promise<LessonResponse> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return MOCK_COURSE_DETAIL.chapters[0].lessons[0] as LessonResponse;
}
const formData = new FormData();
files.forEach(file => {
formData.append('attachments', file);
});
const response = await authRequest<{ code: number; data: LessonResponse }>(
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/attachments`,
{ method: 'POST', body: formData }
);
return response.data;
},
async deleteAttachment(courseId: number, chapterId: number, lessonId: number, attachmentId: number): Promise<void> {
const config = useRuntimeConfig();
const useMockData = config.public.useMockData as boolean;
if (useMockData) {
await new Promise(resolve => setTimeout(resolve, 500));
return;
}
await authRequest(
`/api/instructors/courses/${courseId}/chapters/${chapterId}/lessons/${lessonId}/attachments/${attachmentId}`,
{ method: 'DELETE' }
);
} }
}; };
@ -472,9 +642,32 @@ export interface LessonResponse {
is_published: boolean; is_published: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
video_url: string | null;
attachments: AttachmentResponse[];
quiz: QuizResponse | null; quiz: QuizResponse | null;
} }
export interface QuizChoiceResponse {
id: number;
question_id: number;
text: { en: string; th: string };
is_correct: boolean;
sort_order: number;
}
export interface QuizQuestionResponse {
id: number;
quiz_id: number;
question: { en: string; th: string };
explanation: { en: string; th: string } | null;
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
score: number;
sort_order: number;
created_at: string;
updated_at: string;
choices: QuizChoiceResponse[];
}
export interface QuizResponse { export interface QuizResponse {
id: number; id: number;
lesson_id: number; lesson_id: number;
@ -485,6 +678,9 @@ export interface QuizResponse {
shuffle_questions: boolean; shuffle_questions: boolean;
shuffle_choices: boolean; shuffle_choices: boolean;
show_answers_after_completion: boolean; show_answers_after_completion: boolean;
created_at?: string;
updated_at?: string;
questions?: QuizQuestionResponse[];
} }
export interface CreateChapterRequest { export interface CreateChapterRequest {
@ -494,6 +690,20 @@ export interface CreateChapterRequest {
is_published?: boolean; is_published?: boolean;
} }
export interface CreateChoiceRequest {
text: { th: string; en: string };
is_correct: boolean;
sort_order?: number;
}
export interface CreateQuestionRequest {
question: { th: string; en: string };
explanation?: { th: string; en: string } | null;
question_type: 'MULTIPLE_CHOICE' | 'TRUE_FALSE';
sort_order?: number;
choices: CreateChoiceRequest[];
}
export interface CreateLessonRequest { export interface CreateLessonRequest {
title: { th: string; en: string }; title: { th: string; en: string };
content?: { th: string; en: string } | null; content?: { th: string; en: string } | null;
@ -550,6 +760,8 @@ const MOCK_COURSE_DETAIL: CourseDetailResponse = {
is_published: true, is_published: true,
created_at: '2024-01-15T00:00:00Z', created_at: '2024-01-15T00:00:00Z',
updated_at: '2024-01-15T00:00:00Z', updated_at: '2024-01-15T00:00:00Z',
video_url: null,
attachments: [],
quiz: null quiz: null
}, },
{ {
@ -566,6 +778,8 @@ const MOCK_COURSE_DETAIL: CourseDetailResponse = {
is_published: true, is_published: true,
created_at: '2024-01-15T00:00:00Z', created_at: '2024-01-15T00:00:00Z',
updated_at: '2024-01-15T00:00:00Z', updated_at: '2024-01-15T00:00:00Z',
video_url: null,
attachments: [],
quiz: null quiz: null
}, },
{ {
@ -582,6 +796,8 @@ const MOCK_COURSE_DETAIL: CourseDetailResponse = {
is_published: true, is_published: true,
created_at: '2024-01-15T00:00:00Z', created_at: '2024-01-15T00:00:00Z',
updated_at: '2024-01-15T00:00:00Z', updated_at: '2024-01-15T00:00:00Z',
video_url: null,
attachments: [],
quiz: { quiz: {
id: 1, id: 1,
lesson_id: 3, lesson_id: 3,
@ -620,6 +836,8 @@ const MOCK_COURSE_DETAIL: CourseDetailResponse = {
is_published: true, is_published: true,
created_at: '2024-01-15T00:00:00Z', created_at: '2024-01-15T00:00:00Z',
updated_at: '2024-01-15T00:00:00Z', updated_at: '2024-01-15T00:00:00Z',
video_url: null,
attachments: [],
quiz: null quiz: null
} }
] ]