diff --git a/src/api/modules/course.ts b/src/api/modules/course.ts index 7a2077b..efc2396 100644 --- a/src/api/modules/course.ts +++ b/src/api/modules/course.ts @@ -16,6 +16,9 @@ import type { CourseSectionListResponse, BackendCourseSection, BackendInstructor, + BackendSectionVideo, + SectionVideo, + VideoQuality, Quiz, LearningProgress, SearchRequest, @@ -572,8 +575,8 @@ export class CourseApi { lessonId: section.courseId, // 使用courseId作为lessonId outline: '', // 暂时为空,根据type可以设置不同的内容 name: section.name, - type: section.type, - parentId: section.parentId, + type: section.type, // 保持原值,可能为null + parentId: section.parentId || '', // 如果parentId为空字符串,保持为空字符串 sort: section.sortOrder, level: section.level, revision: 1, // 默认版本号 @@ -823,6 +826,128 @@ export class CourseApi { } } + // 获取章节视频列表 + static async getSectionVideos(courseId: string, sectionId: string): Promise> { + try { + console.log('🔍 获取章节视频数据,课程ID:', courseId, '章节ID:', sectionId) + console.log('🔍 API请求URL: /biz/course/' + courseId + '/section_video/' + sectionId) + + const response = await ApiRequest.get(`/biz/course/${courseId}/section_video/${sectionId}`) + console.log('🔍 章节视频API响应:', response) + + // 处理后端响应格式 + if (response.data && response.data.success && response.data.result) { + console.log('✅ 响应状态码:', response.data.code) + console.log('✅ 响应消息:', response.data.message) + console.log('✅ 原始视频数据:', response.data.result) + console.log('✅ 视频数据数量:', response.data.result.length || 0) + + // 适配数据格式 + const adaptedVideos: SectionVideo[] = response.data.result.map((video: BackendSectionVideo) => { + // 解析fileUrl中的多个清晰度URL + const qualities = this.parseVideoQualities(video.fileUrl) + + return { + id: video.id, + name: video.name, + description: video.description, + type: video.type, + thumbnailUrl: video.thumbnailUrl, + duration: video.duration, + fileSize: video.fileSize, + qualities: qualities, + defaultQuality: '360', // 默认360p + currentQuality: '360' // 当前选中360p + } + }) + + console.log('✅ 适配后的视频数据:', adaptedVideos) + + return { + code: response.data.code, + message: response.data.message, + data: adaptedVideos + } + } else { + console.warn('⚠️ API返回的数据结构不正确:', response.data) + return { + code: 500, + message: '数据格式错误', + data: [] + } + } + } catch (error) { + console.error('❌ 章节视频API调用失败:', error) + console.error('❌ 错误详情:', { + message: (error as Error).message, + stack: (error as Error).stack, + response: (error as any).response?.data, + status: (error as any).response?.status, + statusText: (error as any).response?.statusText + }) + + // 重新抛出错误,不使用模拟数据 + throw error + } + } + + // 解析视频URL(逗号分隔的多个清晰度URL) + private static parseVideoQualities(fileUrl: string): VideoQuality[] { + const qualities: VideoQuality[] = [] + + try { + if (!fileUrl || fileUrl.trim() === '') { + console.warn('视频URL为空') + return qualities + } + + // 按逗号分割URL + const urls = fileUrl.split(',').map(url => url.trim()).filter(url => url.length > 0) + console.log('🔍 分割后的视频URL:', urls) + + // 支持的清晰度列表(按优先级排序) + const supportedQualities = [ + { value: '1080', label: '1080p' }, + { value: '720', label: '720p' }, + { value: '480', label: '480p' }, + { value: '360', label: '360p' } + ] + + // 根据URL数量分配清晰度 + // 假设URL按清晰度从高到低排列:1080p, 720p, 480p, 360p + urls.forEach((url, index) => { + if (index < supportedQualities.length) { + qualities.push({ + label: supportedQualities[index].label, + value: supportedQualities[index].value, + url: url + }) + } + }) + + // 如果没有解析到任何清晰度,使用第一个URL作为360p + if (qualities.length === 0 && urls.length > 0) { + qualities.push({ + label: '360p', + value: '360', + url: urls[0] + }) + } + + console.log('✅ 解析后的视频清晰度:', qualities) + } catch (error) { + console.warn('解析视频清晰度失败,使用原始URL:', error) + // 如果解析失败,将整个fileUrl作为360p使用 + qualities.push({ + label: '360p', + value: '360', + url: fileUrl + }) + } + + return qualities + } + } export default CourseApi diff --git a/src/api/types.ts b/src/api/types.ts index fccf490..7f4de62 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -371,7 +371,7 @@ export interface BackendCourseSection { id: string courseId: string name: string // 章节名称 - type: number // 章节类型:0=视频、1=资料、2=考试、3=作业 + type: number | null // 章节类型:0=视频、1=资料、2=考试、3=作业,null=未设置 sortOrder: number // 排序 parentId: string // 父章节ID level: number // 章节层级:0=一级章节、1=二级章节 @@ -387,7 +387,7 @@ export interface CourseSection { lessonId: string // 改为string类型,与Course.id保持一致 outline: string // 章节大纲/内容链接 name: string // 章节名称 - type: number // 章节类型:0=视频、1=资料、2=考试、3=作业 + type: number | null // 章节类型:0=视频、1=资料、2=考试、3=作业,null=未设置 parentId: string // 父章节ID,改为string类型 sort: number // 排序(从sortOrder适配) level: number // 层级:0=一级章节、1=二级章节 @@ -426,6 +426,55 @@ export interface BackendInstructorListResponse { timestamp: number } +// 后端章节视频数据结构 +export interface BackendSectionVideo { + id: string + name: string + description: string + type: number + fileUrl: string // 视频文件URL,包含多个清晰度 + thumbnailUrl: string + duration: number + fileSize: number + metadata: string + izFeatured: number + status: number + createdBy: string + createdTime: string + updatedBy: string + updatedTime: string +} + +// 后端章节视频列表响应格式 +export interface BackendSectionVideoListResponse { + success: boolean + message: string + code: number + result: BackendSectionVideo[] + timestamp: number +} + +// 前端视频质量选项 +export interface VideoQuality { + label: string // 显示名称:1080p, 720p, 480p, 360p + value: string // 实际值:1080, 720, 480, 360 + url: string // 对应的视频URL +} + +// 前端章节视频类型 +export interface SectionVideo { + id: string + name: string + description: string + type: number + thumbnailUrl: string + duration: number + fileSize: number + qualities: VideoQuality[] // 可用的视频质量选项 + defaultQuality: string // 默认质量(360p) + currentQuality: string // 当前选中的质量 +} + // 前端章节列表响应格式 export interface CourseSectionListResponse { list: CourseSection[] diff --git a/src/views/CourseDetailEnrolled.vue b/src/views/CourseDetailEnrolled.vue index 4181720..f932ada 100644 --- a/src/views/CourseDetailEnrolled.vue +++ b/src/views/CourseDetailEnrolled.vue @@ -49,6 +49,29 @@

请选择要播放的视频课程

+ + +
+
+ +
+
+ {{ quality.label }} +
+
+
+
@@ -377,7 +400,7 @@ import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useUserStore } from '@/stores/user' import { CourseApi } from '@/api/modules/course' -import type { Course, CourseSection } from '@/api/types' +import type { Course, CourseSection, SectionVideo, VideoQuality } from '@/api/types' import SafeAvatar from '@/components/common/SafeAvatar.vue' import LearningProgressStats from '@/components/common/LearningProgressStats.vue' import NotesModal from '@/components/common/NotesModal.vue' @@ -405,6 +428,13 @@ const currentSection = ref(null) const currentVideoUrl = ref('') const ckplayer = ref(null) +// 视频相关状态 +const currentVideo = ref(null) +const videoQualities = ref([]) +const currentQuality = ref('360') // 默认360p +const videoLoading = ref(false) +const showQualityMenu = ref(false) + // 视频源配置 const VIDEO_CONFIG = { // 本地视频(当前使用) @@ -540,33 +570,44 @@ const generateMockSections = (): CourseSection[] => { return [] } -// 将章节按章分组 +// 将章节按章分组 - 根据后端数据结构重新实现 const groupSectionsByChapter = (sections: CourseSection[]) => { - const chapterTitles = [ - '课前准备', - '程序设计基础知识', - '实战项目', - '高级应用', - '拓展学习', - '答疑与交流' - ] + console.log('🔍 开始分组章节数据:', sections) const groups: ChapterGroup[] = [] - let sectionsPerChapter = [4, 5, 6, 4, 3, 2] // 每章的课程数量 - let sectionIndex = 0 - for (let i = 0; i < chapterTitles.length; i++) { - const chapterSections = sections.slice(sectionIndex, sectionIndex + sectionsPerChapter[i]) - if (chapterSections.length > 0) { - groups.push({ - title: chapterTitles[i], - sections: chapterSections, - expanded: i === 0 // 默认展开第一章 - }) - } - sectionIndex += sectionsPerChapter[i] + // 找出所有一级章节(level=1,这些是父章节) + const parentChapters = sections.filter(section => section.level === 1) + console.log('🔍 找到一级章节:', parentChapters) + + // 为每个一级章节创建分组 + parentChapters.forEach((parentChapter, index) => { + // 找出该章节下的所有子章节(level=2,parentId匹配) + const childSections = sections.filter(section => + section.level === 2 && section.parentId === parentChapter.id + ) + + console.log(`🔍 章节"${parentChapter.name}"的子章节:`, childSections) + + // 创建章节分组 + groups.push({ + title: parentChapter.name, // 使用后端返回的章节名称 + sections: childSections.length > 0 ? childSections : [parentChapter], // 如果有子章节就用子章节,否则用父章节本身 + expanded: index === 0 // 默认展开第一章 + }) + }) + + // 如果没有找到一级章节,可能所有章节都是同级的,直接作为一个组 + if (groups.length === 0 && sections.length > 0) { + console.log('🔍 没有找到层级结构,将所有章节作为一组') + groups.push({ + title: '课程章节', + sections: sections, + expanded: true + }) } + console.log('✅ 章节分组完成:', groups) return groups } @@ -620,11 +661,11 @@ const loadCourseSections = async () => { console.log('章节API响应:', response) if (response.code === 0 || response.code === 200) { - if (response.data && Array.isArray(response.data)) { - courseSections.value = response.data - groupedSections.value = groupSectionsByChapter(response.data) - console.log('章节数据设置成功:', courseSections.value) - console.log('分组数据:', groupedSections.value) + if (response.data && response.data.list && Array.isArray(response.data.list)) { + courseSections.value = response.data.list + groupedSections.value = groupSectionsByChapter(response.data.list) + console.log('✅ 章节数据设置成功:', courseSections.value) + console.log('✅ 分组数据:', groupedSections.value) // 默认播放右侧第一个视频章节(当未强制使用本地视频时) if (!FORCE_LOCAL_VIDEO) { const firstVideo = courseSections.value.find(s => s.outline && (s.outline.includes('.m3u8') || s.outline.includes('.mp4'))) @@ -686,6 +727,103 @@ const toggleChapter = (chapterIndex: number) => { groupedSections.value[chapterIndex].expanded = !groupedSections.value[chapterIndex].expanded } +// 加载章节视频 +const loadSectionVideo = async (section: CourseSection) => { + try { + videoLoading.value = true + console.log('🔍 加载章节视频,章节ID:', section.id) + + const response = await CourseApi.getSectionVideos(courseId.value, section.id) + console.log('🔍 视频API响应:', response) + + if (response.code === 0 || response.code === 200) { + if (response.data && response.data.length > 0) { + const video = response.data[0] // 取第一个视频 + currentVideo.value = video + videoQualities.value = video.qualities + currentQuality.value = video.defaultQuality + + // 获取默认清晰度的URL + const defaultQualityVideo = video.qualities.find(q => q.value === video.defaultQuality) + if (defaultQualityVideo) { + currentVideoUrl.value = defaultQualityVideo.url + console.log('✅ 设置视频URL:', currentVideoUrl.value) + + // 更新播放器 + await updateVideoPlayer() + } + } else { + console.warn('⚠️ 没有找到视频数据') + } + } else { + console.error('❌ 获取视频失败:', response.message) + } + } catch (error) { + console.error('❌ 加载章节视频失败:', error) + } finally { + videoLoading.value = false + } +} + +// 切换视频清晰度 +const changeVideoQuality = async (quality: string) => { + if (!currentVideo.value) return + + const qualityVideo = currentVideo.value.qualities.find(q => q.value === quality) + if (qualityVideo) { + currentQuality.value = quality + currentVideoUrl.value = qualityVideo.url + console.log('🔍 切换清晰度到:', quality, 'URL:', qualityVideo.url) + + // 更新播放器 + await updateVideoPlayer() + } +} + +// 更新视频播放器 +const updateVideoPlayer = async () => { + if (!currentVideoUrl.value) { + console.warn('⚠️ 视频URL为空,无法更新播放器') + return + } + + try { + console.log('🔍 更新播放器视频源:', currentVideoUrl.value) + + if (ckplayer.value) { + // 尝试不同的CKPlayer API方法 + if (typeof ckplayer.value.newVideo === 'function') { + console.log('✅ 使用newVideo方法更新视频源') + ckplayer.value.newVideo(currentVideoUrl.value) + } else if (typeof ckplayer.value.changeVideo === 'function') { + console.log('✅ 使用changeVideo方法更新视频源') + ckplayer.value.changeVideo(currentVideoUrl.value) + } else if (typeof ckplayer.value.videoSrc === 'function') { + console.log('✅ 使用videoSrc方法更新视频源') + ckplayer.value.videoSrc(currentVideoUrl.value) + } else { + console.log('⚠️ 未找到合适的更新方法,重新初始化播放器') + // 如果没有找到合适的方法,重新初始化播放器 + await nextTick() + initCKPlayer(currentVideoUrl.value) + } + } else { + console.log('🔍 播放器未初始化,开始初始化') + await nextTick() + initCKPlayer(currentVideoUrl.value) + } + } catch (error) { + console.error('❌ 更新播放器失败:', error) + // 如果更新失败,尝试重新初始化 + try { + await nextTick() + initCKPlayer(currentVideoUrl.value) + } catch (initError) { + console.error('❌ 重新初始化播放器也失败:', initError) + } + } +} + // 获取章节编号 const getChapterNumber = (num: number) => { const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'] @@ -694,19 +832,39 @@ const getChapterNumber = (num: number) => { // 课程类型判断函数 const isVideoLesson = (section: CourseSection) => { - console.log(section.outline) + console.log('检查章节类型:', section.name, 'type:', section.type, 'outline:', section.outline) + // 优先根据type字段判断:0=视频 + if (section.type === 0) { + return true + } + // 如果type为null,则根据outline判断 return section.outline && (section.outline.includes('.m3u8') || section.outline.includes('.mp4')) } const isResourceLesson = (section: CourseSection) => { + // 优先根据type字段判断:1=资料 + if (section.type === 1) { + return true + } + // 如果type为null,则根据outline或名称判断 return section.outline && (section.outline.includes('.pdf') || section.outline.includes('.ppt') || section.outline.includes('.zip')) } const isHomeworkLesson = (section: CourseSection) => { + // 优先根据type字段判断:3=作业 + if (section.type === 3) { + return true + } + // 如果type为null,则根据名称判断 return section.name.includes('作业') || section.name.includes('练习') } const isExamLesson = (section: CourseSection) => { + // 优先根据type字段判断:2=考试 + if (section.type === 2) { + return true + } + // 如果type为null,则根据名称判断 return section.name.includes('考试') || section.name.includes('测试') } @@ -736,18 +894,39 @@ const formatLessonDuration = (section: CourseSection) => { // 处理章节点击 - 已报名状态,可以正常点击 const handleSectionClick = (section: CourseSection) => { - console.log('点击课程章节:', section.name) + console.log('🔍 点击课程章节:', section.name, section) currentSection.value = section - // 如果是视频课程,直接播放 - if (isVideoLesson(section)) { - handleVideoPlay(section) - } else if (isResourceLesson(section)) { + // 检查章节类型 + const isVideo = isVideoLesson(section) + const isResource = isResourceLesson(section) + const isHomework = isHomeworkLesson(section) + const isExam = isExamLesson(section) + + console.log('🔍 章节类型判断结果:', { + isVideo, + isResource, + isHomework, + isExam, + type: section.type + }) + + // 如果是视频课程,加载视频数据 + if (isVideo) { + console.log('✅ 识别为视频课程,开始加载视频数据') + loadSectionVideo(section) + } else if (isResource) { + console.log('✅ 识别为资料课程') handleDownload(section) - } else if (isHomeworkLesson(section)) { + } else if (isHomework) { + console.log('✅ 识别为作业课程') handleHomework(section) - } else if (isExamLesson(section)) { + } else if (isExam) { + console.log('✅ 识别为考试课程') handleExam(section) + } else { + console.log('⚠️ 未识别的课程类型,默认当作视频处理') + loadSectionVideo(section) } } @@ -1111,6 +1290,73 @@ onUnmounted(() => { opacity: 0.9; } +/* 清晰度选择器 */ +.video-quality-selector { + position: absolute; + top: 15px; + right: 15px; + z-index: 10; +} + +.quality-dropdown { + position: relative; +} + +.quality-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: rgba(0, 0, 0, 0.6); + color: white; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s; +} + +.quality-btn:hover { + background: rgba(0, 0, 0, 0.8); +} + +.dropdown-icon { + transition: transform 0.2s; +} + +.quality-btn:hover .dropdown-icon { + transform: rotate(180deg); +} + +.quality-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: rgba(0, 0, 0, 0.9); + border-radius: 4px; + overflow: hidden; + min-width: 80px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.quality-option { + padding: 8px 12px; + color: white; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s; +} + +.quality-option:hover { + background: rgba(255, 255, 255, 0.1); +} + +.quality-option.active { + background: #1890ff; + color: white; +} + /* 课程信息区域 */ .course-info-section { /* padding: 24px 0; */