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">
|
<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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue