feat: Implement instructor course management, including student progress tracking and course content views, alongside admin user management.

This commit is contained in:
Missez 2026-02-05 09:31:24 +07:00
parent c9381b9385
commit be5b9756be
11 changed files with 2116 additions and 1386 deletions

View file

@ -85,69 +85,117 @@
<h3 class="text-lg font-semibold mb-4">โอ</h3>
<!-- Current Video -->
<div v-if="lesson.video_url" class="mb-4">
<div v-if="lesson.video_url" class="mb-6">
<p class="text-sm text-gray-500 mb-2">โอปจจ:</p>
<div v-if="isYoutubeUrl(lesson.video_url)" class="aspect-video w-full max-w-xl rounded-lg overflow-hidden bg-black">
<iframe
:src="getEmbedUrl(lesson.video_url)"
width="100%"
height="100%"
frameborder="0"
allowfullscreen
></iframe>
</div>
<video
v-else
:src="lesson.video_url"
controls
class="w-full max-w-xl rounded-lg"
/>
</div>
<div class="mb-4">
<q-tabs
v-model="videoSourceType"
dense
class="text-grey"
active-color="primary"
indicator-color="primary"
align="left"
narrow-indicator
>
<q-tab name="upload" label="อัพโหลดไฟล์" />
<q-tab name="youtube" label="YouTube" />
</q-tabs>
<q-separator />
</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 v-if="videoSourceType === 'upload'">
<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>
<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>
<!-- Upload Button -->
<div v-if="selectedVideo" class="mt-4 text-center">
<q-btn
class="mt-4"
color="primary"
outline
label="เลือกไฟล์วิดีโอ"
icon="upload"
label="อัพโหลดวิดีโอ"
icon="cloud_upload"
:loading="uploadingVideo"
@click="uploadVideo"
/>
</div>
</div>
<!-- Upload Button -->
<div v-if="selectedVideo" class="mt-4 text-center">
<!-- YouTube Input -->
<div v-else class="max-w-xl">
<q-input
v-model="youtubeUrl"
label="YouTube URL"
hint="ตัวอย่าง: https://www.youtube.com/watch?v=..."
outlined
class="mb-4"
/>
<q-btn
color="primary"
label="อัพโหลดวิดีโอ"
icon="cloud_upload"
:loading="uploadingVideo"
@click="uploadVideo"
label="บันทึก YouTube Video"
icon="save"
:loading="savingYoutube"
@click="saveYoutubeVideo"
:disable="!isValidYoutubeUrl"
/>
</div>
</q-card-section>
</q-card-section>
<!-- Prerequisite Settings Section -->
<q-separator />
<q-card-section>
@ -196,7 +244,7 @@
<!-- Attachments List -->
<div v-if="lesson.attachments && lesson.attachments.length > 0" class="space-y-2 mb-4">
<div
v-for="attachment in lesson.attachments"
v-for="attachment in visibleAttachments"
:key="attachment.id"
class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg"
>
@ -249,7 +297,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useQuasar } from 'quasar';
import { instructorService, type LessonResponse } from '~/services/instructor.service';
@ -265,6 +313,11 @@ const loading = ref(true);
const saving = ref(false);
const lesson = ref<LessonResponse | null>(null);
const visibleAttachments = computed(() => {
if (!lesson.value?.attachments) return [];
return lesson.value.attachments.filter(a => !a.mime_type.includes('video'));
});
const form = ref({
title: { th: '', en: '' },
content: { th: '', en: '' }
@ -292,6 +345,103 @@ const selectedVideo = ref<File | null>(null);
const uploadingVideo = ref(false);
const videoInput = ref<HTMLInputElement | null>(null);
// YouTube Support
const videoSourceType = ref('upload');
const youtubeUrl = ref('');
const savingYoutube = ref(false);
const youtubeRegex = /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
const isYoutubeUrl = (url: string) => {
return youtubeRegex.test(url);
};
const getEmbedUrl = (url: string) => {
const match = url.match(youtubeRegex);
if (match && match[1]) {
return `https://www.youtube.com/embed/${match[1]}`;
}
return url;
};
const fetchYoutubeTitle = async (url: string) => {
try {
const response = await fetch(`https://noembed.com/embed?url=${url}`);
const data = await response.json();
if (data.title) {
return data.title;
}
} catch (error) {
console.warn('Failed to fetch YouTube title:', error);
}
return '';
};
const isValidYoutubeUrl = computed(() => {
return youtubeRegex.test(youtubeUrl.value);
});
// Watch for YouTube URL changes to fetch title
watch(youtubeUrl, async (newUrl) => {
if (isValidYoutubeUrl.value) {
const title = await fetchYoutubeTitle(newUrl);
if (title && !form.value.title.th) {
// Autocomplete lesson title if empty
form.value.title.th = title;
}
// We could store it to use as video_title specifically,
// but for now let's rely on form.title.th or the fetched title for the API call validation if we want to be strict.
// However, the user probably wants the 'video_title' passed to backend to be correct.
// Let's store it.
fetchedVideoTitle.value = title;
} else {
fetchedVideoTitle.value = '';
}
});
const fetchedVideoTitle = ref('');
const saveYoutubeVideo = async () => {
if (!isValidYoutubeUrl.value) return;
const match = youtubeUrl.value.match(youtubeRegex);
if (!match || !match[1]) return;
const videoId = match[1];
savingYoutube.value = true;
try {
// If we have a fetched title, we can genericallly use it, or fallback to lesson title
const videoTitle = fetchedVideoTitle.value || form.value.title.th || 'Untitled Video';
const response = await instructorService.setLessonYoutubeVideo(
courseId,
chapterId,
lessonId,
videoId,
videoTitle
);
// If lesson title is empty, update it too
if (!form.value.title.th && fetchedVideoTitle.value) {
form.value.title.th = fetchedVideoTitle.value;
// We might want to save the lesson title change as well?
// The current flow saves video separately.
// Let's just update the local form for now, user can click "Save" on the form if they want to persist the name change to the lesson itself.
}
$q.notify({ type: 'positive', message: response.message || 'บันทึกวิดีโอ YouTube สำเร็จ', position: 'top' });
youtubeUrl.value = '';
fetchedVideoTitle.value = '';
await fetchLesson();
} catch (error) {
console.error('Failed to save YouTube video:', error);
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถบันทึกวิดีโอได้', position: 'top' });
} finally {
savingYoutube.value = false;
}
};
const triggerVideoInput = () => {
videoInput.value?.click();
};
@ -306,6 +456,14 @@ const fetchLesson = async () => {
content: data.content ? { ...data.content } : { th: '', en: '' }
};
// Check if current video is YouTube
if (data.video_url && isYoutubeUrl(data.video_url)) {
videoSourceType.value = 'youtube';
youtubeUrl.value = data.video_url;
} else {
videoSourceType.value = 'upload';
}
// Load prerequisite settings
prerequisiteSettings.value = {
prerequisite_lesson_ids: data.prerequisite_lesson_ids || []

File diff suppressed because it is too large Load diff

View file

@ -42,6 +42,7 @@
placeholder="ค้นหาหลักสูตร..."
outlined
dense
debounce="600"
bg-color="grey-1"
>
<template v-slot:prepend>