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:
parent
be7348c74d
commit
310a5e7dd7
4 changed files with 1085 additions and 3 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
<div v-show="chapter.expanded !== false">
|
||||
<q-list separator>
|
||||
<q-item
|
||||
v-for="(lesson, lessonIndex) in chapter.lessons"
|
||||
v-for="(lesson, lessonIndex) in getSortedLessons(chapter.lessons)"
|
||||
:key="lesson.id"
|
||||
class="py-3"
|
||||
:class="{ 'opacity-50': draggedLesson?.id === lesson.id, 'bg-primary-50': dragOverLesson?.id === lesson.id }"
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<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-btn>
|
||||
<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 icons: Record<string, string> = {
|
||||
VIDEO: 'play_circle',
|
||||
|
|
@ -513,6 +519,10 @@ const getLessonTypeLabel = (type: string) => {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -417,9 +417,179 @@ export const instructorService = {
|
|||
}
|
||||
|
||||
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 } }
|
||||
);
|
||||
},
|
||||
|
||||
// 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;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
video_url: string | null;
|
||||
attachments: AttachmentResponse[];
|
||||
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 {
|
||||
id: number;
|
||||
lesson_id: number;
|
||||
|
|
@ -485,6 +678,9 @@ export interface QuizResponse {
|
|||
shuffle_questions: boolean;
|
||||
shuffle_choices: boolean;
|
||||
show_answers_after_completion: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
questions?: QuizQuestionResponse[];
|
||||
}
|
||||
|
||||
export interface CreateChapterRequest {
|
||||
|
|
@ -494,6 +690,20 @@ export interface CreateChapterRequest {
|
|||
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 {
|
||||
title: { th: string; en: string };
|
||||
content?: { th: string; en: string } | null;
|
||||
|
|
@ -550,6 +760,8 @@ const MOCK_COURSE_DETAIL: CourseDetailResponse = {
|
|||
is_published: true,
|
||||
created_at: '2024-01-15T00:00:00Z',
|
||||
updated_at: '2024-01-15T00:00:00Z',
|
||||
video_url: null,
|
||||
attachments: [],
|
||||
quiz: null
|
||||
},
|
||||
{
|
||||
|
|
@ -566,6 +778,8 @@ const MOCK_COURSE_DETAIL: CourseDetailResponse = {
|
|||
is_published: true,
|
||||
created_at: '2024-01-15T00:00:00Z',
|
||||
updated_at: '2024-01-15T00:00:00Z',
|
||||
video_url: null,
|
||||
attachments: [],
|
||||
quiz: null
|
||||
},
|
||||
{
|
||||
|
|
@ -582,6 +796,8 @@ const MOCK_COURSE_DETAIL: CourseDetailResponse = {
|
|||
is_published: true,
|
||||
created_at: '2024-01-15T00:00:00Z',
|
||||
updated_at: '2024-01-15T00:00:00Z',
|
||||
video_url: null,
|
||||
attachments: [],
|
||||
quiz: {
|
||||
id: 1,
|
||||
lesson_id: 3,
|
||||
|
|
@ -620,6 +836,8 @@ const MOCK_COURSE_DETAIL: CourseDetailResponse = {
|
|||
is_published: true,
|
||||
created_at: '2024-01-15T00:00:00Z',
|
||||
updated_at: '2024-01-15T00:00:00Z',
|
||||
video_url: null,
|
||||
attachments: [],
|
||||
quiz: null
|
||||
}
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue