feat: Implement core course management, enrollment, and classroom learning functionalities with new composables and components.
This commit is contained in:
parent
05755992a7
commit
754f211a08
4 changed files with 221 additions and 66 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue