feat: Implement core course management, enrollment, and classroom learning functionalities with new composables and components.

This commit is contained in:
supalerk-ar66 2026-02-04 16:22:42 +07:00
parent 05755992a7
commit 754f211a08
4 changed files with 221 additions and 66 deletions

View file

@ -20,7 +20,7 @@ const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { user } = useAuth()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements } = useCourse()
const { fetchCourseLearningInfo, fetchLessonContent, saveVideoProgress, checkLessonAccess, fetchVideoProgress, fetchCourseAnnouncements, markLessonComplete } = useCourse()
// Media Prefs (Global Volume)
const { volume, muted: isMuted, setVolume, setMuted, applyTo } = useMediaPrefs()
@ -196,6 +196,11 @@ const loadLesson = async (lessonId: number) => {
if (res.success) {
currentLesson.value = res.data
// Initialize progress object if missing (Critical for New Users)
if (!currentLesson.value.progress) {
currentLesson.value.progress = {}
}
// Update Lesson Completion UI status safely
if (currentLesson.value?.progress?.is_completed && courseData.value) {
for (const chapter of courseData.value.chapters) {
@ -307,8 +312,10 @@ const handleVideoTimeUpdate = (cTime: number, dur: number) => {
}
}
const onVideoMetadataLoaded = () => {
// Component handles seek and volume
const onVideoMetadataLoaded = (duration: number) => {
if (duration > 0) {
currentDuration.value = duration
}
}
const isCompleting = ref(false) // Flag to prevent race conditions during completion
@ -321,7 +328,9 @@ const isCompleting = ref(false) // Flag to prevent race conditions during comple
const performSaveProgress = async (force: boolean = false, keepalive: boolean = false) => {
const lesson = currentLesson.value
if (!lesson || lesson.type !== 'VIDEO') return
if (!lesson.progress) return
// Ensure progress object exists
if (!lesson.progress) lesson.progress = {}
// 1. Completed Guard: Stop everything if already completed
if (lesson.progress.is_completed) return
@ -333,8 +342,16 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
const maxSec = Math.floor(maxWatchedTime.value) // Use max watched time
const durationSec = Math.floor(currentDuration.value || 0)
// 3. Monotonic Check: Don't save if progress hasn't increased (unless forced)
if (!force && maxSec <= lastSavedTime.value) return
// 3. Monotonic Check: Allow saving 0 if it's the very first save (lastSavedTime is -1)
if (!force) {
if (lastSavedTime.value === -1) {
// First time save: allow 0 or more
if (maxSec < 0) return
} else if (maxSec <= lastSavedTime.value) {
// Subsequent saves: must be greater than last saved
return
}
}
// 4. Throttle Check: Server Throttle (15 seconds)
if (!force && (now - lastSavedTimestamp.value < 15000)) return
@ -343,8 +360,8 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
lastSavedTime.value = maxSec
lastSavedTimestamp.value = now
// Check if this save might complete the lesson (e.g. > 90% or forced end)
const isFinishing = force || (durationSec > 0 && maxSec >= durationSec * 0.9)
// Check if this save might complete the lesson (e.g. 100% or forced end)
const isFinishing = force || (durationSec > 0 && maxSec >= durationSec)
if (isFinishing) {
isCompleting.value = true
@ -353,7 +370,7 @@ const performSaveProgress = async (force: boolean = false, keepalive: boolean =
try {
const res = await saveVideoProgress(lesson.id, maxSec, durationSec, keepalive)
// Handle Completion Response
// Handle Completion (Rely on Backend's auto-complete response)
if (res.success && res.data?.is_completed) {
markLessonAsCompletedLocally(lesson.id)
if (lesson.progress) lesson.progress.is_completed = true
@ -385,15 +402,29 @@ const markLessonAsCompletedLocally = (lessonId: number) => {
const videoSrc = computed(() => {
if (!currentLesson.value) return ''
// Use explicit video_url from API first
if (currentLesson.value.video_url) return currentLesson.value.video_url
let url = ''
// Fallback (deprecated logic, but keeping just in case)
const content = getLocalizedText(currentLesson.value.content)
if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) {
return content
// Use explicit video_url from API first
if (currentLesson.value.video_url) {
url = currentLesson.value.video_url
} else {
// Fallback (deprecated logic)
const content = getLocalizedText(currentLesson.value.content)
if (content && (content.startsWith('http') || content.startsWith('/')) && !content.includes(' ')) {
url = content
}
}
return ''
if (!url) return ''
// Support Resume for YouTube
const isYoutube = url.toLowerCase().includes('youtube.com') || url.toLowerCase().includes('youtu.be')
if (isYoutube && initialSeekTime.value > 0) {
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}t=${Math.floor(initialSeekTime.value)}`
}
return url
})
// (Complete)
@ -402,12 +433,15 @@ const onVideoEnded = async () => {
const lesson = currentLesson.value
if (!lesson || !lesson.progress || lesson.progress.is_completed || isCompleting.value) return
// Force save progress at 100%
await performSaveProgress(true, false)
// Double check completion state
if (currentLesson.value && !currentLesson.value.progress?.is_completed) {
markLessonAsCompletedLocally(currentLesson.value.id)
isCompleting.value = true
try {
// 1. Force save progress at 100%
// This will trigger the backend's auto-complete logic
await performSaveProgress(true, false)
} catch (err) {
console.error('Failed to save progress on end:', err)
} finally {
isCompleting.value = false
}
}
@ -471,7 +505,7 @@ onBeforeUnmount(() => {
:initialSeekTime="initialSeekTime"
@timeupdate="handleVideoTimeUpdate"
@ended="onVideoEnded"
@loadedmetadata="onVideoMetadataLoaded"
@loadedmetadata="(d: number) => onVideoMetadataLoaded(d)"
/>
<!-- Lesson Info -->