feat:章节对应的视频,视频url切割,可切换清晰度

This commit is contained in:
小张 2025-08-16 13:08:38 +08:00
parent f4a5f6f782
commit 6bdd7d6999
3 changed files with 459 additions and 39 deletions

View File

@ -16,6 +16,9 @@ import type {
CourseSectionListResponse, CourseSectionListResponse,
BackendCourseSection, BackendCourseSection,
BackendInstructor, BackendInstructor,
BackendSectionVideo,
SectionVideo,
VideoQuality,
Quiz, Quiz,
LearningProgress, LearningProgress,
SearchRequest, SearchRequest,
@ -572,8 +575,8 @@ export class CourseApi {
lessonId: section.courseId, // 使用courseId作为lessonId lessonId: section.courseId, // 使用courseId作为lessonId
outline: '', // 暂时为空根据type可以设置不同的内容 outline: '', // 暂时为空根据type可以设置不同的内容
name: section.name, name: section.name,
type: section.type, type: section.type, // 保持原值可能为null
parentId: section.parentId, parentId: section.parentId || '', // 如果parentId为空字符串保持为空字符串
sort: section.sortOrder, sort: section.sortOrder,
level: section.level, level: section.level,
revision: 1, // 默认版本号 revision: 1, // 默认版本号
@ -823,6 +826,128 @@ export class CourseApi {
} }
} }
// 获取章节视频列表
static async getSectionVideos(courseId: string, sectionId: string): Promise<ApiResponse<SectionVideo[]>> {
try {
console.log('🔍 获取章节视频数据课程ID:', courseId, '章节ID:', sectionId)
console.log('🔍 API请求URL: /biz/course/' + courseId + '/section_video/' + sectionId)
const response = await ApiRequest.get<any>(`/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 export default CourseApi

View File

@ -371,7 +371,7 @@ export interface BackendCourseSection {
id: string id: string
courseId: string courseId: string
name: string // 章节名称 name: string // 章节名称
type: number // 章节类型0=视频、1=资料、2=考试、3=作业 type: number | null // 章节类型0=视频、1=资料、2=考试、3=作业null=未设置
sortOrder: number // 排序 sortOrder: number // 排序
parentId: string // 父章节ID parentId: string // 父章节ID
level: number // 章节层级0=一级章节、1=二级章节 level: number // 章节层级0=一级章节、1=二级章节
@ -387,7 +387,7 @@ export interface CourseSection {
lessonId: string // 改为string类型与Course.id保持一致 lessonId: string // 改为string类型与Course.id保持一致
outline: string // 章节大纲/内容链接 outline: string // 章节大纲/内容链接
name: string // 章节名称 name: string // 章节名称
type: number // 章节类型0=视频、1=资料、2=考试、3=作业 type: number | null // 章节类型0=视频、1=资料、2=考试、3=作业null=未设置
parentId: string // 父章节ID改为string类型 parentId: string // 父章节ID改为string类型
sort: number // 排序从sortOrder适配 sort: number // 排序从sortOrder适配
level: number // 层级0=一级章节、1=二级章节 level: number // 层级0=一级章节、1=二级章节
@ -426,6 +426,55 @@ export interface BackendInstructorListResponse {
timestamp: number 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 { export interface CourseSectionListResponse {
list: CourseSection[] list: CourseSection[]

View File

@ -49,6 +49,29 @@
<p>请选择要播放的视频课程</p> <p>请选择要播放的视频课程</p>
</div> </div>
</div> </div>
<!-- 清晰度选择器 -->
<div v-if="videoQualities.length > 1" class="video-quality-selector">
<div class="quality-dropdown">
<button class="quality-btn" @click="showQualityMenu = !showQualityMenu">
{{ currentQuality }}p
<svg width="12" height="12" viewBox="0 0 12 12" class="dropdown-icon">
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</button>
<div v-if="showQualityMenu" class="quality-menu">
<div
v-for="quality in videoQualities"
:key="quality.value"
class="quality-option"
:class="{ active: quality.value === currentQuality }"
@click="changeVideoQuality(quality.value); showQualityMenu = false"
>
{{ quality.label }}
</div>
</div>
</div>
</div>
</div> </div>
<!-- 底部交互区域 --> <!-- 底部交互区域 -->
@ -377,7 +400,7 @@ import { ref, onMounted, onUnmounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { CourseApi } from '@/api/modules/course' 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 SafeAvatar from '@/components/common/SafeAvatar.vue'
import LearningProgressStats from '@/components/common/LearningProgressStats.vue' import LearningProgressStats from '@/components/common/LearningProgressStats.vue'
import NotesModal from '@/components/common/NotesModal.vue' import NotesModal from '@/components/common/NotesModal.vue'
@ -405,6 +428,13 @@ const currentSection = ref<CourseSection | null>(null)
const currentVideoUrl = ref<string>('') const currentVideoUrl = ref<string>('')
const ckplayer = ref<any>(null) const ckplayer = ref<any>(null)
//
const currentVideo = ref<SectionVideo | null>(null)
const videoQualities = ref<VideoQuality[]>([])
const currentQuality = ref<string>('360') // 360p
const videoLoading = ref(false)
const showQualityMenu = ref(false)
// //
const VIDEO_CONFIG = { const VIDEO_CONFIG = {
// 使 // 使
@ -540,33 +570,44 @@ const generateMockSections = (): CourseSection[] => {
return [] return []
} }
// // -
const groupSectionsByChapter = (sections: CourseSection[]) => { const groupSectionsByChapter = (sections: CourseSection[]) => {
const chapterTitles = [ console.log('🔍 开始分组章节数据:', sections)
'课前准备',
'程序设计基础知识',
'实战项目',
'高级应用',
'拓展学习',
'答疑与交流'
]
const groups: ChapterGroup[] = [] const groups: ChapterGroup[] = []
let sectionsPerChapter = [4, 5, 6, 4, 3, 2] //
let sectionIndex = 0
for (let i = 0; i < chapterTitles.length; i++) { // level=1
const chapterSections = sections.slice(sectionIndex, sectionIndex + sectionsPerChapter[i]) const parentChapters = sections.filter(section => section.level === 1)
if (chapterSections.length > 0) { console.log('🔍 找到一级章节:', parentChapters)
groups.push({
title: chapterTitles[i], //
sections: chapterSections, parentChapters.forEach((parentChapter, index) => {
expanded: i === 0 // // level=2parentId
}) const childSections = sections.filter(section =>
} section.level === 2 && section.parentId === parentChapter.id
sectionIndex += sectionsPerChapter[i] )
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 return groups
} }
@ -620,11 +661,11 @@ const loadCourseSections = async () => {
console.log('章节API响应:', response) console.log('章节API响应:', response)
if (response.code === 0 || response.code === 200) { if (response.code === 0 || response.code === 200) {
if (response.data && Array.isArray(response.data)) { if (response.data && response.data.list && Array.isArray(response.data.list)) {
courseSections.value = response.data courseSections.value = response.data.list
groupedSections.value = groupSectionsByChapter(response.data) groupedSections.value = groupSectionsByChapter(response.data.list)
console.log('章节数据设置成功:', courseSections.value) console.log('章节数据设置成功:', courseSections.value)
console.log('分组数据:', groupedSections.value) console.log('分组数据:', groupedSections.value)
// 使 // 使
if (!FORCE_LOCAL_VIDEO) { if (!FORCE_LOCAL_VIDEO) {
const firstVideo = courseSections.value.find(s => s.outline && (s.outline.includes('.m3u8') || s.outline.includes('.mp4'))) 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 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 getChapterNumber = (num: number) => {
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'] const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
@ -694,19 +832,39 @@ const getChapterNumber = (num: number) => {
// //
const isVideoLesson = (section: CourseSection) => { const isVideoLesson = (section: CourseSection) => {
console.log(section.outline) console.log('检查章节类型:', section.name, 'type:', section.type, 'outline:', section.outline)
// type0=
if (section.type === 0) {
return true
}
// typenulloutline
return section.outline && (section.outline.includes('.m3u8') || section.outline.includes('.mp4')) return section.outline && (section.outline.includes('.m3u8') || section.outline.includes('.mp4'))
} }
const isResourceLesson = (section: CourseSection) => { const isResourceLesson = (section: CourseSection) => {
// type1=
if (section.type === 1) {
return true
}
// typenulloutline
return section.outline && (section.outline.includes('.pdf') || section.outline.includes('.ppt') || section.outline.includes('.zip')) return section.outline && (section.outline.includes('.pdf') || section.outline.includes('.ppt') || section.outline.includes('.zip'))
} }
const isHomeworkLesson = (section: CourseSection) => { const isHomeworkLesson = (section: CourseSection) => {
// type3=
if (section.type === 3) {
return true
}
// typenull
return section.name.includes('作业') || section.name.includes('练习') return section.name.includes('作业') || section.name.includes('练习')
} }
const isExamLesson = (section: CourseSection) => { const isExamLesson = (section: CourseSection) => {
// type2=
if (section.type === 2) {
return true
}
// typenull
return section.name.includes('考试') || section.name.includes('测试') return section.name.includes('考试') || section.name.includes('测试')
} }
@ -736,18 +894,39 @@ const formatLessonDuration = (section: CourseSection) => {
// - // -
const handleSectionClick = (section: CourseSection) => { const handleSectionClick = (section: CourseSection) => {
console.log('点击课程章节:', section.name) console.log('🔍 点击课程章节:', section.name, section)
currentSection.value = section currentSection.value = section
// //
if (isVideoLesson(section)) { const isVideo = isVideoLesson(section)
handleVideoPlay(section) const isResource = isResourceLesson(section)
} else if (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) handleDownload(section)
} else if (isHomeworkLesson(section)) { } else if (isHomework) {
console.log('✅ 识别为作业课程')
handleHomework(section) handleHomework(section)
} else if (isExamLesson(section)) { } else if (isExam) {
console.log('✅ 识别为考试课程')
handleExam(section) handleExam(section)
} else {
console.log('⚠️ 未识别的课程类型,默认当作视频处理')
loadSectionVideo(section)
} }
} }
@ -1111,6 +1290,73 @@ onUnmounted(() => {
opacity: 0.9; 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 { .course-info-section {
/* padding: 24px 0; */ /* padding: 24px 0; */